diff --git a/Cargo.lock b/Cargo.lock index 5d280d30..f03e4c82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9250,6 +9250,7 @@ dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] @@ -9852,6 +9853,7 @@ dependencies = [ "system-configuration-sys", "tempfile", "tokio", + "toml_edit 0.25.13+spec-1.1.0", "ureq", "uuid", "windows-sys 0.59.0", diff --git a/README.md b/README.md index 0ea6a2a3..0ed27f98 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com |---|---| | **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 · nine themes · IME | -| **Agent-aware** | per-pane detection (18 CLIs): status dot · notifications · branch + diff · resume after reboot · tray icon when input is needed | +| **Agent-aware** | per-pane detection (19 CLIs): status dot · notifications · branch + diff · resume after reboot · tray icon when input is needed | | **Remote workspaces** | remote files, repos, changes, diffs, worktrees, tabs, and panes · reconnect from any client and continue where you left off | | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · pane/workspace control · real PTY commands · output, process, port, and agent status | | **SSH** | native russh stack: profiles with keychain secrets · SFTP panel · port forwarding · jump hosts · one-time, unprivileged `tty7-server` install | diff --git a/README.zh-CN.md b/README.zh-CN.md index bb075519..46fb6354 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -50,7 +50,7 @@ |---|---| | **编辑器级输入** | 历史影子建议 · 带说明的 Tab 补全 · 语法高亮 · 多行编辑 · 点击定位光标 · ⌃ R 模糊历史搜索 | | **窗口** | 标签页与分屏 · ⌘ P 命令面板 · ⌘ F 回滚搜索 · 9 套主题 · 输入法 | -| **Agent-aware** | 按 pane 识别 18 个 CLI agent:状态点 · 通知 · 分支 + diff · 重启后续上会话 · 托盘图标提醒需要输入 | +| **Agent-aware** | 按 pane 识别 19 个 CLI agent:状态点 · 通知 · 分支 + diff · 重启后续上会话 · 托盘图标提醒需要输入 | | **远程工作区** | 远端文件、仓库、Changes、diff、worktree、标签页和 pane · 任意客户端重连后原地继续 | | **CLI + Skills** | 安装包自带 `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · pane/工作区控制 · 真实 PTY 命令 · 输出、进程、端口和 agent 状态 | | **SSH** | 原生 russh 栈:profile 凭据进 keychain · SFTP 面板 · 端口转发 · 跳板机 · 一次无 sudo 安装 `tty7-server` | diff --git a/assets/icons/agents/kimi.svg b/assets/icons/agents/kimi.svg new file mode 100644 index 00000000..577d2f56 --- /dev/null +++ b/assets/icons/agents/kimi.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml index d58d6dc7..9c12b7d5 100644 --- a/crates/tty7-core/Cargo.toml +++ b/crates/tty7-core/Cargo.toml @@ -48,6 +48,14 @@ sha2 = "0.11" # implementation. ignore = "0.4" +# Format-preserving TOML editing for `core::agent_hooks`: Kimi Code takes its +# hooks as `[[hooks]]` entries in the same `config.toml` that holds the user's +# providers, models and comments, so installing must edit that file in place +# without reformatting it — which rules out the plain `toml` crate's +# parse-and-reserialize round trip. Already in the tree transitively, so this +# pins no new code. +toml_edit = "0.25" + # Lane assignment for the commit graph (`core::git::log`) keeps a couple of # parents and a handful of edges per row; a SmallVec keeps those off the heap # for the shapes that make up almost all of a real history. Already in the tree diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 545fe1c2..6ceeea62 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -223,10 +223,11 @@ pub enum HookAgent { Droid, Qwen, Goose, + Kimi, } impl HookAgent { - pub const ALL: [HookAgent; 11] = [ + pub const ALL: [HookAgent; 12] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::Copilot, @@ -238,6 +239,7 @@ impl HookAgent { HookAgent::Droid, HookAgent::Qwen, HookAgent::Goose, + HookAgent::Kimi, ]; /// The hooks behind a detected agent process, if it has any. @@ -258,6 +260,7 @@ impl HookAgent { CLIAgent::Droid => Some(HookAgent::Droid), CLIAgent::Qwen => Some(HookAgent::Qwen), CLIAgent::Goose => Some(HookAgent::Goose), + CLIAgent::Kimi => Some(HookAgent::Kimi), CLIAgent::Aider | CLIAgent::Amp | CLIAgent::Cursor @@ -283,7 +286,18 @@ impl HookAgent { | HookAgent::Pi | HookAgent::Grok | HookAgent::OhMyPi - | HookAgent::Goose => None, + | HookAgent::Goose + | HookAgent::Kimi => None, + } + } + + /// The events this agent's hooks merge into a shared TOML config, if that + /// is how it takes them — the third strategy, for the agents whose hooks + /// live as `[[hooks]]` entries in a config file the user also hand-edits. + fn toml_hook_events(self) -> Option<&'static [(&'static str, &'static str)]> { + match self { + HookAgent::Kimi => Some(KIMI_HOOK_EVENTS), + _ => None, } } @@ -300,6 +314,7 @@ impl HookAgent { HookAgent::Droid => "droid", HookAgent::Qwen => "qwen", HookAgent::Goose => "goose", + HookAgent::Kimi => "kimi", } } @@ -316,6 +331,7 @@ impl HookAgent { HookAgent::Droid => "Droid", HookAgent::Qwen => "Qwen Code", HookAgent::Goose => "Goose", + HookAgent::Kimi => "Kimi Code", } } @@ -346,6 +362,7 @@ impl HookAgent { HookAgent::Goose => { target.under_home(&[".agents", "plugins", "tty7", "hooks", "hooks.json"]) } + HookAgent::Kimi => target.kimi_config_path(), } } @@ -429,6 +446,15 @@ impl<'a> HookTarget<'a> { self.under_home(&[".config"]) } + fn kimi_config_path(&self) -> PathBuf { + if self.is_local() + && let Some(dir) = std::env::var_os("KIMI_CODE_HOME").filter(|d| !d.is_empty()) + { + return PathBuf::from(dir).join("config.toml"); + } + self.under_home(&[".kimi-code", "config.toml"]) + } + fn hook_command(&self, agent: HookAgent, event: &str) -> String { if let Some(exe) = self.hook_command_exe() { return format!("{exe} agent-hook {} {event}", agent.slug()); @@ -498,6 +524,9 @@ pub enum HooksState { pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState { let path = agent.target_path(target); + if let Some(events) = agent.toml_hook_events() { + return toml_hooks_state(target, &path, agent, events); + } if let Some(events) = agent.hook_map_events() { return hook_map_state(target, &path, agent, events); } @@ -531,6 +560,10 @@ pub enum HookOutcome { pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { let path = agent.target_path(target); + if let Some(events) = agent.toml_hook_events() { + toml_hooks_install(target, &path, agent, events)?; + return Ok(HookOutcome::Installed); + } if let Some(events) = agent.hook_map_events() { hook_map_install(target, &path, agent, events)?; if agent != HookAgent::Codex { @@ -552,6 +585,9 @@ pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result anyhow::Result { let path = agent.target_path(target); + if agent.toml_hook_events().is_some() { + return toml_hooks_uninstall(target, &path, agent); + } match agent.hook_map_events() { Some(_) => hook_map_uninstall(target, &path, agent), None => owned_file_uninstall(target, &path, &agent.marker()), @@ -663,6 +699,30 @@ const QWEN_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionEnd", "session-end"), ]; +/// Kimi Code's hooks live as `[[hooks]]` entries in its main `config.toml` — +/// the same file that holds the user's providers and models — so they go +/// through the TOML merge strategy rather than a JSON map or an owned file. +/// Like Qwen it has a first-class permission event, so it needs no +/// `Notification` hook and none of the sniffing in [`effective_event`]. +/// +/// `Stop` alone does not cover every way a turn ends here: Kimi's own event +/// reference says `Stop` "does not fire on interrupts, so this event fires +/// instead" of `Interrupt`, and a turn that dies on an error reports +/// `StopFailure`. Without those two an Esc or a failed turn would +/// leave the pane on "working" forever and `tty7 wait` would only ever time +/// out, so both report the same end-of-turn as `Stop` does. All three are +/// observation-only events, and a doubled `stop` is idempotent. +const KIMI_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("PermissionRequest", "permission-request"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("Interrupt", "stop"), + ("StopFailure", "stop"), + ("SessionEnd", "session-end"), +]; + const GROK_HOOK_TIMEOUT_SECS: u32 = 10; const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[ @@ -816,6 +876,145 @@ fn marker_command<'a>(matcher: &'a serde_json::Value, marker: &str) -> Option<&' }) } +fn toml_hooks_state( + target: &HookTarget, + path: &Path, + agent: HookAgent, + events: &[(&str, &str)], +) -> HooksState { + let Ok(text) = target.read(path) else { + return HooksState::NotInstalled; + }; + let Ok(doc) = text.parse::() else { + return HooksState::NotInstalled; + }; + let marker = agent.marker(); + let marked: Vec<&toml_edit::Table> = doc + .get("hooks") + .and_then(|h| h.as_array_of_tables()) + .into_iter() + .flatten() + .filter(|entry| toml_command_is_marked(entry, &marker)) + .collect(); + if marked.is_empty() { + return HooksState::NotInstalled; + } + // Every marked entry counts towards the total, `event` or no `event`: a + // hand-edit that drops the key leaves an entry that is ours and is broken, + // which is exactly what Outdated means. Reporting NotInstalled instead + // would hide it from `refresh_hooks`, which only ever revisits Outdated. + let complete = marked.len() == events.len() + && events.iter().all(|(hook_event, tty7_event)| { + let command = target.hook_command(agent, tty7_event); + marked.iter().any(|entry| { + entry.get("event").and_then(|e| e.as_str()) == Some(*hook_event) + && entry.get("command").and_then(|c| c.as_str()) == Some(command.as_str()) + }) + }); + if complete { + HooksState::Installed + } else { + HooksState::Outdated + } +} + +fn toml_hooks_install( + target: &HookTarget, + path: &Path, + agent: HookAgent, + events: &[(&str, &str)], +) -> anyhow::Result<()> { + let mut doc: toml_edit::DocumentMut = match target.read(path) { + Ok(text) => text.parse().map_err(|e| { + anyhow::anyhow!( + "{} is not valid TOML ({e}); not touching it", + path.display() + ) + })?, + Err(e) if e.kind() == io::ErrorKind::NotFound => toml_edit::DocumentMut::new(), + Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())), + }; + + // `hooks = []` and no `hooks` key at all say the same thing, but toml_edit + // keeps an empty inline array and an array of tables apart. Promote the + // one to the other rather than refusing over a difference that carries no + // configuration and that the user cannot see. + if doc + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|a| a.is_empty()) + { + doc.remove("hooks"); + } + + let hooks = doc.entry("hooks").or_insert(toml_edit::Item::ArrayOfTables( + toml_edit::ArrayOfTables::new(), + )); + let Some(list) = hooks.as_array_of_tables_mut() else { + return Err(anyhow::anyhow!( + "\"hooks\" in {} is not an array of tables; not touching it", + path.display() + )); + }; + + let marker = agent.marker(); + list.retain(|entry| !toml_command_is_marked(entry, &marker)); + for (hook_event, tty7_event) in events { + let mut entry = toml_edit::Table::new(); + entry["event"] = toml_edit::value(*hook_event); + entry["command"] = toml_edit::value(target.hook_command(agent, tty7_event)); + list.push(entry); + } + + target.write(path, doc.to_string().as_bytes()) +} + +fn toml_hooks_uninstall( + target: &HookTarget, + path: &Path, + agent: HookAgent, +) -> anyhow::Result { + let text = match target.read(path) { + Ok(text) => text, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + return Ok(HookOutcome::NothingInstalled); + } + Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())), + }; + let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e| { + anyhow::anyhow!( + "{} is not valid TOML ({e}); not touching it", + path.display() + ) + })?; + + let marker = agent.marker(); + let mut removed = 0; + if let Some(list) = doc + .get_mut("hooks") + .and_then(|h| h.as_array_of_tables_mut()) + { + let before = list.len(); + list.retain(|entry| !toml_command_is_marked(entry, &marker)); + removed = before - list.len(); + if list.is_empty() { + doc.remove("hooks"); + } + } + if removed == 0 { + return Ok(HookOutcome::NoTty7Hooks); + } + target.write(path, doc.to_string().as_bytes())?; + Ok(HookOutcome::Removed) +} + +fn toml_command_is_marked(entry: &toml_edit::Table, marker: &str) -> bool { + entry + .get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains(marker)) +} + /// Returns the bare file name of `exe` when resolving that name from PATH /// yields the same binary. Returns `None` when the name does not resolve, or /// when an earlier PATH entry contains a different file with the same name @@ -877,7 +1076,8 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::Codex | HookAgent::Gemini | HookAgent::Droid - | HookAgent::Qwen => None, + | HookAgent::Qwen + | HookAgent::Kimi => None, } } @@ -1236,6 +1436,7 @@ mod tests { .chain(DROID_HOOK_EVENTS) .chain(QWEN_HOOK_EVENTS) .chain(GOOSE_HOOK_EVENTS) + .chain(KIMI_HOOK_EVENTS) .map(|(_, e)| *e) .chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e)) .collect(); @@ -1268,6 +1469,7 @@ mod tests { HookAgent::Goose, "/home/me/.agents/plugins/tty7/hooks/hooks.json", ), + (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), ] { assert_eq!( agent.target_path(&t), @@ -1287,6 +1489,7 @@ mod tests { HookAgent::Droid, HookAgent::Qwen, HookAgent::Goose, + HookAgent::Kimi, ] { assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled); install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug())); @@ -1564,6 +1767,7 @@ mod tests { HookAgent::OhMyPi, "/home/me/.omp/agent/extensions/tty7/index.ts", ), + (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), ] { assert_eq!( agent.target_path(&target), @@ -1878,4 +2082,296 @@ mod tests { unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") }; let _ = std::fs::remove_dir_all(&dir); } + + /// Kimi's hooks share `config.toml` with the user's providers and models, + /// so the merge must leave everything that is not ours — including + /// comments and formatting — byte-for-byte alone. + #[test] + fn kimi_install_preserves_the_user_s_config_toml() { + let dir = std::env::temp_dir().join(format!("tty7-kimi-hooks-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let config = dir.join("config.toml"); + let user_half = concat!( + "# my providers\n", + "default_model = \"kimi-k2\"\n", + "\n", + "[[hooks]]\n", + "event = \"Stop\"\n", + "command = \"afplay ding.aiff\"\n", + ); + std::fs::write(&config, user_half).unwrap(); + unsafe { std::env::set_var("KIMI_CODE_HOME", &dir) }; + + let host = local_host(); + let t = HookTarget::local(&*host).expect("home resolves in tests"); + assert_eq!(HookAgent::Kimi.target_path(&t), config); + + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::NotInstalled); + install_hooks(&t, HookAgent::Kimi).expect("install succeeds"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + install_hooks(&t, HookAgent::Kimi).expect("re-install succeeds"); + + let written = std::fs::read_to_string(&config).unwrap(); + assert!( + written.starts_with(user_half), + "the user's half of config.toml — comment included — survives untouched" + ); + let doc: toml_edit::DocumentMut = written.parse().expect("still valid TOML"); + let hooks = doc["hooks"].as_array_of_tables().unwrap(); + assert_eq!( + hooks + .iter() + .filter(|e| toml_command_is_marked(e, "agent-hook kimi")) + .count(), + KIMI_HOOK_EVENTS.len(), + "exactly one tty7 entry per event after two installs" + ); + for (event, _) in KIMI_HOOK_EVENTS { + assert!( + hooks.iter().any(|e| { + toml_command_is_marked(e, "agent-hook kimi") + && e.get("event").and_then(|v| v.as_str()) == Some(*event) + }), + "{event} carries the tty7 hook" + ); + } + + let healthy = std::fs::read_to_string(&config).unwrap(); + std::fs::write( + &config, + healthy.replace("agent-hook kimi stop", "agent-hook kimi stop --stale"), + ) + .unwrap(); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Outdated); + install_hooks(&t, HookAgent::Kimi).expect("reinstall over an outdated entry succeeds"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + + uninstall_hooks(&t, HookAgent::Kimi).expect("uninstall succeeds"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::NotInstalled); + let after = std::fs::read_to_string(&config).unwrap(); + assert!( + after.contains("afplay ding.aiff"), + "the user's own Stop hook survives uninstall" + ); + assert!(!after.contains("agent-hook kimi")); + uninstall_hooks(&t, HookAgent::Kimi).expect("uninstall is idempotent"); + + std::fs::write(&config, "not = valid = toml").unwrap(); + assert!( + install_hooks(&t, HookAgent::Kimi).is_err(), + "a config.toml that does not parse is left alone" + ); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + "not = valid = toml", + "and is not rewritten on the way out" + ); + assert!( + uninstall_hooks(&t, HookAgent::Kimi).is_err(), + "uninstall refuses the same file" + ); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + "not = valid = toml" + ); + + unsafe { std::env::remove_var("KIMI_CODE_HOME") }; + let _ = std::fs::remove_dir_all(&dir); + } + + /// A `config.toml` that parses but spells `hooks` as something other than + /// an array of tables is a file we do not understand. Every one of these + /// must come back as a refusal with the file untouched — the one thing + /// that must never happen to the file holding the user's API keys is a + /// silent rewrite. + #[test] + fn kimi_refuses_a_hooks_key_of_the_wrong_toml_type() { + let host = FakeRemote::shared(); + let base = std::env::temp_dir().join(format!("tty7-kimi-shapes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + + for (name, text) in [ + ("a_string", "hooks = \"nope\"\n"), + ("a_table", "[hooks]\nfoo = 1\n"), + ( + "an_inline_array", + "hooks = [{ event = \"Stop\", command = \"afplay a.aiff\" }]\n", + ), + ] { + let home = base.join(name); + let t = HookTarget::remote(&*host, home.clone()); + let config = HookAgent::Kimi.target_path(&t); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::write(&config, text).unwrap(); + + assert_eq!( + hooks_state(&t, HookAgent::Kimi), + HooksState::NotInstalled, + "{name}: nothing of ours is in there" + ); + assert!( + install_hooks(&t, HookAgent::Kimi).is_err(), + "{name}: install refuses" + ); + assert_eq!( + uninstall_hooks(&t, HookAgent::Kimi).unwrap(), + HookOutcome::NoTty7Hooks, + "{name}: uninstall finds nothing of ours" + ); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + text, + "{name}: the file is byte-for-byte what it was" + ); + } + + // `hooks = []` is the one shape that carries no configuration at all, + // so it is promoted instead of refused. + let home = base.join("an_empty_array"); + let t = HookTarget::remote(&*host, home.clone()); + let config = HookAgent::Kimi.target_path(&t); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::write(&config, "model = \"k2\"\nhooks = []\n").unwrap(); + install_hooks(&t, HookAgent::Kimi).expect("an empty inline array is promoted, not refused"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + let written = std::fs::read_to_string(&config).unwrap(); + assert!(written.starts_with("model = \"k2\"\n"), "{written}"); + assert!(!written.contains("hooks = []"), "{written}"); + assert_eq!( + uninstall_hooks(&t, HookAgent::Kimi).unwrap(), + HookOutcome::Removed + ); + assert!( + !std::fs::read_to_string(&config).unwrap().contains("hooks"), + "the key goes when the last entry in it does" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + /// The rest of the TOML merge contract: a file that does not exist yet, a + /// second install that changes nothing, entries a hand-edit has mangled, + /// and an uninstall that has to thread its removals between the user's own + /// entries and the tables that follow them. + #[test] + fn kimi_toml_merge_holds_up_across_the_awkward_shapes() { + let host = FakeRemote::shared(); + let base = std::env::temp_dir().join(format!("tty7-kimi-merge-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let marker = "agent-hook kimi"; + + // Nothing there at all: install creates the directory and the file. + let t = HookTarget::remote(&*host, base.join("fresh")); + let config = HookAgent::Kimi.target_path(&t); + assert!(!config.exists()); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::NotInstalled); + assert_eq!( + uninstall_hooks(&t, HookAgent::Kimi).unwrap(), + HookOutcome::NothingInstalled, + "there is no file to take anything out of" + ); + install_hooks(&t, HookAgent::Kimi).expect("install writes a fresh config.toml"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + let once = std::fs::read_to_string(&config).unwrap(); + install_hooks(&t, HookAgent::Kimi).expect("re-install succeeds"); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + once, + "a second install is byte-for-byte the first — no churn, no growth" + ); + + // A hand-edit that drops `event` leaves an entry that is ours and is + // broken. That is Outdated, not NotInstalled: `refresh_hooks` only + // ever revisits Outdated, so anything else hides the damage. + let mangled = once.replacen("event = \"SessionStart\"\n", "", 1); + assert_ne!(mangled, once); + std::fs::write(&config, &mangled).unwrap(); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Outdated); + install_hooks(&t, HookAgent::Kimi).expect("install repairs it"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + assert_eq!(std::fs::read_to_string(&config).unwrap(), once); + + // One marked entry too many is Outdated too, and install prunes it. + // This one has no `event` at all, so counting only the entries that + // still name one would find the full roster and call it Installed + // while a broken ninth entry sat there. + let mut extra = std::fs::read_to_string(&config).unwrap(); + extra.push_str("\n[[hooks]]\ncommand = \"tty7 agent-hook kimi stop\"\n"); + std::fs::write(&config, &extra).unwrap(); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Outdated); + install_hooks(&t, HookAgent::Kimi).expect("install prunes the stray"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + let doc: toml_edit::DocumentMut = + std::fs::read_to_string(&config).unwrap().parse().unwrap(); + assert_eq!( + doc["hooks"] + .as_array_of_tables() + .unwrap() + .iter() + .filter(|e| toml_command_is_marked(e, marker)) + .count(), + KIMI_HOOK_EVENTS.len() + ); + + // The user's own entry comes first and another table follows ours: + // uninstall has to take out the middle and leave both ends alone. + let t = HookTarget::remote(&*host, base.join("sandwich")); + let config = HookAgent::Kimi.target_path(&t); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + let user_half = concat!( + "# mine\n", + "[[hooks]]\n", + "event = \"Stop\"\n", + "command = \"afplay a.aiff\" # ding\n", + "\n", + "[providers.moonshot]\n", + "api_key = \"secret\"\n", + ); + std::fs::write(&config, user_half).unwrap(); + install_hooks(&t, HookAgent::Kimi).expect("install"); + assert_eq!(hooks_state(&t, HookAgent::Kimi), HooksState::Installed); + let merged = std::fs::read_to_string(&config).unwrap(); + assert!( + merged.starts_with( + "# mine\n[[hooks]]\nevent = \"Stop\"\ncommand = \"afplay a.aiff\" # ding\n" + ), + "the user's entry and its trailing comment come through verbatim:\n{merged}" + ); + assert!(merged.contains("[providers.moonshot]\napi_key = \"secret\"\n")); + assert_eq!( + uninstall_hooks(&t, HookAgent::Kimi).unwrap(), + HookOutcome::Removed + ); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + user_half, + "uninstall puts the file back exactly as the user left it" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + /// Kimi's `Stop` does not fire when the user interrupts a turn or when one + /// dies on an error, so the pane would sit on "working" forever without + /// the two events that do. + #[test] + fn kimi_reports_every_way_a_turn_ends() { + for event in ["Stop", "Interrupt", "StopFailure"] { + assert_eq!( + KIMI_HOOK_EVENTS + .iter() + .find(|(hook_event, _)| *hook_event == event) + .map(|(_, tty7_event)| *tty7_event), + Some("stop"), + "{event} has to end the turn like Stop does" + ); + } + assert!( + !KIMI_HOOK_EVENTS + .iter() + .any(|(hook_event, _)| *hook_event == "Notification"), + "Notification fires for background-task chatter and would strand the pane on waiting" + ); + } } diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 577dbf28..37a8ba6a 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -22,10 +22,11 @@ pub enum CLIAgent { Grok, Qwen, OhMyPi, + Kimi, } impl CLIAgent { - pub const ALL: [CLIAgent; 18] = [ + pub const ALL: [CLIAgent; 19] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::Gemini, @@ -44,6 +45,7 @@ impl CLIAgent { CLIAgent::Grok, CLIAgent::Qwen, CLIAgent::OhMyPi, + CLIAgent::Kimi, ]; fn aliases(self) -> &'static [&'static str] { @@ -72,6 +74,10 @@ impl CLIAgent { // Oh My Pi is a fork of Pi, but it ships one binary of its own and // never installs a `pi`, so the two names stay disjoint. CLIAgent::OhMyPi => &["omp"], + // Both the standalone Kimi Code CLI and the legacy open-source + // kimi-cli install a `kimi` — same vendor, same brand, so one + // detection covers them. Only the standalone one has hooks. + CLIAgent::Kimi => &["kimi", "kimi-code"], } } @@ -95,6 +101,7 @@ impl CLIAgent { CLIAgent::Grok => "grok", CLIAgent::Qwen => "qwen", CLIAgent::OhMyPi => "omp", + CLIAgent::Kimi => "kimi", } } @@ -123,6 +130,7 @@ impl CLIAgent { CLIAgent::Grok => "Grok", CLIAgent::Qwen => "Qwen Code", CLIAgent::OhMyPi => "Oh My Pi", + CLIAgent::Kimi => "Kimi Code", } } @@ -155,6 +163,7 @@ impl CLIAgent { CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")), 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}")), _ => None, } } @@ -342,6 +351,21 @@ impl CLIAgent { // `--resume`, `-r` and `--session` are three spellings of one flag // in Oh My Pi; `--session-dir` is a different one and survives. CLIAgent::OhMyPi => &["--resume", "-r", "--session", "--fork", "--continue", "-c"], + // `--resume`/`-r` is Kimi's hidden alias for `--session`/`-S`. + // `--agent`/`--agent-file` bind the main agent at session creation + // and Kimi rejects either next to `--session` outright; resuming + // restores the bound agent by itself, so replaying them would only + // turn a working resume into a startup error. + CLIAgent::Kimi => &[ + "--session", + "-S", + "--resume", + "-r", + "--continue", + "-c", + "--agent", + "--agent-file", + ], CLIAgent::Grok => &[ "--resume", "-r", @@ -414,6 +438,9 @@ impl CLIAgent { CLIAgent::Grok => 0x000000, CLIAgent::Qwen => 0x6D44E8, CLIAgent::OhMyPi => 0xF97316, + // The blue of the flame in Kimi's brand mark; the glyph itself is + // black, which Codex and Grok already have covered. + CLIAgent::Kimi => 0x027AFF, } } @@ -432,6 +459,7 @@ impl CLIAgent { CLIAgent::Pi => "icons/agents/pi.svg", CLIAgent::OhMyPi => "icons/agents/omp.svg", CLIAgent::Qwen => "icons/agents/qwen.svg", + CLIAgent::Kimi => "icons/agents/kimi.svg", CLIAgent::Aider | CLIAgent::Auggie | CLIAgent::Hermes @@ -865,6 +893,8 @@ mod tests { ("hermes", CLIAgent::Hermes), ("omp", CLIAgent::OhMyPi), ("/opt/homebrew/bin/omp", CLIAgent::OhMyPi), + ("kimi", CLIAgent::Kimi), + ("/usr/local/bin/kimi", CLIAgent::Kimi), ] { assert_eq!(CLIAgent::detect_from_argv(&argv(&[cmd])), Some(agent)); } @@ -1155,6 +1185,10 @@ mod tests { .as_deref(), Some("pi --session 0199c3f2-1b0e-7c3a-9f21-6d4b8e2a5c17") ); + assert_eq!( + CLIAgent::Kimi.resume_command("abc-123", None).as_deref(), + Some("kimi --session abc-123") + ); assert_eq!(CLIAgent::Aider.resume_command("abc", None), None); assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /", None), None); assert_eq!(CLIAgent::Claude.resume_command("$(boom)", None), None); @@ -1180,6 +1214,53 @@ mod tests { .as_deref(), Some("claude --model opus --resume abc") ); + assert_eq!( + CLIAgent::Kimi + .resume_command( + "abc-123", + Some(&argv(&["kimi", "--session", "old", "--yolo"])) + ) + .as_deref(), + Some("kimi --yolo --session abc-123"), + "a stale --session flag comes off before the new one goes on" + ); + assert_eq!( + CLIAgent::Kimi + .resume_command("abc-123", Some(&argv(&["kimi", "--session=old", "--yolo"]))) + .as_deref(), + Some("kimi --yolo --session abc-123"), + "and so does the one-token spelling of it" + ); + assert_eq!( + CLIAgent::Kimi + .resume_command( + "abc-123", + Some(&argv(&["kimi", "--resume", "--model", "kimi-k2"])) + ) + .as_deref(), + Some("kimi --model kimi-k2 --session abc-123"), + "`--session` takes an optional id, so a bare one must not eat the flag after it" + ); + assert_eq!( + CLIAgent::Kimi + .resume_command( + "abc-123", + Some(&argv(&["kimi", "--continue", "--model", "kimi-k2"])) + ) + .as_deref(), + Some("kimi --model kimi-k2 --session abc-123"), + "`--continue` is mutually exclusive with `--session` and takes no value" + ); + assert_eq!( + CLIAgent::Kimi + .resume_command( + "abc-123", + Some(&argv(&["kimi", "--agent", "reviewer", "--yolo"])) + ) + .as_deref(), + Some("kimi --yolo --session abc-123"), + "Kimi rejects `--agent` next to `--session`, and resume rebinds the agent itself" + ); assert_eq!( CLIAgent::Claude .resume_command( diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index 903c94a6..5888288c 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, and 16 others — without ever wrapping them." +description: "What tty7 does around Claude Code, Codex, and 17 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 -Eighteen CLIs are recognised on sight, by the command running in the pane: +Nineteen CLIs are recognised on sight, by the command running in the pane: | Agent | Command | |---|---| @@ -31,6 +31,7 @@ Eighteen CLIs are recognised on sight, by the command running in the pane: | Droid | `droid` | | Grok | `grok` | | Qwen Code | `qwen`, `qwen-code` | +| Kimi Code | `kimi`, `kimi-code` | | Auggie | `auggie` | | Hermes | `hermes` | | Vibe | `vibe`, `vibe-acp` | @@ -59,7 +60,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`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, -`omp`). +`omp`, `kimi`). ## What you get for free diff --git a/docs/agents/sessions.mdx b/docs/agents/sessions.mdx index bc8f685b..61d4fba8 100644 --- a/docs/agents/sessions.mdx +++ b/docs/agents/sessions.mdx @@ -19,8 +19,8 @@ claude --dangerously-skip-permissions --resume 8f3c… The original launch flags are replayed, so the pane comes back the way you started it, not the way the defaults would. -Supported for Claude Code, Codex, Gemini, OpenCode, Amp, Cursor, Copilot, Grok, -Pi, and Oh My Pi. Turn it off with `restore_agent_sessions: false`. +Supported for every recognised agent except Aider. Turn it off with +`restore_agent_sessions: false`. Resume needs the agent's hooks installed, since the session id comes from diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index 70eab33c..f4fb2a87 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -14,8 +14,8 @@ the agent say which one it is. | Agent | | |---|---| -| Claude Code · Codex · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi | Hooks available | -| Gemini · Aider · Amp · Cursor · Goose · Droid · Auggie · Hermes · Vibe · Antigravity · Qwen Code | Detected and labelled, but no status channel yet | +| Claude Code · Codex · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code | 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 the row grows a second **Uninstall** button beside the first, which itself diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx index 89cab4db..71f88c9d 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 18 coding CLIs by process name on its own — +**Settings → Agents.** tty7 detects 19 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 d70cc602..d1f0d721 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. - 18 coding CLIs are recognised on sight. Per-pane status dots, notifications + 19 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 5f89789f..94a7bb1f 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -60,6 +60,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/agents/pi.svg" => include_bytes!("../../assets/icons/agents/pi.svg"), "icons/agents/omp.svg" => include_bytes!("../../assets/icons/agents/omp.svg"), "icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"), + "icons/agents/kimi.svg" => include_bytes!("../../assets/icons/agents/kimi.svg"), _ => return None, }; Some(bytes) diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 30371ff3..ae2c1cfb 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -718,6 +718,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentDroid => "Droid", L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", + L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -804,6 +805,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { "agent integration hooks install qwen code qwen-code" } L10nKey::SettingsSearchGooseKeywords => "agent integration hooks plugin install goose", + L10nKey::SettingsSearchKimiCodeKeywords => { + "agent integration hooks install kimi code kimi-code moonshot" + } 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 2bc12f6c..56f6f416 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -727,6 +727,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentDroid => "Droid", L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", + L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -855,6 +856,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchGooseKeywords => { "エージェント 統合 フック プラグイン インストール goose agent integration hooks plugin install" } + L10nKey::SettingsSearchKimiCodeKeywords => { + "エージェント 統合 フック インストール kimi code moonshot 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 e24e1f56..4e012720 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -573,6 +573,7 @@ l10n_keys! { SettingsAgentDroid, SettingsAgentQwenCode, SettingsAgentGoose, + SettingsAgentKimiCode, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -611,6 +612,7 @@ l10n_keys! { SettingsSearchItalicFontKeywords, SettingsSearchKeybindingsKeywords, SettingsSearchKeybindingsTitle, + SettingsSearchKimiCodeKeywords, SettingsSearchLineHeightKeywords, SettingsSearchNewTabPositionKeywords, SettingsSearchNotifyOnCommandFinishKeywords, @@ -1521,6 +1523,7 @@ mod tests { L10nKey::SettingsAgentGemini, L10nKey::SettingsAgentGoose, L10nKey::SettingsAgentGrokBuild, + L10nKey::SettingsAgentKimiCode, L10nKey::SettingsAgentOhMyPi, L10nKey::SettingsAgentOpencode, L10nKey::SettingsAgentPi, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 6948668d..65463832 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -634,6 +634,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentDroid => "Droid", L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", + L10nKey::SettingsAgentKimiCode => "Kimi Code", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -760,6 +761,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchGooseKeywords => { "Goose agent 集成 钩子 插件 安装 goose agent integration hooks plugin install" } + L10nKey::SettingsSearchKimiCodeKeywords => { + "Kimi Code 月之暗面 agent 集成 钩子 安装 kimi code moonshot agent integration hooks install" + } L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 81335a02..70938108 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -628,6 +628,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentGoose, keywords: SettingsSearchGooseKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentKimiCode, + keywords: SettingsSearchKimiCodeKeywords, + }, SearchEntry { section: WindowTabs, title: SettingsStartupWindow,