mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 08:02:21 +00:00
added support for Crush agent
This commit is contained in:
@@ -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 | ✓ | | |
|
||||
|
||||
@@ -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 |
@@ -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()));
|
||||
|
||||
@@ -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<_>>();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user