From 9e338b1d60feae28c97154d3f6e37c0fa87a96c4 Mon Sep 17 00:00:00 2001 From: White Date: Fri, 11 Sep 2026 10:30:19 +0800 Subject: [PATCH] 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,