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,