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,