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:
Ismail Pelaseyed authored and GitHub committed 2026-04-01 20:26:49 +02:00
1 parent 93e6b4aa0b
commit 955d9973db
8 files changed
+671 -25

No files matched your search

+5 -5
View File
@@ -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/)
[![CI](https://github.com/superagent-ai/grok-cli/actions/workflows/typecheck.yml/badge.svg)](https://github.com/superagent-ai/grok-cli/actions/workflows/typecheck.yml)
[![npm](https://img.shields.io/npm/v/grok-dev.svg)](https://www.npmjs.com/package/grok-dev)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Bun](https://img.shields.io/badge/Bun-1.x-000000?logo=bun&logoColor=white)](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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+38
View File
@@ -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>
);
}
+146
View File
@@ -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,
};
}
+120
View File
@@ -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,
};
}
+152
View File
@@ -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);
}
}