feat(agents): add Qoder CLI integration

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.
This commit is contained in:
White
2026-09-11 10:30:19 +08:00
parent a60d992c99
commit 9e338b1d60
9 changed files with 377 additions and 3 deletions
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 304" width="304" height="304">
<path d="M231.5 283.39C212.11 292.92 189 301.61 166.75 297.81C153.5 295.55 142.22 289.6 130.68 283.17C115.17 274.53 99.21 266.04 83.28 258.21C74.31 253.8 65.61 248.73 56.69 244.16C39.8 235.52 27.3 228.1 17.19 211.3C6.03 192.76 6.99 171.12 6.99 150.17C6.99 134.63 5.88 118.64 9.18 103.38C16.37 70.11 37.46 40.35 66.32 22.17C85.66 9.98 115.65 1.77 138.41 7.15C150.97 10.12 161.64 17.11 173.02 22.81C189.54 31.08 205.77 40.01 222.1 48.74C245.52 61.26 270.45 69.84 283.84 94.65C297.12 119.25 293.01 150.06 293.01 177.17C293.01 197.72 293.01 218.28 293.01 238.83C293.01 248.73 294.8 260.66 291.76 270.19C287.47 283.7 273.26 294.72 258.6 293.5C252.45 292.98 247.76 290.52 242.24 288.23C238.74 286.77 234.68 285.48 231.5 283.39ZM191.9 63.5C187.95 59.69 181.25 57.23 176.29 54.85C167.96 50.85 159.83 46.17 151.7 41.8C145.39 38.41 139.18 34.97 132.17 33.26C122.25 30.84 112.15 33.2 102.61 35.81C72.6 44.02 50.57 67.35 39.21 95.73C32.39 112.77 33.84 131.45 33.84 149.5C33.84 166.9 32 185.63 42.15 200.69C49.73 211.94 62.59 217.37 74.29 223.2C82.51 227.3 90.62 231.76 98.72 236.14C101.09 237.43 105.96 241.15 108.54 240.83C108.5 236.87 107.2 232.85 107.07 228.83C106.72 217.74 106.99 206.6 106.99 195.5C106.99 184.24 106.63 173.01 108.11 161.84C111.9 133.07 128.92 104.76 150.97 86.14C159.42 79 168.85 72.6 179.02 68.19C182.64 66.62 188.93 65.5 191.9 63.5ZM134.04 226.5C133.71 230.93 134.73 235.44 135.82 239.68C141.78 262.78 164.37 275.32 187.6 270.81C196.28 269.13 204.62 266.22 212.7 262.84C217.41 260.88 221.61 256.31 226.59 255.15C235.92 252.97 245.64 261.06 253.98 264.19C258.08 265.72 263.09 268.43 265.74 262.9C267.25 259.77 266.06 249.39 266.06 245.5C266.06 231.17 266.06 216.83 266.06 202.5C266.06 184.5 266.16 166.5 266.16 148.5C266.16 137.6 267.14 125.92 263.79 115.4C257.07 94.29 236.49 81.97 214.51 85.73C206.58 87.08 198.51 89.11 191.17 92.27C190.26 96.24 190.94 101.07 190.94 105.17C190.94 117.19 191.88 129.73 189.82 141.62C185.49 166.6 174.02 189.29 156.68 207.84C151.56 213.31 145.67 218.38 139.59 222.76C137.8 224.05 135.67 225.04 134.04 226.5ZM163.5 111C147.55 126.96 134.79 149.54 134 172.5C133.84 177.17 133.84 181.83 133.84 186.5C133.84 188.15 133.3 190.79 134.5 192C150.7 175.79 163.86 153.06 163.99 129.5C164.01 126.31 165.05 112.87 163.5 111Z" fill="#0f0d0c" fill-rule="evenodd" stroke="#0f0d0c" stroke-width="0.25" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+240 -1
View File
@@ -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::<serde_json::Value>(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<String> {
| 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()));
+116 -2
View File
@@ -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]
+1
View File
@@ -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)
+2
View File
@@ -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"
+4
View File
@@ -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"
}
+3
View File
@@ -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
+2
View File
@@ -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"
}
+5
View File
@@ -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,