From 0d0dc597ee26b6b44a57862724ef115b2f52ea24 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:04:06 +0800 Subject: [PATCH] feat(agents): add CodeBuddy CLI integration (#892) CodeBuddy Code takes Claude Code's hooks as-is: the same nested `hooks.[].hooks[{type, command}]` shape in ~/.codebuddy/settings.json (or $CODEBUDDY_CONFIG_DIR/settings.json), the same event names, and the same session_id/cwd payload fields. So it rides the shared hook-map installer and needs no payload aliases. The event table follows Qoder rather than Claude: CodeBuddy has a first-class PermissionRequest and Elicitation, so it gets no Notification hook, and StopFailure ends a turn like Stop. CodeBuddy also emits SessionStart with source "compact" mid-turn, which is filtered out the same way Qoder's is so the pane does not drop back to idle. Detection covers all three npm bins (codebuddy, codebuddy-code, cbc). Resume is `codebuddy --resume ` and fork appends --fork-session; stale session and worktree flags are dropped from the replayed launch argv, and --no-session-persistence disables both commands. Icon, en/ja/zh names and search keywords, and docs are updated. --- README.md | 5 +- assets/icons/agents/codebuddy.svg | 6 + crates/tty7-core/src/core/agent_hooks.rs | 214 ++++++++++++++++++++++- crates/tty7-core/src/core/cli_agent.rs | 124 ++++++++++++- docs/agents/overview.mdx | 7 +- docs/agents/status.mdx | 2 +- docs/getting-started/first-launch.mdx | 2 +- docs/index.mdx | 2 +- src/ui/assets.rs | 3 + src/ui/i18n/en.rs | 4 + src/ui/i18n/ja.rs | 4 + src/ui/i18n/mod.rs | 3 + src/ui/i18n/zh.rs | 4 + src/ui/settings.rs | 5 + 14 files changed, 370 insertions(+), 15 deletions(-) create mode 100644 assets/icons/agents/codebuddy.svg diff --git a/README.md b/README.md index 0261dbf7..ba470780 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com | | | |---|---| -| **Agent-aware** | per-pane detection (22 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository | +| **Agent-aware** | per-pane detection (23 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository | | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` | | **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | | **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME | @@ -67,7 +67,7 @@ after a reboot. **Fork** needs both — the agent's own fork command, and the ho that tells tty7 which session to fork.
-The full support matrix, all twenty-two +The full support matrix, all twenty-three | Agent | Detected | Status · resume | Fork | |---|:-:|:-:|:-:| @@ -81,6 +81,7 @@ that tells tty7 which session to fork. | **Qwen Code** | ✓ | ✓ | ✓ | | **Goose** | ✓ | ✓ | ✓ | | **Qoder CLI** | ✓ | ✓ | ✓ | +| **CodeBuddy** | ✓ | ✓ | ✓ | | **Gemini** | ✓ | ✓ | | | **Copilot** | ✓ | ✓ | | | **Kimi Code** | ✓ | ✓ | | diff --git a/assets/icons/agents/codebuddy.svg b/assets/icons/agents/codebuddy.svg new file mode 100644 index 00000000..c07cb1be --- /dev/null +++ b/assets/icons/agents/codebuddy.svg @@ -0,0 +1,6 @@ + + + + diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 4720232c..fd77c5d8 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -43,9 +43,9 @@ 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" + // Qoder and CodeBuddy also emit SessionStart after compacting the active + // turn. Preserve its status until a real turn or session boundary arrives. + if matches!(agent, "qodercli" | "codebuddy") && event == "session-start" && let Ok(payload) = serde_json::from_str::(stdin_json) && payload.get("source").and_then(|value| value.as_str()) == Some("compact") @@ -268,10 +268,11 @@ pub enum HookAgent { Kimi, QoderCLI, Crush, + CodeBuddy, } impl HookAgent { - pub const ALL: [HookAgent; 15] = [ + pub const ALL: [HookAgent; 16] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::TraeCode, @@ -287,6 +288,7 @@ impl HookAgent { HookAgent::Kimi, HookAgent::QoderCLI, HookAgent::Crush, + HookAgent::CodeBuddy, ]; /// The hooks behind a detected agent process, if it has any. @@ -311,6 +313,7 @@ impl HookAgent { CLIAgent::Kimi => Some(HookAgent::Kimi), CLIAgent::QoderCLI => Some(HookAgent::QoderCLI), CLIAgent::Crush => Some(HookAgent::Crush), + CLIAgent::CodeBuddy => Some(HookAgent::CodeBuddy), CLIAgent::Aider | CLIAgent::Amp | CLIAgent::Cursor @@ -334,6 +337,7 @@ impl HookAgent { HookAgent::Qwen => Some(QWEN_HOOK_EVENTS), HookAgent::QoderCLI => Some(QODER_HOOK_EVENTS), HookAgent::Crush => Some(CRUSH_HOOK_EVENTS), + HookAgent::CodeBuddy => Some(CODEBUDDY_HOOK_EVENTS), HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi @@ -379,6 +383,7 @@ impl HookAgent { HookAgent::Kimi => "kimi", HookAgent::QoderCLI => "qodercli", HookAgent::Crush => "crush", + HookAgent::CodeBuddy => "codebuddy", } } @@ -399,6 +404,7 @@ impl HookAgent { HookAgent::Kimi => "Kimi Code", HookAgent::QoderCLI => "Qoder CLI", HookAgent::Crush => "Crush", + HookAgent::CodeBuddy => "CodeBuddy", } } @@ -433,6 +439,7 @@ impl HookAgent { HookAgent::Kimi => target.kimi_config_path(), HookAgent::QoderCLI => target.qoder_settings_path(), HookAgent::Crush => target.crush_settings_path(), + HookAgent::CodeBuddy => target.codebuddy_settings_path(), } } @@ -548,6 +555,19 @@ impl<'a> HookTarget<'a> { self.under(&self.xdg_config_dir(), &["crush", "crush.json"]) } + /// CodeBuddy moves its whole home, `settings.json` included, to + /// `CODEBUDDY_CONFIG_DIR` when that is set and not blank. Local-only, like + /// the other overrides. + fn codebuddy_settings_path(&self) -> PathBuf { + if self.is_local() + && let Some(dir) = std::env::var_os("CODEBUDDY_CONFIG_DIR") + .filter(|d| !d.to_string_lossy().trim().is_empty()) + { + return PathBuf::from(dir).join("settings.json"); + } + self.under_home(&[".codebuddy", "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()) { @@ -860,6 +880,23 @@ const QODER_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionEnd", "session-end"), ]; +/// CodeBuddy's hooks are Claude Code's, file layout and event names alike, but +/// like Qoder it has a first-class `PermissionRequest`, so it takes Qoder's +/// table rather than Claude's `Notification` sniffing. A turn that dies on an +/// API error reports `StopFailure` instead of `Stop`; without it the pane +/// would stay on working. +const CODEBUDDY_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"), +]; + /// Crush currently fires exactly one hook, `PreToolUse`, before every /// top-level tool call and before its permission check. Its payload still /// carries `session_id` and `cwd`, which is what resume needs. @@ -1235,6 +1272,7 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::Qwen | HookAgent::QoderCLI | HookAgent::Crush + | HookAgent::CodeBuddy | HookAgent::Kimi => None, } } @@ -1692,6 +1730,7 @@ mod tests { .chain(GOOSE_HOOK_EVENTS) .chain(KIMI_HOOK_EVENTS) .chain(CRUSH_HOOK_EVENTS) + .chain(CODEBUDDY_HOOK_EVENTS) .map(|(_, e)| *e) .chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e)) .collect(); @@ -1728,6 +1767,7 @@ mod tests { (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), (HookAgent::Crush, "/home/me/.config/crush/crush.json"), + (HookAgent::CodeBuddy, "/home/me/.codebuddy/settings.json"), ] { assert_eq!( agent.target_path(&t), @@ -1750,6 +1790,7 @@ mod tests { HookAgent::Kimi, HookAgent::QoderCLI, HookAgent::Crush, + HookAgent::CodeBuddy, ] { assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled); install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug())); @@ -1879,6 +1920,71 @@ mod tests { assert_eq!(state.status, AgentStatus::Done); } + /// CodeBuddy reuses Claude Code's hook shape and payload, so a turn walks + /// the same way — but it fires `SessionStart` with `source: "compact"` + /// in the middle of a turn, which must not reset the pane to idle. + #[test] + fn codebuddy_turns_survive_compaction_and_record_the_session() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; + + let apply_hook = |state: &mut AgentSessionState, hook: &str, input: &str| { + let event = HookAgent::CodeBuddy + .hook_map_events() + .unwrap() + .iter() + .find_map(|(name, event)| (*name == hook).then_some(*event)) + .unwrap_or_else(|| panic!("CodeBuddy installs no {hook} hook")); + if let Some(event) = effective_event("codebuddy", event, input) { + let ev = round_trip("codebuddy", event, input); + assert_eq!(ev.agent, Some(CLIAgent::CodeBuddy)); + state.apply_event(&ev); + } + }; + let mut state = AgentSessionState::default(); + apply_hook( + &mut state, + "SessionStart", + r#"{"session_id":"cb-1","cwd":"/repo","source":"startup"}"#, + ); + assert_eq!(state.status, AgentStatus::Idle); + apply_hook( + &mut state, + "UserPromptSubmit", + r#"{"session_id":"cb-1","cwd":"/repo","prompt":"Fix the build"}"#, + ); + assert_eq!(state.status, AgentStatus::Working); + + let before = state.clone(); + apply_hook( + &mut state, + "SessionStart", + r#"{"session_id":"cb-1","cwd":"/repo","source":"compact"}"#, + ); + assert_eq!(state, before, "compaction must preserve the active turn"); + + apply_hook( + &mut state, + "PermissionRequest", + r#"{"session_id":"cb-1","tool_name":"Bash"}"#, + ); + assert_eq!(state.status, AgentStatus::Waiting); + apply_hook(&mut state, "PostToolUse", r#"{"session_id":"cb-1"}"#); + assert_eq!(state.status, AgentStatus::Working); + apply_hook(&mut state, "StopFailure", r#"{"session_id":"cb-1"}"#); + assert_eq!(state.status, AgentStatus::Done, "a failed turn still ends"); + assert_eq!(state.session_id.as_deref(), Some("cb-1")); + assert_eq!(state.cwd.as_deref(), Some(Path::new("/repo"))); + + assert!( + !HookAgent::CodeBuddy + .hook_map_events() + .unwrap() + .iter() + .any(|(hook, _)| *hook == "Notification"), + "PermissionRequest reports the block; Notification would only muddy it" + ); + } + /// Crush's lone `PreToolUse` has no `Stop` to close a turn behind it, so it /// must not open one: the pane would read as busy until Crush exits. #[test] @@ -2170,6 +2276,7 @@ mod tests { (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), (HookAgent::Crush, "/home/me/.config/crush/crush.json"), + (HookAgent::CodeBuddy, "/home/me/.codebuddy/settings.json"), ] { assert_eq!( agent.target_path(&target), @@ -2499,6 +2606,105 @@ mod tests { } } + #[test] + fn codebuddy_config_dir_controls_local_hook_lifecycle() { + const CASE_ENV: &str = "TTY7_TEST_CODEBUDDY_CONFIG_CASE"; + const ROOT_ENV: &str = "TTY7_TEST_CODEBUDDY_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", "blank", "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::codebuddy_config_dir_controls_local_hook_lifecycle", + "--nocapture", + ]) + .env(CASE_ENV, case) + .env(ROOT_ENV, sandbox.path()); + match case { + "override" => { + child.env("CODEBUDDY_CONFIG_DIR", sandbox.path().join("custom config")) + } + // CodeBuddy trims the value, so whitespace is as unset as empty. + "blank" => child.env("CODEBUDDY_CONFIG_DIR", " "), + _ => child.env_remove("CODEBUDDY_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 CodeBuddy 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(".codebuddy").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": "codebuddy-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::CodeBuddy; + 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/.codebuddy/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 codebuddy") + ); + 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()); + } + } + /// Crush's hook map is one list under `hooks.PreToolUse` like Claude's, but /// each entry carries `command` and `matcher` directly instead of wrapping /// them in a nested `hooks` array. The merge has to write the flat shape and diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index c4340f67..eb7d5865 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -28,10 +28,11 @@ pub enum CLIAgent { TraeCode, QoderCLI, Crush, + CodeBuddy, } impl CLIAgent { - pub const ALL: [CLIAgent; 22] = [ + pub const ALL: [CLIAgent; 23] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::TraeCode, @@ -54,6 +55,7 @@ impl CLIAgent { CLIAgent::Kimi, CLIAgent::QoderCLI, CLIAgent::Crush, + CLIAgent::CodeBuddy, ]; fn aliases(self) -> &'static [&'static str] { @@ -99,6 +101,11 @@ impl CLIAgent { // Charm's terminal agent. One binary, and the name on `PATH` is // the one it starts as. CLIAgent::Crush => &["crush"], + // Tencent's terminal agent. The npm package installs `codebuddy`, + // `codebuddy-code` and the short `cbc`, all pointing at one script. + // `cbc` also names the COIN-OR solver; a solver run wearing the + // avatar until it exits is the cost of catching the short name. + CLIAgent::CodeBuddy => &["codebuddy", "codebuddy-code", "cbc"], } } @@ -126,6 +133,7 @@ impl CLIAgent { CLIAgent::Kimi => "kimi", CLIAgent::QoderCLI => "qodercli", CLIAgent::Crush => "crush", + CLIAgent::CodeBuddy => "codebuddy", } } @@ -158,6 +166,7 @@ impl CLIAgent { CLIAgent::Kimi => "Kimi Code", CLIAgent::QoderCLI => "Qoder CLI", CLIAgent::Crush => "Crush", + CLIAgent::CodeBuddy => "CodeBuddy", } } @@ -194,6 +203,7 @@ impl CLIAgent { CLIAgent::OhMyPi => Some(format!("omp{flags} --resume {session_id}")), CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")), CLIAgent::Crush => Some(format!("crush{flags} --session {session_id}")), + CLIAgent::CodeBuddy => Some(format!("codebuddy{flags} --resume {session_id}")), _ => None, } } @@ -209,7 +219,7 @@ impl CLIAgent { 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"], + CLIAgent::QoderCLI | CLIAgent::CodeBuddy => &["--no-session-persistence"], _ => &[], }; argv.iter().any(|t| ephemeral.contains(&t.as_str())) @@ -230,6 +240,9 @@ impl CLIAgent { CLIAgent::QoderCLI => Some(format!( "qodercli{flags} --resume {session_id} --fork-session" )), + CLIAgent::CodeBuddy => Some(format!( + "codebuddy{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. @@ -258,7 +271,8 @@ impl CLIAgent { | CLIAgent::Amp | CLIAgent::Qwen | CLIAgent::Goose - | CLIAgent::QoderCLI => Some("Fork Session"), + | CLIAgent::QoderCLI + | CLIAgent::CodeBuddy => Some("Fork Session"), _ => None, } } @@ -442,6 +456,22 @@ impl CLIAgent { // `--session {id}` this command appends. `-c` is *not* in that // group here — Crush spells `--cwd` with it, and it must survive. CLIAgent::Crush => &["--session", "-s", "--continue", "-C"], + // CodeBuddy keeps Claude Code's session flags. `--resume-session-at` + // cuts a resumed conversation short and only makes sense next to + // the resume it came with. Unlike Qoder, `-w` *is* `--worktree` + // here, and replaying either would branch off a fresh tree. + CLIAgent::CodeBuddy => &[ + "--resume", + "-r", + "--continue", + "-c", + "--session-id", + "--fork-session", + "--resume-session-at", + "--worktree", + "-w", + "--worktree-branch", + ], _ => &[], }; let mut i = 0; @@ -509,6 +539,8 @@ impl CLIAgent { CLIAgent::QoderCLI => 0xFFFFFF, // The blue-violet field Charm ships the Crush heart on. CLIAgent::Crush => 0x6B50FF, + // The near-black field CodeBuddy's own app icon sits on. + CLIAgent::CodeBuddy => 0x1F1F1F, } } @@ -551,6 +583,7 @@ impl CLIAgent { CLIAgent::Kimi => "icons/agents/kimi.svg", CLIAgent::QoderCLI => "icons/agents/qodercli.svg", CLIAgent::Crush => "icons/agents/crush.svg", + CLIAgent::CodeBuddy => "icons/agents/codebuddy.svg", CLIAgent::Aider | CLIAgent::Auggie | CLIAgent::Hermes @@ -938,6 +971,35 @@ mod tests { } } + /// All three of CodeBuddy's npm bins are node scripts pointing at one file, + /// so the pty sees node plus whichever shim name was typed. + #[test] + fn codebuddy_is_detected_through_each_of_its_binaries() { + for launcher in [ + "codebuddy", + "codebuddy-code", + "cbc", + "/opt/homebrew/bin/codebuddy", + "/usr/local/bin/cbc", + ] { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["node", launcher])), + Some(CLIAgent::CodeBuddy), + "on {launcher}" + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&[launcher])), + Some(CLIAgent::CodeBuddy), + "on {launcher}" + ); + } + assert_eq!( + CLIAgent::from_slug("codebuddy"), + Some(CLIAgent::CodeBuddy), + "hook events name the agent by this slug" + ); + } + #[test] fn detects_npx_package_form() { assert_eq!( @@ -1750,6 +1812,62 @@ mod tests { ); } + #[test] + fn codebuddy_resume_and_fork_drop_stale_session_and_worktree_flags() { + assert_eq!( + CLIAgent::CodeBuddy.resume_command("cb-1", None).as_deref(), + Some("codebuddy --resume cb-1") + ); + assert_eq!( + CLIAgent::CodeBuddy.fork_command("cb-1", None).as_deref(), + Some("codebuddy --resume cb-1 --fork-session") + ); + for stale in [ + vec!["--resume", "cb-0"], + vec!["-r", "cb-0"], + vec!["--continue"], + vec!["-c"], + vec!["--session-id", "cb-0"], + vec!["--resume", "cb-0", "--resume-session-at", "m-9"], + vec!["--worktree"], + vec!["--worktree", "old-tree", "--worktree-branch", "main"], + vec!["-w", "old-tree"], + vec!["--worktree=old-tree"], + vec!["--resume", "cb-0", "--fork-session"], + ] { + let mut launch = argv(&["codebuddy", "--model", "m-1"]); + launch.extend(argv(&stale)); + launch.extend(argv(&["--permission-mode", "acceptEdits"])); + assert_eq!( + CLIAgent::CodeBuddy + .resume_command("cb-2", Some(&launch)) + .as_deref(), + Some("codebuddy --model m-1 --permission-mode acceptEdits --resume cb-2"), + "launch argv: {launch:?}" + ); + assert_eq!( + CLIAgent::CodeBuddy + .fork_command("cb-2", Some(&launch)) + .as_deref(), + Some( + "codebuddy --model m-1 --permission-mode acceptEdits --resume cb-2 --fork-session" + ), + "launch argv: {launch:?}" + ); + } + + let ephemeral = argv(&["codebuddy", "-p", "--no-session-persistence"]); + assert_eq!( + CLIAgent::CodeBuddy.resume_command("cb-1", Some(&ephemeral)), + None, + "nothing was saved, so there is nothing to reopen" + ); + assert_eq!( + CLIAgent::CodeBuddy.fork_command("cb-1", Some(&ephemeral)), + None + ); + } + #[test] fn oh_my_pi_resume_and_fork_use_its_own_flags() { let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::>(); diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index 94fd0c10..affe5b2d 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -1,6 +1,6 @@ --- title: "Coding agents" -description: "What tty7 does around Claude Code, Codex, TraeCode, and 19 others — without ever wrapping them." +description: "What tty7 does around Claude Code, Codex, TraeCode, and 20 others — without ever wrapping them." --- tty7 recognises coding agents running in a pane and builds around them. It does @@ -15,7 +15,7 @@ need, and what changed. ## Which agents -Twenty-two CLIs are recognised on sight, by the command running in the pane: +Twenty-three CLIs are recognised on sight, by the command running in the pane: | Agent | Command | |---|---| @@ -35,6 +35,7 @@ Twenty-two CLIs are recognised on sight, by the command running in the pane: | Kimi Code | `kimi`, `kimi-code` | | Qoder CLI | `qoder`, `qodercli` | | Crush | `crush` | +| CodeBuddy | `codebuddy`, `codebuddy-code`, `cbc` | | Auggie | `auggie` | | Hermes | `hermes` | | Vibe | `vibe`, `vibe-acp` | @@ -63,7 +64,7 @@ If you launch agents through a wrapper script, map its name to an agent in The key is your command's name; the value is one of the slugs above (`claude`, `codex`, `traecli`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, -`omp`, `kimi`, `qodercli`, `crush`). +`omp`, `kimi`, `qodercli`, `crush`, `codebuddy`). ## What you get for free diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index 57ae6438..3ac83361 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -14,7 +14,7 @@ the agent say which one it is. | Agent | | |---|---| -| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Crush | Hooks available | +| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Crush · CodeBuddy | Hooks available | | Aider · Amp · Cursor · Auggie · Hermes · Vibe · Antigravity | Detected and labelled, but no status channel yet | Installing writes into that agent's own configuration directory. Once installed diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx index 20e2e571..3785e32a 100644 --- a/docs/getting-started/first-launch.mdx +++ b/docs/getting-started/first-launch.mdx @@ -54,7 +54,7 @@ leave it off if you type accented characters. ## 4. If you use coding agents, install the hooks -**Settings → Integrations.** tty7 detects 22 coding CLIs by process name on its own — +**Settings → Integrations.** tty7 detects 23 coding CLIs by process name on its own — you get brand avatars and tab labels for free. The *status dots*, the "needs your permission" notifications, and `tty7 wait` all need one more thing: a small hook the agent calls to report what it is doing. diff --git a/docs/index.mdx b/docs/index.mdx index 07e72400..511b0b6f 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -31,7 +31,7 @@ something floods the screen. syntax highlighting, click-to-place-caret, real multi-line editing. - 22 coding CLIs are recognised on sight. Per-pane status dots, notifications + 23 coding CLIs are recognised on sight. Per-pane status dots, notifications when one needs you, git context, and session resume after a reboot. diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 882a7113..2bf9e9ab 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -64,6 +64,9 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/agents/kimi.svg" => include_bytes!("../../assets/icons/agents/kimi.svg"), "icons/agents/qodercli.svg" => include_bytes!("../../assets/icons/agents/qodercli.svg"), "icons/agents/crush.svg" => include_bytes!("../../assets/icons/agents/crush.svg"), + "icons/agents/codebuddy.svg" => { + include_bytes!("../../assets/icons/agents/codebuddy.svg") + } _ => return None, }; Some(bytes) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index c6de1a62..056926da 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -795,6 +795,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsAgentQoderCLI => "Qoder CLI", L10nKey::SettingsAgentCrush => "Crush", + L10nKey::SettingsAgentCodeBuddy => "CodeBuddy", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -892,6 +893,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::SettingsSearchQoderCLIKeywords => "agent integration hooks install qoder qodercli", L10nKey::SettingsSearchCrushKeywords => "agent integration hooks install crush", + L10nKey::SettingsSearchCodeBuddyKeywords => { + "agent integration hooks install codebuddy codebuddy-code cbc tencent" + } 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 7b7eb124..f89a0b09 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -805,6 +805,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsAgentQoderCLI => "Qoder CLI", L10nKey::SettingsAgentCrush => "Crush", + L10nKey::SettingsAgentCodeBuddy => "CodeBuddy", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -948,6 +949,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchCrushKeywords => { "エージェント 統合 フック インストール crush agent integration hooks install" } + L10nKey::SettingsSearchCodeBuddyKeywords => { + "エージェント 統合 フック インストール codebuddy cbc tencent 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 f3d6b4c7..dd78acd6 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -614,6 +614,7 @@ l10n_keys! { SettingsAgentKimiCode, SettingsAgentQoderCLI, SettingsAgentCrush, + SettingsAgentCodeBuddy, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -670,6 +671,7 @@ l10n_keys! { SettingsSearchQwenCodeKeywords, SettingsSearchQoderCLIKeywords, SettingsSearchCrushKeywords, + SettingsSearchCodeBuddyKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, SettingsSearchRestoreLastLayoutKeywords, @@ -1607,6 +1609,7 @@ mod tests { L10nKey::SettingsAgentQwenCode, L10nKey::SettingsAgentQoderCLI, L10nKey::SettingsAgentCrush, + L10nKey::SettingsAgentCodeBuddy, // 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 fe153ff8..a1d7af2d 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -702,6 +702,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsAgentQoderCLI => "Qoder CLI", L10nKey::SettingsAgentCrush => "Crush", + L10nKey::SettingsAgentCodeBuddy => "CodeBuddy", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -839,6 +840,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { } L10nKey::SettingsSearchQoderCLIKeywords => "Qoder CLI agent 集成 钩子 安装 qoder qodercli", L10nKey::SettingsSearchCrushKeywords => "Crush agent 集成 钩子 安装 crush", + L10nKey::SettingsSearchCodeBuddyKeywords => { + "CodeBuddy 腾讯云代码助手 agent 集成 钩子 安装 codebuddy cbc tencent" + } L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 59bd1bf2..976de1f4 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -770,6 +770,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentCrush, keywords: SettingsSearchCrushKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentCodeBuddy, + keywords: SettingsSearchCodeBuddyKeywords, + }, SearchEntry { section: General, title: SettingsStartupWindow,