From 955d9973db37ebda4d1cec0295fbc20ec52b1a9b Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Wed, 1 Apr 2026 20:26:49 +0200 Subject: [PATCH] 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 --- README.md | 10 +- package.json | 2 +- src/index.ts | 7 +- src/ui/app.tsx | 221 ++++++++++++++++++++++-- src/ui/components/SuggestionOverlay.tsx | 38 ++++ src/ui/hooks/useTypeahead.ts | 146 ++++++++++++++++ src/utils/at-mentions.ts | 120 +++++++++++++ src/utils/file-index.ts | 152 ++++++++++++++++ 8 files changed, 671 insertions(+), 25 deletions(-) create mode 100644 src/ui/components/SuggestionOverlay.tsx create mode 100644 src/ui/hooks/useTypeahead.ts create mode 100644 src/utils/at-mentions.ts create mode 100644 src/utils/file-index.ts diff --git a/README.md b/README.md index 850fca8..2752824 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index 17f463c..7d684ca 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 991f267..f76cb9a 100755 --- a/src/index.ts +++ b/src/index.ts @@ -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); diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 8c91214..d2bda7a 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -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): string { if (block.isImage) { @@ -112,6 +118,11 @@ function getPasteBlockToken(block: Pick) 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(null); /** Incremented on each successful TUI copy; drives a brief "Copied" banner. */ const [copyFlashId, setCopyFlashId] = useState(0); + const [expandedMessages, setExpandedMessages] = useState>(() => new Set()); const [activeSubagent, setActiveSubagent] = useState(null); const [pqs, setPqs] = useState(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([]); - const processMessageRef = useRef<(text: string) => Promise | void>(() => {}); + const queuedMessagesRef = useRef([]); + const processMessageRef = useRef<(text: string, displayText?: string) => Promise | void>(() => {}); const [queuedMessages, setQueuedMessages] = useState([]); 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(null); const showUpdateModalRef = useRef(false); + const fileIndexRef = useRef(null); + if (!fileIndexRef.current) { + fileIndexRef.current = new FileIndex(agent.getCwd()); + } + const fileMentionCounterRef = useRef(0); + const fileMentionBlocksRef = useRef([]); + + 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 */} {messages.map((msg, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: append-only message list without stable IDs - + ))} {liveTurnSourceLabel && (activeToolCalls.length > 0 || streamContent || isProcessing) && ( @@ -3062,6 +3168,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) contextStats={contextStats} queuedCount={queuedMessages.length} queuedMessages={queuedMessages} + typeahead={typeahead} /> @@ -3099,6 +3206,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) modelInfo={modelInfo} contextStats={contextStats} placeholder={"What are we building?"} + typeahead={typeahead} /> @@ -3367,6 +3475,7 @@ function PromptBox({ placeholder, queuedCount, queuedMessages, + typeahead, }: { t: Theme; inputRef: React.RefObject; @@ -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 ( @@ -3422,6 +3533,9 @@ function PromptBox({ )} + {showSuggestions && typeahead && ( + + )} {(queuedCount ?? 0) > 0 ? "clear queue" : "interrupt"} + ) : showSuggestions ? ( + + + {"tab "} + {"accept"} + + + {"↑↓ "} + {"navigate"} + + + {"esc "} + {"dismiss"} + + ) : ( <> + + {"@ "} + {"files"} + {"shift+enter "} {"new line"} @@ -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 {content}; + } + + if (expanded) { + return ( + <> + {content} + + + {"ctrl+e "} + {"collapse"} + + + + ); + } + + const preview = lines.slice(0, USER_MSG_COLLAPSED_LINES).join("\n"); + const hiddenCount = lines.length - USER_MSG_COLLAPSED_LINES; + return ( + <> + {preview} + + + {"ctrl+e "} + {`expand (${hiddenCount} more lines)`} + + + + ); +} + +function MessageView({ + entry, + index, + t, + modeColor, + expandedMessages, +}: { + entry: ChatEntry; + index: number; + t: Theme; + modeColor: string; + expandedMessages?: Set; +}) { switch (entry.type) { case "user": return ( @@ -3710,7 +3894,7 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index: flexDirection="column" > {entry.sourceLabel ? {entry.sourceLabel} : null} - {entry.content} + ); @@ -4212,8 +4396,11 @@ function MediaToolResultView({ t, label, toolResult }: { t: Theme; label: string ) : null} {media.length > 0 ? ( - {media.map((asset, index) => ( - + {media.map((asset) => ( + {asset.path} {asset.url ? {`url: ${asset.url}`} : null} {asset.sourcePath ? {`source: ${asset.sourcePath}`} : null} diff --git a/src/ui/components/SuggestionOverlay.tsx b/src/ui/components/SuggestionOverlay.tsx new file mode 100644 index 0000000..ce8ef7d --- /dev/null +++ b/src/ui/components/SuggestionOverlay.tsx @@ -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 ( + + {visible.map((filePath, i) => { + const isSelected = i === selectedIndex; + return ( + + {isSelected ? "›" : " "} + {filePath} + + ); + })} + {suggestions.length > MAX_VISIBLE && {` +${suggestions.length - MAX_VISIBLE} more`}} + + ); +} diff --git a/src/ui/hooks/useTypeahead.ts b/src/ui/hooks/useTypeahead.ts new file mode 100644 index 0000000..1f723b3 --- /dev/null +++ b/src/ui/hooks/useTypeahead.ts @@ -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, + fileIndex: FileIndex | null, + onAccept?: (filePath: string, tokenInfo: TokenInfo) => void, +): TypeaheadState { + const [suggestions, setSuggestions] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const tokenRef = useRef(null); + const pollRef = useRef | null>(null); + const lastTextRef = useRef(""); + const lastCursorRef = useRef(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, + }; +} diff --git a/src/utils/at-mentions.ts b/src/utils/at-mentions.ts new file mode 100644 index 0000000..0b4aace --- /dev/null +++ b/src/utils/at-mentions.ts @@ -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(); + + 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 = `\n${result.content}\n`; + if (result.truncated) { + block += `\n`; + } + fileBlocks.push(block); + } + + if (fileBlocks.length === 0) { + return { enhancedMessage: text, mentionedFiles: [] }; + } + + const attachedBlock = `\n${fileBlocks.join("\n\n")}\n`; + return { + enhancedMessage: `${attachedBlock}\n\n${text}`, + mentionedFiles, + }; +} diff --git a/src/utils/file-index.ts b/src/utils/file-index.ts new file mode 100644 index 0000000..e5973b7 --- /dev/null +++ b/src/utils/file-index.ts @@ -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 { + 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 { + if (Date.now() - this.lastRefresh > REFRESH_TTL_MS) { + await this.refresh(); + } + } + + async match(query: string, maxResults = 10): Promise { + 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); + } +}