From 9e338b1d60feae28c97154d3f6e37c0fa87a96c4 Mon Sep 17 00:00:00 2001 From: White Date: Fri, 11 Sep 2026 10:30:19 +0800 Subject: [PATCH 1/2] feat(agents): add Qoder CLI integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook events map Qoder's lifecycle to tty7's state machine: session start, prompt submit, permission requests, MCP tool elicitation (an authorized MCP tool can still pause for user input mid-call), tool completion, stop, and session end. Compaction events are filtered out—Qoder emits a session-start after compacting the active turn, which would reset the status line to Idle without this filter, even though the turn is still running. Settings path resolution respects QODER_CONFIG_DIR for local installs, falling back to ~/.qoder/settings.json. Remote targets ignore the override (a local env var must not redirect remote hooks). Session commands support --resume and --fork-session. The resume command strips conflicting flags (--resume, -r, --continue, -c, --session-id, --worktree, --fork-session) from the original launch argv before appending the new session id. The -w/--cwd flags survive (Qoder's -w means --cwd, not --worktree). Both commands require session persistence: when --no-session-persistence is present, there is no saved conversation to reopen, so the commands return None. Tests cover compaction preservation, MCP elicitation state transitions, QODER_CONFIG_DIR's effect on the hook lifecycle (multi-case isolation), resume/fork command generation, worktree flag handling, and persistence requirements. Localization complete for en/ja/zh. Icon embedded, search keywords wired. --- assets/icons/agents/qodercli.svg | 4 + crates/tty7-core/src/core/agent_hooks.rs | 241 ++++++++++++++++++++++- crates/tty7-core/src/core/cli_agent.rs | 118 ++++++++++- src/ui/assets.rs | 1 + src/ui/i18n/en.rs | 2 + src/ui/i18n/ja.rs | 4 + src/ui/i18n/mod.rs | 3 + src/ui/i18n/zh.rs | 2 + src/ui/settings.rs | 5 + 9 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 assets/icons/agents/qodercli.svg diff --git a/assets/icons/agents/qodercli.svg b/assets/icons/agents/qodercli.svg new file mode 100644 index 00000000..44eb1276 --- /dev/null +++ b/assets/icons/agents/qodercli.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 63298afb..6d60c386 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -43,6 +43,15 @@ 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> { + // Qoder also emits SessionStart after compacting the active turn. + // Preserve its status until a real turn or session boundary arrives. + if agent == "qodercli" + && event == "session-start" + && let Ok(payload) = serde_json::from_str::(stdin_json) + && payload.get("source").and_then(|value| value.as_str()) == Some("compact") + { + return None; + } if matches!(agent, "copilot" | "grok" | "droid" | "gemini") && event == "notification" { let blocks = stdin_json.contains("elicitation_dialog") || (matches!(agent, "copilot" | "droid") && stdin_json.contains("permission_prompt")) @@ -257,10 +266,11 @@ pub enum HookAgent { Qwen, Goose, Kimi, + QoderCLI, } impl HookAgent { - pub const ALL: [HookAgent; 13] = [ + pub const ALL: [HookAgent; 14] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::TraeCode, @@ -274,6 +284,7 @@ impl HookAgent { HookAgent::Qwen, HookAgent::Goose, HookAgent::Kimi, + HookAgent::QoderCLI, ]; /// The hooks behind a detected agent process, if it has any. @@ -296,6 +307,7 @@ impl HookAgent { CLIAgent::Qwen => Some(HookAgent::Qwen), CLIAgent::Goose => Some(HookAgent::Goose), CLIAgent::Kimi => Some(HookAgent::Kimi), + CLIAgent::QoderCLI => Some(HookAgent::QoderCLI), CLIAgent::Aider | CLIAgent::Amp | CLIAgent::Cursor @@ -317,6 +329,7 @@ impl HookAgent { HookAgent::Gemini => Some(GEMINI_HOOK_EVENTS), HookAgent::Droid => Some(DROID_HOOK_EVENTS), HookAgent::Qwen => Some(QWEN_HOOK_EVENTS), + HookAgent::QoderCLI => Some(QODER_HOOK_EVENTS), HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi @@ -352,6 +365,7 @@ impl HookAgent { HookAgent::Qwen => "qwen", HookAgent::Goose => "goose", HookAgent::Kimi => "kimi", + HookAgent::QoderCLI => "qodercli", } } @@ -370,6 +384,7 @@ impl HookAgent { HookAgent::Qwen => "Qwen Code", HookAgent::Goose => "Goose", HookAgent::Kimi => "Kimi Code", + HookAgent::QoderCLI => "Qoder CLI", } } @@ -402,6 +417,7 @@ impl HookAgent { target.under_home(&[".agents", "plugins", "tty7", "hooks", "hooks.json"]) } HookAgent::Kimi => target.kimi_config_path(), + HookAgent::QoderCLI => target.qoder_settings_path(), } } @@ -494,6 +510,15 @@ impl<'a> HookTarget<'a> { self.under_home(&[".kimi-code", "config.toml"]) } + fn qoder_settings_path(&self) -> PathBuf { + if self.is_local() + && let Some(dir) = std::env::var_os("QODER_CONFIG_DIR").filter(|d| !d.is_empty()) + { + return PathBuf::from(dir).join("settings.json"); + } + self.under_home(&[".qoder", "settings.json"]) + } + fn traecli_hooks_path(&self) -> PathBuf { if self.is_local() { if let Some(dir) = std::env::var_os("TRAECLI_HOME").filter(|d| !d.is_empty()) { @@ -794,6 +819,18 @@ const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[ ("SessionEnd", "session-end", None), ]; +const QODER_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("PermissionRequest", "permission-request"), + // An authorized MCP tool can still pause for user input mid-call. + ("Elicitation", "question-asked"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("StopFailure", "stop"), + ("SessionEnd", "session-end"), +]; + fn hook_map_state( target: &HookTarget, path: &Path, @@ -1138,6 +1175,7 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::Gemini | HookAgent::Droid | HookAgent::Qwen + | HookAgent::QoderCLI | HookAgent::Kimi => None, } } @@ -1570,6 +1608,7 @@ mod tests { .chain(TRAE_CODE_HOOK_EVENTS) .chain(GEMINI_HOOK_EVENTS) .chain(DROID_HOOK_EVENTS) + .chain(QODER_HOOK_EVENTS) .chain(QWEN_HOOK_EVENTS) .chain(GOOSE_HOOK_EVENTS) .chain(KIMI_HOOK_EVENTS) @@ -1607,6 +1646,7 @@ mod tests { "/home/me/.agents/plugins/tty7/hooks/hooks.json", ), (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), + (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), ] { assert_eq!( agent.target_path(&t), @@ -1627,6 +1667,7 @@ mod tests { HookAgent::Qwen, HookAgent::Goose, HookAgent::Kimi, + HookAgent::QoderCLI, ] { assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled); install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug())); @@ -1649,6 +1690,105 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn qoder_compaction_preserves_the_active_turn() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + let mut state = AgentSessionState::default(); + state.apply_event(&round_trip( + "qodercli", + "prompt-submit", + r#"{"session_id":"q-1","cwd":"/repo","prompt":"Continue the task"}"#, + )); + let before = state.clone(); + let compact = r#"{"session_id":"q-1","cwd":"/repo","source":"compact"}"#; + if let Some(event) = effective_event("qodercli", "session-start", compact) { + state.apply_event(&round_trip("qodercli", event, compact)); + } + assert_eq!(state, before, "compaction must preserve the active turn"); + + state.apply_event(&round_trip("qodercli", "tool-complete", "{}")); + assert_eq!(state.status, AgentStatus::Working); + state.apply_event(&round_trip("qodercli", "stop", "{}")); + assert_eq!(state.status, AgentStatus::Done); + + for input in [ + r#"{"source":"startup","message":"compact"}"#, + r#"{"source":"resume"}"#, + r#"{"source":"clear"}"#, + "{}", + "not JSON", + ] { + let event = effective_event("qodercli", "session-start", input) + .expect("ordinary session starts still reach the state machine"); + let mut session = before.clone(); + session.apply_event(&round_trip("qodercli", event, input)); + assert_eq!(session.status, AgentStatus::Idle, "{input}"); + } + assert_eq!( + effective_event("claude", "session-start", compact), + Some("session-start"), + "the filter is specific to Qoder" + ); + assert_eq!( + effective_event("qodercli", "prompt-submit", compact), + Some("prompt-submit") + ); + } + + #[test] + fn qoder_mcp_elicitation_waits_for_user_input() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + let apply_hook = |state: &mut AgentSessionState, hook: &str, input: &str| { + let event = HookAgent::QoderCLI + .hook_map_events() + .unwrap() + .iter() + .find_map(|(name, event)| (*name == hook).then_some(*event)) + .and_then(|event| effective_event("qodercli", event, input)); + if let Some(event) = event { + state.apply_event(&round_trip("qodercli", event, input)); + } + }; + let mut state = AgentSessionState::default(); + apply_hook( + &mut state, + "UserPromptSubmit", + r#"{"session_id":"q-1","prompt":"Look up my tickets"}"#, + ); + assert_eq!(state.status, AgentStatus::Working); + apply_hook( + &mut state, + "Notification", + r#"{"notification_type":"auth_success","message":"Signed in"}"#, + ); + assert_eq!(state.status, AgentStatus::Working); + + // The MCP tool is already authorized, so no PermissionRequest precedes + // its request for more information from the user. + apply_hook( + &mut state, + "Elicitation", + r#"{ + "session_id":"q-1", + "hook_event_name":"Elicitation", + "mcp_server_name":"tickets", + "message":"Choose a project", + "mode":"form" + }"#, + ); + assert_eq!(state.status, AgentStatus::Waiting); + assert_eq!(state.message.as_deref(), Some("Choose a project")); + assert_eq!(state.session_id.as_deref(), Some("q-1")); + + apply_hook(&mut state, "PostToolUse", r#"{"session_id":"q-1"}"#); + assert_eq!(state.status, AgentStatus::Working); + assert_eq!(state.message, None); + apply_hook(&mut state, "Stop", r#"{"session_id":"q-1"}"#); + assert_eq!(state.status, AgentStatus::Done); + } + /// 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". @@ -1906,6 +2046,7 @@ mod tests { "/home/me/.omp/agent/extensions/tty7/index.ts", ), (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), + (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), ] { assert_eq!( agent.target_path(&target), @@ -2131,6 +2272,104 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn qoder_config_dir_controls_local_hook_lifecycle() { + const CASE_ENV: &str = "TTY7_TEST_QODER_CONFIG_CASE"; + const ROOT_ENV: &str = "TTY7_TEST_QODER_CONFIG_ROOT"; + let Ok(case) = std::env::var(CASE_ENV) else { + // Each case gets its own environment, without changing the one + // shared by the other tests or touching the user's settings. + for case in ["override", "empty", "unset"] { + let sandbox = tempfile::tempdir().unwrap(); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "core::agent_hooks::tests::qoder_config_dir_controls_local_hook_lifecycle", + "--nocapture", + ]) + .env(CASE_ENV, case) + .env(ROOT_ENV, sandbox.path()); + match case { + "override" => { + child.env("QODER_CONFIG_DIR", sandbox.path().join("custom config")) + } + "empty" => child.env("QODER_CONFIG_DIR", ""), + _ => child.env_remove("QODER_CONFIG_DIR"), + }; + let output = crate::core::proc::output_within( + crate::core::proc::hide_console(&mut child), + std::time::Duration::from_secs(30), + ) + .expect("run the isolated Qoder hook test"); + assert!( + output.status.success(), + "{case}:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + }; + + let root = PathBuf::from(std::env::var_os(ROOT_ENV).unwrap()); + let host = local_host(); + let target = HookTarget { + host: &*host, + home: root.join("home"), + exe: std::env::current_exe().unwrap(), + }; + let default_settings = target.home.join(".qoder").join("settings.json"); + let custom_settings = root.join("custom config").join("settings.json"); + let (settings, untouched) = if case == "override" { + (&custom_settings, &default_settings) + } else { + (&default_settings, &custom_settings) + }; + let user_config = serde_json::json!({ + "model": "qoder-test", + "hooks": { + "Stop": [{ "hooks": [{ "type": "command", "command": "echo user-hook" }] }] + } + }); + for path in [settings, untouched] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, user_config.to_string()).unwrap(); + } + + let agent = HookAgent::QoderCLI; + assert_eq!(agent.target_path(&target), *settings); + let remote_host = FakeRemote::shared(); + let remote = HookTarget::remote(&*remote_host, PathBuf::from("/home/me")); + assert_eq!( + agent.target_path(&remote), + PathBuf::from("/home/me/.qoder/settings.json"), + "a local override must not redirect remote hooks" + ); + + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + assert_eq!( + install_hooks(&target, agent).unwrap(), + HookOutcome::Installed + ); + assert_eq!(hooks_state(&target, agent), HooksState::Installed); + assert!( + std::fs::read_to_string(settings) + .unwrap() + .contains("agent-hook qodercli") + ); + assert_eq!( + uninstall_hooks(&target, agent).unwrap(), + HookOutcome::Removed + ); + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + for path in [settings, untouched] { + let actual: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(actual, user_config, "{}", path.display()); + } + } + #[test] fn install_is_idempotent_and_preserves_user_hooks() { let dir = std::env::temp_dir().join(format!("tty7-hooks-test-{}", std::process::id())); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index f504631f..b12431eb 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -26,10 +26,11 @@ pub enum CLIAgent { // Keep new variants at the end: daemon messages serialize this enum and // moving an existing discriminant would break mixed-version clients. TraeCode, + QoderCLI, } impl CLIAgent { - pub const ALL: [CLIAgent; 20] = [ + pub const ALL: [CLIAgent; 21] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::TraeCode, @@ -50,6 +51,7 @@ impl CLIAgent { CLIAgent::Qwen, CLIAgent::OhMyPi, CLIAgent::Kimi, + CLIAgent::QoderCLI, ]; fn aliases(self) -> &'static [&'static str] { @@ -83,6 +85,8 @@ impl CLIAgent { // kimi-cli install a `kimi` — same vendor, same brand, so one // detection covers them. Only the standalone one has hooks. CLIAgent::Kimi => &["kimi", "kimi-code"], + // `qoder` launches the IDE; CLI wrappers can use a custom rule. + CLIAgent::QoderCLI => &["qodercli"], } } @@ -108,6 +112,7 @@ impl CLIAgent { CLIAgent::Qwen => "qwen", CLIAgent::OhMyPi => "omp", CLIAgent::Kimi => "kimi", + CLIAgent::QoderCLI => "qodercli", } } @@ -138,6 +143,7 @@ impl CLIAgent { CLIAgent::Qwen => "Qwen Code", CLIAgent::OhMyPi => "Oh My Pi", CLIAgent::Kimi => "Kimi Code", + CLIAgent::QoderCLI => "Qoder CLI", } } @@ -169,6 +175,7 @@ impl CLIAgent { 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::QoderCLI => Some(format!("qodercli{flags} --resume {session_id}")), CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")), CLIAgent::OhMyPi => Some(format!("omp{flags} --resume {session_id}")), CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")), @@ -185,6 +192,9 @@ impl CLIAgent { // "If false, chat history is not saved and --continue/--resume // will not work" — the yargs negation of `--chat-recording`. CLIAgent::Qwen => &["--no-chat-recording"], + // Print mode still emits a session id in hooks when persistence + // is disabled, but there is no saved conversation to reopen. + CLIAgent::QoderCLI => &["--no-session-persistence"], _ => &[], }; argv.iter().any(|t| ephemeral.contains(&t.as_str())) @@ -202,6 +212,9 @@ impl CLIAgent { "claude{flags} --resume {session_id} --fork-session" )), CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id} --fork-session")), + CLIAgent::QoderCLI => Some(format!( + "qodercli{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. @@ -229,7 +242,8 @@ impl CLIAgent { | CLIAgent::Droid | CLIAgent::Amp | CLIAgent::Qwen - | CLIAgent::Goose => Some("Fork Session"), + | CLIAgent::Goose + | CLIAgent::QoderCLI => Some("Fork Session"), _ => None, } } @@ -393,6 +407,21 @@ impl CLIAgent { "--worktree-ref", "--ref", ], + // `--resume`/`-r` resumes a past session and `--continue`/`-c` the + // most recent one, both of which clash with the `--resume {id}` + // this command appends; `--session-id` names a *new* session and is + // rejected next to `--resume`, and `--fork-session` is the flag the + // fork variant appends itself. `--worktree` would create or switch + // trees again; Qoder's `-w` means `--cwd` and must survive. + CLIAgent::QoderCLI => &[ + "--resume", + "-r", + "--continue", + "-c", + "--session-id", + "--fork-session", + "--worktree", + ], _ => &[], }; let mut i = 0; @@ -457,6 +486,7 @@ impl CLIAgent { // The blue of the flame in Kimi's brand mark; the glyph itself is // black, which Codex and Grok already have covered. CLIAgent::Kimi => 0x027AFF, + CLIAgent::QoderCLI => 0xFFFFFF, } } @@ -475,6 +505,7 @@ impl CLIAgent { pub fn icon_rgb(self) -> u32 { match self { CLIAgent::TraeCode => 0x32F08C, + CLIAgent::QoderCLI => 0x000000, _ => 0xFFFFFF, } } @@ -496,6 +527,7 @@ impl CLIAgent { CLIAgent::OhMyPi => "icons/agents/omp.svg", CLIAgent::Qwen => "icons/agents/qwen.svg", CLIAgent::Kimi => "icons/agents/kimi.svg", + CLIAgent::QoderCLI => "icons/agents/qodercli.svg", CLIAgent::Aider | CLIAgent::Auggie | CLIAgent::Hermes @@ -1508,6 +1540,88 @@ mod tests { .as_deref(), Some("grok --yolo --resume g-3") ); + assert_eq!( + CLIAgent::QoderCLI + .resume_command("q-1", Some(&argv(&["qodercli", "--model", "qoder-1"]))) + .as_deref(), + Some("qodercli --model qoder-1 --resume q-1") + ); + assert_eq!( + CLIAgent::QoderCLI + .resume_command( + "q-2", + Some(&argv(&["qodercli", "--resume", "q-1", "--fork-session"])) + ) + .as_deref(), + Some("qodercli --resume q-2"), + "a stale --resume id and --fork-session come off before the new one goes on" + ); + assert_eq!( + CLIAgent::QoderCLI + .resume_command( + "q-3", + Some(&argv(&["qodercli", "--session-id", "old", "--yolo"])) + ) + .as_deref(), + Some("qodercli --yolo --resume q-3"), + "`--session-id` names a new session and is rejected next to `--resume`" + ); + } + + #[test] + fn qoder_resume_and_fork_do_not_recreate_worktrees() { + for worktree in [ + vec!["--worktree"], + vec!["--worktree", "old-tree"], + vec!["--worktree=old-tree"], + ] { + for cwd_flag in ["-w", "--cwd"] { + let mut launch = argv(&["qodercli", "--model", "qoder-1"]); + launch.extend(argv(&worktree)); + launch.extend(argv(&[cwd_flag, "/repo/current-tree"])); + assert_eq!( + CLIAgent::QoderCLI.resume_command("q-1", Some(&launch)), + Some(format!( + "qodercli --model qoder-1 {cwd_flag} /repo/current-tree --resume q-1" + )), + "launch argv: {launch:?}" + ); + assert_eq!( + CLIAgent::QoderCLI.fork_command("q-1", Some(&launch)), + Some(format!( + "qodercli --model qoder-1 {cwd_flag} /repo/current-tree --resume q-1 --fork-session" + )), + "launch argv: {launch:?}" + ); + } + } + } + + #[test] + fn qoder_session_commands_require_persistence() { + let ephemeral = argv(&["qodercli", "--print", "--no-session-persistence"]); + assert_eq!( + CLIAgent::QoderCLI.resume_command("q-1", Some(&ephemeral)), + None + ); + assert_eq!( + CLIAgent::QoderCLI.fork_command("q-1", Some(&ephemeral)), + None + ); + + let persistent = argv(&["qodercli", "--model", "qoder-1"]); + assert_eq!( + CLIAgent::QoderCLI + .resume_command("q-1", Some(&persistent)) + .as_deref(), + Some("qodercli --model qoder-1 --resume q-1") + ); + assert_eq!( + CLIAgent::QoderCLI + .fork_command("q-1", Some(&persistent)) + .as_deref(), + Some("qodercli --model qoder-1 --resume q-1 --fork-session") + ); } #[test] diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 801694f7..7c449076 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -62,6 +62,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/agents/omp.svg" => include_bytes!("../../assets/icons/agents/omp.svg"), "icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"), "icons/agents/kimi.svg" => include_bytes!("../../assets/icons/agents/kimi.svg"), + "icons/agents/qodercli.svg" => include_bytes!("../../assets/icons/agents/qodercli.svg"), _ => return None, }; Some(bytes) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index d2fd7cf9..15768a5e 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -760,6 +760,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", + L10nKey::SettingsAgentQoderCLI => "Qoder CLI", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -855,6 +856,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchKimiCodeKeywords => { "agent integration hooks install kimi code kimi-code moonshot" } + L10nKey::SettingsSearchQoderCLIKeywords => "agent integration hooks install qoder qodercli", 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 c4b3810e..a45e7b07 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -769,6 +769,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", + L10nKey::SettingsAgentQoderCLI => "Qoder CLI", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -906,6 +907,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchKimiCodeKeywords => { "エージェント 統合 フック インストール kimi code moonshot agent integration hooks install" } + L10nKey::SettingsSearchQoderCLIKeywords => { + "エージェント 統合 フック インストール qoder qodercli agent integration hooks install" + } L10nKey::SettingsSearchPiKeywords => { "エージェント 統合 拡張 インストール pi agent integration extension install" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 90f47ca6..4f512c23 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -586,6 +586,7 @@ l10n_keys! { SettingsAgentQwenCode, SettingsAgentGoose, SettingsAgentKimiCode, + SettingsAgentQoderCLI, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -640,6 +641,7 @@ l10n_keys! { SettingsSearchPortForwardingKeywords, SettingsSearchProgramKeywords, SettingsSearchQwenCodeKeywords, + SettingsSearchQoderCLIKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, SettingsSearchRestoreLastLayoutKeywords, @@ -1565,6 +1567,7 @@ mod tests { L10nKey::SettingsAgentOpencode, L10nKey::SettingsAgentPi, L10nKey::SettingsAgentQwenCode, + L10nKey::SettingsAgentQoderCLI, // 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 b3d2ddc3..c570bf0a 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -676,6 +676,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", + L10nKey::SettingsAgentQoderCLI => "Qoder CLI", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -811,6 +812,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchKimiCodeKeywords => { "Kimi Code 月之暗面 agent 集成 钩子 安装 kimi code moonshot agent integration hooks install" } + L10nKey::SettingsSearchQoderCLIKeywords => "Qoder CLI agent 集成 钩子 安装 qoder qodercli", L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 45fdfe3b..5c2cb38c 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -656,6 +656,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentKimiCode, keywords: SettingsSearchKimiCodeKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentQoderCLI, + keywords: SettingsSearchQoderCLIKeywords, + }, SearchEntry { section: WindowTabs, title: SettingsStartupWindow, From 63b3951ef3ef848d1d4298b18364f2b7ef3f8031 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:38:40 +0800 Subject: [PATCH 2/2] fix(agents): detect Qoder through the binary its docs tell you to run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm package installs two binaries. `qoder` is a dispatcher that routes to the CLI for a bare invocation, a flag, or a prompt, and only hands off to the IDE for `ide`/`chat`/`serve-web`/`tunnel` or a path that exists — and it is the one the documentation tells people to run. Both are `#!/usr/bin/env node` scripts, so what the pty carries is node plus the path to the shim; the dispatcher's child, where `qodercli` appears on the path, is not the process group leader and is never read. Detecting `qodercli` alone missed every session started the documented way. An IDE launch now wears the CLI's avatar for as long as the launcher takes to exit, which is the cost of covering the common case. Also: `--session-id` restores a session rather than naming a new one, so say that where the flag is stripped, and assert Qoder has no `Notification` seat instead of putting a payload through a hook map that has none. Claude-Session: https://claude.ai/code/session_01LAqfzqELnoDWU56LBXS1Nh --- crates/tty7-core/src/core/agent_hooks.rs | 18 ++++++--- crates/tty7-core/src/core/cli_agent.rs | 49 ++++++++++++++++++++---- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 6d60c386..e8865107 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -1758,12 +1758,20 @@ mod tests { r#"{"session_id":"q-1","prompt":"Look up my tickets"}"#, ); assert_eq!(state.status, AgentStatus::Working); - apply_hook( - &mut state, - "Notification", - r#"{"notification_type":"auth_success","message":"Signed in"}"#, + // Qoder says outright when it is blocked — `PermissionRequest` and + // `Elicitation` — so it must not also carry `Notification`, which + // fires for non-blocking alerts and would strand the pane on + // "waiting". Asserting the map has no seat for it is the check; a + // `Notification` payload put through `apply_hook` would be dropped + // for want of one and prove nothing. + assert!( + !HookAgent::QoderCLI + .hook_map_events() + .unwrap() + .iter() + .any(|(hook, _)| *hook == "Notification"), + "an unblocking alert must not read as a question" ); - assert_eq!(state.status, AgentStatus::Working); // The MCP tool is already authorized, so no PermissionRequest precedes // its request for more information from the user. diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index b12431eb..1af51403 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -85,8 +85,15 @@ impl CLIAgent { // kimi-cli install a `kimi` — same vendor, same brand, so one // detection covers them. Only the standalone one has hooks. CLIAgent::Kimi => &["kimi", "kimi-code"], - // `qoder` launches the IDE; CLI wrappers can use a custom rule. - CLIAgent::QoderCLI => &["qodercli"], + // The npm package installs two binaries and `qoder` is the one the + // documentation tells people to run: it dispatches to the CLI for a + // bare invocation, a flag, or a prompt, and only hands off to the + // IDE for `ide`/`chat`/`serve-web`/`tunnel` or a path that exists. + // Detecting only `qodercli` would miss every session started the + // documented way, since the dispatcher is what the pty sees. An IDE + // launch is the cost: it wears the CLI's avatar for as long as the + // launcher takes to exit. + CLIAgent::QoderCLI => &["qoder", "qodercli"], } } @@ -407,12 +414,12 @@ impl CLIAgent { "--worktree-ref", "--ref", ], - // `--resume`/`-r` resumes a past session and `--continue`/`-c` the - // most recent one, both of which clash with the `--resume {id}` - // this command appends; `--session-id` names a *new* session and is - // rejected next to `--resume`, and `--fork-session` is the flag the - // fork variant appends itself. `--worktree` would create or switch - // trees again; Qoder's `-w` means `--cwd` and must survive. + // `--resume`/`-r` restores a past session and `--continue`/`-c` the + // most recent one; `--session-id` is a third spelling of the same + // thing. All three clash with the `--resume {id}` this command + // appends, and `--fork-session` is the flag the fork variant + // appends itself. `--worktree` would create or switch trees again; + // Qoder's `-w` means `--cwd` and must survive. CLIAgent::QoderCLI => &[ "--resume", "-r", @@ -880,6 +887,32 @@ mod tests { ); } + /// The npm package installs `qoder` and `qodercli`, and the documentation + /// tells people to run the first one. Both are `#!/usr/bin/env node` + /// scripts, so what the pty carries is node plus the path to the shim — + /// the dispatcher's own child, which is where the name `qodercli` appears + /// on that path, is not the process group leader and is never read. + #[test] + fn qoder_is_detected_through_either_of_its_binaries() { + for launcher in [ + "qoder", + "qodercli", + "/opt/homebrew/bin/qoder", + "/opt/homebrew/bin/qodercli", + ] { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["node", launcher])), + Some(CLIAgent::QoderCLI), + "on {launcher}" + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&[launcher])), + Some(CLIAgent::QoderCLI), + "on {launcher}" + ); + } + } + #[test] fn detects_npx_package_form() { assert_eq!(