diff --git a/package.json b/package.json index 50b8080..c7469f8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeg", "private": true, - "version": "0.9.4", + "version": "0.9.5", "scripts": { "dev": "next dev --turbopack", "build": "next build", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 27b3d5b..86dba71 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -853,7 +853,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "codeg" -version = "0.9.4" +version = "0.9.5" dependencies = [ "agent-client-protocol-schema", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b8b1d8d..ba7fe33 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeg" -version = "0.9.4" +version = "0.9.5" description = "Agent Code Generation App" authors = ["feitao"] edition = "2021" 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", }, ], }, 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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6c2b6cb..1d5d466 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "codeg", - "version": "0.9.4", + "version": "0.9.5", "identifier": "app.codeg", "build": { "beforeDevCommand": "pnpm dev", 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({ )} - +