added support for Crush agent

This commit is contained in:
Fabrice Aneche
2026-09-11 21:56:22 -04:00
parent 66c42e48b7
commit c2d2db2cfc
14 changed files with 386 additions and 15 deletions
+4 -2
View File
@@ -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 · <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 |
@@ -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.
<details>
<summary>The full support matrix, all twenty</summary>
<summary>The full support matrix, all twenty-two</summary>
| 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 | ✓ | | |
+31
View File
@@ -0,0 +1,31 @@
<!-- Crush's pixel heart, from charmbracelet/crush (MIT). The source mark is
the 512x512 notification icon; its silhouette is traced to a 24x20 grid,
centred in tty7's 24x24 icon format. The avatars tint the whole mark flat,
so the source's magenta-on-violet shading collapses to one sentinel fill. -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="#FF0000">
<rect x="3" y="2" width="7" height="1"/>
<rect x="14" y="2" width="7" height="1"/>
<rect x="2" y="3" width="9" height="1"/>
<rect x="13" y="3" width="9" height="1"/>
<rect x="2" y="4" width="9" height="1"/>
<rect x="13" y="4" width="9" height="1"/>
<rect x="0" y="5" width="24" height="1"/>
<rect x="0" y="6" width="24" height="1"/>
<rect x="0" y="7" width="24" height="1"/>
<rect x="0" y="8" width="24" height="1"/>
<rect x="0" y="9" width="24" height="1"/>
<rect x="0" y="10" width="24" height="1"/>
<rect x="0" y="11" width="24" height="1"/>
<rect x="0" y="12" width="24" height="1"/>
<rect x="0" y="13" width="24" height="1"/>
<rect x="2" y="14" width="20" height="1"/>
<rect x="2" y="15" width="20" height="1"/>
<rect x="2" y="16" width="20" height="1"/>
<rect x="3" y="17" width="18" height="1"/>
<rect x="4" y="18" width="16" height="1"/>
<rect x="6" y="19" width="12" height="1"/>
<rect x="8" y="20" width="8" height="1"/>
<rect x="10" y="21" width="4" height="1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+265 -6
View File
@@ -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.<Event>`, 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<String> {
| 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()));
+55 -1
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
+5 -3
View File
@@ -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
+7 -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 | 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.
<Note>
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.
</Note>
<Note>
The hooks only do anything inside tty7. Running the same agent in another
terminal is unaffected.
+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 → 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.
+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">
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.
</Card>
<Card title="Remote work that feels local" icon="server" href="/remote/workspaces">
+1
View File
@@ -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)
+2
View File
@@ -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"
+4
View File
@@ -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"
}
+3
View File
@@ -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
+2
View File
@@ -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"
}
+5
View File
@@ -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,