feat(agents): add CodeBuddy CLI integration (#892)

CodeBuddy Code takes Claude Code's hooks as-is: the same nested
`hooks.<Event>[].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 <id>` 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.
This commit is contained in:
l0ng-ai
2026-09-23 15:04:06 +08:00
parent d534c085b9
commit 0d0dc597ee
14 changed files with 370 additions and 15 deletions
+3 -2
View File
@@ -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 · <kbd>⌃ R</kbd> fuzzy history |
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> scrollback search · <kbd>⌘ J</kbd> 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.
<details>
<summary>The full support matrix, all twenty-two</summary>
<summary>The full support matrix, all twenty-three</summary>
| 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** | ✓ | ✓ | |
+6
View File
@@ -0,0 +1,6 @@
<!-- CodeBuddy's mark, from the pwa-icon.svg shipped in @tencent-ai/codebuddy-code
(MIT, Copyright 2023 Tencent Cloud). The source draws it on a 12x12 grid;
scaled to tty7's 24x24 icon format, geometry unchanged. -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path transform="scale(2)" fill="#FFFFFF" fill-rule="evenodd" d="M0.0001 5.9182L0.0001 6.0001L0.0001 6.0821C0 8.8444 0 10.2426 0.8788 11.1214C1.0848 11.3274 1.3193 11.4851 1.5932 11.6058C1.5625 11.2148 1.6512 10.8775 1.855 10.6444C1.9344 10.5539 1.9369 10.5501 1.9196 10.398C1.8909 10.149 1.878 9.7805 1.891 9.5427L1.9014 9.3205L1.5679 8.7305C1.0513 7.8115 0.7233 7.0398 0.5967 6.4501C0.5299 6.1268 0.534 5.9834 0.6161 5.8773C0.6661 5.8132 0.8298 5.7469 1.0273 5.7104C1.5245 5.6231 2.6087 5.7021 3.815 5.9151L3.9403 5.9367L4.2156 5.6931C4.6728 5.2883 4.9764 5.0612 5.5363 4.7122C6.1198 4.3471 6.7783 4.0468 7.5198 3.809L7.7578 3.7327L7.8885 3.3893C8.3568 2.1531 8.8364 1.2418 9.1784 0.9386C9.296 0.8331 9.3031 0.829 9.3894 0.8238C9.5292 0.8136 9.6572 0.8807 9.8752 1.0791C10.3844 1.5418 11.0934 2.4931 11.5342 3.3054L11.7045 3.6207L11.9451 3.7403C11.955 3.7453 11.9652 3.7505 11.9756 3.756C11.9193 2.3548 11.7335 1.4908 11.1214 0.8788C10.2426 0 8.8444 0 6.0821 0.0001L6.0001 0.0001L5.9182 0.0001C3.1558 0 1.7576 0 0.8788 0.8788C0 1.7576 0 3.1558 0.0001 5.9182ZM12.0001 5.5869C11.3059 4.9017 10.2729 4.6581 9.3399 4.9737C9.0955 5.0564 8.8304 5.2094 8.3004 5.5154L5.2558 7.2732C4.7258 7.5792 4.4608 7.7322 4.2669 7.9026C3.481 8.5933 3.1852 9.6974 3.5204 10.6886C3.6031 10.9331 3.7561 11.1981 4.0621 11.7281C4.1171 11.8234 4.1672 11.9101 4.2133 11.9894C4.7159 12.0002 5.2807 12.0001 5.9182 12.0001L6.0001 12.0001L6.0821 12.0001C8.8444 12.0002 10.2426 12.0003 11.1214 11.1214C12.0003 10.2426 12.0002 8.8444 12.0001 6.0821L12.0001 6.0001L12.0001 5.9182C12.0001 5.8055 12.0001 5.6951 12.0001 5.5869ZM9.3152 7.2263Q9.5309 7.1018 9.7715 7.1662Q10.0122 7.2307 10.1367 7.4464L10.7843 8.5681Q10.9089 8.7838 10.8444 9.0245Q10.7799 9.2651 10.5642 9.3896Q10.4606 9.4494 10.342 9.465Q10.2234 9.4806 10.1079 9.4497Q9.9923 9.4187 9.8974 9.3459Q9.8025 9.2731 9.7427 9.1695L9.0951 8.0478Q9.0353 7.9442 9.0197 7.8256Q9.0041 7.707 9.035 7.5915Q9.066 7.4759 9.1388 7.381Q9.2116 7.2861 9.3152 7.2263ZM6.5262 9.0398Q6.4107 9.0088 6.2921 9.0244Q6.1735 9.0401 6.0699 9.0999Q5.9663 9.1597 5.8935 9.2546Q5.8207 9.3495 5.7897 9.465Q5.7588 9.5806 5.7744 9.6992Q5.79 9.8177 5.8498 9.9213L6.4974 11.043Q6.6219 11.2588 6.8625 11.3232Q7.1031 11.3877 7.3189 11.2632Q7.4225 11.2033 7.4953 11.1084Q7.5681 11.0135 7.5991 10.898Q7.63 10.7825 7.6144 10.6639Q7.5988 10.5453 7.539 10.4417L6.8914 9.32Q6.8316 9.2164 6.7367 9.1436Q6.6418 9.0708 6.5262 9.0398Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+210 -4
View File
@@ -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::<serde_json::Value>(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<String> {
| 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
+121 -3
View File
@@ -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::<Vec<_>>();
+4 -3
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -31,7 +31,7 @@ something floods the screen.
syntax highlighting, click-to-place-caret, real multi-line editing.
</Card>
<Card title="Agent-aware" icon="robot" href="/agents/overview">
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.
</Card>
<Card title="Remote work that feels local" icon="server" href="/remote/workspaces">
+3
View File
@@ -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)
+4
View File
@@ -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"
+4
View File
@@ -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"
}
+3
View File
@@ -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
+4
View File
@@ -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"
}
+5
View File
@@ -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,