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..e8865107 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,113 @@ 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);
+ // 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"
+ );
+
+ // 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 +2054,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 +2280,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..1af51403 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,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"],
+ // 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"],
}
}
@@ -108,6 +119,7 @@ impl CLIAgent {
CLIAgent::Qwen => "qwen",
CLIAgent::OhMyPi => "omp",
CLIAgent::Kimi => "kimi",
+ CLIAgent::QoderCLI => "qodercli",
}
}
@@ -138,6 +150,7 @@ impl CLIAgent {
CLIAgent::Qwen => "Qwen Code",
CLIAgent::OhMyPi => "Oh My Pi",
CLIAgent::Kimi => "Kimi Code",
+ CLIAgent::QoderCLI => "Qoder CLI",
}
}
@@ -169,6 +182,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 +199,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 +219,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 +249,8 @@ impl CLIAgent {
| CLIAgent::Droid
| CLIAgent::Amp
| CLIAgent::Qwen
- | CLIAgent::Goose => Some("Fork Session"),
+ | CLIAgent::Goose
+ | CLIAgent::QoderCLI => Some("Fork Session"),
_ => None,
}
}
@@ -393,6 +414,21 @@ impl CLIAgent {
"--worktree-ref",
"--ref",
],
+ // `--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",
+ "--continue",
+ "-c",
+ "--session-id",
+ "--fork-session",
+ "--worktree",
+ ],
_ => &[],
};
let mut i = 0;
@@ -457,6 +493,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 +512,7 @@ impl CLIAgent {
pub fn icon_rgb(self) -> u32 {
match self {
CLIAgent::TraeCode => 0x32F08C,
+ CLIAgent::QoderCLI => 0x000000,
_ => 0xFFFFFF,
}
}
@@ -496,6 +534,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
@@ -848,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!(
@@ -1508,6 +1573,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,