feat: add @-mention file autocomplete (#236)
* feat: add @-mention file autocomplete Let prompts attach files directly from the composer with inline suggestions while injecting file context for the agent. * bump version * minor pr fixes
This commit is contained in:
1 parent
93e6b4aa0b
commit
955d9973db
8 files changed
+671
-25
No files matched your search
@@ -1,10 +1,10 @@
|
||||
# There are many coding agents. **This is Grok’s.**
|
||||
|
||||
[CI](https://github.com/superagent-ai/grok-cli/actions/workflows/typecheck.yml)
|
||||
[npm](https://www.npmjs.com/package/grok-dev)
|
||||
[License: MIT](./LICENSE)
|
||||
[TypeScript](https://www.typescriptlang.org/)
|
||||
[Bun](https://bun.sh/)
|
||||
[](https://github.com/superagent-ai/grok-cli/actions/workflows/typecheck.yml)
|
||||
[](https://www.npmjs.com/package/grok-dev)
|
||||
[](./LICENSE)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://bun.sh/)
|
||||
|
||||
The rest borrowed from each other. We borrowed from *all of them*, then wired it to **Grok**—real-time **X search**, **web search**, `**grok-code-fast-1`** and the full Grok model lineup, **sub-agents on by default**, **remote control via Telegram** (pair once, drive the agent from your phone while the CLI runs), and a terminal UI that doesn’t feel like it was assembled in a hurry.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "grok-dev",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"description": "An open-source AI coding agent powered by Grok, built with Bun and OpenTUI.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+5
-2
@@ -14,6 +14,7 @@ import {
|
||||
} from "./headless/output";
|
||||
import { runTelegramHeadlessBridge } from "./telegram/headless-bridge";
|
||||
import { startScheduleDaemon } from "./tools/schedule";
|
||||
import { processAtMentions } from "./utils/at-mentions.js";
|
||||
import {
|
||||
getApiKey,
|
||||
getBaseURL,
|
||||
@@ -117,9 +118,11 @@ async function runHeadless(
|
||||
if (prelude.stdout) process.stdout.write(prelude.stdout);
|
||||
if (prelude.stderr) process.stderr.write(prelude.stderr);
|
||||
|
||||
const { enhancedMessage } = processAtMentions(prompt, process.cwd());
|
||||
|
||||
if (format === "json") {
|
||||
const { observer, consumeChunk, flush } = createHeadlessJsonlEmitter(agent.getSessionId() || undefined);
|
||||
for await (const chunk of agent.processMessage(prompt, observer)) {
|
||||
for await (const chunk of agent.processMessage(enhancedMessage, observer)) {
|
||||
const writes = consumeChunk(chunk);
|
||||
if (writes.stdout) process.stdout.write(writes.stdout);
|
||||
if (writes.stderr) process.stderr.write(writes.stderr ?? "");
|
||||
@@ -130,7 +133,7 @@ async function runHeadless(
|
||||
return;
|
||||
}
|
||||
|
||||
for await (const chunk of agent.processMessage(prompt)) {
|
||||
for await (const chunk of agent.processMessage(enhancedMessage)) {
|
||||
const writes = renderHeadlessChunk(chunk);
|
||||
if (writes.stdout) process.stdout.write(writes.stdout);
|
||||
if (writes.stderr) process.stderr.write(writes.stderr);
|
||||
|
||||
+204
-17
@@ -33,6 +33,8 @@ import type {
|
||||
ToolResult,
|
||||
} from "../types/index";
|
||||
import { MODES } from "../types/index";
|
||||
import { processAtMentions } from "../utils/at-mentions.js";
|
||||
import { FileIndex } from "../utils/file-index.js";
|
||||
import { copyTextToHostClipboard } from "../utils/host-clipboard";
|
||||
import {
|
||||
type CustomSubagentConfig,
|
||||
@@ -62,6 +64,8 @@ import {
|
||||
SubagentEditorModal,
|
||||
SubagentsBrowserModal,
|
||||
} from "./agents-modal";
|
||||
import { SuggestionOverlay } from "./components/SuggestionOverlay.js";
|
||||
import { type TypeaheadState, useTypeahead } from "./hooks/useTypeahead.js";
|
||||
import { Markdown } from "./markdown";
|
||||
import { buildMcpBrowseRows, McpBrowserModal, McpEditorModal } from "./mcp-modal";
|
||||
import { createEmptyMcpEditorDraft, type McpEditorDraft, type McpEditorField } from "./mcp-modal-types";
|
||||
@@ -104,6 +108,8 @@ type ContextStats = {
|
||||
ratioRemaining: number;
|
||||
};
|
||||
type PasteBlock = { id: number; content: string; lines: number; isImage?: boolean };
|
||||
type FileMentionBlock = { id: number; path: string };
|
||||
type QueuedMessage = { text: string; displayText: string };
|
||||
|
||||
function getPasteBlockToken(block: Pick<PasteBlock, "id" | "lines" | "isImage">): string {
|
||||
if (block.isImage) {
|
||||
@@ -112,6 +118,11 @@ function getPasteBlockToken(block: Pick<PasteBlock, "id" | "lines" | "isImage">)
|
||||
return `[Pasted #${block.id} ${block.lines}+ lines]`;
|
||||
}
|
||||
|
||||
function getFileMentionToken(block: FileMentionBlock): string {
|
||||
const name = block.path.split("/").pop() || block.path;
|
||||
return `[File: ${name}]`;
|
||||
}
|
||||
|
||||
const HERO_ROWS: Row[] = [
|
||||
{
|
||||
stars: [
|
||||
@@ -571,6 +582,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
const [activePlan, setActivePlan] = useState<Plan | null>(null);
|
||||
/** Incremented on each successful TUI copy; drives a brief "Copied" banner. */
|
||||
const [copyFlashId, setCopyFlashId] = useState(0);
|
||||
const [expandedMessages, setExpandedMessages] = useState<Set<number>>(() => new Set());
|
||||
const [activeSubagent, setActiveSubagent] = useState<SubagentStatus | null>(null);
|
||||
const [pqs, setPqs] = useState<PlanQuestionsState>(initialPlanQuestionsState());
|
||||
const pasteCounterRef = useRef(0);
|
||||
@@ -585,8 +597,8 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
const isProcessingRef = useRef(false);
|
||||
const hasApiKeyRef = useRef(initialHasApiKey);
|
||||
const showApiKeyModalRef = useRef(!initialHasApiKey);
|
||||
const queuedMessagesRef = useRef<string[]>([]);
|
||||
const processMessageRef = useRef<(text: string) => Promise<void> | void>(() => {});
|
||||
const queuedMessagesRef = useRef<QueuedMessage[]>([]);
|
||||
const processMessageRef = useRef<(text: string, displayText?: string) => Promise<void> | void>(() => {});
|
||||
const [queuedMessages, setQueuedMessages] = useState<string[]>([]);
|
||||
const modeInfoRef = useRef<(typeof MODES)[number]>(MODES[0]);
|
||||
const activeRunIdRef = useRef(0);
|
||||
@@ -659,6 +671,34 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
const [updateOutput, setUpdateOutput] = useState<string | null>(null);
|
||||
const showUpdateModalRef = useRef(false);
|
||||
|
||||
const fileIndexRef = useRef<FileIndex | null>(null);
|
||||
if (!fileIndexRef.current) {
|
||||
fileIndexRef.current = new FileIndex(agent.getCwd());
|
||||
}
|
||||
const fileMentionCounterRef = useRef(0);
|
||||
const fileMentionBlocksRef = useRef<FileMentionBlock[]>([]);
|
||||
|
||||
const handleFileAccept = useCallback((filePath: string, tokenInfo: { startPos: number; endPos: number }) => {
|
||||
const ta = inputRef.current;
|
||||
if (!ta) return;
|
||||
|
||||
const id = ++fileMentionCounterRef.current;
|
||||
const block: FileMentionBlock = { id, path: fileIndexRef.current?.resolvePath(filePath) ?? filePath };
|
||||
fileMentionBlocksRef.current = [...fileMentionBlocksRef.current, block];
|
||||
|
||||
const text = ta.plainText;
|
||||
const before = text.slice(0, tokenInfo.startPos);
|
||||
const after = text.slice(tokenInfo.endPos);
|
||||
const token = getFileMentionToken(block);
|
||||
const newText = `${before}${token} ${after}`;
|
||||
ta.setText(newText);
|
||||
ta.cursorOffset = before.length + token.length + 1;
|
||||
}, []);
|
||||
|
||||
const typeahead = useTypeahead(inputRef, fileIndexRef.current, handleFileAccept);
|
||||
const typeaheadRef = useRef(typeahead);
|
||||
typeaheadRef.current = typeahead;
|
||||
|
||||
const setMode = useCallback(
|
||||
(m: AgentMode) => {
|
||||
if (m === "agent" && mode === "plan" && activePlan) {
|
||||
@@ -1235,9 +1275,9 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
const finishTurnProcessing = useCallback(() => {
|
||||
const nextQueued = queuedMessagesRef.current.shift();
|
||||
if (nextQueued) {
|
||||
setQueuedMessages([...queuedMessagesRef.current]);
|
||||
setQueuedMessages(queuedMessagesRef.current.map((msg) => msg.displayText));
|
||||
isProcessingRef.current = false;
|
||||
void processMessageRef.current(nextQueued);
|
||||
void processMessageRef.current(nextQueued.text, nextQueued.displayText);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1382,7 +1422,19 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
|
||||
if (!wasInterrupted) {
|
||||
if (activeTurn.kind === "local" && activeTurn.agent.getSessionId()) {
|
||||
setMessages(activeTurn.agent.getChatEntries());
|
||||
setMessages((prev) => {
|
||||
const fresh = activeTurn.agent.getChatEntries();
|
||||
let prevUserIdx = 0;
|
||||
for (let i = 0; i < fresh.length; i++) {
|
||||
if (fresh[i]!.type !== "user") continue;
|
||||
while (prevUserIdx < prev.length && prev[prevUserIdx]!.type !== "user") prevUserIdx++;
|
||||
if (prevUserIdx < prev.length) {
|
||||
fresh[i] = { ...fresh[i]!, content: prev[prevUserIdx]!.content };
|
||||
prevUserIdx++;
|
||||
}
|
||||
}
|
||||
return fresh;
|
||||
});
|
||||
setSessionTitle(activeTurn.agent.getSessionTitle());
|
||||
setSessionId(activeTurn.agent.getSessionId());
|
||||
} else if (activeTurn.kind === "telegram") {
|
||||
@@ -1848,6 +1900,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
const resetToNewSession = useCallback(() => {
|
||||
const snapshot = agent.startNewSession();
|
||||
setMessages(snapshot?.entries ?? []);
|
||||
setExpandedMessages(new Set());
|
||||
activeTurnRef.current = null;
|
||||
clearLiveTurnUi();
|
||||
setSessionTitle(snapshot?.session.title ?? null);
|
||||
@@ -1860,7 +1913,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
}, [agent, clearLiveTurnUi, replacePasteBlocks]);
|
||||
|
||||
const processMessage = useCallback(
|
||||
async (text: string) => {
|
||||
async (text: string, displayText?: string) => {
|
||||
if (!text.trim() || isProcessingRef.current) return;
|
||||
const runId = ++activeRunIdRef.current;
|
||||
const isStale = () => activeRunIdRef.current !== runId;
|
||||
@@ -1868,14 +1921,15 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
setIsProcessing(true);
|
||||
if (!sessionTitle)
|
||||
agent
|
||||
.generateTitle(text.trim())
|
||||
.generateTitle((displayText ?? text).trim())
|
||||
.then(setSessionTitle)
|
||||
.catch(() => {});
|
||||
await coordinatorRef.current.run(async () => {
|
||||
const color = modeInfoRef.current.color;
|
||||
beginLiveTurn({ kind: "local", agent, modeColor: color });
|
||||
setMessages((prev) => [...prev, buildUserEntry(text.trim(), { modeColor: color })]);
|
||||
setMessages((prev) => [...prev, buildUserEntry((displayText ?? text).trim(), { modeColor: color })]);
|
||||
setTimeout(scrollToBottom, 50);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
try {
|
||||
for await (const chunk of agent.processMessage(text.trim())) {
|
||||
if (isStale()) {
|
||||
@@ -2805,6 +2859,24 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
}
|
||||
}
|
||||
|
||||
if (key.name === "e" && key.ctrl) {
|
||||
let lastUserIdx = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]!.type === "user") {
|
||||
lastUserIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastUserIdx >= 0) {
|
||||
setExpandedMessages((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(lastUserIdx)) next.delete(lastUserIdx);
|
||||
else next.add(lastUserIdx);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.name === "c" && key.ctrl && key.shift) {
|
||||
if (copyTuiSelectionToHost()) {
|
||||
key.preventDefault();
|
||||
@@ -2840,6 +2912,26 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeaheadRef.current.visible) {
|
||||
if (key.name === "up") {
|
||||
typeaheadRef.current.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (key.name === "down") {
|
||||
typeaheadRef.current.navigateDown();
|
||||
return;
|
||||
}
|
||||
if (key.name === "tab" || key.name === "return") {
|
||||
key.preventDefault();
|
||||
key.stopPropagation();
|
||||
typeaheadRef.current.accept();
|
||||
return;
|
||||
}
|
||||
if (isEscapeKey(key)) {
|
||||
typeaheadRef.current.dismiss();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (key.name === "tab" && !isProcessing) {
|
||||
cycleMode();
|
||||
return;
|
||||
@@ -2907,6 +2999,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
submitPlanAnswers,
|
||||
copyTuiSelectionToHost,
|
||||
toggleSavedMcp,
|
||||
messages,
|
||||
],
|
||||
);
|
||||
useKeyboard(handleKey);
|
||||
@@ -2960,19 +3053,26 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
for (const block of blocks) {
|
||||
message = message.replace(getPasteBlockToken(block), block.content);
|
||||
}
|
||||
const displayText = message.trim();
|
||||
const fileBlocks = [...fileMentionBlocksRef.current];
|
||||
fileMentionBlocksRef.current = [];
|
||||
for (const block of fileBlocks) {
|
||||
message = message.replace(getFileMentionToken(block), `@${block.path}`);
|
||||
}
|
||||
if (!message.trim()) return;
|
||||
if (!hasApiKeyRef.current) {
|
||||
openApiKeyModal();
|
||||
return;
|
||||
}
|
||||
if (handleCommand(message)) return;
|
||||
const { enhancedMessage } = processAtMentions(message.trim(), agent.getCwd());
|
||||
if (isProcessingRef.current) {
|
||||
queuedMessagesRef.current.push(message.trim());
|
||||
setQueuedMessages([...queuedMessagesRef.current]);
|
||||
queuedMessagesRef.current.push({ text: enhancedMessage, displayText });
|
||||
setQueuedMessages(queuedMessagesRef.current.map((msg) => msg.displayText));
|
||||
setTimeout(scrollToBottom, 10);
|
||||
return;
|
||||
}
|
||||
processMessage(message);
|
||||
processMessage(enhancedMessage, displayText);
|
||||
}, [agent, clearLiveTurnUi, handleCommand, openApiKeyModal, processMessage, replacePasteBlocks, scrollToBottom]);
|
||||
|
||||
const hasMessages = messages.length > 0 || streamContent || isProcessing;
|
||||
@@ -2995,8 +3095,14 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
{/* biome-ignore lint/suspicious/noExplicitAny: OpenTUI type mismatch for stickyStart */}
|
||||
<scrollbox ref={scrollRef} flexGrow={1} stickyScroll={true} stickyStart={"bottom" as any}>
|
||||
{messages.map((msg, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: append-only message list without stable IDs
|
||||
<MessageView key={i} entry={msg} index={i} t={t} modeColor={modeInfo.color} />
|
||||
<MessageView
|
||||
key={`${msg.timestamp.getTime()}-${msg.type}-${msg.remoteKey ?? ""}-${msg.content.slice(0, 24)}`}
|
||||
entry={msg}
|
||||
index={i}
|
||||
t={t}
|
||||
modeColor={modeInfo.color}
|
||||
expandedMessages={expandedMessages}
|
||||
/>
|
||||
))}
|
||||
{liveTurnSourceLabel && (activeToolCalls.length > 0 || streamContent || isProcessing) && (
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||
@@ -3062,6 +3168,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
contextStats={contextStats}
|
||||
queuedCount={queuedMessages.length}
|
||||
queuedMessages={queuedMessages}
|
||||
typeahead={typeahead}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
@@ -3099,6 +3206,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
|
||||
modelInfo={modelInfo}
|
||||
contextStats={contextStats}
|
||||
placeholder={"What are we building?"}
|
||||
typeahead={typeahead}
|
||||
/>
|
||||
</box>
|
||||
<box height={2} minHeight={0} flexShrink={1} />
|
||||
@@ -3367,6 +3475,7 @@ function PromptBox({
|
||||
placeholder,
|
||||
queuedCount,
|
||||
queuedMessages,
|
||||
typeahead,
|
||||
}: {
|
||||
t: Theme;
|
||||
inputRef: React.RefObject<TextareaRenderable | null>;
|
||||
@@ -3387,8 +3496,10 @@ function PromptBox({
|
||||
placeholder?: string;
|
||||
queuedCount?: number;
|
||||
queuedMessages?: string[];
|
||||
typeahead?: TypeaheadState;
|
||||
}) {
|
||||
const hasQueue = (queuedMessages?.length ?? 0) > 0;
|
||||
const showSuggestions = typeahead?.visible ?? false;
|
||||
|
||||
return (
|
||||
<box backgroundColor={t.backgroundPanel}>
|
||||
@@ -3422,6 +3533,9 @@ function PromptBox({
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
{showSuggestions && typeahead && (
|
||||
<SuggestionOverlay t={t} suggestions={typeahead.suggestions} selectedIndex={typeahead.selectedIndex} />
|
||||
)}
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
@@ -3484,8 +3598,27 @@ function PromptBox({
|
||||
<span style={{ fg: t.textMuted }}>{(queuedCount ?? 0) > 0 ? "clear queue" : "interrupt"}</span>
|
||||
</text>
|
||||
</box>
|
||||
) : showSuggestions ? (
|
||||
<box flexDirection="row" gap={3}>
|
||||
<text fg={t.text}>
|
||||
{"tab "}
|
||||
<span style={{ fg: t.textMuted }}>{"accept"}</span>
|
||||
</text>
|
||||
<text fg={t.text}>
|
||||
{"↑↓ "}
|
||||
<span style={{ fg: t.textMuted }}>{"navigate"}</span>
|
||||
</text>
|
||||
<text fg={t.text}>
|
||||
{"esc "}
|
||||
<span style={{ fg: t.textMuted }}>{"dismiss"}</span>
|
||||
</text>
|
||||
</box>
|
||||
) : (
|
||||
<>
|
||||
<text fg={t.text}>
|
||||
{"@ "}
|
||||
<span style={{ fg: t.textMuted }}>{"files"}</span>
|
||||
</text>
|
||||
<text fg={t.text}>
|
||||
{"shift+enter "}
|
||||
<span style={{ fg: t.textMuted }}>{"new line"}</span>
|
||||
@@ -3690,7 +3823,58 @@ function ApiKeyModal({
|
||||
|
||||
/* ── Messages ────────────────────────────────────────────────── */
|
||||
|
||||
function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index: number; t: Theme; modeColor: string }) {
|
||||
const USER_MSG_COLLAPSED_LINES = 5;
|
||||
|
||||
function UserMessageContent({ content, t, expanded }: { content: string; t: Theme; expanded: boolean }) {
|
||||
const lines = content.split("\n");
|
||||
const isLong = lines.length > USER_MSG_COLLAPSED_LINES;
|
||||
|
||||
if (!isLong) {
|
||||
return <text fg={t.text}>{content}</text>;
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<>
|
||||
<text fg={t.text}>{content}</text>
|
||||
<box marginTop={1}>
|
||||
<text fg={t.textDim}>
|
||||
{"ctrl+e "}
|
||||
<span style={{ fg: t.textMuted }}>{"collapse"}</span>
|
||||
</text>
|
||||
</box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const preview = lines.slice(0, USER_MSG_COLLAPSED_LINES).join("\n");
|
||||
const hiddenCount = lines.length - USER_MSG_COLLAPSED_LINES;
|
||||
return (
|
||||
<>
|
||||
<text fg={t.text}>{preview}</text>
|
||||
<box marginTop={1}>
|
||||
<text fg={t.textDim}>
|
||||
{"ctrl+e "}
|
||||
<span style={{ fg: t.textMuted }}>{`expand (${hiddenCount} more lines)`}</span>
|
||||
</text>
|
||||
</box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageView({
|
||||
entry,
|
||||
index,
|
||||
t,
|
||||
modeColor,
|
||||
expandedMessages,
|
||||
}: {
|
||||
entry: ChatEntry;
|
||||
index: number;
|
||||
t: Theme;
|
||||
modeColor: string;
|
||||
expandedMessages?: Set<number>;
|
||||
}) {
|
||||
switch (entry.type) {
|
||||
case "user":
|
||||
return (
|
||||
@@ -3710,7 +3894,7 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index:
|
||||
flexDirection="column"
|
||||
>
|
||||
{entry.sourceLabel ? <text fg={t.textMuted}>{entry.sourceLabel}</text> : null}
|
||||
<text fg={t.text}>{entry.content}</text>
|
||||
<UserMessageContent content={entry.content} t={t} expanded={expandedMessages?.has(index) ?? false} />
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
@@ -4212,8 +4396,11 @@ function MediaToolResultView({ t, label, toolResult }: { t: Theme; label: string
|
||||
) : null}
|
||||
{media.length > 0 ? (
|
||||
<box paddingLeft={5} marginTop={toolResult.output ? 1 : 0} flexDirection="column">
|
||||
{media.map((asset, index) => (
|
||||
<box key={`${asset.path}-${index}`} flexDirection="column">
|
||||
{media.map((asset) => (
|
||||
<box
|
||||
key={`${asset.path}-${asset.url ?? ""}-${asset.sourcePath ?? ""}-${asset.sourceUrl ?? ""}`}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg={t.text}>{asset.path}</text>
|
||||
{asset.url ? <text fg={t.textMuted}>{`url: ${asset.url}`}</text> : null}
|
||||
{asset.sourcePath ? <text fg={t.textMuted}>{`source: ${asset.sourcePath}`}</text> : null}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Theme } from "../theme.js";
|
||||
|
||||
const MAX_VISIBLE = 8;
|
||||
|
||||
export function SuggestionOverlay({
|
||||
t,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
}: {
|
||||
t: Theme;
|
||||
suggestions: string[];
|
||||
selectedIndex: number;
|
||||
}) {
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
const visible = suggestions.slice(0, MAX_VISIBLE);
|
||||
|
||||
return (
|
||||
<box paddingLeft={1} paddingRight={1} paddingTop={1} paddingBottom={1} flexShrink={0}>
|
||||
{visible.map((filePath, i) => {
|
||||
const isSelected = i === selectedIndex;
|
||||
return (
|
||||
<box
|
||||
key={filePath}
|
||||
height={1}
|
||||
backgroundColor={isSelected ? t.selectedBg : undefined}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
>
|
||||
<text fg={isSelected ? t.accent : t.textDim}>{isSelected ? "›" : " "}</text>
|
||||
<text fg={isSelected ? t.selected : t.text}>{filePath}</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
{suggestions.length > MAX_VISIBLE && <text fg={t.textDim}>{` +${suggestions.length - MAX_VISIBLE} more`}</text>}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { TextareaRenderable } from "@opentui/core";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { FileIndex } from "../../utils/file-index.js";
|
||||
|
||||
const AT_TOKEN_RE = /(^|\s)@([\w\-./\\~][\w\-./\\~:]*|"[^"]*"?)$/u;
|
||||
const BARE_PATH_RE = /(^|\s)(\.{0,2}\/[\w\-./\\~]*|[\w-]+\/[\w\-./\\~]*)$/u;
|
||||
|
||||
export interface TypeaheadState {
|
||||
suggestions: string[];
|
||||
selectedIndex: number;
|
||||
visible: boolean;
|
||||
accept: () => void;
|
||||
dismiss: () => void;
|
||||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
token: string;
|
||||
startPos: number;
|
||||
endPos: number;
|
||||
hasAtPrefix: boolean;
|
||||
}
|
||||
|
||||
function extractToken(text: string, cursorPos: number): TokenInfo | null {
|
||||
const before = text.slice(0, cursorPos);
|
||||
|
||||
const atMatch = before.match(AT_TOKEN_RE);
|
||||
if (atMatch) {
|
||||
const fullMatch = atMatch[0];
|
||||
const leading = atMatch[1] ?? "";
|
||||
const token = atMatch[2] ?? "";
|
||||
const startPos = before.length - fullMatch.length + leading.length;
|
||||
return { token, startPos, endPos: cursorPos, hasAtPrefix: true };
|
||||
}
|
||||
|
||||
const bareMatch = before.match(BARE_PATH_RE);
|
||||
if (bareMatch) {
|
||||
const fullMatch = bareMatch[0];
|
||||
const leading = bareMatch[1] ?? "";
|
||||
const token = bareMatch[2] ?? "";
|
||||
if (!token.includes("/")) return null;
|
||||
const startPos = before.length - fullMatch.length + leading.length;
|
||||
return { token, startPos, endPos: cursorPos, hasAtPrefix: false };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useTypeahead(
|
||||
inputRef: React.RefObject<TextareaRenderable | null>,
|
||||
fileIndex: FileIndex | null,
|
||||
onAccept?: (filePath: string, tokenInfo: TokenInfo) => void,
|
||||
): TypeaheadState {
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const tokenRef = useRef<TokenInfo | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const lastTextRef = useRef("");
|
||||
const lastCursorRef = useRef<number | null>(null);
|
||||
const onAcceptRef = useRef(onAccept);
|
||||
onAcceptRef.current = onAccept;
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(0);
|
||||
tokenRef.current = null;
|
||||
}, []);
|
||||
|
||||
const accept = useCallback(() => {
|
||||
const ta = inputRef.current;
|
||||
const token = tokenRef.current;
|
||||
if (!ta || !token || suggestions.length === 0) return;
|
||||
|
||||
const selected = suggestions[selectedIndex] ?? suggestions[0];
|
||||
if (!selected) return;
|
||||
|
||||
if (onAcceptRef.current) {
|
||||
onAcceptRef.current(selected, token);
|
||||
} else {
|
||||
const text = ta.plainText;
|
||||
const before = text.slice(0, token.startPos);
|
||||
const after = text.slice(token.endPos);
|
||||
const needsQuotes = selected.includes(" ");
|
||||
const replacement = needsQuotes ? `@"${selected}" ` : `@${selected} `;
|
||||
const newText = before + replacement + after;
|
||||
ta.setText(newText);
|
||||
ta.cursorOffset = before.length + replacement.length;
|
||||
}
|
||||
|
||||
dismiss();
|
||||
}, [inputRef, suggestions, selectedIndex, dismiss]);
|
||||
|
||||
const navigateUp = useCallback(() => {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1));
|
||||
}, [suggestions.length]);
|
||||
|
||||
const navigateDown = useCallback(() => {
|
||||
setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0));
|
||||
}, [suggestions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileIndex) return;
|
||||
|
||||
const poll = () => {
|
||||
const ta = inputRef.current;
|
||||
if (!ta) return;
|
||||
|
||||
const text = ta.plainText;
|
||||
const cursor = ta.cursorOffset;
|
||||
|
||||
if (text === lastTextRef.current && cursor === lastCursorRef.current && tokenRef.current) return;
|
||||
lastTextRef.current = text;
|
||||
lastCursorRef.current = cursor;
|
||||
|
||||
const token = extractToken(text, cursor);
|
||||
if (!token || token.token.length === 0) {
|
||||
if (suggestions.length > 0) dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
tokenRef.current = token;
|
||||
const searchQuery = token.token.replace(/^@/, "").replace(/^"/, "").replace(/"$/, "");
|
||||
|
||||
fileIndex.match(searchQuery, 8).then((results) => {
|
||||
setSuggestions(results);
|
||||
setSelectedIndex(0);
|
||||
});
|
||||
};
|
||||
|
||||
pollRef.current = setInterval(poll, 100);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [fileIndex, inputRef, dismiss, suggestions.length]);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
visible: suggestions.length > 0,
|
||||
accept,
|
||||
dismiss,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { isAbsolute, resolve } from "path";
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024; // 100 KB
|
||||
|
||||
const UNQUOTED_AT_RE = /(?:^|\s)@([\w\-./\\~][\w\-./\\~:]*)/g;
|
||||
const QUOTED_AT_RE = /(?:^|\s)@"([^"]+)"/g;
|
||||
|
||||
export interface AtMentionedFile {
|
||||
raw: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}
|
||||
|
||||
export interface ProcessedMentions {
|
||||
enhancedMessage: string;
|
||||
mentionedFiles: string[];
|
||||
}
|
||||
|
||||
function parseLineRange(raw: string): { filePath: string; startLine?: number; endLine?: number } {
|
||||
const hashIdx = raw.lastIndexOf("#");
|
||||
if (hashIdx === -1) return { filePath: raw };
|
||||
|
||||
const filePath = raw.slice(0, hashIdx);
|
||||
const fragment = raw.slice(hashIdx + 1);
|
||||
const rangeMatch = fragment.match(/^L(\d+)(?:-L?(\d+))?$/);
|
||||
if (!rangeMatch) return { filePath: raw };
|
||||
|
||||
const startLine = Number.parseInt(rangeMatch[1]!, 10);
|
||||
const endLine = rangeMatch[2] ? Number.parseInt(rangeMatch[2], 10) : undefined;
|
||||
return { filePath, startLine, endLine };
|
||||
}
|
||||
|
||||
export function extractAtMentionedFiles(text: string): AtMentionedFile[] {
|
||||
const mentions: AtMentionedFile[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const re of [QUOTED_AT_RE, UNQUOTED_AT_RE]) {
|
||||
re.lastIndex = 0;
|
||||
while (true) {
|
||||
const m = re.exec(text);
|
||||
if (m === null) break;
|
||||
const raw = m[1]!;
|
||||
if (seen.has(raw)) continue;
|
||||
seen.add(raw);
|
||||
const parsed = parseLineRange(raw);
|
||||
mentions.push({ raw, ...parsed });
|
||||
}
|
||||
}
|
||||
return mentions;
|
||||
}
|
||||
|
||||
function resolvePath(filePath: string, cwd: string): string {
|
||||
return isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
|
||||
}
|
||||
|
||||
function readFileContent(
|
||||
filePath: string,
|
||||
cwd: string,
|
||||
startLine?: number,
|
||||
endLine?: number,
|
||||
): { content: string; truncated: boolean; resolvedPath: string } | null {
|
||||
const full = resolvePath(filePath, cwd);
|
||||
if (!existsSync(full)) return null;
|
||||
|
||||
try {
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) return null;
|
||||
|
||||
const raw = readFileSync(full, "utf-8");
|
||||
const lines = raw.split("\n");
|
||||
const totalLines = lines.length;
|
||||
|
||||
const start = Math.max(0, (startLine ?? 1) - 1);
|
||||
const end = Math.min(totalLines, endLine ?? totalLines);
|
||||
const slice = lines.slice(start, end);
|
||||
const joined = slice.map((line, i) => `${start + i + 1} | ${line}`).join("\n");
|
||||
|
||||
const truncated = raw.length > MAX_FILE_SIZE;
|
||||
const content = truncated ? joined.slice(0, MAX_FILE_SIZE) : joined;
|
||||
const header = `[${full}: lines ${start + 1}-${end} of ${totalLines}]`;
|
||||
|
||||
return { content: `${header}\n${content}`, truncated, resolvedPath: full };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function processAtMentions(text: string, cwd: string): ProcessedMentions {
|
||||
const mentions = extractAtMentionedFiles(text);
|
||||
if (mentions.length === 0) {
|
||||
return { enhancedMessage: text, mentionedFiles: [] };
|
||||
}
|
||||
|
||||
const fileBlocks: string[] = [];
|
||||
const mentionedFiles: string[] = [];
|
||||
|
||||
for (const mention of mentions) {
|
||||
const result = readFileContent(mention.filePath, cwd, mention.startLine, mention.endLine);
|
||||
if (!result) continue;
|
||||
|
||||
mentionedFiles.push(result.resolvedPath);
|
||||
let block = `<file path="${result.resolvedPath}">\n${result.content}\n</file>`;
|
||||
if (result.truncated) {
|
||||
block += `\n<!-- Note: ${result.resolvedPath} was truncated (exceeded ${MAX_FILE_SIZE / 1024}KB). Use read_file for the full content. -->`;
|
||||
}
|
||||
fileBlocks.push(block);
|
||||
}
|
||||
|
||||
if (fileBlocks.length === 0) {
|
||||
return { enhancedMessage: text, mentionedFiles: [] };
|
||||
}
|
||||
|
||||
const attachedBlock = `<attached_files>\n${fileBlocks.join("\n\n")}\n</attached_files>`;
|
||||
return {
|
||||
enhancedMessage: `${attachedBlock}\n\n${text}`,
|
||||
mentionedFiles,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { execSync } from "child_process";
|
||||
import { basename, resolve } from "path";
|
||||
import { findGitRoot } from "./git-root.js";
|
||||
|
||||
const REFRESH_TTL_MS = 30_000;
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".ico",
|
||||
".bmp",
|
||||
".tiff",
|
||||
".mp3",
|
||||
".mp4",
|
||||
".wav",
|
||||
".avi",
|
||||
".mov",
|
||||
".mkv",
|
||||
".flac",
|
||||
".ogg",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".bz2",
|
||||
".7z",
|
||||
".rar",
|
||||
".xz",
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".eot",
|
||||
".otf",
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".exe",
|
||||
".dll",
|
||||
".so",
|
||||
".dylib",
|
||||
".o",
|
||||
".a",
|
||||
".pyc",
|
||||
".class",
|
||||
".wasm",
|
||||
]);
|
||||
|
||||
function isBinaryPath(filePath: string): boolean {
|
||||
const ext = filePath.lastIndexOf(".");
|
||||
if (ext === -1) return false;
|
||||
return BINARY_EXTENSIONS.has(filePath.slice(ext).toLowerCase());
|
||||
}
|
||||
|
||||
function collectFiles(cwd: string): string[] {
|
||||
const gitRoot = findGitRoot(cwd);
|
||||
try {
|
||||
const cmd = gitRoot
|
||||
? "git ls-files --cached --others --exclude-standard"
|
||||
: "find . -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' | head -5000";
|
||||
const raw = execSync(cmd, {
|
||||
cwd: gitRoot ?? cwd,
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 5000,
|
||||
});
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !isBinaryPath(l));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function scoreMatch(filePath: string, query: string): number {
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
if (!lowerPath.includes(lowerQuery)) return -1;
|
||||
|
||||
let score = 0;
|
||||
const name = basename(filePath).toLowerCase();
|
||||
|
||||
if (name === lowerQuery) score += 100;
|
||||
else if (name.startsWith(lowerQuery)) score += 60;
|
||||
else if (name.includes(lowerQuery)) score += 30;
|
||||
|
||||
if (lowerPath.startsWith(lowerQuery)) score += 20;
|
||||
|
||||
const idx = lowerPath.indexOf(lowerQuery);
|
||||
score -= idx * 0.1;
|
||||
|
||||
score -= filePath.length * 0.05;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export class FileIndex {
|
||||
private files: string[] = [];
|
||||
private cwd: string;
|
||||
private baseDir: string;
|
||||
private lastRefresh = 0;
|
||||
|
||||
constructor(cwd: string) {
|
||||
this.cwd = cwd;
|
||||
this.baseDir = findGitRoot(cwd) ?? cwd;
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
this.files = collectFiles(this.cwd);
|
||||
this.lastRefresh = Date.now();
|
||||
}
|
||||
|
||||
updateCwd(cwd: string): void {
|
||||
if (cwd !== this.cwd) {
|
||||
this.cwd = cwd;
|
||||
this.baseDir = findGitRoot(cwd) ?? cwd;
|
||||
this.lastRefresh = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureFresh(): Promise<void> {
|
||||
if (Date.now() - this.lastRefresh > REFRESH_TTL_MS) {
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async match(query: string, maxResults = 10): Promise<string[]> {
|
||||
await this.ensureFresh();
|
||||
if (!query) return this.files.slice(0, maxResults);
|
||||
|
||||
const scored: { path: string; score: number }[] = [];
|
||||
for (const f of this.files) {
|
||||
const s = scoreMatch(f, query);
|
||||
if (s >= 0) scored.push({ path: f, score: s });
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return scored.slice(0, maxResults).map((s) => s.path);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.files.length;
|
||||
}
|
||||
|
||||
resolvePath(filePath: string): string {
|
||||
return resolve(this.baseDir, filePath);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user