From c825291b1e6fa113de1660d681f8c967792c2a4e Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 20 Apr 2026 21:38:36 +0800 Subject: [PATCH 1/5] chore(acp): bump claude-agent-acp to 0.30.0 and opencode to 1.14.19 --- src-tauri/src/acp/registry.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/acp/registry.rs b/src-tauri/src/acp/registry.rs index 6395780..6b8a406 100644 --- a/src-tauri/src/acp/registry.rs +++ b/src-tauri/src/acp/registry.rs @@ -116,8 +116,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "Claude Code", description: "ACP wrapper for Anthropic's Claude", distribution: AgentDistribution::Npx { - version: "0.29.2", - package: "@agentclientprotocol/claude-agent-acp@0.29.2", + version: "0.30.0", + package: "@agentclientprotocol/claude-agent-acp@0.30.0", cmd: "claude-agent-acp", args: &[], env: &[], @@ -205,34 +205,34 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "OpenCode", description: "The open source coding agent", distribution: AgentDistribution::Binary { - version: "1.4.11", + version: "1.14.19", cmd: "opencode", args: &["acp"], env: &[], platforms: &[ PlatformBinary { platform: "darwin-aarch64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-darwin-arm64.zip", }, PlatformBinary { platform: "darwin-x86_64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-darwin-x64.zip", }, PlatformBinary { platform: "linux-aarch64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz", + url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-linux-arm64.tar.gz", }, PlatformBinary { platform: "linux-x86_64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz", + url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-linux-x64.tar.gz", }, PlatformBinary { platform: "windows-aarch64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-arm64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-windows-arm64.zip", }, PlatformBinary { platform: "windows-x86_64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-windows-x64.zip", }, ], }, From baf3b6e89ffd2027434362f7b14adaf1639dcfce Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 20 Apr 2026 22:57:24 +0800 Subject: [PATCH 2/5] fix(file-tree): keep aux-panel tree in sync with filesystem changes Deep directories (beyond the workspace snapshot's depth limit) relied on a lazy-loaded override cache that was never invalidated, so agent-created files, in-app deletes / renames / rollbacks, and external changes inside expanded deep folders stayed invisible until the folder was reopened. Propagate watcher `changed_paths` through the delta envelope and fire a Meta event whenever FS activity doesn't alter the tree/git snapshots, so the frontend can surgically invalidate affected cache entries and re-fetch. Manual refresh (Reload from disk) clears the cache and re-hydrates still-expanded deep dirs through the same path. Replayed deltas after reconnect are forwarded to the same listeners. Also split the combined workspace-state effect into tree / git / status slices so unrelated state transitions (e.g. the 'resyncing' flip during a refresh) no longer rebuild the entire node tree and cause a flash. --- src-tauri/src/workspace_state/mod.rs | 26 +++- .../layout/aux-panel-file-tree-tab.tsx | 143 ++++++++++++++++-- src/hooks/use-workspace-state-store.ts | 64 ++++++++ src/lib/types.ts | 2 + 4 files changed, 220 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/workspace_state/mod.rs b/src-tauri/src/workspace_state/mod.rs index b6ce74b..815cbc2 100644 --- a/src-tauri/src/workspace_state/mod.rs +++ b/src-tauri/src/workspace_state/mod.rs @@ -48,6 +48,8 @@ pub struct WorkspaceDeltaEnvelope { pub kind: String, pub payload: Vec, pub requires_resync: bool, + #[serde(default)] + pub changed_paths: Vec, } #[derive(Debug, Clone, Serialize)] @@ -58,6 +60,8 @@ pub struct WorkspaceStateEvent { pub kind: String, pub payload: Vec, pub requires_resync: bool, + #[serde(default)] + pub changed_paths: Vec, } #[derive(Debug, Clone, Serialize)] @@ -108,6 +112,7 @@ impl WorkspaceStateCore { kind: String, payload: Vec, requires_resync: bool, + changed_paths: Vec, ) -> WorkspaceStateEvent { self.seq += 1; @@ -120,6 +125,7 @@ impl WorkspaceStateCore { kind: kind.clone(), payload: payload.clone(), requires_resync, + changed_paths: changed_paths.clone(), }; self.push_recent_event(envelope); @@ -130,6 +136,7 @@ impl WorkspaceStateCore { kind, payload, requires_resync, + changed_paths, } } @@ -713,6 +720,17 @@ async fn flush_watch_batch( }); } + // Surface FS activity that doesn't otherwise change tree/git snapshots + // (e.g. files added/removed in a directory beyond WORKSPACE_TREE_MAX_DEPTH, + // or gitignored / non-git-repo changes). The envelope's `changed_paths` + // lets the frontend invalidate its lazy-loaded overrides for deep + // directories without waiting for a manual reload. + if payload.is_empty() && !changed_paths.is_empty() { + payload.push(WorkspaceDelta::Meta { + reason: "fs_events".to_string(), + }); + } + if payload.is_empty() { return; } @@ -731,7 +749,7 @@ async fn flush_watch_batch( "meta".to_string() }; - guard.append_event(kind, payload, git_presence_changed) + guard.append_event(kind, payload, git_presence_changed, changed_paths) }; emit_event(emitter, "folder://workspace-state-event", event); @@ -1072,6 +1090,7 @@ mod tests { reason: "boot".to_string(), }], false, + Vec::new(), ); let e2 = core.append_event( @@ -1080,6 +1099,7 @@ mod tests { reason: "tick".to_string(), }], false, + Vec::new(), ); assert!(e2.seq > e1.seq); @@ -1095,6 +1115,7 @@ mod tests { reason: "a".to_string(), }], false, + Vec::new(), ); core.append_event( @@ -1103,6 +1124,7 @@ mod tests { reason: "b".to_string(), }], false, + Vec::new(), ); let snapshot = core.snapshot(Some(e1.seq)); @@ -1123,6 +1145,7 @@ mod tests { reason: "a".to_string(), }], false, + Vec::new(), ); core.append_event( "meta".to_string(), @@ -1130,6 +1153,7 @@ mod tests { reason: "b".to_string(), }], false, + Vec::new(), ); let snapshot = core.snapshot(Some(0)); diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index a35ed1e..beb5b1f 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -888,6 +888,11 @@ export function FileTreeTab() { new Map() ) const lazyLoadingDirPathsRef = useRef>(new Set()) + const loadDirectoryChildrenRef = useRef< + ((dirPath: string) => Promise) | null + >(null) + const expandedPathsRef = useRef>(new Set([FILE_TREE_ROOT_PATH])) + const workspaceTreeRef = useRef([]) const externalConflictSignatureByPathRef = useRef>( new Map() ) @@ -967,36 +972,58 @@ export function FileTreeTab() { return } + // Drop the lazy-load override cache so the fresh snapshot is not + // masked by stale children (e.g. after deletes / renames / rollbacks + // or files the agent just created). Reading expanded paths via a ref + // keeps fetchTree's identity stable across expand/collapse so + // downstream memoization is not invalidated on every tree interaction. + const pathsToReload = Array.from(expandedPathsRef.current).filter( + (path) => path !== FILE_TREE_ROOT_PATH + ) + lazyLoadedChildrenByPathRef.current.clear() await workspaceState.requestResync("manual_refresh") + // Re-hydrate children for directories beyond WORKSPACE_TREE_MAX_DEPTH + // that are still expanded — the backend snapshot does not include them. + const loader = loadDirectoryChildrenRef.current + if (loader) { + for (const path of pathsToReload) { + void loader(path) + } + } }, [folder?.path, workspaceState] ) + // Tree updates are the only source that should cause a full setNodes. + // applyLazyTreeOverrides rebuilds every directory node object, which forces + // React to re-render the entire tree. Keeping this effect narrow avoids + // wasted work on health / seq / error / git transitions that don't touch + // the tree shape (e.g. the intermediate "resyncing" patch during a refresh). useEffect(() => { + workspaceTreeRef.current = workspaceState.tree setNodes( applyLazyTreeOverrides( workspaceState.tree, lazyLoadedChildrenByPathRef.current ) ) + }, [folder?.path, workspaceState.tree]) + + useEffect(() => { const nextStatusByPath = new Map() for (const entry of workspaceState.git) { nextStatusByPath.set(entry.path, entry.status) } setGitStatusByPath(nextStatusByPath) setGitEnabled(true) + }, [workspaceState.git]) + + useEffect(() => { setLoading( workspaceState.health === "resyncing" && workspaceState.seq === 0 ) setError(workspaceState.health === "degraded" ? workspaceState.error : null) - }, [ - folder?.path, - workspaceState.error, - workspaceState.git, - workspaceState.health, - workspaceState.seq, - workspaceState.tree, - ]) + }, [workspaceState.error, workspaceState.health, workspaceState.seq]) const loadDirectoryChildren = useCallback( async (dirPath: string) => { @@ -1007,12 +1034,19 @@ export function FileTreeTab() { if (lazyLoadedChildrenByPathRef.current.has(normalizedDirPath)) return if (lazyLoadingDirPathsRef.current.has(normalizedDirPath)) return - const existingChildren = findDirectoryChildren(nodes, normalizedDirPath) + // Check the backend tree (source of truth), not the rendered `nodes`. + // `nodes` carries stale lazy-cache overrides that don't invalidate + // until a tree_replace delta arrives — but for directories beyond + // WORKSPACE_TREE_MAX_DEPTH the backend never emits tree_replace for + // changes inside them (their children are not in tree_snapshot, so + // the refreshed tree compares equal to the old one). Checking + // `nodes` would cause fetchTree's forced reload to short-circuit on + // the stale override and miss deletions / creations in deep dirs. + const existingChildren = findDirectoryChildren( + workspaceTreeRef.current, + normalizedDirPath + ) if (existingChildren && existingChildren.length > 0) { - lazyLoadedChildrenByPathRef.current.set( - normalizedDirPath, - existingChildren - ) return } @@ -1033,9 +1067,90 @@ export function FileTreeTab() { lazyLoadingDirPathsRef.current.delete(normalizedDirPath) } }, - [folder?.path, nodes] + [folder?.path] ) + useEffect(() => { + loadDirectoryChildrenRef.current = loadDirectoryChildren + }, [loadDirectoryChildren]) + + useEffect(() => { + expandedPathsRef.current = expandedPaths + }, [expandedPaths]) + + // Subscribe to workspace envelopes to invalidate lazy-loaded overrides for + // directories beyond WORKSPACE_TREE_MAX_DEPTH. Those directories are never + // reflected in the backend's depth-2 tree_snapshot, so changes inside them + // don't emit a tree_replace delta — the frontend has to target invalidation + // by matching each `changed_paths` entry against its cached ancestors. + // The backend already debounces raw FS events (1s / 3s max), so we only + // need a microtask hop here to merge paths that hit the same cached + // ancestor within one envelope (or any synchronous burst of envelopes). + const subscribeWorkspaceEnvelopes = workspaceState.subscribeEnvelopes + useEffect(() => { + if (!subscribeWorkspaceEnvelopes) return + + const pendingPaths = new Set() + let flushScheduled = false + let disposed = false + + const flushPending = () => { + flushScheduled = false + if (disposed || pendingPaths.size === 0) return + const paths = Array.from(pendingPaths) + pendingPaths.clear() + + const loader = loadDirectoryChildrenRef.current + const cache = lazyLoadedChildrenByPathRef.current + const invalidated = new Set() + + for (const changed of paths) { + const normalized = normalizeComparePath(changed) + if (!normalized) continue + // Walk from the changed path up to the root, invalidating the + // nearest cached ancestor (or the path itself if a directory of + // that name is in the cache). Checking the path itself covers + // FS events that report the directory path directly. + let cursor = normalized + while (cursor.length > 0) { + if (cache.has(cursor)) { + invalidated.add(cursor) + break + } + const slash = cursor.lastIndexOf("/") + const parent = slash === -1 ? "" : cursor.slice(0, slash) + if (parent.length === 0) break + cursor = parent + } + } + + if (invalidated.size === 0) return + for (const path of invalidated) { + cache.delete(path) + } + if (!loader) return + for (const path of invalidated) { + void loader(path) + } + } + + const unsubscribe = subscribeWorkspaceEnvelopes(({ changed_paths }) => { + if (!changed_paths || changed_paths.length === 0) return + for (const path of changed_paths) { + pendingPaths.add(path) + } + if (flushScheduled) return + flushScheduled = true + queueMicrotask(flushPending) + }) + + return () => { + disposed = true + unsubscribe() + pendingPaths.clear() + } + }, [subscribeWorkspaceEnvelopes]) + useEffect(() => { const previousExpanded = previousExpandedPathsRef.current for (const path of expandedPaths) { diff --git a/src/hooks/use-workspace-state-store.ts b/src/hooks/use-workspace-state-store.ts index 718293a..156e096 100644 --- a/src/hooks/use-workspace-state-store.ts +++ b/src/hooks/use-workspace-state-store.ts @@ -30,9 +30,16 @@ export interface WorkspaceStateView { isGitRepo: boolean } +export type WorkspaceEnvelopeListener = (envelope: { + seq: number + kind: string + changed_paths: string[] +}) => void + export interface WorkspaceStateResult extends WorkspaceStateView { requestResync: (reason?: string) => Promise restart: () => Promise + subscribeEnvelopes: (listener: WorkspaceEnvelopeListener) => () => void } const WORKSPACE_PROTOCOL_VERSION = 1 @@ -140,6 +147,7 @@ class WorkspaceStateStore { private readonly rootPath: string private readonly normalizedRootPath: string private listeners = new Set<() => void>() + private envelopeListeners = new Set() private state: WorkspaceStateView private refCount = 0 private started = false @@ -171,6 +179,13 @@ class WorkspaceStateStore { } } + subscribeEnvelopes = (listener: WorkspaceEnvelopeListener): (() => void) => { + this.envelopeListeners.add(listener) + return () => { + this.envelopeListeners.delete(listener) + } + } + acquire = () => { this.cancelPendingShutdown() this.cancelEviction() @@ -212,6 +227,16 @@ class WorkspaceStateStore { this.patchState((prev) => applySnapshot(prev, snapshot)) if (snapshot.full) { this.hasBaselineSnapshot = true + } else { + // Forward replayed envelopes so downstream cache-invalidation + // hooks catch up on FS activity that happened while disconnected. + for (const envelope of snapshot.deltas) { + this.notifyEnvelope({ + seq: envelope.seq, + kind: envelope.kind, + changed_paths: envelope.changed_paths ?? [], + }) + } } } catch (error) { this.patchState((prev) => ({ @@ -315,6 +340,15 @@ class WorkspaceStateStore { ) if (!this.isLifecycleActive(lifecycleId)) return this.patchState((prev) => applySnapshot(prev, catchUpSnapshot)) + if (!catchUpSnapshot.full) { + for (const envelope of catchUpSnapshot.deltas) { + this.notifyEnvelope({ + seq: envelope.seq, + kind: envelope.kind, + changed_paths: envelope.changed_paths ?? [], + }) + } + } } catch (error) { this.patchState((prev) => ({ ...prev, @@ -418,6 +452,26 @@ class WorkspaceStateStore { health: "healthy", error: null, })) + + this.notifyEnvelope({ + seq: event.seq, + kind: event.kind, + changed_paths: event.changed_paths ?? [], + }) + } + + private notifyEnvelope = (envelope: { + seq: number + kind: string + changed_paths: string[] + }) => { + for (const listener of this.envelopeListeners) { + try { + listener(envelope) + } catch (error) { + console.error("[workspace-state] envelope listener failed", error) + } + } } private patchState = ( @@ -509,11 +563,20 @@ export function useWorkspaceStateStore( await store.restart() }, [store]) + const subscribeEnvelopes = useCallback( + (listener: WorkspaceEnvelopeListener) => { + if (!store) return () => {} + return store.subscribeEnvelopes(listener) + }, + [store] + ) + if (!rootPath) { return { ...EMPTY_STATE, requestResync, restart, + subscribeEnvelopes, } } @@ -521,5 +584,6 @@ export function useWorkspaceStateStore( ...snapshot, requestResync, restart, + subscribeEnvelopes, } } diff --git a/src/lib/types.ts b/src/lib/types.ts index c127533..4f78fc0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -879,6 +879,7 @@ export interface WorkspaceDeltaEnvelope { kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string payload: WorkspaceDelta[] requires_resync: boolean + changed_paths?: string[] } export interface WorkspaceStateEvent { @@ -888,6 +889,7 @@ export interface WorkspaceStateEvent { kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string payload: WorkspaceDelta[] requires_resync: boolean + changed_paths?: string[] } export interface WorkspaceSnapshotResponse { From 692b700c0da4e68ca0b87b59484499f3f79b4d2b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 20 Apr 2026 23:20:08 +0800 Subject: [PATCH 3/5] fix(file-tree): invalidate both cached target and ancestor, skip collapsed refetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a deep directory was renamed or deleted externally and both the directory itself and a parent directory were cached, only the self entry was cleared — leaving the parent's children list holding a ghost reference to the old child. Walk up to the nearest cached ancestor in addition to the direct hit so both stale entries are dropped together. Also gate the follow-up getFileTree refetch on the directory still being expanded. Collapsed branches only need their cache cleared; they will re-hydrate naturally on the next expansion, which avoids unnecessary IPC traffic during FS event bursts. --- .../layout/aux-panel-file-tree-tab.tsx | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index beb5b1f..be481cf 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -1107,19 +1107,26 @@ export function FileTreeTab() { for (const changed of paths) { const normalized = normalizeComparePath(changed) if (!normalized) continue - // Walk from the changed path up to the root, invalidating the - // nearest cached ancestor (or the path itself if a directory of - // that name is in the cache). Checking the path itself covers - // FS events that report the directory path directly. + // When the changed path is itself a cached directory (FS events + // that report the directory directly, e.g. a rename or a dir-level + // notification), its own entry is stale — invalidate it. + if (cache.has(normalized)) { + invalidated.add(normalized) + } + // Independently of the above, walk up to the nearest cached + // ancestor: the ancestor's children listing may also be stale + // (a child was added, removed, or renamed). Without this, cases + // where both a parent and child are cached leave the parent + // holding a ghost reference to the old child. let cursor = normalized while (cursor.length > 0) { - if (cache.has(cursor)) { - invalidated.add(cursor) - break - } const slash = cursor.lastIndexOf("/") const parent = slash === -1 ? "" : cursor.slice(0, slash) if (parent.length === 0) break + if (cache.has(parent)) { + invalidated.add(parent) + break + } cursor = parent } } @@ -1129,7 +1136,13 @@ export function FileTreeTab() { cache.delete(path) } if (!loader) return + // Skip refetching directories that are no longer expanded — their + // cleared cache will be re-hydrated on the next expansion via the + // expandedPaths effect. This avoids spurious getFileTree traffic + // for collapsed branches under bursty FS activity. + const expanded = expandedPathsRef.current for (const path of invalidated) { + if (!expanded.has(path)) continue void loader(path) } } From 98792a696c91d45f1d61d67a6817c2e880c0dd30 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 21 Apr 2026 00:26:00 +0800 Subject: [PATCH 4/5] feat(message-input): add search box to slash-command popups Both the inline autocomplete (triggered by `/` in the textarea) and the dropdown popup (triggered by the slash-command button) now show a search field at the top. Matching uses substring on name and description, and ranks name matches above description/id-only matches. --- src/components/chat/message-input.tsx | 337 ++++++++++++++++++++------ src/i18n/messages/ar.json | 4 +- src/i18n/messages/de.json | 4 +- src/i18n/messages/en.json | 4 +- src/i18n/messages/es.json | 4 +- src/i18n/messages/fr.json | 4 +- src/i18n/messages/ja.json | 4 +- src/i18n/messages/ko.json | 4 +- src/i18n/messages/pt.json | 4 +- src/i18n/messages/zh-CN.json | 4 +- src/i18n/messages/zh-TW.json | 4 +- 11 files changed, 293 insertions(+), 84 deletions(-) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index f15763a..5fba2b7 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -20,6 +20,7 @@ import { GitFork, ListPlus, Plus, + Search, Send, Command, Sparkles, @@ -543,19 +544,58 @@ export function MessageInput({ () => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)), [availableCommands, expertIdSet] ) + const [slashDropdownOpen, setSlashDropdownOpen] = useState(false) + const [slashDropdownSearch, setSlashDropdownSearch] = useState("") + const slashDropdownInputRef = useRef(null) + const filteredSlashDropdownCommands = useMemo(() => { + const q = slashDropdownSearch.toLowerCase().trim() + if (!q) return slashCommands + const nameMatches: typeof slashCommands = [] + const descOnlyMatches: typeof slashCommands = [] + for (const cmd of slashCommands) { + if (cmd.name.toLowerCase().includes(q)) { + nameMatches.push(cmd) + } else if (cmd.description?.toLowerCase().includes(q)) { + descOnlyMatches.push(cmd) + } + } + return [...nameMatches, ...descOnlyMatches] + }, [slashCommands, slashDropdownSearch]) + const handleSlashDropdownOpenChange = useCallback((open: boolean) => { + setSlashDropdownOpen(open) + if (!open) setSlashDropdownSearch("") + }, []) + // Radix composes this handler with its own content-focus via + // composeEventHandlers (default `checkForDefaultPrevented: true`), so + // calling preventDefault here skips radix's autofocus entirely. The prop + // is accepted at runtime but omitted from DropdownMenuContent's public + // TypeScript surface, so it has to be passed through an untyped spread. + const slashDropdownFocusProps = useMemo( + () => ({ + onOpenAutoFocus: (event: Event) => { + event.preventDefault() + slashDropdownInputRef.current?.focus() + }, + }), + [] + ) + const slashFilterText = useMemo(() => { + if (!slashMenuOpen || slashTriggerPos == null) return "" + const trigger = text[slashTriggerPos] + if (trigger !== "/" && trigger !== "$") return "" + const afterTrigger = text.slice(slashTriggerPos + 1) + const endIdx = afterTrigger.search(/\s/) + return endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx) + }, [slashMenuOpen, text, slashTriggerPos]) const filteredSlashCommands = useMemo(() => { if (!slashMenuOpen || slashCommands.length === 0 || slashTriggerPos == null) return [] if (text[slashTriggerPos] !== "/") return [] - const afterTrigger = text.slice(slashTriggerPos + 1) - const endIdx = afterTrigger.search(/\s/) - const filter = ( - endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx) - ).toLowerCase() + const filter = slashFilterText.toLowerCase() return slashCommands.filter((cmd) => - cmd.name.toLowerCase().startsWith(filter) + cmd.name.toLowerCase().includes(filter) ) - }, [slashMenuOpen, slashCommands, text, slashTriggerPos]) + }, [slashMenuOpen, slashCommands, text, slashTriggerPos, slashFilterText]) const filteredSlashSkills = useMemo(() => { // Skills autocomplete is Codex-only and triggered by `$`. if (agentType !== "codex") return [] @@ -566,15 +606,26 @@ export function MessageInput({ ) return [] if (text[slashTriggerPos] !== "$") return [] - const afterTrigger = text.slice(slashTriggerPos + 1) - const endIdx = afterTrigger.search(/\s/) - const filter = ( - endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx) - ).toLowerCase() - return nonExpertSkills.filter((skill) => - skill.id.toLowerCase().startsWith(filter) - ) - }, [slashMenuOpen, nonExpertSkills, text, agentType, slashTriggerPos]) + const filter = slashFilterText.toLowerCase() + if (!filter) return nonExpertSkills + const nameMatches: typeof nonExpertSkills = [] + const idOnlyMatches: typeof nonExpertSkills = [] + for (const skill of nonExpertSkills) { + if (skill.name.toLowerCase().includes(filter)) { + nameMatches.push(skill) + } else if (skill.id.toLowerCase().includes(filter)) { + idOnlyMatches.push(skill) + } + } + return [...nameMatches, ...idOnlyMatches] + }, [ + slashMenuOpen, + nonExpertSkills, + text, + agentType, + slashTriggerPos, + slashFilterText, + ]) const slashAutocompleteCount = filteredSlashCommands.length + filteredSlashSkills.length @@ -950,6 +1001,25 @@ export function MessageInput({ [onModeChange] ) + const handleSlashSearchChange = useCallback( + (e: React.ChangeEvent) => { + const pos = slashTriggerPosRef.current + const current = textRef.current + if (pos == null || pos < 0 || pos >= current.length) return + const trigger = current[pos] + if (trigger !== "/" && trigger !== "$") return + const afterTrigger = current.slice(pos + 1) + const endIdx = afterTrigger.search(/\s/) + const tokenEnd = endIdx === -1 ? current.length : pos + 1 + endIdx + const before = current.slice(0, pos + 1) + const rest = current.slice(tokenEnd) + const sanitized = e.target.value.replace(/\s+/g, "") + setText(before + sanitized + rest) + setSlashSelectedIndex(0) + }, + [] + ) + const handleSlashSelect = useCallback((cmd: AvailableCommandInfo) => { const pos = slashTriggerPosRef.current const current = textRef.current @@ -1046,6 +1116,49 @@ export function MessageInput({ [expertPrefix] ) + const handleSlashSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const total = filteredSlashCommands.length + filteredSlashSkills.length + if (e.key === "ArrowDown") { + e.preventDefault() + if (total === 0) return + setSlashSelectedIndex((i) => (i < total - 1 ? i + 1 : 0)) + return + } + if (e.key === "ArrowUp") { + e.preventDefault() + if (total === 0) return + setSlashSelectedIndex((i) => (i > 0 ? i - 1 : total - 1)) + return + } + if (e.key === "Enter" || e.key === "Tab") { + if (total === 0) return + e.preventDefault() + if (slashSelectedIndex < filteredSlashCommands.length) { + handleSlashSelect(filteredSlashCommands[slashSelectedIndex]) + } else { + const skillIndex = slashSelectedIndex - filteredSlashCommands.length + const skill = filteredSlashSkills[skillIndex] + if (skill) handleSkillAutocompleteSelect(skill) + } + return + } + if (e.key === "Escape") { + e.preventDefault() + setSlashMenuOpen(false) + setSlashTriggerPos(null) + requestAnimationFrame(() => textareaRef.current?.focus()) + } + }, + [ + filteredSlashCommands, + filteredSlashSkills, + slashSelectedIndex, + handleSlashSelect, + handleSkillAutocompleteSelect, + ] + ) + // Experts always inject `prefix + expert-id ` at the very front of the // input, never at the cursor. The expert skill is a whole-turn directive // that the agent inspects first, so prepending keeps semantics unambiguous @@ -1719,63 +1832,77 @@ export function MessageInput({ onDrop={handleContainerDrop} > {slashMenuOpen && slashAutocompleteCount > 0 && ( -
- {filteredSlashCommands.map((cmd, i) => ( - - ))} - {filteredSlashSkills.map((skill, i) => { - const absoluteIndex = filteredSlashCommands.length + i - return ( +
+
+ + +
+
+ {filteredSlashCommands.map((cmd, i) => ( - ) - })} + ))} + {filteredSlashSkills.map((skill, i) => { + const absoluteIndex = filteredSlashCommands.length + i + return ( + + ) + })} +
)} {atMenuOpen && filteredAtFiles.length > 0 && ( @@ -1946,7 +2073,10 @@ export function MessageInput({ )} - +