diff --git a/README.md b/README.md
index fa649748..8aad1808 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com
| | |
|---|---|
-| **Agent-aware** | per-pane detection (20 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 (22 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 |
@@ -69,7 +69,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
+The full support matrix, all twenty-two
| Agent | Detected | Status · resume | Fork |
|---|:-:|:-:|:-:|
@@ -82,10 +82,12 @@ that tells tty7 which session to fork.
| **Droid** | ✓ | ✓ | ✓ |
| **Qwen Code** | ✓ | ✓ | ✓ |
| **Goose** | ✓ | ✓ | ✓ |
+| **Qoder CLI** | ✓ | ✓ | ✓ |
| **Gemini** | ✓ | ✓ | |
| **Copilot** | ✓ | ✓ | |
| **Kimi Code** | ✓ | ✓ | |
| **Pi** | ✓ | ✓ | |
+| **Crush** | ✓ | ✓ | |
| Aider | ✓ | | |
| Amp | ✓ | | |
| Cursor | ✓ | | |
diff --git a/assets/icons/agents/crush.svg b/assets/icons/agents/crush.svg
new file mode 100644
index 00000000..9c900100
--- /dev/null
+++ b/assets/icons/agents/crush.svg
@@ -0,0 +1,31 @@
+
+
diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs
index e8865107..e2a05d1c 100644
--- a/crates/tty7-core/src/core/agent_hooks.rs
+++ b/crates/tty7-core/src/core/agent_hooks.rs
@@ -267,10 +267,11 @@ pub enum HookAgent {
Goose,
Kimi,
QoderCLI,
+ Crush,
}
impl HookAgent {
- pub const ALL: [HookAgent; 14] = [
+ pub const ALL: [HookAgent; 15] = [
HookAgent::Claude,
HookAgent::Codex,
HookAgent::TraeCode,
@@ -285,6 +286,7 @@ impl HookAgent {
HookAgent::Goose,
HookAgent::Kimi,
HookAgent::QoderCLI,
+ HookAgent::Crush,
];
/// The hooks behind a detected agent process, if it has any.
@@ -308,6 +310,7 @@ impl HookAgent {
CLIAgent::Goose => Some(HookAgent::Goose),
CLIAgent::Kimi => Some(HookAgent::Kimi),
CLIAgent::QoderCLI => Some(HookAgent::QoderCLI),
+ CLIAgent::Crush => Some(HookAgent::Crush),
CLIAgent::Aider
| CLIAgent::Amp
| CLIAgent::Cursor
@@ -330,6 +333,7 @@ impl HookAgent {
HookAgent::Droid => Some(DROID_HOOK_EVENTS),
HookAgent::Qwen => Some(QWEN_HOOK_EVENTS),
HookAgent::QoderCLI => Some(QODER_HOOK_EVENTS),
+ HookAgent::Crush => Some(CRUSH_HOOK_EVENTS),
HookAgent::Copilot
| HookAgent::OpenCode
| HookAgent::Pi
@@ -350,6 +354,14 @@ impl HookAgent {
}
}
+ /// Whether this agent's hook-map entries carry `command` and `matcher` at
+ /// the top level rather than nesting a `hooks` array of `{type, command}`
+ /// objects inside the matcher. Claude's shape is the latter; Crush flattened
+ /// it, and still calls itself Claude-Code-compatible on the wire.
+ fn flat_hook_map(self) -> bool {
+ matches!(self, HookAgent::Crush)
+ }
+
pub fn slug(self) -> &'static str {
match self {
HookAgent::Claude => "claude",
@@ -366,6 +378,7 @@ impl HookAgent {
HookAgent::Goose => "goose",
HookAgent::Kimi => "kimi",
HookAgent::QoderCLI => "qodercli",
+ HookAgent::Crush => "crush",
}
}
@@ -385,6 +398,7 @@ impl HookAgent {
HookAgent::Goose => "Goose",
HookAgent::Kimi => "Kimi Code",
HookAgent::QoderCLI => "Qoder CLI",
+ HookAgent::Crush => "Crush",
}
}
@@ -418,6 +432,7 @@ impl HookAgent {
}
HookAgent::Kimi => target.kimi_config_path(),
HookAgent::QoderCLI => target.qoder_settings_path(),
+ HookAgent::Crush => target.crush_settings_path(),
}
}
@@ -519,6 +534,20 @@ impl<'a> HookTarget<'a> {
self.under_home(&[".qoder", "settings.json"])
}
+ /// The global `crush.json`. Crush resolves it through `CRUSH_GLOBAL_CONFIG`
+ /// when set, otherwise `$XDG_CONFIG_HOME/crush/crush.json` (and `~/.config`
+ /// when that is unset) — which is exactly what [`Self::xdg_config_dir`]
+ /// answers. The override is local-only: a local env var must not redirect a
+ /// remote machine's hooks.
+ fn crush_settings_path(&self) -> PathBuf {
+ if self.is_local()
+ && let Some(dir) = std::env::var_os("CRUSH_GLOBAL_CONFIG").filter(|d| !d.is_empty())
+ {
+ return PathBuf::from(dir).join("crush.json");
+ }
+ self.under(&self.xdg_config_dir(), &["crush", "crush.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()) {
@@ -831,6 +860,17 @@ const QODER_HOOK_EVENTS: &[(&str, &str)] = &[
("SessionEnd", "session-end"),
];
+/// Crush currently fires exactly one hook, `PreToolUse`, before every
+/// top-level tool call and before its permission check. There is no turn
+/// boundary to report, so a tool call is the only evidence that Crush is
+/// working at all: it maps to `prompt-submit`, and the pane is cleared when
+/// Crush exits and the foreground process goes back to the shell.
+///
+/// The cost is that a turn cannot report done: `tty7 wait` will time out
+/// rather than return. That is Crush's limitation, not tty7's; when it ships
+/// `UserPromptSubmit`/`Stop` and friends, they slot in here.
+const CRUSH_HOOK_EVENTS: &[(&str, &str)] = &[("PreToolUse", "prompt-submit")];
+
fn hook_map_state(
target: &HookTarget,
path: &Path,
@@ -915,9 +955,13 @@ fn hook_map_install(
continue;
};
list.retain(|matcher| marker_command(matcher, &marker).is_none());
- list.push(serde_json::json!({
- "hooks": [{ "type": "command", "command": command }]
- }));
+ if agent.flat_hook_map() {
+ list.push(serde_json::json!({ "command": command }));
+ } else {
+ list.push(serde_json::json!({
+ "hooks": [{ "type": "command", "command": command }]
+ }));
+ }
}
target.write(path, serde_json::to_string_pretty(&root)?.as_bytes())
@@ -961,8 +1005,21 @@ fn hook_map_uninstall(
Ok(HookOutcome::Removed)
}
-fn marker_command<'a>(matcher: &'a serde_json::Value, marker: &str) -> Option<&'a str> {
- matcher
+/// The tty7 command an entry in a hook map carries, if it is one of ours.
+///
+/// Two shapes reach here. Claude and its imitators nest it at
+/// `entry.hooks[].command`; Crush lifts `command` to the entry itself, the
+/// same level as its `matcher`. Both are one list under `hooks.`, so
+/// the state reader, the installer and the uninstaller all stay shared.
+fn marker_command<'a>(entry: &'a serde_json::Value, marker: &str) -> Option<&'a str> {
+ if let Some(command) = entry
+ .get("command")
+ .and_then(|c| c.as_str())
+ .filter(|c| c.contains(marker))
+ {
+ return Some(command);
+ }
+ entry
.get("hooks")
.and_then(|h| h.as_array())?
.iter()
@@ -1176,6 +1233,7 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option {
| HookAgent::Droid
| HookAgent::Qwen
| HookAgent::QoderCLI
+ | HookAgent::Crush
| HookAgent::Kimi => None,
}
}
@@ -1612,6 +1670,7 @@ mod tests {
.chain(QWEN_HOOK_EVENTS)
.chain(GOOSE_HOOK_EVENTS)
.chain(KIMI_HOOK_EVENTS)
+ .chain(CRUSH_HOOK_EVENTS)
.map(|(_, e)| *e)
.chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e))
.collect();
@@ -1647,6 +1706,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"),
] {
assert_eq!(
agent.target_path(&t),
@@ -1668,6 +1728,7 @@ mod tests {
HookAgent::Goose,
HookAgent::Kimi,
HookAgent::QoderCLI,
+ HookAgent::Crush,
] {
assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled);
install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug()));
@@ -1945,6 +2006,18 @@ mod tests {
});
assert!(marker_command(&theirs, "agent-hook claude").is_none());
assert!(marker_command(&serde_json::json!({}), "agent-hook claude").is_none());
+
+ // Crush flattens the same entry: `command` sits beside `matcher` rather
+ // than inside a nested `hooks` array, and the reader has to see it.
+ let flat = serde_json::json!({
+ "matcher": "^bash$",
+ "command": "\"/x/tty7\" agent-hook crush prompt-submit"
+ });
+ assert_eq!(
+ marker_command(&flat, "agent-hook crush"),
+ Some("\"/x/tty7\" agent-hook crush prompt-submit")
+ );
+ assert!(marker_command(&flat, "agent-hook claude").is_none());
}
fn local_host() -> crate::host::SharedHost {
@@ -2055,6 +2128,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"),
] {
assert_eq!(
agent.target_path(&target),
@@ -2378,6 +2452,191 @@ mod tests {
}
}
+ /// 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
+ /// still leave a user's own hooks and the rest of `crush.json` alone.
+ #[test]
+ fn crush_installs_a_flat_hook_and_preserves_user_entries() {
+ let host = FakeRemote::shared();
+ let base = std::env::temp_dir().join(format!("tty7-crush-hooks-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&base);
+ let target = HookTarget::remote(&*host, base.clone());
+ let config = HookAgent::Crush.target_path(&target);
+ std::fs::create_dir_all(config.parent().unwrap()).unwrap();
+ let user_config = serde_json::json!({
+ "model": "crush-test",
+ "hooks": {
+ "PreToolUse": [
+ { "matcher": "^bash$", "command": "echo user-hook", "timeout": 5 }
+ ]
+ }
+ });
+ std::fs::write(&config, serde_json::to_string_pretty(&user_config).unwrap()).unwrap();
+
+ assert_eq!(
+ hooks_state(&target, HookAgent::Crush),
+ HooksState::NotInstalled
+ );
+ install_hooks(&target, HookAgent::Crush).expect("install succeeds");
+ assert_eq!(
+ hooks_state(&target, HookAgent::Crush),
+ HooksState::Installed
+ );
+ install_hooks(&target, HookAgent::Crush).expect("re-install succeeds");
+
+ let merged: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
+ assert_eq!(merged["model"], "crush-test");
+ let entries = merged["hooks"]["PreToolUse"].as_array().unwrap();
+ let ours: Vec<&serde_json::Value> = entries
+ .iter()
+ .filter(|e| {
+ e.get("command")
+ .and_then(|c| c.as_str())
+ .is_some_and(|c| c.contains("agent-hook crush"))
+ })
+ .collect();
+ assert_eq!(ours.len(), 1, "exactly one tty7 entry after two installs");
+ assert_eq!(
+ ours[0]["command"].as_str(),
+ Some(
+ target
+ .hook_command(HookAgent::Crush, "prompt-submit")
+ .as_str()
+ ),
+ "the entry is flat and names the prompt-submit emitter"
+ );
+ assert!(
+ ours[0].get("hooks").is_none(),
+ "Crush does not nest a hooks array inside the entry"
+ );
+ assert!(
+ entries.iter().any(|e| e
+ .get("command")
+ .and_then(|c| c.as_str())
+ .is_some_and(|c| c.contains("user-hook"))),
+ "the user's own PreToolUse hook survives"
+ );
+
+ assert_eq!(
+ uninstall_hooks(&target, HookAgent::Crush).unwrap(),
+ HookOutcome::Removed
+ );
+ assert_eq!(
+ hooks_state(&target, HookAgent::Crush),
+ HooksState::NotInstalled
+ );
+ let after: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
+ assert_eq!(
+ after, user_config,
+ "uninstall restores the user's file exactly"
+ );
+
+ let _ = std::fs::remove_dir_all(&base);
+ }
+
+ #[test]
+ fn crush_global_config_controls_local_hook_lifecycle() {
+ const CASE_ENV: &str = "TTY7_TEST_CRUSH_CONFIG_CASE";
+ const ROOT_ENV: &str = "TTY7_TEST_CRUSH_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::crush_global_config_controls_local_hook_lifecycle",
+ "--nocapture",
+ ])
+ .env(CASE_ENV, case)
+ .env(ROOT_ENV, sandbox.path());
+ match case {
+ "override" => {
+ child.env("CRUSH_GLOBAL_CONFIG", sandbox.path().join("custom config"))
+ }
+ "empty" => child.env("CRUSH_GLOBAL_CONFIG", ""),
+ _ => child.env_remove("CRUSH_GLOBAL_CONFIG"),
+ };
+ let output = crate::core::proc::output_within(
+ crate::core::proc::hide_console(&mut child),
+ std::time::Duration::from_secs(30),
+ )
+ .expect("run the isolated Crush 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_config = target.home.join(".config").join("crush").join("crush.json");
+ let custom_config = root.join("custom config").join("crush.json");
+ let (config, untouched) = if case == "override" {
+ (&custom_config, &default_config)
+ } else {
+ (&default_config, &custom_config)
+ };
+ let user_config = serde_json::json!({
+ "model": "crush-test",
+ "hooks": {
+ "PreToolUse": [
+ { "command": "echo user-hook" }
+ ]
+ }
+ });
+ for path in [config, untouched] {
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, serde_json::to_string(&user_config).unwrap()).unwrap();
+ }
+
+ let agent = HookAgent::Crush;
+ assert_eq!(agent.target_path(&target), *config);
+ 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/.config/crush/crush.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(config)
+ .unwrap()
+ .contains("agent-hook crush")
+ );
+ assert_eq!(
+ uninstall_hooks(&target, agent).unwrap(),
+ HookOutcome::Removed
+ );
+ assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled);
+ for path in [config, 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 1af51403..952831e8 100644
--- a/crates/tty7-core/src/core/cli_agent.rs
+++ b/crates/tty7-core/src/core/cli_agent.rs
@@ -27,10 +27,11 @@ pub enum CLIAgent {
// moving an existing discriminant would break mixed-version clients.
TraeCode,
QoderCLI,
+ Crush,
}
impl CLIAgent {
- pub const ALL: [CLIAgent; 21] = [
+ pub const ALL: [CLIAgent; 22] = [
CLIAgent::Claude,
CLIAgent::Codex,
CLIAgent::TraeCode,
@@ -52,6 +53,7 @@ impl CLIAgent {
CLIAgent::OhMyPi,
CLIAgent::Kimi,
CLIAgent::QoderCLI,
+ CLIAgent::Crush,
];
fn aliases(self) -> &'static [&'static str] {
@@ -94,6 +96,9 @@ impl CLIAgent {
// launch is the cost: it wears the CLI's avatar for as long as the
// launcher takes to exit.
CLIAgent::QoderCLI => &["qoder", "qodercli"],
+ // Charm's terminal agent. One binary, and the name on `PATH` is
+ // the one it starts as.
+ CLIAgent::Crush => &["crush"],
}
}
@@ -120,6 +125,7 @@ impl CLIAgent {
CLIAgent::OhMyPi => "omp",
CLIAgent::Kimi => "kimi",
CLIAgent::QoderCLI => "qodercli",
+ CLIAgent::Crush => "crush",
}
}
@@ -151,6 +157,7 @@ impl CLIAgent {
CLIAgent::OhMyPi => "Oh My Pi",
CLIAgent::Kimi => "Kimi Code",
CLIAgent::QoderCLI => "Qoder CLI",
+ CLIAgent::Crush => "Crush",
}
}
@@ -186,6 +193,7 @@ impl CLIAgent {
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}")),
+ CLIAgent::Crush => Some(format!("crush{flags} --session {session_id}")),
_ => None,
}
}
@@ -429,6 +437,11 @@ impl CLIAgent {
"--fork-session",
"--worktree",
],
+ // `--session`/`-s` names the conversation to restore and
+ // `--continue`/`-C` the most recent one; both clash with the
+ // `--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"],
_ => &[],
};
let mut i = 0;
@@ -494,6 +507,8 @@ impl CLIAgent {
// black, which Codex and Grok already have covered.
CLIAgent::Kimi => 0x027AFF,
CLIAgent::QoderCLI => 0xFFFFFF,
+ // The blue-violet field Charm ships the Crush heart on.
+ CLIAgent::Crush => 0x6B50FF,
}
}
@@ -535,6 +550,7 @@ impl CLIAgent {
CLIAgent::Qwen => "icons/agents/qwen.svg",
CLIAgent::Kimi => "icons/agents/kimi.svg",
CLIAgent::QoderCLI => "icons/agents/qodercli.svg",
+ CLIAgent::Crush => "icons/agents/crush.svg",
CLIAgent::Aider
| CLIAgent::Auggie
| CLIAgent::Hermes
@@ -1014,6 +1030,8 @@ mod tests {
("/opt/homebrew/bin/omp", CLIAgent::OhMyPi),
("kimi", CLIAgent::Kimi),
("/usr/local/bin/kimi", CLIAgent::Kimi),
+ ("crush", CLIAgent::Crush),
+ ("/opt/homebrew/bin/crush", CLIAgent::Crush),
] {
assert_eq!(CLIAgent::detect_from_argv(&argv(&[cmd])), Some(agent));
}
@@ -1657,6 +1675,42 @@ mod tests {
);
}
+ #[test]
+ fn crush_resumes_by_session_and_keeps_its_cwd_flag() {
+ let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::>();
+
+ assert_eq!(
+ CLIAgent::Crush.resume_command("c-1", None).as_deref(),
+ Some("crush --session c-1")
+ );
+ assert_eq!(
+ CLIAgent::Crush
+ .resume_command("c-2", Some(&argv(&["crush", "--session", "c-1", "--yolo"])))
+ .as_deref(),
+ Some("crush --yolo --session c-2"),
+ "a stale --session and its id come off before the new one goes on"
+ );
+ assert_eq!(
+ CLIAgent::Crush
+ .resume_command("c-3", Some(&argv(&["crush", "--continue", "-C", "--yolo"])))
+ .as_deref(),
+ Some("crush --yolo --session c-3"),
+ "both spellings of continue are stale with it"
+ );
+ // `-c` is `--cwd` in Crush, not `--continue`, and must survive.
+ assert_eq!(
+ CLIAgent::Crush
+ .resume_command("c-4", Some(&argv(&["crush", "-c", "/repo/tree", "--yolo"])))
+ .as_deref(),
+ Some("crush -c /repo/tree --yolo --session c-4")
+ );
+ assert_eq!(
+ CLIAgent::Crush.fork_command("c-1", None),
+ None,
+ "Crush has no fork command"
+ );
+ }
+
#[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 e0e21f5b..62f9a602 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 17 others — without ever wrapping them."
+description: "What tty7 does around Claude Code, Codex, TraeCode, and 19 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 CLIs are recognised on sight, by the command running in the pane:
+Twenty-two CLIs are recognised on sight, by the command running in the pane:
| Agent | Command |
|---|---|
@@ -33,6 +33,8 @@ Twenty CLIs are recognised on sight, by the command running in the pane:
| Grok | `grok` |
| Qwen Code | `qwen`, `qwen-code` |
| Kimi Code | `kimi`, `kimi-code` |
+| Qoder CLI | `qoder`, `qodercli` |
+| Crush | `crush` |
| Auggie | `auggie` |
| Hermes | `hermes` |
| Vibe | `vibe`, `vibe-acp` |
@@ -61,7 +63,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`).
+`omp`, `kimi`, `qodercli`, `crush`).
## What you get for free
diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx
index 256e7a87..d6a40293 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 | 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 | 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
@@ -22,6 +22,12 @@ the row grows a second **Uninstall** button beside the first, which itself
becomes **Reinstall** — or **Update**, against an **Outdated** state, when tty7
ships a newer hook.
+
+ Crush ships only a `PreToolUse` hook today, so it reports **working** — a
+ tool call is the only turn signal there is — but never **done**. On a Crush
+ pane, `tty7 wait` times out rather than returning.
+
+
The hooks only do anything inside tty7. Running the same agent in another
terminal is unaffected.
diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx
index 80eb645a..314693c0 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 → Agents.** tty7 detects 20 coding CLIs by process name on its own —
+**Settings → Agents.** tty7 detects 22 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 a298a154..07e72400 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.
- 20 coding CLIs are recognised on sight. Per-pane status dots, notifications
+ 22 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 7c449076..f8243eb0 100644
--- a/src/ui/assets.rs
+++ b/src/ui/assets.rs
@@ -63,6 +63,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"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"),
+ "icons/agents/crush.svg" => include_bytes!("../../assets/icons/agents/crush.svg"),
_ => return None,
};
Some(bytes)
diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs
index 15768a5e..9bd68e69 100644
--- a/src/ui/i18n/en.rs
+++ b/src/ui/i18n/en.rs
@@ -761,6 +761,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsAgentKimiCode => "Kimi Code",
L10nKey::SettingsAgentQoderCLI => "Qoder CLI",
+ L10nKey::SettingsAgentCrush => "Crush",
L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github",
L10nKey::SettingsSearchAppHttpProxyKeywords => {
"proxy http https socks socks5 clash v2ray network download update"
@@ -857,6 +858,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
"agent integration hooks install kimi code kimi-code moonshot"
}
L10nKey::SettingsSearchQoderCLIKeywords => "agent integration hooks install qoder qodercli",
+ L10nKey::SettingsSearchCrushKeywords => "agent integration hooks install crush",
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 a45e7b07..b1c55184 100644
--- a/src/ui/i18n/ja.rs
+++ b/src/ui/i18n/ja.rs
@@ -770,6 +770,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsAgentKimiCode => "Kimi Code",
L10nKey::SettingsAgentQoderCLI => "Qoder CLI",
+ L10nKey::SettingsAgentCrush => "Crush",
L10nKey::SettingsSearchAboutKeywords => {
"バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check"
}
@@ -910,6 +911,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchQoderCLIKeywords => {
"エージェント 統合 フック インストール qoder qodercli agent integration hooks install"
}
+ L10nKey::SettingsSearchCrushKeywords => {
+ "エージェント 統合 フック インストール crush 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 4f512c23..33a35755 100644
--- a/src/ui/i18n/mod.rs
+++ b/src/ui/i18n/mod.rs
@@ -587,6 +587,7 @@ l10n_keys! {
SettingsAgentGoose,
SettingsAgentKimiCode,
SettingsAgentQoderCLI,
+ SettingsAgentCrush,
SettingsSearchAppHttpProxyKeywords,
SettingsSearchAboutKeywords,
SettingsSearchAutoDownloadKeywords,
@@ -642,6 +643,7 @@ l10n_keys! {
SettingsSearchProgramKeywords,
SettingsSearchQwenCodeKeywords,
SettingsSearchQoderCLIKeywords,
+ SettingsSearchCrushKeywords,
SettingsSearchRememberWindowSizeKeywords,
SettingsSearchReportMouseToAppsKeywords,
SettingsSearchRestoreLastLayoutKeywords,
@@ -1568,6 +1570,7 @@ mod tests {
L10nKey::SettingsAgentPi,
L10nKey::SettingsAgentQwenCode,
L10nKey::SettingsAgentQoderCLI,
+ L10nKey::SettingsAgentCrush,
// 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 c570bf0a..cc33a21f 100644
--- a/src/ui/i18n/zh.rs
+++ b/src/ui/i18n/zh.rs
@@ -677,6 +677,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsAgentKimiCode => "Kimi Code",
L10nKey::SettingsAgentQoderCLI => "Qoder CLI",
+ L10nKey::SettingsAgentCrush => "Crush",
L10nKey::SettingsSearchAboutKeywords => {
"关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update"
}
@@ -813,6 +814,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
"Kimi Code 月之暗面 agent 集成 钩子 安装 kimi code moonshot agent integration hooks install"
}
L10nKey::SettingsSearchQoderCLIKeywords => "Qoder CLI agent 集成 钩子 安装 qoder qodercli",
+ L10nKey::SettingsSearchCrushKeywords => "Crush agent 集成 钩子 安装 crush",
L10nKey::SettingsSearchPiKeywords => {
"Pi agent 集成 扩展 安装 pi agent integration extension install"
}
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index 5c2cb38c..3e26c714 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -661,6 +661,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: SettingsAgentQoderCLI,
keywords: SettingsSearchQoderCLIKeywords,
},
+ SearchEntry {
+ section: Agents,
+ title: SettingsAgentCrush,
+ keywords: SettingsSearchCrushKeywords,
+ },
SearchEntry {
section: WindowTabs,
title: SettingsStartupWindow,