From 5cb4a19db8d5666d36fedfbab2a0e6b9cf936a1f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:50:14 -0400 Subject: [PATCH 1/6] Composer chips anywhere: cursor-relative /command and @file triggers across desktop, TUI, and iOS - shared/composerTriggers.ts: detectComposerTrigger + replaceComposerTriggerSpan; slash and @ triggers work at any cursor position with word-boundary guards (paths/URLs/fractions/emails never trigger) - Desktop: both composer inputs (rich contenteditable + textarea) and WorkViewArea consume the shared util; selection splices exactly the trigger span; confirmed tokens render as chips (chip nodes in rich mode, backdrop overlay in textarea); IME composition guarded - ChatCommandMenu: 300ms -> 40ms debounce, per-menu query cache with same-frame warm hits, stale-result sequence guard, spinner only when nothing to show - fileSearchIndexService: name index split from content index (no file reads on build; quickOpen never touches content; searchText loads lazily with bounds); per-query quickOpen cache invalidated by watcher events - registry fileSearch: sessionId->laneId cache via getSessionSummary; empty query warms the index (fileService.warmQuickOpenIndex); composer pings on session bind so first @ is instant - TUI: prompt trigger detection is cursor-relative via the same shared module; slash menu works mid-sentence (Enter/Tab complete the token); confirmed tokens render as colored chips - iOS: new typed-trigger detection (UITextView-backed composer), inline suggestion strip above the keyboard, attributed chip pills; dead WorkSlashCommandsSheet/WorkMentionsPickerSheet removed Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/tuiClient/app.tsx | 123 ++- .../main/services/adeActions/registry.test.ts | 65 ++ .../src/main/services/adeActions/registry.ts | 67 +- .../files/fileSearchIndexService.test.ts | 235 ++++++ .../services/files/fileSearchIndexService.ts | 114 ++- .../main/services/files/fileService.test.ts | 50 ++ .../src/main/services/files/fileService.ts | 15 + .../components/chat/AgentChatComposer.tsx | 409 +++++++--- .../components/chat/ChatCommandMenu.tsx | 60 +- .../components/terminals/WorkViewArea.tsx | 48 +- .../src/shared/composerTriggers.test.ts | 115 +++ apps/desktop/src/shared/composerTriggers.ts | 65 ++ apps/ios/ADE.xcodeproj/project.pbxproj | 12 +- .../ADE/Views/Work/WorkChatSessionView.swift | 44 +- .../Work/WorkComposerTypedTriggers.swift | 719 ++++++++++++++++++ .../Views/Work/WorkMentionsPickerSheet.swift | 61 -- .../Views/Work/WorkSlashCommandsSheet.swift | 79 -- docs/features/chat/composer-and-ui.md | 44 +- .../file-watcher-and-trust.md | 11 + 19 files changed, 1998 insertions(+), 338 deletions(-) create mode 100644 apps/desktop/src/main/services/files/fileSearchIndexService.test.ts create mode 100644 apps/desktop/src/shared/composerTriggers.test.ts create mode 100644 apps/desktop/src/shared/composerTriggers.ts create mode 100644 apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift delete mode 100644 apps/ios/ADE/Views/Work/WorkMentionsPickerSheet.swift delete mode 100644 apps/ios/ADE/Views/Work/WorkSlashCommandsSheet.swift diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 30956bbfc..b005a68d2 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -12,6 +12,11 @@ import { } from "../../../desktop/src/shared/modelRegistry"; import { LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch"; import { getAgentSkillRootCandidates } from "../../../desktop/src/shared/agentSkillRoots"; +import { + composerTriggerSpansWholeDraft, + detectComposerTrigger, + replaceComposerTriggerSpan, +} from "../../../desktop/src/shared/composerTriggers"; import type { AgentChatClaudePlugin, AgentChatReloadClaudePluginsResult, @@ -2001,13 +2006,31 @@ function loginUnavailableHint(provider: AdeCodeProvider): string { return "No terminal login command is known for this provider."; } -function activeMention(value: string): { start: number; query: string } | null { - const match = value.match(/(^|\s)@([^\s@]*)$/); - if (!match || match.index == null) return null; - return { - start: match.index + match[1].length, - query: match[2] ?? "", - }; +type PromptTokenRange = { start: number; end: number; kind: "file" | "command" }; + +/** + * Split a visual prompt row's text into plain and confirmed-token segments so + * the renderer can style `@file` / `/command` chips. `rowStart` is the prompt + * code-unit offset of `text[0]`; token ranges are in prompt coordinates. + */ +function segmentPromptLineText( + text: string, + rowStart: number, + tokens: PromptTokenRange[], +): Array<{ text: string; kind: "plain" | "file" | "command" }> { + if (!tokens.length || !text) return text ? [{ text, kind: "plain" }] : []; + const segments: Array<{ text: string; kind: "plain" | "file" | "command" }> = []; + let pos = 0; + for (const token of tokens) { + const start = Math.max(0, token.start - rowStart); + const end = Math.min(text.length, token.end - rowStart); + if (end <= pos || start >= text.length) continue; + if (start > pos) segments.push({ text: text.slice(pos, start), kind: "plain" }); + segments.push({ text: text.slice(Math.max(pos, start), end), kind: token.kind }); + pos = end; + } + if (pos < text.length) segments.push({ text: text.slice(pos), kind: "plain" }); + return segments; } export const MENTION_REMOTE_DEBOUNCE_MS = 160; @@ -3869,6 +3892,28 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const rightPaneWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen, rightPaneMaxWidth); const centerWidth = resolveCenterPaneWidth(columns, drawerOpen, rightPaneWidth); const promptPaneWidth = Math.max(MIN_CENTER_PANE_WIDTH, finiteFloor(columns, MIN_CENTER_PANE_WIDTH)); + // Confirmed chip tokens in the prompt: mentions that were actually inserted + // from the picker and /commands matching the known catalog. Rendered as + // colored tokens in the prompt rows below. + const promptTokenRanges = useMemo(() => { + if (!prompt) return []; + const mentionTexts = new Set(selectedMentions.map((mention) => mention.insertText)); + const commandNames = new Set([ + ...BUILTIN_COMMANDS.map((command) => command.name.replace(/^\//, "").toLowerCase()), + ...slashCommands.map((command) => command.name.replace(/^\//, "").toLowerCase()), + ]); + const ranges: PromptTokenRange[] = []; + for (const match of prompt.matchAll(/(^|\s)([@/])(\S+)/g)) { + const start = (match.index ?? 0) + match[1]!.length; + const body = match[3]!; + if (match[2] === "@") { + if (mentionTexts.has(`@${body}`)) ranges.push({ start, end: start + 1 + body.length, kind: "file" }); + } else if (commandNames.has(body.toLowerCase())) { + ranges.push({ start, end: start + 1 + body.length, kind: "command" }); + } + } + return ranges; + }, [prompt, selectedMentions, slashCommands]); const promptDisplay = promptDisplayRowsWithCursor(prompt, Math.max(1, promptPaneWidth - 5), promptCursor, PROMPT_MAX_ROWS); const promptRows = promptDisplay.rows; const chatRowBudget = Math.max(4, rows - 8 - (promptRows.length - 1) - statusRows - goalBannerRows - addModeRows - backgroundLaunchRows); @@ -4198,14 +4243,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setSelectedDrawerChatAction(action); applyDrawerChatSelection({ session: session ?? null, action }); }, [applyDrawerChatSelection, openDrawerSessions, selectActiveLaneId]); + const activeComposerTrigger = useMemo(() => ( + activePane === "chat" ? detectComposerTrigger(prompt, promptCursor) : null + ), [activePane, prompt, promptCursor]); const activeMentionRange = useMemo(() => ( - activePane === "chat" ? activeMention(prompt) : null - ), [activePane, prompt]); - const slashRows = useMemo(() => ( - activePane === "chat" && prompt.startsWith("/") - ? paletteCommands(prompt, slashCommands, { provider: activeCommandProvider }) - : [] - ), [activeCommandProvider, activePane, prompt, slashCommands]); + activeComposerTrigger?.type === "at" + ? { start: activeComposerTrigger.start, query: activeComposerTrigger.query } + : null + ), [activeComposerTrigger]); + const slashComposerTrigger = activeComposerTrigger?.type === "slash" ? activeComposerTrigger : null; + const slashRows = useMemo(() => ( + slashComposerTrigger + ? paletteCommands(`/${slashComposerTrigger.query}`, slashCommands, { provider: activeCommandProvider }) + : [] + ), [activeCommandProvider, slashComposerTrigger, slashCommands]); + // Mid-sentence slash triggers complete into the draft on Enter instead of + // submitting/running, mirroring the desktop command menu. + const slashTriggerMidSentence = slashComposerTrigger != null + && !composerTriggerSpansWholeDraft(prompt, slashComposerTrigger); const commandPaletteItems = useMemo(() => { if (!commandPaletteOpen) return []; const commandItems = paletteCommands("", slashCommands, { provider: activeCommandProvider }).map((command) => ({ @@ -10270,10 +10325,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }, [addNotice, chatRowBudget, lanes, models, refreshState, registerOptimisticTerminalSession, selectedMentions, setChatScrollOffset, setDraftChatMode, terminalPaneWidth]); const insertMention = useCallback((suggestion: MentionSuggestion) => { - const range = activeMention(prompt); - if (!range) return; - const nextPrompt = `${prompt.slice(0, range.start)}${suggestion.insertText} ${prompt.slice(range.start + range.query.length + 1)}`; - setPromptValue(nextPrompt, range.start + suggestion.insertText.length + 1); + const trigger = detectComposerTrigger(prompt, promptCursorRef.current); + if (trigger?.type !== "at") return; + const next = replaceComposerTriggerSpan(prompt, trigger, `${suggestion.insertText} `); + setPromptValue(next.text, next.caret); setSelectedMentions((prev) => { if (prev.some((entry) => entry.insertText === suggestion.insertText)) return prev; return [...prev, suggestion].slice(-12); @@ -10285,9 +10340,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const insertSlashCommand = useCallback(() => { const selected = slashRows[slashIndex] ?? slashRows[0]; if (!selected) return; - const nextPrompt = `${selected.name}${selected.argumentHint ? " " : ""}`; - setPromptValue(nextPrompt); - }, [setPromptValue, slashIndex, slashRows]); + const trigger = detectComposerTrigger(prompt, promptCursorRef.current); + if (trigger?.type !== "slash" || composerTriggerSpansWholeDraft(prompt, trigger)) { + // Lone leading command keeps the legacy fill-the-prompt behavior. + setPromptValue(`${selected.name}${selected.argumentHint ? " " : ""}`); + return; + } + const next = replaceComposerTriggerSpan(prompt, trigger, `${selected.name} `); + setPromptValue(next.text, next.caret); + }, [prompt, setPromptValue, slashIndex, slashRows]); const applyModelState = useCallback((updater: (prev: AdeCodeModelState) => AdeCodeModelState) => { setModelState((prev) => { @@ -11979,7 +12040,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setSlashIndex((index) => (index + 1) % slashRows.length); return; } - if (key.tab) { + if (key.tab || (key.return && slashTriggerMidSentence)) { insertSlashCommand(); return; } @@ -13040,7 +13101,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setSlashIndex((index) => (index + 1) % slashRows.length); return; } - if (pane === "chat" && key.tab && slashRows.length) { + if (pane === "chat" && (key.tab || (key.return && slashTriggerMidSentence)) && slashRows.length) { insertSlashCommand(); return; } @@ -14736,17 +14797,27 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const beforeCursor = cursorParts?.before ?? line.text; const cursorText = cursorParts ? (cursorParts.selected || " ") : ""; const afterCursor = cursorParts?.after ?? ""; + const renderSegments = (text: string, rowStart: number, keyPrefix: string) => + segmentPromptLineText(text, rowStart, promptTokenRanges).map((segment, segmentIndex) => ( + + {segment.text} + + )); return ( {index === 0 ? "› " : " "} {hasCursor ? ( <> - {beforeCursor} + {renderSegments(beforeCursor, line.start, "b")} {cursorText} - {afterCursor} + {renderSegments(afterCursor, line.start + beforeCursor.length + cursorText.length, "a")} ) : ( - {line.text} + renderSegments(line.text, line.start, "t") )} {index === 0 && !prompt ? {" ^V paste image"} : null} {last && streaming && !goalBannerText ? {" · streaming"} : null} diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index d119c4719..7517f3ea6 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -435,6 +435,71 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(sendMessage).toHaveBeenCalledTimes(1); }); + it("resolves chat fileSearch lanes from getSessionSummary and caches the result", async () => { + const sessionId = "file-search-summary-session"; + const getSessionSummary = vi.fn(async (id: string) => ({ sessionId: id, laneId: "lane-fast" })); + const listSessions = vi.fn(async () => [{ sessionId, laneId: "lane-slow" }]); + const quickOpen = vi.fn(async () => [{ path: "src/fast.ts", score: 600 }]); + const runtime = { + agentChatService: { + getSessionSummary, + listSessions, + }, + fileService: { + quickOpen, + warmQuickOpenIndex: vi.fn(async () => undefined), + }, + } as unknown as Parameters[0]; + + const chat = getAdeActionDomainServices(runtime).chat as { + fileSearch?: (args?: unknown) => Promise; + }; + + await expect(chat.fileSearch?.({ sessionId, query: "fast" })).resolves.toEqual([ + { path: "src/fast.ts", score: 600 }, + ]); + await expect(chat.fileSearch?.({ sessionId, query: "fast" })).resolves.toEqual([ + { path: "src/fast.ts", score: 600 }, + ]); + + expect(getSessionSummary).toHaveBeenCalledTimes(1); + expect(getSessionSummary).toHaveBeenCalledWith(sessionId); + expect(listSessions).not.toHaveBeenCalled(); + expect(quickOpen).toHaveBeenCalledTimes(2); + expect(quickOpen).toHaveBeenNthCalledWith(1, { + workspaceId: "lane-fast", + query: "fast", + limit: 20, + }); + }); + + it("returns empty chat fileSearch results for whitespace queries and warms quickOpen", async () => { + const sessionId = "file-search-warm-session"; + const getSessionSummary = vi.fn(async (id: string) => ({ sessionId: id, laneId: "lane-warm" })); + const quickOpen = vi.fn(async () => [{ path: "src/unused.ts", score: 600 }]); + const warmQuickOpenIndex = vi.fn(() => new Promise(() => undefined)); + const runtime = { + agentChatService: { + getSessionSummary, + listSessions: vi.fn(async () => []), + }, + fileService: { + quickOpen, + warmQuickOpenIndex, + }, + } as unknown as Parameters[0]; + + const chat = getAdeActionDomainServices(runtime).chat as { + fileSearch?: (args?: unknown) => Promise; + }; + + await expect(chat.fileSearch?.({ sessionId, query: " " })).resolves.toEqual([]); + + expect(getSessionSummary).toHaveBeenCalledTimes(1); + expect(quickOpen).not.toHaveBeenCalled(); + expect(warmQuickOpenIndex).toHaveBeenCalledWith({ workspaceId: "lane-warm" }); + }); + it("falls back to headless chat transcript reads when readTranscript is unavailable", async () => { const getChatTranscript = vi.fn(async () => ({ sessionId: "chat-1", diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index ac5a84cd1..718c78e35 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -860,11 +860,63 @@ function buildOrchestrationDomainService(runtime: AdeRuntime): OpaqueService | n } const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024; +const FILE_SEARCH_SESSION_LANE_CACHE_MAX = 200; +const fileSearchLaneIdBySessionId = new Map(); function agentChatParallelLaunchStateKey(projectRoot: string, parentLaneId: string): string { return `agent-chat-parallel-launch:${projectRoot}:${parentLaneId}`; } +function rememberFileSearchLaneId(sessionId: string, laneId: string): void { + if (fileSearchLaneIdBySessionId.has(sessionId)) { + fileSearchLaneIdBySessionId.delete(sessionId); + } + fileSearchLaneIdBySessionId.set(sessionId, laneId); + while (fileSearchLaneIdBySessionId.size > FILE_SEARCH_SESSION_LANE_CACHE_MAX) { + const oldest = fileSearchLaneIdBySessionId.keys().next().value; + if (typeof oldest !== "string") break; + fileSearchLaneIdBySessionId.delete(oldest); + } +} + +function readSessionLaneId(value: unknown): string | null { + if (!isRecord(value)) return null; + const laneId = value.laneId; + return typeof laneId === "string" && laneId.trim() ? laneId.trim() : null; +} + +async function resolveFileSearchLaneId(agentChatService: unknown, sessionId: string): Promise { + const cached = fileSearchLaneIdBySessionId.get(sessionId); + if (cached) return cached; + + const service = agentChatService as { + getSessionSummary?: (sessionId: string) => Promise | unknown; + listSessions?: () => Promise | unknown; + }; + + if (typeof service.getSessionSummary === "function") { + try { + const laneId = readSessionLaneId(await service.getSessionSummary(sessionId)); + if (laneId) { + rememberFileSearchLaneId(sessionId, laneId); + return laneId; + } + } catch { + // Fall back to listSessions below for older or partially available runtimes. + } + } + + if (typeof service.listSessions !== "function") return null; + const sessions = await service.listSessions(); + if (!Array.isArray(sessions)) return null; + const session = sessions.find((entry) => isRecord(entry) && entry.sessionId === sessionId); + const laneId = readSessionLaneId(session); + if (laneId) { + rememberFileSearchLaneId(sessionId, laneId); + } + return laneId; +} + function normalizeStringList(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); @@ -1131,10 +1183,19 @@ function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null { fileSearch: async (args?: AgentChatFileSearchArgs): Promise => { const sessionId = requireNonEmptyString(args?.sessionId, "sessionId"); const query = typeof args?.query === "string" ? args.query : ""; - const session = (await agentChatService.listSessions()).find((entry) => entry.sessionId === sessionId); - if (!session?.laneId || !runtime.fileService) return []; + const laneId = await resolveFileSearchLaneId(agentChatService, sessionId); + if (!laneId || !runtime.fileService) return []; + if (!query.trim()) { + const warmQuickOpenIndex = (runtime.fileService as { + warmQuickOpenIndex?: (args: { workspaceId: string; includeIgnored?: boolean }) => Promise; + }).warmQuickOpenIndex; + if (typeof warmQuickOpenIndex === "function") { + void warmQuickOpenIndex({ workspaceId: laneId }).catch(() => undefined); + } + return []; + } const matches = await runtime.fileService.quickOpen({ - workspaceId: session.laneId, + workspaceId: laneId, query, limit: 20, }); diff --git a/apps/desktop/src/main/services/files/fileSearchIndexService.test.ts b/apps/desktop/src/main/services/files/fileSearchIndexService.test.ts new file mode 100644 index 000000000..7e6ef7ae7 --- /dev/null +++ b/apps/desktop/src/main/services/files/fileSearchIndexService.test.ts @@ -0,0 +1,235 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createFileSearchIndexService } from "./fileSearchIndexService"; + +const shouldIgnore = vi.fn(async () => false); +const primeIgnoreCache = vi.fn(async () => undefined); + +function createTempWorkspace(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +async function flushFileChange(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +describe("fileSearchIndexService", () => { + afterEach(() => { + vi.restoreAllMocks(); + shouldIgnore.mockClear(); + primeIgnoreCache.mockClear(); + }); + + it("returns quickOpen matches without reading file contents", async () => { + const rootPath = createTempWorkspace("ade-file-index-quick-open-"); + const service = createFileSearchIndexService(); + + try { + fs.mkdirSync(path.join(rootPath, "src"), { recursive: true }); + fs.writeFileSync(path.join(rootPath, "src", "feature.ts"), "const secret = 'needle';\n", "utf8"); + const readFileSync = vi.spyOn(fs, "readFileSync").mockImplementation((() => { + throw new Error("quickOpen should not read file contents"); + }) as typeof fs.readFileSync); + + const matches = await service.quickOpen({ + workspaceId: "workspace-1", + rootPath, + query: "feature", + limit: 10, + includeIgnored: false, + shouldIgnore, + primeIgnoreCache, + }); + + expect(matches).toEqual([expect.objectContaining({ path: "src/feature.ts" })]); + expect(readFileSync).not.toHaveBeenCalled(); + } finally { + service.dispose(); + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("loads text content lazily for searchText and reuses cached lines", async () => { + const rootPath = createTempWorkspace("ade-file-index-lazy-text-"); + const service = createFileSearchIndexService(); + + try { + fs.mkdirSync(path.join(rootPath, "notes"), { recursive: true }); + fs.writeFileSync(path.join(rootPath, "notes", "plan.md"), "alpha\nlazy needle\n", "utf8"); + const readFileSync = vi.spyOn(fs, "readFileSync"); + + await expect(service.quickOpen({ + workspaceId: "workspace-1", + rootPath, + query: "plan", + limit: 10, + includeIgnored: false, + shouldIgnore, + primeIgnoreCache, + })).resolves.toEqual([expect.objectContaining({ path: "notes/plan.md" })]); + expect(readFileSync).not.toHaveBeenCalled(); + + const firstSearch = await service.searchText({ + workspaceId: "workspace-1", + rootPath, + query: "lazy needle", + limit: 10, + includeIgnored: false, + shouldIgnore, + primeIgnoreCache, + }); + expect(firstSearch).toEqual([ + expect.objectContaining({ path: "notes/plan.md", line: 2, column: 1 }), + ]); + expect(readFileSync).toHaveBeenCalledTimes(1); + + readFileSync.mockClear(); + await expect(service.searchText({ + workspaceId: "workspace-1", + rootPath, + query: "lazy needle", + limit: 10, + includeIgnored: false, + shouldIgnore, + primeIgnoreCache, + })).resolves.toEqual(firstSearch); + expect(readFileSync).not.toHaveBeenCalled(); + } finally { + service.dispose(); + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("caches quickOpen queries and invalidates them after file changes", async () => { + const rootPath = createTempWorkspace("ade-file-index-cache-"); + const service = createFileSearchIndexService(); + + try { + fs.writeFileSync(path.join(rootPath, "alpha-one.ts"), "export const one = 1;\n", "utf8"); + + const first = await service.quickOpen({ + workspaceId: "workspace-1", + rootPath, + query: "alpha", + limit: 10, + includeIgnored: false, + shouldIgnore, + primeIgnoreCache, + }); + expect(first.map((item) => item.path)).toEqual(["alpha-one.ts"]); + + fs.writeFileSync(path.join(rootPath, "alpha-two.ts"), "export const two = 2;\n", "utf8"); + const cached = await service.quickOpen({ + workspaceId: "workspace-1", + rootPath, + query: "alpha", + limit: 10, + includeIgnored: false, + shouldIgnore, + primeIgnoreCache, + }); + expect(cached.map((item) => item.path)).toEqual(["alpha-one.ts"]); + + service.onFileChanged({ + workspaceId: "workspace-1", + rootPath, + path: "alpha-two.ts", + type: "created", + shouldIgnore, + }); + await flushFileChange(); + + const afterCreate = await service.quickOpen({ + workspaceId: "workspace-1", + rootPath, + query: "alpha", + limit: 10, + includeIgnored: false, + shouldIgnore, + primeIgnoreCache, + }); + expect(afterCreate.map((item) => item.path)).toEqual(["alpha-one.ts", "alpha-two.ts"]); + + fs.rmSync(path.join(rootPath, "alpha-one.ts"), { force: true }); + service.onFileChanged({ + workspaceId: "workspace-1", + rootPath, + path: "alpha-one.ts", + type: "deleted", + shouldIgnore, + }); + + const afterDelete = await service.quickOpen({ + workspaceId: "workspace-1", + rootPath, + query: "alpha", + limit: 10, + includeIgnored: false, + shouldIgnore, + primeIgnoreCache, + }); + expect(afterDelete.map((item) => item.path)).toEqual(["alpha-two.ts"]); + } finally { + service.dispose(); + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("keeps content byte accounting bounded across repeated invalidations", async () => { + const rootPath = createTempWorkspace("ade-file-index-content-bytes-"); + const service = createFileSearchIndexService(); + const filePath = path.join(rootPath, "budget.txt"); + const padding = "x".repeat(999_980); + + try { + for (let i = 0; i < 86; i += 1) { + fs.writeFileSync(filePath, `needle ${i}\n${padding}`, "utf8"); + service.onFileChanged({ + workspaceId: "workspace-1", + rootPath, + path: "budget.txt", + type: i === 0 ? "created" : "modified", + shouldIgnore, + }); + await flushFileChange(); + + const matches = await service.searchText({ + workspaceId: "workspace-1", + rootPath, + query: "needle", + limit: 1, + includeIgnored: false, + shouldIgnore, + primeIgnoreCache, + }); + expect(matches).toEqual([ + expect.objectContaining({ path: "budget.txt", preview: `needle ${i}` }), + ]); + } + + fs.rmSync(filePath, { force: true }); + service.onFileChanged({ + workspaceId: "workspace-1", + rootPath, + path: "budget.txt", + type: "deleted", + shouldIgnore, + }); + + await expect(service.searchText({ + workspaceId: "workspace-1", + rootPath, + query: "needle", + limit: 1, + includeIgnored: false, + shouldIgnore, + primeIgnoreCache, + })).resolves.toEqual([]); + } finally { + service.dispose(); + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/files/fileSearchIndexService.ts b/apps/desktop/src/main/services/files/fileSearchIndexService.ts index 42f8a176b..ba5bfa91e 100644 --- a/apps/desktop/src/main/services/files/fileSearchIndexService.ts +++ b/apps/desktop/src/main/services/files/fileSearchIndexService.ts @@ -6,6 +6,7 @@ import { hasNullByte, normalizeRelative } from "../shared/utils"; const MAX_INDEXED_FILES = 25_000; const MAX_TEXT_FILE_BYTES = 1_000_000; const MAX_TOTAL_CONTENT_BYTES = 80 * 1024 * 1024; +const QUICK_OPEN_QUERY_CACHE_MAX = 50; const YIELD_EVERY_FILES = 120; const VOLATILE_ADE_PREFIXES = [ ".ade/artifacts/", @@ -38,7 +39,9 @@ type IndexedFile = { lowerPath: string; size: number; mtimeMs: number; + contentLoaded: boolean; hasTextContent: boolean; + contentBytes: number; lines: string[]; }; @@ -47,6 +50,7 @@ type WorkspaceIndex = { rootPath: string; includeIgnored: boolean; files: Map; + quickOpenCache: Map; totalContentBytes: number; buildingPromise: Promise | null; builtAt: string | null; @@ -85,6 +89,10 @@ async function cooperativeYield(): Promise { }); } +function cloneQuickOpenItems(items: FilesQuickOpenItem[]): FilesQuickOpenItem[] { + return items.map((item) => ({ ...item })); +} + export function createFileSearchIndexService() { const byWorkspace = new Map(); @@ -101,6 +109,7 @@ export function createFileSearchIndexService() { rootPath, includeIgnored, files: new Map(), + quickOpenCache: new Map(), totalContentBytes: 0, buildingPromise: null, builtAt: null @@ -111,6 +120,20 @@ export function createFileSearchIndexService() { const toAbsolute = (rootPath: string, relPath: string): string => path.join(rootPath, normalizeRelative(relPath)); + const invalidateQuickOpenCache = (index: WorkspaceIndex): void => { + index.quickOpenCache.clear(); + }; + + const invalidateFileContent = (index: WorkspaceIndex, entry: IndexedFile): void => { + if (entry.contentBytes > 0) { + index.totalContentBytes = Math.max(0, index.totalContentBytes - entry.contentBytes); + } + entry.contentLoaded = false; + entry.hasTextContent = false; + entry.contentBytes = 0; + entry.lines = []; + }; + const removePath = (index: WorkspaceIndex, relPath: string): void => { const normalized = normalizeRelative(relPath); if (!normalized) return; @@ -118,9 +141,7 @@ export function createFileSearchIndexService() { for (const [key, entry] of index.files.entries()) { if (key === normalized || key.startsWith(descendantsPrefix)) { - if (entry.hasTextContent) { - index.totalContentBytes = Math.max(0, index.totalContentBytes - entry.size); - } + invalidateFileContent(index, entry); index.files.delete(key); } } @@ -143,44 +164,70 @@ export function createFileSearchIndexService() { if (!stat.isFile()) return; const existing = index.files.get(normalized); - if (existing?.hasTextContent) { - index.totalContentBytes = Math.max(0, index.totalContentBytes - existing.size); + if (existing) { + invalidateFileContent(index, existing); } - let hasTextContent = false; - let lines: string[] = []; const size = stat.size; - if (size <= MAX_TEXT_FILE_BYTES) { - try { - const buf = fs.readFileSync(absPath); - if (!hasNullByte(buf)) { - const nextBytes = index.totalContentBytes + size; - if (nextBytes <= MAX_TOTAL_CONTENT_BYTES) { - hasTextContent = true; - lines = buf.toString("utf8").split("\n"); - index.totalContentBytes = nextBytes; - } - } - } catch { - // keep path-only index entry - } - } - index.files.set(normalized, { path: normalized, lowerPath: normalized.toLowerCase(), size, mtimeMs: stat.mtimeMs, - hasTextContent, - lines + contentLoaded: false, + hasTextContent: false, + contentBytes: 0, + lines: [] }); }; + const loadTextContent = (index: WorkspaceIndex, entry: IndexedFile): void => { + if (entry.contentLoaded) return; + if (entry.size > MAX_TEXT_FILE_BYTES) { + entry.contentLoaded = true; + return; + } + + const nextBytes = index.totalContentBytes + entry.size; + if (nextBytes > MAX_TOTAL_CONTENT_BYTES) { + return; + } + + try { + const buf = fs.readFileSync(toAbsolute(index.rootPath, entry.path)); + entry.contentLoaded = true; + if (hasNullByte(buf)) { + return; + } + entry.hasTextContent = true; + entry.contentBytes = entry.size; + entry.lines = buf.toString("utf8").split("\n"); + index.totalContentBytes = nextBytes; + } catch { + entry.contentLoaded = true; + } + }; + + const quickOpenCacheKey = (query: string, limit: number): string => `${query.toLowerCase().trim()}\0${limit}`; + + const rememberQuickOpenCache = (index: WorkspaceIndex, cacheKey: string, items: FilesQuickOpenItem[]): void => { + if (index.quickOpenCache.has(cacheKey)) { + index.quickOpenCache.delete(cacheKey); + } + index.quickOpenCache.set(cacheKey, cloneQuickOpenItems(items)); + while (index.quickOpenCache.size > QUICK_OPEN_QUERY_CACHE_MAX) { + const oldest = index.quickOpenCache.keys().next().value; + if (typeof oldest !== "string") break; + index.quickOpenCache.delete(oldest); + } + }; + const shouldSkipDirectoryName = (name: string): boolean => ALWAYS_SKIPPED_DIRECTORY_NAMES.has(name); const buildWorkspace = async (index: WorkspaceIndex, opts: IgnoreOptions): Promise => { index.files.clear(); + invalidateQuickOpenCache(index); index.totalContentBytes = 0; const stack: string[] = [""]; @@ -277,6 +324,10 @@ export function createFileSearchIndexService() { primeIgnoreCache: args.primeIgnoreCache }); + const cacheKey = quickOpenCacheKey(args.query, args.limit); + const cached = index.quickOpenCache.get(cacheKey); + if (cached) return cloneQuickOpenItems(cached); + const scored: FilesQuickOpenItem[] = []; for (const entry of index.files.values()) { const score = scorePath(entry.lowerPath, args.query); @@ -284,7 +335,9 @@ export function createFileSearchIndexService() { scored.push({ path: entry.path, score }); } scored.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)); - return scored.slice(0, args.limit); + const result = scored.slice(0, args.limit); + rememberQuickOpenCache(index, cacheKey, result); + return cloneQuickOpenItems(result); }, async searchText(args: { @@ -304,8 +357,16 @@ export function createFileSearchIndexService() { const out: FilesSearchTextMatch[] = []; const queryLower = args.query.toLowerCase(); + let loadedFiles = 0; for (const entry of index.files.values()) { if (out.length >= args.limit) break; + if (!entry.contentLoaded) { + loadTextContent(index, entry); + loadedFiles += 1; + if (loadedFiles % YIELD_EVERY_FILES === 0) { + await cooperativeYield(); + } + } if (!entry.hasTextContent) continue; for (let i = 0; i < entry.lines.length && out.length < args.limit; i++) { const line = entry.lines[i] ?? ""; @@ -338,6 +399,7 @@ export function createFileSearchIndexService() { for (const index of matchingIndexes) { // If this workspace/mode was never indexed yet, defer indexing until first query. if (!index.builtAt && index.files.size === 0) continue; + invalidateQuickOpenCache(index); if (args.oldPath) { removePath(index, args.oldPath); diff --git a/apps/desktop/src/main/services/files/fileService.test.ts b/apps/desktop/src/main/services/files/fileService.test.ts index bbbfb3136..a4f555886 100644 --- a/apps/desktop/src/main/services/files/fileService.test.ts +++ b/apps/desktop/src/main/services/files/fileService.test.ts @@ -496,6 +496,56 @@ describe("fileService", () => { } }); + it("warms the quick open index for subsequent lookups", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-warm-search-")); + const { execSync } = await import("node:child_process"); + execSync("git init", { cwd: rootPath, stdio: "ignore" }); + const laneService = createLaneServiceStub(rootPath); + const service = createFileService({ laneService }); + + try { + fs.mkdirSync(path.join(rootPath, "src"), { recursive: true }); + fs.writeFileSync(path.join(rootPath, "src", "warmTarget.ts"), "export const warmed = true;\n", "utf8"); + + await expect(service.warmQuickOpenIndex({ workspaceId: "workspace-1" })).resolves.toBeUndefined(); + + const readdirSync = vi.spyOn(fs, "readdirSync").mockImplementation((() => { + throw new Error("quickOpen should use the warmed index"); + }) as typeof fs.readdirSync); + try { + const quickOpen = await service.quickOpen({ + workspaceId: "workspace-1", + query: "warmTarget", + }); + + expect(quickOpen).toEqual([expect.objectContaining({ path: "src/warmTarget.ts" })]); + expect(readdirSync).not.toHaveBeenCalled(); + } finally { + readdirSync.mockRestore(); + } + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("swallows warmQuickOpenIndex errors for unknown workspaces", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-warm-missing-")); + const laneService = { + ...createLaneServiceStub(rootPath), + resolveWorkspaceById: vi.fn(() => { + throw new Error("unknown workspace"); + }), + } as any; + const service = createFileService({ laneService }); + + try { + await expect(service.warmQuickOpenIndex({ workspaceId: "missing" })).resolves.toBeUndefined(); + expect(laneService.resolveWorkspaceById).toHaveBeenCalledWith("missing"); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + it("lists only the requested tree depth without extra file metadata", async () => { const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-tree-")); const { execSync } = await import("node:child_process"); diff --git a/apps/desktop/src/main/services/files/fileService.ts b/apps/desktop/src/main/services/files/fileService.ts index 224dea7f5..e0470852d 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -1374,6 +1374,21 @@ export function createFileService({ }); }, + async warmQuickOpenIndex(args: { workspaceId: string; includeIgnored?: boolean }): Promise { + try { + const workspace = resolveWorkspace(args.workspaceId); + await indexService.ensureIndexed({ + workspaceId: args.workspaceId, + rootPath: workspace.rootPath, + includeIgnored: Boolean(args.includeIgnored), + shouldIgnore: shouldIgnoreForRoot(workspace.rootPath), + primeIgnoreCache: primeIgnoreCacheForRoot(workspace.rootPath) + }); + } catch { + // Warming is best-effort; the interactive query path reports real errors. + } + }, + async searchText(args: FilesSearchTextArgs): Promise { const workspace = resolveWorkspace(args.workspaceId); const query = args.query.trim(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 61eed3a38..033088b1c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -38,6 +38,12 @@ import type { OrchestrationRole, } from "../../../shared/types/orchestration"; import { getModelById, modelSupportsFastMode, type ProviderFamily } from "../../../shared/modelRegistry"; +import { + composerTriggerSpansWholeDraft, + detectComposerTrigger, + replaceComposerTriggerSpan, + type ComposerTrigger, +} from "../../../shared/composerTriggers"; import { cn } from "../ui/cn"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import type { AuthStatus } from "../shared/ModelPicker/ModelPickerRail"; @@ -1112,7 +1118,7 @@ export function AgentChatComposer({ const codexPresetPickerRef = useRef(null); const issueContextButtonRef = useRef(null); const [dragActive, setDragActive] = useState(false); - const [commandMenuTrigger, setCommandMenuTrigger] = useState<{ type: "at" | "slash"; query: string; cursorIndex: number } | null>(null); + const [commandMenuTrigger, setCommandMenuTrigger] = useState(null); const [commandMenuAnchor, setCommandMenuAnchor] = useState(null); const commandMenuRef = useRef(null); @@ -1138,6 +1144,10 @@ export function AgentChatComposer({ // image. handlePaste consults this to avoid attaching the same image twice // when the real paste event lands after the 80ms fallback has already fired. const clipboardImagePasteFallbackAttachedRef = useRef(false); + // IME composition guard: while composing we keep updating the draft but + // freeze trigger/menu re-evaluation so half-composed text can't open or + // retarget the command menu; detection re-runs once on compositionend. + const imeComposingRef = useRef(false); const useRichComposer = iosElementContextItems.length > 0 || appControlContextItems.length > 0 || builtInBrowserContextItems.length > 0; @@ -1236,6 +1246,69 @@ export function AgentChatComposer({ [sdkSlashCommands, onClearEvents], ); + // Confirmed chip tokens in the plain-textarea draft: `@path` tokens whose + // path is actually attached and `/name` tokens matching a known command. + // Rendered by a backdrop overlay behind the (then transparent-text) + // textarea; derived purely from draft + attachments + commands so it + // survives draft persistence/restore without extra state. + const knownSlashCommandNames = useMemo( + () => new Set(effectiveSlashCommands.map((cmd) => cmd.command.replace(/^\//, "").toLowerCase())), + [effectiveSlashCommands], + ); + const plainComposerTokens = useMemo(() => { + if (useRichComposer || !draft) return [] as Array<{ start: number; end: number }>; + const tokens: Array<{ start: number; end: number }> = []; + for (const match of draft.matchAll(/(^|\s)([@/])(\S+)/g)) { + const start = (match.index ?? 0) + match[1]!.length; + const body = match[3]!; + const confirmed = match[2] === "@" + ? attachedPaths.has(body) + : knownSlashCommandNames.has(body.toLowerCase()); + if (confirmed) tokens.push({ start, end: start + 1 + body.length }); + } + return tokens; + }, [attachedPaths, draft, knownSlashCommandNames, useRichComposer]); + const [plainOverlayScrollTop, setPlainOverlayScrollTop] = useState(0); + useLayoutEffect(() => { + if (!plainComposerTokens.length) return; + setPlainOverlayScrollTop(textareaRef.current?.scrollTop ?? 0); + }, [draft, plainComposerTokens.length]); + const plainOverlayContent = useMemo(() => { + if (!plainComposerTokens.length) return null; + const segments: React.ReactNode[] = []; + let pos = 0; + plainComposerTokens.forEach((token, index) => { + if (token.start > pos) segments.push(draft.slice(pos, token.start)); + segments.push( + + {draft.slice(token.start, token.end)} + , + ); + pos = token.end; + }); + segments.push(draft.slice(pos)); + // Zero-width terminator keeps a trailing newline's final (empty) line + // measurable so the overlay height matches the textarea's scrollHeight. + segments.push("​"); + return segments; + }, [draft, plainComposerTokens]); + + // Pre-warm the lane's quick-open file index as soon as the composer is + // bound to a session so the first "@" query is served from a warm index. + // The empty-query fileSearch is a cheap warm ping: it returns [] right away + // and kicks the name-index build in the background. + useEffect(() => { + if (!sessionId) return; + try { + void window.ade?.agentChat?.fileSearch?.({ sessionId, query: "" })?.catch?.(() => {}); + } catch { + // warming is best-effort + } + }, [sessionId]); + const revokePreviewUrl = useCallback((url: string | null | undefined) => { if (!url || !objectPreviewUrlsRef.current.has(url)) return; if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { @@ -1553,6 +1626,10 @@ export function AgentChatComposer({ return; } if (!(node instanceof HTMLElement)) return; + if (node.dataset.composerChipText != null) { + parts.push(node.dataset.composerChipText); + return; + } if ( node.dataset.iosContextId || node.dataset.appControlContextId @@ -1641,6 +1718,129 @@ export function AgentChatComposer({ syncRichDraft(); }, [syncRichDraft]); + const restoreTextareaCaret = useCallback((caret: number) => { + lastPlainSelectionRef.current = caret; + requestAnimationFrame(() => { + const node = textareaRef.current; + if (!node) return; + node.focus({ preventScroll: true }); + try { + node.setSelectionRange(caret, caret); + } catch { + // selection may not apply if the node is detached; ignore + } + resizeTextarea(); + }); + }, [resizeTextarea]); + + const createComposerTokenChipNode = useCallback((kind: "file" | "command", text: string): HTMLElement => { + const chip = document.createElement("span"); + chip.contentEditable = "false"; + chip.dataset.composerChip = kind; + chip.dataset.composerChipText = text; + chip.className = "mx-0.5 inline-flex max-w-[280px] translate-y-[1px] items-center rounded-md border border-violet-300/22 bg-violet-500/12 px-1.5 py-0.5 font-sans text-[length:calc(var(--chat-font-size)*12/14)] leading-5 text-violet-100/88 align-baseline"; + chip.title = text; + const label = document.createElement("span"); + label.className = "truncate"; + label.textContent = text; + chip.appendChild(label); + return chip; + }, []); + + // Finds the in-progress /command or @file token that ends at the caret in + // the rich contenteditable. Works on the DOM text run around the caret + // instead of serialized-draft offsets: serialization collapses whitespace + // and flattens chips, so serialized indices cannot be mapped back onto DOM + // positions. Chips,
, and block edges terminate the run and act as + // word boundaries. + const getRichTriggerContext = useCallback((): { trigger: ComposerTrigger; range: Range } | null => { + const editor = richEditorRef.current; + if (!editor) return null; + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) return null; + const caret = selection.getRangeAt(0); + if (!caret.collapsed || !editor.contains(caret.startContainer)) return null; + + let caretNode: Text; + let caretOffset = caret.startOffset; + if (caret.startContainer.nodeType === Node.TEXT_NODE) { + caretNode = caret.startContainer as Text; + } else { + const prev = caret.startContainer.childNodes[caret.startOffset - 1]; + if (!prev || prev.nodeType !== Node.TEXT_NODE) return null; + caretNode = prev as Text; + caretOffset = (caretNode.textContent ?? "").length; + } + + const runNodes: Text[] = [caretNode]; + let runText = (caretNode.textContent ?? "").slice(0, caretOffset); + let walker: Node | null = caretNode.previousSibling; + while (walker && walker.nodeType === Node.TEXT_NODE) { + runNodes.unshift(walker as Text); + runText = (walker.textContent ?? "") + runText; + walker = walker.previousSibling; + } + + const trigger = detectComposerTrigger(runText, runText.length); + if (!trigger) return null; + + let remaining = trigger.start; + let startNode: Text = caretNode; + let startOffset = caretOffset; + for (const node of runNodes) { + const length = node === caretNode ? caretOffset : (node.textContent ?? "").length; + if (remaining <= length && (node !== caretNode || remaining < caretOffset)) { + startNode = node; + startOffset = remaining; + break; + } + remaining -= length; + } + + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(caretNode, caretOffset); + return { trigger, range }; + }, []); + + // Replaces the active trigger span in the rich editor with either plain + // text or a non-editable chip node followed by a space. Returns false when + // no trigger span can be located (caller falls back to caret insertion). + const replaceRichTriggerWith = useCallback((insertion: { text: string } | { chipKind: "file" | "command"; chipText: string }): boolean => { + const editor = richEditorRef.current; + if (!editor) return false; + editor.focus({ preventScroll: true }); + const selection = window.getSelection(); + const saved = richSelectionRef.current?.cloneRange(); + if (saved && editor.contains(saved.commonAncestorContainer)) { + selection?.removeAllRanges(); + selection?.addRange(saved); + } + const context = getRichTriggerContext(); + if (!context) return false; + selection?.removeAllRanges(); + selection?.addRange(context.range); + if ("text" in insertion) { + document.execCommand("insertText", false, insertion.text); + captureRichSelection(); + syncRichDraft(); + return true; + } + context.range.deleteContents(); + const chip = createComposerTokenChipNode(insertion.chipKind, insertion.chipText); + context.range.insertNode(chip); + const space = document.createTextNode(" "); + chip.after(space); + const caretRange = document.createRange(); + caretRange.setStart(space, 1); + caretRange.collapse(true); + selection?.removeAllRanges(); + selection?.addRange(caretRange); + richSelectionRef.current = caretRange.cloneRange(); + syncRichDraft(); + return true; + }, [captureRichSelection, createComposerTokenChipNode, getRichTriggerContext, syncRichDraft]); + // Brief shimmer over the composer (CSS honors prefers-reduced-motion). Fired // both on the optimistic mic-down (recording start) and on transcript insert. const voiceShimmerTimerRef = useRef(null); @@ -2743,60 +2943,58 @@ export function AgentChatComposer({ setCommandMenuTrigger(null); return; } - // Replace the @query with @filepath + // Replace exactly the @query trigger span with the confirmed token. if (useRichComposer) { - insertTextIntoRichEditor(`@${item.path} `); + if (!replaceRichTriggerWith({ chipKind: "file", chipText: `@${item.path}` })) { + insertTextIntoRichEditor(`@${item.path} `); + } } else { - const before = draft.slice(0, commandMenuTrigger.cursorIndex); - const after = draft.slice(commandMenuTrigger.cursorIndex + commandMenuTrigger.query.length + 1); // +1 for @ - onDraftChange(`${before}@${item.path} ${after}`); + const next = replaceComposerTriggerSpan(draft, commandMenuTrigger, `@${item.path} `); + onDraftChange(next.text); + restoreTextareaCaret(next.caret); } onAddAttachment({ path: item.path, type: inferAttachmentType(item.path) }); - } else if (item.type === "command") { + } else if (item.type === "command" && commandMenuTrigger) { const selected = effectiveSlashCommands.find((cmd) => cmd.command.replace(/^\//, "") === item.name); - if (selected) { + const wholeDraft = composerTriggerSpansWholeDraft(draft, commandMenuTrigger); + if (selected && wholeDraft && !useRichComposer) { + // Lone leading command keeps the legacy path: local /clear intercept + // plus the argument-hint scaffold. handleSlashSelect(selected); + } else if (selected?.command === "/clear" && selected.source === "local" && wholeDraft) { + handleSlashSelect(selected); + } else if (useRichComposer) { + if (!replaceRichTriggerWith({ chipKind: "command", chipText: `/${item.name}` })) { + insertTextIntoRichEditor(`/${item.name} `); + } } else { - const next = `/${item.name} `; - if (useRichComposer) setRichEditorText(next); - onDraftChange(next); + const next = replaceComposerTriggerSpan(draft, commandMenuTrigger, `/${item.name} `); + onDraftChange(next.text); + restoreTextareaCaret(next.caret); } } setCommandMenuTrigger(null); - }, [attachBlockedReason, canAttach, commandMenuTrigger, composerInputLocked, draft, effectiveSlashCommands, handleSlashSelect, insertTextIntoRichEditor, onDraftChange, onAddAttachment, setRichEditorText, useRichComposer]); + }, [attachBlockedReason, canAttach, commandMenuTrigger, composerInputLocked, draft, effectiveSlashCommands, handleSlashSelect, insertTextIntoRichEditor, onDraftChange, onAddAttachment, replaceRichTriggerWith, restoreTextareaCaret, useRichComposer]); const handleRichEditorInput = useCallback(() => { const editor = richEditorRef.current; if (!editor) return; const val = serializeRichEditor(); onDraftChange(val); - const anchor = getCommandMenuAnchor(editor); - - if (val.startsWith("/") && !val.slice(1).includes("\n")) { - const afterSlash = val.slice(1); - if (!/\s/.test(afterSlash)) { - const query = afterSlash.match(/^[^\s/]*/)?.[0] ?? ""; - setCommandMenuTrigger({ type: "slash", query, cursorIndex: 0 }); - if (anchor) setCommandMenuAnchor(anchor); - captureRichSelection(); - return; - } - setCommandMenuTrigger(null); + if (imeComposingRef.current) { captureRichSelection(); return; } - - const cursorPos = getRichCursorTextOffset(); - const textBeforeCursor = val.slice(0, cursorPos); - const atMatch = textBeforeCursor.match(/@([^\s@]*)$/); - if (atMatch) { - setCommandMenuTrigger({ type: "at", query: atMatch[1], cursorIndex: cursorPos - atMatch[0].length }); + const context = getRichTriggerContext(); + if (context) { + setCommandMenuTrigger(context.trigger); + const anchor = getCommandMenuAnchor(editor); if (anchor) setCommandMenuAnchor(anchor); } else { setCommandMenuTrigger(null); } captureRichSelection(); - }, [captureRichSelection, getRichCursorTextOffset, onDraftChange, serializeRichEditor]); + }, [captureRichSelection, getRichTriggerContext, onDraftChange, serializeRichEditor]); const singleModelBlockedMessage = modelUnavailableMessage?.trim() ? modelUnavailableMessage : null; const singleModelReady = Boolean(modelId) && !singleModelBlockedMessage; @@ -4109,6 +4307,13 @@ export function AgentChatComposer({ )} data-chat-layout-variant={layoutVariant} onInput={handleRichEditorInput} + onCompositionStart={() => { + imeComposingRef.current = true; + }} + onCompositionEnd={() => { + imeComposingRef.current = false; + handleRichEditorInput(); + }} onKeyDown={handleKeyDown} onPaste={handlePaste} onKeyUp={captureRichSelection} @@ -4163,63 +4368,95 @@ export function AgentChatComposer({ /> ) : ( -