diff --git a/assets/icons/agents/qwen.svg b/assets/icons/agents/qwen.svg new file mode 100644 index 00000000..efb2e4f3 --- /dev/null +++ b/assets/icons/agents/qwen.svg @@ -0,0 +1,6 @@ + + + diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 93f3837a..545fe1c2 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -43,9 +43,12 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str { } fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> { - if matches!(agent, "copilot" | "grok") && event == "notification" { + if matches!(agent, "copilot" | "grok" | "droid" | "gemini") && event == "notification" { let blocks = stdin_json.contains("elicitation_dialog") - || (agent == "copilot" && stdin_json.contains("permission_prompt")); + || (matches!(agent, "copilot" | "droid") && stdin_json.contains("permission_prompt")) + // Gemini's only notification kind so far, but naming it keeps a + // future non-blocking one from being read as a block. + || (agent == "gemini" && stdin_json.contains("ToolPermission")); return blocks.then_some("permission-request"); } Some(event) @@ -63,6 +66,8 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { ("session_id", "sessionId"), ("message", "message"), ("cwd", "cwd"), + // Goose spells the working directory its own way. + ("cwd", "working_dir"), ] { if let Some(v) = payload .get(key) @@ -214,10 +219,14 @@ pub enum HookAgent { Pi, Grok, OhMyPi, + Gemini, + Droid, + Qwen, + Goose, } impl HookAgent { - pub const ALL: [HookAgent; 7] = [ + pub const ALL: [HookAgent; 11] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::Copilot, @@ -225,6 +234,10 @@ impl HookAgent { HookAgent::Pi, HookAgent::Grok, HookAgent::OhMyPi, + HookAgent::Gemini, + HookAgent::Droid, + HookAgent::Qwen, + HookAgent::Goose, ]; /// The hooks behind a detected agent process, if it has any. @@ -241,17 +254,36 @@ impl HookAgent { CLIAgent::Pi => Some(HookAgent::Pi), CLIAgent::Grok => Some(HookAgent::Grok), CLIAgent::OhMyPi => Some(HookAgent::OhMyPi), - CLIAgent::Gemini - | CLIAgent::Aider + CLIAgent::Gemini => Some(HookAgent::Gemini), + CLIAgent::Droid => Some(HookAgent::Droid), + CLIAgent::Qwen => Some(HookAgent::Qwen), + CLIAgent::Goose => Some(HookAgent::Goose), + CLIAgent::Aider | CLIAgent::Amp | CLIAgent::Cursor - | CLIAgent::Goose - | CLIAgent::Droid | CLIAgent::Auggie | CLIAgent::Hermes | CLIAgent::Vibe - | CLIAgent::Antigravity - | CLIAgent::Qwen => None, + | CLIAgent::Antigravity => None, + } + } + + /// The events this agent's hooks merge into a shared JSON config, if that + /// is how it takes them. `None` means the agent owns a generated file + /// instead — see [`owned_file_content`]. + fn hook_map_events(self) -> Option<&'static [(&'static str, &'static str)]> { + match self { + HookAgent::Claude => Some(CLAUDE_HOOK_EVENTS), + HookAgent::Codex => Some(CODEX_HOOK_EVENTS), + HookAgent::Gemini => Some(GEMINI_HOOK_EVENTS), + HookAgent::Droid => Some(DROID_HOOK_EVENTS), + HookAgent::Qwen => Some(QWEN_HOOK_EVENTS), + HookAgent::Copilot + | HookAgent::OpenCode + | HookAgent::Pi + | HookAgent::Grok + | HookAgent::OhMyPi + | HookAgent::Goose => None, } } @@ -264,6 +296,10 @@ impl HookAgent { HookAgent::Pi => "pi", HookAgent::Grok => "grok", HookAgent::OhMyPi => "omp", + HookAgent::Gemini => "gemini", + HookAgent::Droid => "droid", + HookAgent::Qwen => "qwen", + HookAgent::Goose => "goose", } } @@ -276,6 +312,10 @@ impl HookAgent { HookAgent::Pi => "Pi", HookAgent::Grok => "Grok Build", HookAgent::OhMyPi => "Oh My Pi", + HookAgent::Gemini => "Gemini", + HookAgent::Droid => "Droid", + HookAgent::Qwen => "Qwen Code", + HookAgent::Goose => "Goose", } } @@ -297,6 +337,15 @@ impl HookAgent { HookAgent::OhMyPi => { target.under_home(&[".omp", "agent", "extensions", "tty7", "index.ts"]) } + HookAgent::Gemini => target.under_home(&[".gemini", "settings.json"]), + HookAgent::Droid => target.under_home(&[".factory", "settings.json"]), + HookAgent::Qwen => target.under_home(&[".qwen", "settings.json"]), + // The Open Plugins layout, which Goose implements rather than + // inventing its own: any `.agents/plugins//hooks/hooks.json` + // is picked up at startup. + HookAgent::Goose => { + target.under_home(&[".agents", "plugins", "tty7", "hooks", "hooks.json"]) + } } } @@ -449,20 +498,13 @@ pub enum HooksState { pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState { let path = agent.target_path(target); - match agent { - HookAgent::Claude => hook_map_state(target, &path, agent, CLAUDE_HOOK_EVENTS), - HookAgent::Codex => hook_map_state(target, &path, agent, CODEX_HOOK_EVENTS), - HookAgent::Copilot - | HookAgent::OpenCode - | HookAgent::Pi - | HookAgent::Grok - | HookAgent::OhMyPi => { - let Some(expected) = owned_file_content(target, agent) else { - return HooksState::NotInstalled; - }; - owned_file_state(target, &path, &expected, &agent.marker()) - } + if let Some(events) = agent.hook_map_events() { + return hook_map_state(target, &path, agent, events); } + let Some(expected) = owned_file_content(target, agent) else { + return HooksState::NotInstalled; + }; + owned_file_state(target, &path, &expected, &agent.marker()) } /// What an install or uninstall actually did. @@ -489,43 +531,30 @@ pub enum HookOutcome { pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { let path = agent.target_path(target); - match agent { - HookAgent::Claude => { - hook_map_install(target, &path, agent, CLAUDE_HOOK_EVENTS)?; - Ok(HookOutcome::Installed) + if let Some(events) = agent.hook_map_events() { + hook_map_install(target, &path, agent, events)?; + if agent != HookAgent::Codex { + return Ok(HookOutcome::Installed); } - HookAgent::Codex => { - hook_map_install(target, &path, agent, CODEX_HOOK_EVENTS)?; - if !target.is_local() { - return Ok(HookOutcome::InstalledEnableCodexThere); - } - Ok(match enable_codex_hooks_feature() { - Ok(()) => HookOutcome::Installed, - Err(e) => HookOutcome::InstalledCodexEnableFailed(e.to_string()), - }) - } - HookAgent::Copilot - | HookAgent::OpenCode - | HookAgent::Pi - | HookAgent::Grok - | HookAgent::OhMyPi => { - let content = owned_file_content(target, agent) - .ok_or_else(|| anyhow::anyhow!("{agent:?} has no owned file"))?; - owned_file_install(target, &path, &content, &agent.marker())?; - Ok(HookOutcome::Installed) + if !target.is_local() { + return Ok(HookOutcome::InstalledEnableCodexThere); } + return Ok(match enable_codex_hooks_feature() { + Ok(()) => HookOutcome::Installed, + Err(e) => HookOutcome::InstalledCodexEnableFailed(e.to_string()), + }); } + let content = owned_file_content(target, agent) + .ok_or_else(|| anyhow::anyhow!("{agent:?} has no owned file"))?; + owned_file_install(target, &path, &content, &agent.marker())?; + Ok(HookOutcome::Installed) } pub fn uninstall_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { let path = agent.target_path(target); - match agent { - HookAgent::Claude | HookAgent::Codex => hook_map_uninstall(target, &path, agent), - HookAgent::Copilot - | HookAgent::OpenCode - | HookAgent::Pi - | HookAgent::Grok - | HookAgent::OhMyPi => owned_file_uninstall(target, &path, &agent.marker()), + match agent.hook_map_events() { + Some(_) => hook_map_uninstall(target, &path, agent), + None => owned_file_uninstall(target, &path, &agent.marker()), } } @@ -600,6 +629,40 @@ const CODEX_HOOK_EVENTS: &[(&str, &str)] = &[ ("Stop", "stop"), ]; +/// Gemini names the turn boundaries after the agent rather than the user, and +/// omitting `matcher` matches everything (`hookPlanner.ts`, `!entry.matcher`), +/// so the bare entries [`hook_map_install`] already writes are enough. +const GEMINI_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("BeforeAgent", "prompt-submit"), + ("Notification", "notification"), + ("AfterTool", "tool-complete"), + ("AfterAgent", "stop"), + ("SessionEnd", "session-end"), +]; + +const DROID_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("Notification", "notification"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("SessionEnd", "session-end"), +]; + +/// Qwen is the only agent here with a first-class permission event, so it needs +/// none of the notification sniffing in [`effective_event`] — and it gets no +/// `Notification` hook at all, which would only muddy a status the dedicated +/// event already reports precisely. +const QWEN_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("PermissionRequest", "permission-request"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("SessionEnd", "session-end"), +]; + const GROK_HOOK_TIMEOUT_SECS: u32 = 10; const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[ @@ -809,7 +872,12 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { HookAgent::OpenCode => opencode_plugin_js(target), HookAgent::Pi | HookAgent::OhMyPi => pi_extension_ts(target, agent), HookAgent::Grok => grok_hooks_json(target), - HookAgent::Claude | HookAgent::Codex => None, + HookAgent::Goose => goose_hooks_json(target), + HookAgent::Claude + | HookAgent::Codex + | HookAgent::Gemini + | HookAgent::Droid + | HookAgent::Qwen => None, } } @@ -862,10 +930,26 @@ fn owned_file_uninstall( )); } target.host.remove(path, false)?; - if let Some(parent) = path.parent() - && parent.file_name().is_some_and(|n| n == "tty7") - { - let _ = target.host.remove(parent, false); + // Take the directories tty7 generated with it, innermost first, stopping at + // the one named after tty7. `remove` is not recursive, so a directory still + // holding someone else's file simply survives the attempt. Goose nests one + // level deeper than the rest (`.../tty7/hooks/hooks.json`), which is why + // this walks rather than checking a single parent. + let mut dir = path.parent(); + while let Some(d) = dir { + if d.file_name().is_some_and(|n| n == "tty7") { + let _ = target.host.remove(d, false); + break; + } + if !d + .parent() + .and_then(|p| p.file_name()) + .is_some_and(|n| n == "tty7") + { + break; + } + let _ = target.host.remove(d, false); + dir = d.parent(); } Ok(HookOutcome::Removed) } @@ -909,6 +993,33 @@ fn grok_hooks_json(target: &HookTarget) -> Option { serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok() } +/// Goose has no permission hook — `PreToolUse` fires on every call, approved or +/// not, so there is nothing here that could report a blocked turn. The four +/// events it does have still carry the pane from idle to working to done. +const GOOSE_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("SessionEnd", "session-end"), +]; + +fn goose_hooks_json(target: &HookTarget) -> Option { + let mut hooks = serde_json::Map::new(); + for (event, sentinel) in GOOSE_HOOK_EVENTS { + hooks.insert( + (*event).to_string(), + serde_json::json!([{ + "hooks": [{ + "type": "command", + "command": target.hook_command(HookAgent::Goose, sentinel), + }] + }]), + ); + } + serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok() +} + fn opencode_plugin_js(target: &HookTarget) -> Option { let prefix = serde_json::to_string(&format!( "{} ", @@ -1121,6 +1232,10 @@ mod tests { let mut events: Vec<&str> = CLAUDE_HOOK_EVENTS .iter() .chain(CODEX_HOOK_EVENTS) + .chain(GEMINI_HOOK_EVENTS) + .chain(DROID_HOOK_EVENTS) + .chain(QWEN_HOOK_EVENTS) + .chain(GOOSE_HOOK_EVENTS) .map(|(_, e)| *e) .chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e)) .collect(); @@ -1140,6 +1255,139 @@ mod tests { } } + #[test] + fn the_new_hook_agents_target_the_paths_their_clis_read() { + let host = FakeRemote::shared(); + let t = HookTarget::remote(&*host, PathBuf::from("/home/me")); + + for (agent, want) in [ + (HookAgent::Gemini, "/home/me/.gemini/settings.json"), + (HookAgent::Droid, "/home/me/.factory/settings.json"), + (HookAgent::Qwen, "/home/me/.qwen/settings.json"), + ( + HookAgent::Goose, + "/home/me/.agents/plugins/tty7/hooks/hooks.json", + ), + ] { + assert_eq!( + agent.target_path(&t), + PathBuf::from(want), + "{} writes somewhere its CLI does not read", + agent.slug() + ); + } + + let dir = std::env::temp_dir().join(format!("tty7-new-hooks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let real = HookTarget::remote(&*host, dir.clone()); + + for agent in [ + HookAgent::Gemini, + HookAgent::Droid, + HookAgent::Qwen, + HookAgent::Goose, + ] { + assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled); + install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug())); + assert_eq!( + hooks_state(&real, agent), + HooksState::Installed, + "{} does not read back what it wrote", + agent.slug() + ); + let written = std::fs::read_to_string(agent.target_path(&real)).unwrap(); + assert!( + written.contains(&format!("agent-hook {}", agent.slug())), + "{} wrote a config without its own emitter", + agent.slug() + ); + uninstall_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug())); + assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled); + } + + let _ = std::fs::remove_dir_all(&dir); + } + + /// Qwen is the one agent that reports a blocked turn outright, so it must + /// not also carry the `Notification` hook the others need — that event fires + /// for non-blocking alerts too and would strand the pane on "waiting". + #[test] + fn qwen_reports_permission_requests_natively() { + assert!( + QWEN_HOOK_EVENTS + .iter() + .any(|(hook, tty7)| *hook == "PermissionRequest" && *tty7 == "permission-request") + ); + assert!( + !QWEN_HOOK_EVENTS + .iter() + .any(|(hook, _)| *hook == "Notification") + ); + assert_eq!( + effective_event("qwen", "permission-request", "{}"), + Some("permission-request") + ); + } + + #[test] + fn gemini_and_droid_notifications_filter_to_permission_requests() { + assert_eq!( + effective_event( + "gemini", + "notification", + r#"{"notification_type":"ToolPermission"}"# + ), + Some("permission-request") + ); + assert_eq!( + effective_event( + "droid", + "notification", + r#"{"notification_type":"permission_prompt"}"# + ), + Some("permission-request") + ); + // A non-blocking alert must not strand the pane on "waiting". + for agent in ["gemini", "droid"] { + assert_eq!( + effective_event( + agent, + "notification", + r#"{"notification_type":"auth_success"}"# + ), + None, + "{agent} reported an idle notification as a block" + ); + } + } + + #[test] + fn uninstalling_goose_takes_its_generated_plugin_dirs_with_it() { + let root = std::env::temp_dir().join(format!("tty7-goose-test-{}", std::process::id())); + let plugins = root.join("plugins"); + let plugin = plugins.join("tty7"); + let hooks_dir = plugin.join("hooks"); + std::fs::create_dir_all(&hooks_dir).unwrap(); + let path = hooks_dir.join("hooks.json"); + + let host = local_host(); + let t = HookTarget::local(&*host).expect("home resolves in tests"); + let content = goose_hooks_json(&t).expect("goose content builds"); + assert!(content.contains("agent-hook goose")); + let marker = "agent-hook goose"; + + owned_file_install(&t, &path, &content, marker).expect("install"); + owned_file_uninstall(&t, &path, marker).expect("uninstall"); + + assert!(!path.exists()); + assert!(!hooks_dir.exists(), "the generated hooks/ dir goes too"); + assert!(!plugin.exists(), "and the tty7 plugin dir above it"); + assert!(plugins.exists(), "but never the shared plugins/ dir"); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn copilot_notifications_filter_to_permission_requests() { assert_eq!( diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 8a2d9161..577dbf28 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -62,7 +62,11 @@ impl CLIAgent { CLIAgent::Auggie => &["auggie"], CLIAgent::Hermes => &["hermes"], CLIAgent::Vibe => &["vibe", "vibe-acp"], - CLIAgent::Antigravity => &["agy", "antigravity"], + // `agy` only. The `antigravity` binary the IDE installs is a + // launcher shim in the shape of VS Code's `code`, not the terminal + // agent — and the name also collides with `python3 -m antigravity`, + // the standard way to trigger Python's own easter egg. + CLIAgent::Antigravity => &["agy"], CLIAgent::Grok => &["grok"], CLIAgent::Qwen => &["qwen", "qwen-code"], // Oh My Pi is a fork of Pi, but it ships one binary of its own and @@ -137,7 +141,16 @@ impl CLIAgent { CLIAgent::Gemini => Some(format!("gemini{flags} --resume {session_id}")), CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id}")), CLIAgent::Amp => Some(format!("amp threads continue {session_id}{flags}")), + CLIAgent::Auggie => Some(format!("auggie{flags} --resume {session_id}")), + CLIAgent::Hermes => Some(format!("hermes chat{flags} --resume {session_id}")), + CLIAgent::Qwen => Some(format!("qwen{flags} --resume {session_id}")), + CLIAgent::Goose => Some(format!( + "goose session{flags} --resume --session-id {session_id}" + )), + CLIAgent::Vibe => Some(format!("vibe{flags} --resume {session_id}")), + CLIAgent::Antigravity => Some(format!("agy{flags} --conversation {session_id}")), CLIAgent::Cursor => Some(format!("cursor-agent{flags} --resume {session_id}")), + CLIAgent::Droid => Some(format!("droid{flags} --resume {session_id}")), CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")), CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")), CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")), @@ -149,6 +162,12 @@ impl CLIAgent { fn opts_out_of_sessions(self, argv: &[String]) -> bool { let ephemeral: &[&str] = match self { CLIAgent::Pi | CLIAgent::OhMyPi => &["--no-session"], + // "Do not save conversation history" — nothing is persisted, so + // there is no session left to resume from. + CLIAgent::Auggie => &["--dont-save-session"], + // "If false, chat history is not saved and --continue/--resume + // will not work" — the yargs negation of `--chat-recording`. + CLIAgent::Qwen => &["--no-chat-recording"], _ => &[], }; argv.iter().any(|t| ephemeral.contains(&t.as_str())) @@ -167,6 +186,16 @@ impl CLIAgent { CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id} --fork-session")), CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id} --fork")), CLIAgent::OhMyPi => Some(format!("omp{flags} --fork {session_id}")), + // Droid forks with a standalone flag rather than resume-plus-a-switch. + CLIAgent::Droid => Some(format!("droid{flags} --fork {session_id}")), + // `fork` is missing from `amp threads --help`, but the subcommand is + // real — `amp threads fork --help` prints its own usage. + CLIAgent::Amp => Some(format!("amp threads fork {session_id}{flags}")), + CLIAgent::Qwen => Some(format!("qwen{flags} --resume {session_id} --fork-session")), + // Goose forks by adding a switch to the same resume invocation. + CLIAgent::Goose => Some(format!( + "goose session{flags} --resume --fork --session-id {session_id}" + )), _ => None, } } @@ -177,7 +206,11 @@ impl CLIAgent { | CLIAgent::Codex | CLIAgent::Grok | CLIAgent::OpenCode - | CLIAgent::OhMyPi => Some("Fork Session"), + | CLIAgent::OhMyPi + | CLIAgent::Droid + | CLIAgent::Amp + | CLIAgent::Qwen + | CLIAgent::Goose => Some("Fork Session"), _ => None, } } @@ -225,6 +258,33 @@ impl CLIAgent { } } + // Agents that reach their session through subcommands leave `stale` + // nothing to drop — `amp threads continue ` names the thread with a + // positional argument, and `goose session --resume` hides the flags one + // level down. Either way the prefix has to come off here, because the + // "a bare token must follow a flag" check below would otherwise reject + // the tail wholesale and take every launch flag down with it. The + // replacement command spells the subcommand out again itself. + let (groups, verbs): (&[&str], &[&str]) = match self { + CLIAgent::Amp => ( + &["threads", "t"], + &["continue", "c", "fork", "f", "handoff", "h"], + ), + CLIAgent::Auggie => (&["session"], &["resume", "continue"]), + CLIAgent::Goose => (&["session", "s"], &[]), + CLIAgent::Hermes => (&["chat"], &[]), + _ => (&[], &[]), + }; + if tail.first().is_some_and(|t| groups.contains(t)) { + tail.remove(0); + if tail.first().is_some_and(|t| verbs.contains(t)) { + tail.remove(0); + if tail.first().is_some_and(|t| !t.starts_with('-')) { + tail.remove(0); + } + } + } + let stale: &[&str] = match self { CLIAgent::Claude => &[ "--resume", @@ -235,8 +295,39 @@ impl CLIAgent { "--from-pr", "--fork-session", ], - CLIAgent::Gemini | CLIAgent::Cursor => &["--resume", "-r"], - CLIAgent::Copilot => &["--resume", "-r", "--continue", "-c"], + // `--session-id` and `--session-file` name a session too, and Gemini + // rejects them outright alongside `--resume`. + CLIAgent::Gemini => &["--resume", "-r", "--session-id", "--session-file"], + CLIAgent::Cursor => &["--resume", "-r", "--continue"], + CLIAgent::Copilot | CLIAgent::Auggie | CLIAgent::Hermes => { + &["--resume", "-r", "--continue", "-c"] + } + // `--session-id` names a *new* session and Qwen rejects it + // alongside `--resume`, so it is as stale as the resume flags. + CLIAgent::Qwen => &[ + "--resume", + "-r", + "--continue", + "-c", + "--fork-session", + "--session-id", + ], + CLIAgent::Droid => &["--resume", "-r", "--fork", "--session-id", "-s"], + // `--session-id`/`--id`, `-n`/`--name` and the legacy `--path` are + // one mutually-exclusive clap group in Goose; any of them surviving + // next to the `--session-id` this command appends is a parse error. + CLIAgent::Goose => &[ + "--resume", + "-r", + "--fork", + "--session-id", + "--id", + "--name", + "-n", + "--path", + ], + CLIAgent::Vibe => &["--resume", "--continue", "-c"], + CLIAgent::Antigravity => &["--conversation", "--continue", "-c"], CLIAgent::OpenCode => &["--session", "-s", "--continue", "-c", "--fork"], CLIAgent::Codex => &["--last"], CLIAgent::Pi => &[ @@ -308,20 +399,20 @@ impl CLIAgent { CLIAgent::Claude => 0xD97757, CLIAgent::Codex => 0x000000, CLIAgent::Gemini => 0x4285F4, - CLIAgent::Aider => 0x14B8A6, + CLIAgent::Aider => 0x14B014, CLIAgent::Amp => 0xF34E3F, CLIAgent::OpenCode => 0x6E56CF, CLIAgent::Copilot => 0x8957E5, CLIAgent::Cursor => 0x9AA0A6, - CLIAgent::Goose => 0x9A8CFF, - CLIAgent::Droid => 0xF59E0B, + CLIAgent::Goose => 0x3ECC5F, + CLIAgent::Droid => 0xEF6F2E, CLIAgent::Pi => 0x0EA5E9, CLIAgent::Auggie => 0x16A34A, CLIAgent::Hermes => 0x8B5CF6, - CLIAgent::Vibe => 0xFF7000, - CLIAgent::Antigravity => 0x2563EB, + CLIAgent::Vibe => 0xFA520F, + CLIAgent::Antigravity => 0x3186FF, CLIAgent::Grok => 0x000000, - CLIAgent::Qwen => 0x7C3AED, + CLIAgent::Qwen => 0x6D44E8, CLIAgent::OhMyPi => 0xF97316, } } @@ -340,12 +431,12 @@ impl CLIAgent { CLIAgent::Grok => "icons/agents/grok.svg", CLIAgent::Pi => "icons/agents/pi.svg", CLIAgent::OhMyPi => "icons/agents/omp.svg", + CLIAgent::Qwen => "icons/agents/qwen.svg", CLIAgent::Aider | CLIAgent::Auggie | CLIAgent::Hermes | CLIAgent::Vibe - | CLIAgent::Antigravity - | CLIAgent::Qwen => "icons/bot.svg", + | CLIAgent::Antigravity => "icons/bot.svg", } } @@ -746,7 +837,7 @@ mod tests { .collect(); assert_eq!( fallback, - ["aider", "auggie", "hermes", "vibe", "antigravity", "qwen"] + ["aider", "auggie", "hermes", "vibe", "antigravity"] ); assert!( !fallback.contains(&"omp"), @@ -1349,14 +1440,36 @@ mod tests { CLIAgent::OpenCode.fork_command("s-1", None).as_deref(), Some("opencode --session s-1 --fork") ); + assert_eq!( + CLIAgent::Droid.fork_command("session-abc", None).as_deref(), + Some("droid --fork session-abc") + ); + assert_eq!( + CLIAgent::Qwen.fork_command("q-1", None).as_deref(), + Some("qwen --resume q-1 --fork-session") + ); + assert_eq!( + CLIAgent::Goose.fork_command("20260213_9", None).as_deref(), + Some("goose session --resume --fork --session-id 20260213_9") + ); + // Undocumented in `amp threads --help`, but `amp threads fork --help` + // prints its own usage, so the subcommand is real. + assert_eq!( + CLIAgent::Amp.fork_command("T-abc", None).as_deref(), + Some("amp threads fork T-abc") + ); + // Cursor and Antigravity fork only from inside a running TUI (`/fork`), + // which is not something a launch command line can reach. for agent in [ CLIAgent::Gemini, CLIAgent::Copilot, CLIAgent::Cursor, - CLIAgent::Amp, CLIAgent::Aider, - CLIAgent::Qwen, + CLIAgent::Auggie, + CLIAgent::Hermes, + CLIAgent::Vibe, + CLIAgent::Antigravity, ] { assert_eq!( agent.fork_command("abc", None), @@ -1468,6 +1581,157 @@ mod tests { ); } + #[test] + fn newly_wired_agents_resume_the_way_their_own_cli_spells_it() { + let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::>(); + + for (agent, id, want) in [ + (CLIAgent::Droid, "session-abc", "droid --resume session-abc"), + (CLIAgent::Qwen, "q-1", "qwen --resume q-1"), + (CLIAgent::Auggie, "a-1", "auggie --resume a-1"), + ( + CLIAgent::Goose, + "20260213_9", + "goose session --resume --session-id 20260213_9", + ), + ( + CLIAgent::Hermes, + "20260812_213234_5de948", + "hermes chat --resume 20260812_213234_5de948", + ), + (CLIAgent::Vibe, "v-1", "vibe --resume v-1"), + ( + CLIAgent::Antigravity, + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "agy --conversation a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ), + ] { + assert_eq!( + agent.resume_command(id, None).as_deref(), + Some(want), + "{} resumes with the wrong command", + agent.slug() + ); + } + + // A subcommand-addressed session leaves nothing in `stale` to strip, so + // the prefix has to be dropped structurally — otherwise the launch flags + // go down with it. + assert_eq!( + CLIAgent::Amp + .resume_command( + "T-2", + Some(&argv(&[ + "amp", + "threads", + "continue", + "T-1", + "--dangerously-allow-all", + ])) + ) + .as_deref(), + Some("amp threads continue T-2 --dangerously-allow-all") + ); + assert_eq!( + CLIAgent::Goose + .resume_command( + "20260213_9", + Some(&argv(&["goose", "session", "--resume", "--name", "old"])) + ) + .as_deref(), + Some("goose session --resume --session-id 20260213_9") + ); + assert_eq!( + CLIAgent::Auggie + .resume_command( + "a-2", + Some(&argv(&["auggie", "session", "resume", "a-1", "--verbose"])) + ) + .as_deref(), + Some("auggie --verbose --resume a-2") + ); + assert_eq!( + CLIAgent::Droid + .resume_command( + "s-2", + Some(&argv(&["droid", "--fork", "s-1", "--auto", "low"])) + ) + .as_deref(), + Some("droid --auto low --resume s-2") + ); + + // `--id` is an alias of `--session-id` and `-n` of `--name`, and the + // three share one exclusive clap group — any of them surviving next to + // the `--session-id` the command appends would fail to parse. + assert_eq!( + CLIAgent::Goose + .resume_command( + "20260213_9", + Some(&argv(&["goose", "s", "--resume", "--id", "20260101_1"])) + ) + .as_deref(), + Some("goose session --resume --session-id 20260213_9") + ); + assert_eq!( + CLIAgent::Goose + .resume_command( + "20260213_9", + Some(&argv(&["goose", "session", "-r", "-n", "old"])) + ) + .as_deref(), + Some("goose session --resume --session-id 20260213_9") + ); + // Qwen rejects `--session-id` alongside `--resume`; Vibe spells + // `--continue` as `-c` too. + assert_eq!( + CLIAgent::Qwen + .resume_command("q-2", Some(&argv(&["qwen", "--session-id", "old"]))) + .as_deref(), + Some("qwen --resume q-2") + ); + assert_eq!( + CLIAgent::Vibe + .resume_command("v-2", Some(&argv(&["vibe", "-c"]))) + .as_deref(), + Some("vibe --resume v-2") + ); + + // Nothing was persisted, so there is nothing to resume or fork. + for id in ["a-1"] { + assert_eq!( + CLIAgent::Auggie + .resume_command(id, Some(&argv(&["auggie", "--dont-save-session"]))), + None + ); + } + // "If false, chat history is not saved and --continue/--resume will + // not work" — so neither resume nor fork is offered. + let no_recording = argv(&["qwen", "--no-chat-recording"]); + assert_eq!( + CLIAgent::Qwen.resume_command("q-1", Some(&no_recording)), + None + ); + assert_eq!( + CLIAgent::Qwen.fork_command("q-1", Some(&no_recording)), + None + ); + } + + /// `python3 -m antigravity` opens an xkcd comic. It is the standard way to + /// trigger Python's easter egg, and the interpreter branch used to read that + /// module name as an agent. + #[test] + fn the_python_easter_egg_is_not_a_coding_agent() { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["python3", "-m", "antigravity"])), + None + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["agy"])), + Some(CLIAgent::Antigravity) + ); + } + #[test] fn status_metadata_is_consistent() { assert_eq!(AgentStatus::Idle.dot_rgb(), None); diff --git a/src/ui/assets.rs b/src/ui/assets.rs index e42038e2..5f89789f 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -59,6 +59,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/agents/grok.svg" => include_bytes!("../../assets/icons/agents/grok.svg"), "icons/agents/pi.svg" => include_bytes!("../../assets/icons/agents/pi.svg"), "icons/agents/omp.svg" => include_bytes!("../../assets/icons/agents/omp.svg"), + "icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"), _ => return None, }; Some(bytes) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 4f99d30a..9142dabe 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -707,6 +707,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentPi => "Pi", L10nKey::SettingsAgentGrokBuild => "Grok Build", L10nKey::SettingsAgentOhMyPi => "Oh My Pi", + L10nKey::SettingsAgentGemini => "Gemini", + L10nKey::SettingsAgentDroid => "Droid", + L10nKey::SettingsAgentQwenCode => "Qwen Code", + L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -787,6 +791,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { "alt keyboard modifier escape macos option meta option acts as meta" } L10nKey::SettingsSearchOhMyPiKeywords => "agent integration extension install omp oh my pi", + L10nKey::SettingsSearchGeminiKeywords => "agent integration hooks install gemini google", + L10nKey::SettingsSearchDroidKeywords => "agent integration hooks install droid factory", + L10nKey::SettingsSearchQwenCodeKeywords => { + "agent integration hooks install qwen code qwen-code" + } + L10nKey::SettingsSearchGooseKeywords => "agent integration hooks plugin install goose", L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi", L10nKey::SettingsSearchPortForwardingKeywords => { "ssh tunnel local remote dynamic socks forward rule" diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 4dbe2818..c14d72cc 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -716,6 +716,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentPi => "Pi", L10nKey::SettingsAgentGrokBuild => "Grok Build", L10nKey::SettingsAgentOhMyPi => "Oh My Pi", + L10nKey::SettingsAgentGemini => "Gemini", + L10nKey::SettingsAgentDroid => "Droid", + L10nKey::SettingsAgentQwenCode => "Qwen Code", + L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -832,6 +836,18 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchOhMyPiKeywords => { "エージェント 統合 拡張 インストール omp oh my pi agent integration extension install" } + L10nKey::SettingsSearchGeminiKeywords => { + "エージェント 統合 フック インストール gemini google agent integration hooks install" + } + L10nKey::SettingsSearchDroidKeywords => { + "エージェント 統合 フック インストール droid factory agent integration hooks install" + } + L10nKey::SettingsSearchQwenCodeKeywords => { + "エージェント 統合 フック インストール qwen code agent integration hooks install" + } + L10nKey::SettingsSearchGooseKeywords => { + "エージェント 統合 フック プラグイン インストール goose agent integration hooks plugin install" + } L10nKey::SettingsSearchPiKeywords => { "エージェント 統合 拡張 インストール pi agent integration extension install" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 4e17e6b0..2122034b 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -564,6 +564,10 @@ l10n_keys! { SettingsAgentPi, SettingsAgentGrokBuild, SettingsAgentOhMyPi, + SettingsAgentGemini, + SettingsAgentDroid, + SettingsAgentQwenCode, + SettingsAgentGoose, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -587,11 +591,14 @@ l10n_keys! { SettingsSearchDetectUrlsKeywords, SettingsSearchDiffPreviewFromCountsKeywords, SettingsSearchDimInactivePanesKeywords, + SettingsSearchDroidKeywords, SettingsSearchFocusFollowsMouseKeywords, SettingsSearchFontFamilyKeywords, SettingsSearchFontLigaturesKeywords, SettingsSearchFontSizeKeywords, SettingsSearchForwardSshLoopbackLinksKeywords, + SettingsSearchGeminiKeywords, + SettingsSearchGooseKeywords, SettingsSearchGrokBuildKeywords, SettingsSearchHideMouseWhileTypingKeywords, SettingsSearchHistorySearchKeywords, @@ -611,6 +618,7 @@ l10n_keys! { SettingsSearchPiKeywords, SettingsSearchPortForwardingKeywords, SettingsSearchProgramKeywords, + SettingsSearchQwenCodeKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, SettingsSearchRestoreLastLayoutKeywords, @@ -1497,10 +1505,14 @@ mod tests { L10nKey::SettingsAgentClaudeCode, L10nKey::SettingsAgentCodex, L10nKey::SettingsAgentCopilotCli, + L10nKey::SettingsAgentDroid, + L10nKey::SettingsAgentGemini, + L10nKey::SettingsAgentGoose, L10nKey::SettingsAgentGrokBuild, L10nKey::SettingsAgentOhMyPi, L10nKey::SettingsAgentOpencode, L10nKey::SettingsAgentPi, + L10nKey::SettingsAgentQwenCode, // Windows names its backdrop materials, and Japanese Windows keeps // those names in Latin script — so does this list. Chinese does // translate them (云母 / 亚克力), which is what Microsoft's own diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index aeb8e41d..b32c0a1e 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -625,6 +625,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentPi => "Pi", L10nKey::SettingsAgentGrokBuild => "Grok Build", L10nKey::SettingsAgentOhMyPi => "Oh My Pi", + L10nKey::SettingsAgentGemini => "Gemini", + L10nKey::SettingsAgentDroid => "Droid", + L10nKey::SettingsAgentQwenCode => "Qwen Code", + L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -739,6 +743,18 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchOhMyPiKeywords => { "Oh My Pi agent 集成 扩展 安装 omp oh my pi agent integration extension install" } + L10nKey::SettingsSearchGeminiKeywords => { + "Gemini agent 集成 钩子 安装 gemini google agent integration hooks install" + } + L10nKey::SettingsSearchDroidKeywords => { + "Droid agent 集成 钩子 安装 droid factory agent integration hooks install" + } + L10nKey::SettingsSearchQwenCodeKeywords => { + "Qwen Code 通义千问 agent 集成 钩子 安装 qwen code agent integration hooks install" + } + L10nKey::SettingsSearchGooseKeywords => { + "Goose agent 集成 钩子 插件 安装 goose agent integration hooks plugin install" + } L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 1c0b8f46..709f47c9 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -608,6 +608,26 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentOhMyPi, keywords: SettingsSearchOhMyPiKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentGemini, + keywords: SettingsSearchGeminiKeywords, + }, + SearchEntry { + section: Agents, + title: SettingsAgentDroid, + keywords: SettingsSearchDroidKeywords, + }, + SearchEntry { + section: Agents, + title: SettingsAgentQwenCode, + keywords: SettingsSearchQwenCodeKeywords, + }, + SearchEntry { + section: Agents, + title: SettingsAgentGoose, + keywords: SettingsSearchGooseKeywords, + }, SearchEntry { section: WindowTabs, title: SettingsStartupWindow,