From cd9577c59092dcd517aff2db174958869c0b973f Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Wed, 15 Jul 2026 14:35:24 +0800 Subject: [PATCH] feat(agents): recognize CLI coding agents + git branch in the sidebar (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agents): recognize CLI coding agents + show git branch in the sidebar Observe (never wrap) third-party coding agents running in a pane — Claude Code, Codex, Gemini CLI, Aider, Amp, OpenCode and ~10 more — and enrich the UI around them, plus front each sidebar row with its git branch and diff. Detection & identity - Command-based detection over the foreground argv (launcher basename, and interpreter-wrapped `node …/cli.js` / `npx …` forms), with user rules via `agent_commands` in config. Brand avatars on the tab chip and sidebar row. Rich status channel - A per-pane state machine (idle / working / waiting-for-you / done) driven by agent-reported events over an OSC 777 sentinel channel (`tty7://cli-agent`, versioned JSON), sniffed daemon-side and streamed to the client (DaemonMsg::AgentStatus). - `tty7 agent-hook claude ` + a palette installer wire Claude Code's lifecycle hooks up; the hook writes the sentinel to the controlling tty (with an ancestor-tty fallback for detached hook processes). - Avatar status dot: working (blue) / waiting (amber) / done (green); an unread finished turn gets a crisp outer ring that clears on focus. Notifications, resume, context feed - "Needs your permission…" the moment an agent blocks; "finished after Ns" per turn, honoring the notify policy (rich turns suppress the coarse exit). - Session resume: restored panes re-launch their conversation (`claude --resume …`), gated by `restore_agent_sessions` (default on). - Palette commands send the current selection or the repo `git diff` to the running agent as a ready-made prompt. Sidebar git line - New `terminal::git_status`: off-thread `git` probe (branch, or short sha when detached; `git diff --numstat HEAD` line counts) with GIT_OPTIONAL_LOCKS=0, refreshed on cwd change or command finish, dropped on a stale cwd via a generation tag. - Each row is avatar + title + `⎇ branch +N −M` (green/red), sized to content; the redundant cwd/"Working…" lines and the aggregate rollup are gone — the status dot and branch line carry it. 672 tests pass. * fix(agents): repair CI and refresh the git line when an agent turn ends - The live PTY detection test used `sh -c 'exec -a codex cat'`, but `exec -a` is a bashism dash (Ubuntu's /bin/sh) rejects — spawn bash. - cargo fmt over cli_agent.rs / view.rs / app.rs. - An agent session is one long foreground command, so the back-to-prompt edge never refreshed the sidebar's branch/diff line while the agent worked — exactly when the working tree changes. poll_agent_status now reports a turn ending (transition into Done) and the poll reprobes git on that edge too. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- README.md | 13 + README.zh-CN.md | 11 + assets/icons/agents/amp.svg | 12 + assets/icons/agents/claude.svg | 10 + assets/icons/agents/codex.svg | 10 + assets/icons/agents/copilot.svg | 12 + assets/icons/agents/cursor.svg | 3 + assets/icons/agents/droid.svg | 10 + assets/icons/agents/gemini.svg | 5 + assets/icons/agents/goose.svg | 10 + assets/icons/agents/opencode.svg | 3 + assets/icons/git-branch.svg | 1 + assets/icons/terminal.svg | 1 + src/core/agent_hooks.rs | 420 ++++++++++++++++ src/core/agent_prompt.rs | 123 +++++ src/core/cli_agent.rs | 838 +++++++++++++++++++++++++++++++ src/core/config.rs | 34 ++ src/core/mod.rs | 3 + src/core/osc.rs | 42 ++ src/core/session.rs | 54 ++ src/daemon/pane.rs | 393 ++++++++++++++- src/daemon/protocol.rs | 29 ++ src/main.rs | 17 +- src/terminal/git_status.rs | 130 +++++ src/terminal/mod.rs | 2 + src/terminal/remote.rs | 166 ++++-- src/terminal/view.rs | 301 ++++++++++- src/ui/app.rs | 212 ++++++++ src/ui/assets.rs | 59 +++ src/ui/home.rs | 2 + src/ui/mod.rs | 1 + src/ui/palette.rs | 20 +- src/ui/tab_sidebar.rs | 97 +++- src/ui/tab_strip.rs | 147 +++++- 34 files changed, 3113 insertions(+), 78 deletions(-) create mode 100644 assets/icons/agents/amp.svg create mode 100644 assets/icons/agents/claude.svg create mode 100644 assets/icons/agents/codex.svg create mode 100644 assets/icons/agents/copilot.svg create mode 100644 assets/icons/agents/cursor.svg create mode 100644 assets/icons/agents/droid.svg create mode 100644 assets/icons/agents/gemini.svg create mode 100644 assets/icons/agents/goose.svg create mode 100644 assets/icons/agents/opencode.svg create mode 100644 assets/icons/git-branch.svg create mode 100644 assets/icons/terminal.svg create mode 100644 src/core/agent_hooks.rs create mode 100644 src/core/agent_prompt.rs create mode 100644 src/core/cli_agent.rs create mode 100644 src/terminal/git_status.rs create mode 100644 src/ui/assets.rs diff --git a/README.md b/README.md index 74ce53f6..bad9008e 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,19 @@ Download the build for your platform from - **⌘-click links** · desktop notifications - **Eight themes** · CJK / IME input +### CLI coding agents + +tty7 recognizes third-party coding agents running in a pane (Claude Code, +Codex, Gemini CLI, Aider, Amp, OpenCode, and ~10 more) and enriches them — it +never wraps or replaces the agent. + +- **Brand avatars** — the tab chip / sidebar row shows which agent runs where; custom wrappers map in via `agent_commands` in `config.json` +- **Live status dot** — working (blue) / needs your input (amber) / done (green), driven by agent-reported events over an OSC channel; run *Agent: Install Claude Code Hooks* from the palette to wire Claude Code up +- **Notifications that matter** — "needs your permission…" the moment an agent blocks on you, and "finished after Ns" per turn, honoring your notification policy +- **Branch at a glance** — each sidebar row shows its pane's git branch and working-tree diff (`+N −M`), refreshed on `cd` and when a command finishes +- **Session resume** — panes lost to a reboot re-launch their agent conversation (`claude --resume …`) on restore (`restore_agent_sessions`, on by default) +- **Context feed** — palette commands send the current selection or the repo's `git diff` to the running agent as a ready-made prompt + ### SSH connection manager A native Rust SSH stack (russh) is the **only** path — profiles, credentials, diff --git a/README.zh-CN.md b/README.zh-CN.md index ae958bed..90759c0f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -62,6 +62,17 @@ macOS、Windows、Linux 三平台原生构建,每个 release 一起打出。 - **⌘ 点击打开链接** · 桌面通知 - **8 套主题** · CJK / 输入法输入 +### CLI coding agent + +tty7 能识别 pane 里跑着的第三方 coding agent(Claude Code、Codex、Gemini CLI、Aider、Amp、OpenCode 等约 17 个)并为其增强体验 —— 只观察、只加分,绝不包裹或替代 agent 本身。 + +- **品牌头像** —— 标签 chip / 侧栏行显示每个 pane 跑的是哪个 agent;自定义包装命令可通过 `config.json` 的 `agent_commands` 映射 +- **实时状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在命令面板运行 *Agent: Install Claude Code Hooks* 一键接通 Claude Code +- **真正有用的通知** —— agent 卡在等你批准的那一刻弹 "needs your permission…",每轮结束弹 "finished after Ns",遵循你的通知策略 +- **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N −M`),`cd` 或命令跑完时自动刷新 +- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话(`claude --resume …`;`restore_agent_sessions`,默认开启) +- **上下文回填** —— 面板命令把当前选区或仓库 `git diff` 打包成 prompt 直接喂给正在跑的 agent + ### SSH 连接管理器 **唯一**路径就是原生 Rust SSH 栈(russh)—— profile、凭据、SFTP 全部内置,绝不 shell 出 `ssh`。没有系统 ssh 兼容模式。 diff --git a/assets/icons/agents/amp.svg b/assets/icons/agents/amp.svg new file mode 100644 index 00000000..893b466e --- /dev/null +++ b/assets/icons/agents/amp.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/agents/claude.svg b/assets/icons/agents/claude.svg new file mode 100644 index 00000000..390bb953 --- /dev/null +++ b/assets/icons/agents/claude.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/agents/codex.svg b/assets/icons/agents/codex.svg new file mode 100644 index 00000000..bb97908a --- /dev/null +++ b/assets/icons/agents/codex.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/agents/copilot.svg b/assets/icons/agents/copilot.svg new file mode 100644 index 00000000..59d7732f --- /dev/null +++ b/assets/icons/agents/copilot.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/agents/cursor.svg b/assets/icons/agents/cursor.svg new file mode 100644 index 00000000..b1ebd171 --- /dev/null +++ b/assets/icons/agents/cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/agents/droid.svg b/assets/icons/agents/droid.svg new file mode 100644 index 00000000..009471e7 --- /dev/null +++ b/assets/icons/agents/droid.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/agents/gemini.svg b/assets/icons/agents/gemini.svg new file mode 100644 index 00000000..76dcaa5c --- /dev/null +++ b/assets/icons/agents/gemini.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/agents/goose.svg b/assets/icons/agents/goose.svg new file mode 100644 index 00000000..b9eb73f3 --- /dev/null +++ b/assets/icons/agents/goose.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/agents/opencode.svg b/assets/icons/agents/opencode.svg new file mode 100644 index 00000000..cf87c9b7 --- /dev/null +++ b/assets/icons/agents/opencode.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/git-branch.svg b/assets/icons/git-branch.svg new file mode 100644 index 00000000..b809d259 --- /dev/null +++ b/assets/icons/git-branch.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/terminal.svg b/assets/icons/terminal.svg new file mode 100644 index 00000000..47dab739 --- /dev/null +++ b/assets/icons/terminal.svg @@ -0,0 +1 @@ + diff --git a/src/core/agent_hooks.rs b/src/core/agent_hooks.rs new file mode 100644 index 00000000..8c8b1190 --- /dev/null +++ b/src/core/agent_hooks.rs @@ -0,0 +1,420 @@ +//! Agent-side hook integration: the emitter behind `tty7 agent-hook …` and the +//! installer that wires it into Claude Code's `settings.json`. +//! +//! The rich agent-status channel ([`crate::core::cli_agent`]) needs the agent +//! itself to say what it's doing. For Claude Code that is its hooks system: +//! each lifecycle hook runs `tty7 agent-hook claude `, which reads the +//! hook's JSON payload from stdin and writes one sentinel OSC 777 sequence to +//! the controlling terminal (`/dev/tty`) — where tty7's daemon-side sniffer +//! picks it up and folds it into the pane's session state. The same idea can +//! ship as a Claude *plugin*; a hook + our own binary needs no plugin +//! marketplace and no jq. +//! +//! Emission is gated on the `TTY7` environment variable (injected into every +//! shell tty7 spawns), so hooks installed globally stay silent when Claude +//! runs in another terminal. + +use std::io::Read as _; +use std::path::PathBuf; + +use crate::core::cli_agent::AGENT_EVENT_SENTINEL; + +/// The env var tty7 sets in every spawned shell; the hook emitter refuses to +/// write escape sequences into terminals that aren't tty7. +pub const TTY7_ENV_MARKER: &str = "TTY7"; + +/// Cap on how much hook stdin we'll read: real payloads are a few hundred +/// bytes of JSON; anything huge is not for us. +const MAX_STDIN: u64 = 64 * 1024; + +/// Entry point for the `tty7 agent-hook ` subcommand: read the +/// hook's JSON payload from stdin, build the sentinel event, and write it to +/// the controlling terminal. Always exits quietly — a hook that fails must +/// never break the agent's own flow (Claude Code surfaces nonzero exits). +pub fn run_agent_hook(agent: &str, event: &str) { + // Not inside tty7 (or a remote shell): stay silent, so globally-installed + // hooks don't leak escape sequences into other terminals. + if std::env::var_os(TTY7_ENV_MARKER).is_none() { + return; + } + // Hook payload: Claude Code writes {"session_id": …, "message": …, …} and + // closes stdin. Absent/malformed input still emits the bare event — the + // state machine works without ids or messages. + let mut input = String::new(); + let _ = std::io::stdin().take(MAX_STDIN).read_to_string(&mut input); + write_to_controlling_tty(&build_hook_sequence(agent, event, &input)); +} + +/// Build the sentinel OSC sequence for one hook invocation — the pure core of +/// [`run_agent_hook`], separated so the wire bytes are testable without a PTY. +/// Round-trips through [`crate::core::cli_agent::parse_agent_event`] on the +/// daemon side. +fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { + let payload: serde_json::Value = + serde_json::from_str(stdin_json).unwrap_or(serde_json::json!({})); + let mut body = serde_json::json!({ + "v": 1, + "agent": agent, + "event": event, + }); + for key in ["session_id", "message"] { + if let Some(v) = payload + .get(key) + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + { + body[key] = serde_json::Value::String(v.to_string()); + } + } + format!("\x1b]777;notify;{AGENT_EVENT_SENTINEL};{body}\x07").into_bytes() +} + +/// Write raw bytes to the pane's PTY so the daemon's sniffer reads them as pane +/// output. Two routes, because agents run hooks differently: +/// +/// 1. `/dev/tty` — the hook's own controlling terminal. Works when the agent +/// runs the hook attached to its tty. +/// 2. An ancestor's tty device — Claude Code runs hooks *detached* from the +/// controlling terminal (they have no `/dev/tty`), but the agent process +/// itself still owns the pane's PTY slave. So walk up the parent chain to +/// the nearest process that has a real tty (that's the agent) and write its +/// device (`/dev/ttysNNN` on macOS, `/dev/pts/N` on Linux) directly. Writing +/// the slave sends output to the master, exactly like `/dev/tty` would. +#[cfg(unix)] +fn write_to_controlling_tty(bytes: &[u8]) -> bool { + if write_dev(std::path::Path::new("/dev/tty"), bytes) { + return true; + } + if let Some(dev) = ancestor_tty_device() { + return write_dev(&dev, bytes); + } + false +} + +#[cfg(unix)] +fn write_dev(path: &std::path::Path, bytes: &[u8]) -> bool { + use std::io::Write as _; + match std::fs::OpenOptions::new().write(true).open(path) { + Ok(mut tty) => tty.write_all(bytes).and_then(|_| tty.flush()).is_ok(), + Err(_) => false, + } +} + +/// The controlling-tty device of the nearest ancestor that has one — the agent +/// process, when it ran us detached. Walks the parent chain via `ps` (the hook +/// runs at most a few times per turn, so the process spawn is negligible and +/// beats platform-specific sysctl/`/proc` FFI here). +#[cfg(unix)] +fn ancestor_tty_device() -> Option { + use std::process::Command; + // SAFETY: getppid is always safe and never fails. + let mut pid = unsafe { libc::getppid() }; + for _ in 0..8 { + if pid <= 1 { + break; + } + // `tty=` prints the terminal (`ttys004`, `pts/3`, or `??`/empty for none) + // and `ppid=` the parent, both header-less so parsing is trivial. + let out = Command::new("ps") + .args(["-o", "tty=", "-o", "ppid=", "-p", &pid.to_string()]) + .output() + .ok()?; + let line = String::from_utf8_lossy(&out.stdout); + let mut fields = line.split_whitespace(); + let tty = fields.next().unwrap_or(""); + let ppid: i32 = fields.next().and_then(|s| s.parse().ok()).unwrap_or(1); + if !tty.is_empty() && tty != "??" && tty != "?" { + return Some(std::path::PathBuf::from(format!("/dev/{tty}"))); + } + pid = ppid; + } + None +} + +#[cfg(not(unix))] +fn write_to_controlling_tty(_bytes: &[u8]) -> bool { + false +} + +// --------------------------------------------------------------------------- +// Claude Code installer. +// --------------------------------------------------------------------------- + +/// The Claude Code hook events we subscribe to, and the sentinel event each +/// maps onto. `Notification` covers both "needs permission" and "waiting for +/// input" — exactly the Waiting state. +const CLAUDE_HOOK_EVENTS: [(&str, &str); 5] = [ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("Notification", "notification"), + ("Stop", "stop"), + ("SessionEnd", "session-end"), +]; + +/// Substring that identifies a hook entry as ours, for idempotent +/// install/upgrade (an entry containing it is replaced, never duplicated). +const HOOK_MARKER: &str = "agent-hook claude"; + +/// Claude Code's user settings file: `$CLAUDE_CONFIG_DIR/settings.json`, +/// defaulting to `~/.claude/settings.json`. +fn claude_settings_path() -> Option { + if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|d| !d.is_empty()) { + return Some(PathBuf::from(dir).join("settings.json")); + } + Some(home_dir()?.join(".claude").join("settings.json")) +} + +fn home_dir() -> Option { + #[cfg(unix)] + { + std::env::var_os("HOME").map(PathBuf::from) + } + #[cfg(not(unix))] + { + std::env::var_os("USERPROFILE").map(PathBuf::from) + } +} + +/// The hook command line written into Claude's settings — this binary, by +/// absolute path, so it works regardless of PATH. Quoted because macOS app +/// paths ("/Applications/…") can carry spaces. +fn hook_command(event: &str) -> Option { + let exe = std::env::current_exe().ok()?; + Some(format!("\"{}\" agent-hook claude {event}", exe.display())) +} + +/// Install (or upgrade) the tty7 hooks in Claude Code's `settings.json`, +/// preserving everything else in the file. Idempotent: entries carrying +/// [`HOOK_MARKER`] are rewritten in place (e.g. after the binary moved); +/// user-defined hooks on the same events are left untouched. Returns a short +/// human-readable summary for the caller to toast. +pub fn install_claude_hooks() -> anyhow::Result { + let path = + claude_settings_path().ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?; + + let mut root: serde_json::Value = match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text).map_err(|e| { + anyhow::anyhow!( + "{} is not valid JSON ({e}); not touching it", + path.display() + ) + })?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}), + Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())), + }; + if !root.is_object() { + return Err(anyhow::anyhow!( + "{} is not a JSON object; not touching it", + path.display() + )); + } + + let hooks = root + .as_object_mut() + .unwrap() + .entry("hooks") + .or_insert_with(|| serde_json::json!({})); + if !hooks.is_object() { + return Err(anyhow::anyhow!( + "\"hooks\" in {} is not an object; not touching it", + path.display() + )); + } + + for (claude_event, tty7_event) in CLAUDE_HOOK_EVENTS { + let command = hook_command(tty7_event) + .ok_or_else(|| anyhow::anyhow!("cannot resolve tty7's own executable path"))?; + let entries = hooks + .as_object_mut() + .unwrap() + .entry(claude_event) + .or_insert_with(|| serde_json::json!([])); + let Some(list) = entries.as_array_mut() else { + continue; // malformed user config on this event; leave it alone + }; + // Drop any previous tty7 entry (stale exe path), then append ours. + list.retain(|matcher| !matcher_contains_marker(matcher)); + list.push(serde_json::json!({ + "hooks": [{ "type": "command", "command": command }] + })); + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + crate::core::config::write_atomic(&path, serde_json::to_string_pretty(&root)?.as_bytes())?; + Ok(format!( + "Claude Code hooks installed in {} — restart running claude sessions to pick them up", + path.display() + )) +} + +/// Whether the tty7 hooks are already present in Claude Code's settings (all +/// five events carry a marker entry). +pub fn claude_hooks_installed() -> bool { + let Some(path) = claude_settings_path() else { + return false; + }; + let Ok(text) = std::fs::read_to_string(&path) else { + return false; + }; + let Ok(root) = serde_json::from_str::(&text) else { + return false; + }; + CLAUDE_HOOK_EVENTS.iter().all(|(claude_event, _)| { + root.get("hooks") + .and_then(|h| h.get(claude_event)) + .and_then(|e| e.as_array()) + .is_some_and(|list| list.iter().any(matcher_contains_marker)) + }) +} + +/// Whether one matcher entry (`{"matcher": …, "hooks": [{"command": …}]}`) +/// carries a tty7 hook command. +fn matcher_contains_marker(matcher: &serde_json::Value) -> bool { + matcher + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|hooks| { + hooks.iter().any(|h| { + h.get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains(HOOK_MARKER)) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The emitter's bytes must parse back into the exact event the daemon's + /// sniffer expects — the two ends of the protocol locked together. + #[test] + fn hook_sequence_round_trips_through_the_daemon_parser() { + use crate::core::cli_agent::{AgentEventKind, CLIAgent, parse_agent_event}; + + let seq = build_hook_sequence( + "claude", + "notification", + r#"{"session_id":"abc-123","message":"Claude needs your permission","cwd":"/w"}"#, + ); + // Strip the OSC framing (`ESC ]` … `BEL`) to get the payload the + // tokenizer would deliver. + let payload = &seq[2..seq.len() - 1]; + let ev = parse_agent_event(payload).expect("daemon parses the emitted event"); + assert_eq!(ev.agent, Some(CLIAgent::Claude)); + assert_eq!(ev.kind, AgentEventKind::Notification); + assert_eq!(ev.session_id.as_deref(), Some("abc-123")); + assert!(ev.message.as_deref().unwrap().contains("permission")); + + // Garbage stdin still yields a well-formed bare event. + let seq = build_hook_sequence("claude", "stop", "not json at all"); + let ev = parse_agent_event(&seq[2..seq.len() - 1]).expect("bare event still parses"); + assert_eq!(ev.kind, AgentEventKind::Stop); + assert_eq!(ev.session_id, None); + } + + /// The controlling-tty fallback (`ancestor_tty_device`) is what makes the + /// hook work at all: Claude Code runs hooks detached from the controlling + /// terminal, so `/dev/tty` fails and we must reach the agent's tty via the + /// parent chain (verified end-to-end against a real detached-hook PTY + /// setup). The device path itself is environment-dependent, so this guards + /// only the invariant that survives CI: the `ps`-walk never panics and only + /// ever yields a `/dev/…` device (never a bare tty name we'd fail to open). + #[cfg(unix)] + #[test] + fn ancestor_tty_device_is_none_or_a_dev_path() { + match ancestor_tty_device() { + None => {} + Some(dev) => assert!( + dev.starts_with("/dev/"), + "a resolved tty must be an openable device path, got {dev:?}" + ), + } + } + + #[test] + fn marker_detection_matches_our_entries_only() { + let ours = serde_json::json!({ + "hooks": [{ "type": "command", "command": "\"/x/tty7\" agent-hook claude stop" }] + }); + assert!(matcher_contains_marker(&ours)); + let theirs = serde_json::json!({ + "hooks": [{ "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff" }] + }); + assert!(!matcher_contains_marker(&theirs)); + assert!(!matcher_contains_marker(&serde_json::json!({}))); + } + + #[test] + fn hook_command_quotes_the_exe_path() { + let cmd = hook_command("stop").expect("current_exe resolves in tests"); + assert!(cmd.starts_with('"')); + assert!(cmd.ends_with("agent-hook claude stop")); + } + + /// Full install → verify → re-install round trip against a scratch + /// settings file (`CLAUDE_CONFIG_DIR` is honored, so the test never + /// touches the real `~/.claude`). Serialized with the env-var lock other + /// env-mutating tests use… none exists for this var, so the test sets it + /// once and relies on `cargo test` threads not racing the same var (only + /// this test touches CLAUDE_CONFIG_DIR). + #[test] + fn install_is_idempotent_and_preserves_user_hooks() { + let dir = std::env::temp_dir().join(format!("tty7-hooks-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let settings = dir.join("settings.json"); + // Pre-existing user config: a model pick and their own Stop hook. + std::fs::write( + &settings, + serde_json::json!({ + "model": "opus", + "hooks": { + "Stop": [{ "hooks": [{ "type": "command", "command": "afplay ding.aiff" }] }] + } + }) + .to_string(), + ) + .unwrap(); + // SAFETY: test-only env mutation; no other test reads this var. + unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", &dir) }; + + assert!(!claude_hooks_installed()); + install_claude_hooks().expect("install succeeds"); + assert!(claude_hooks_installed()); + + // Install again: no duplicates. + install_claude_hooks().expect("re-install succeeds"); + let root: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); + // User settings and user hooks survive. + assert_eq!(root["model"], "opus"); + let stop = root["hooks"]["Stop"].as_array().unwrap(); + assert_eq!( + stop.iter().filter(|m| matcher_contains_marker(m)).count(), + 1, + "exactly one tty7 entry after two installs" + ); + assert!( + stop.iter() + .any(|m| m.to_string().contains("afplay ding.aiff")), + "the user's own Stop hook survives" + ); + // All five events are wired. + for (event, _) in CLAUDE_HOOK_EVENTS { + assert!( + root["hooks"][event] + .as_array() + .unwrap() + .iter() + .any(matcher_contains_marker), + "{event} carries the tty7 hook" + ); + } + + // SAFETY: restore for any later test relying on the default path. + unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") }; + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/core/agent_prompt.rs b/src/core/agent_prompt.rs new file mode 100644 index 00000000..f9cd27ab --- /dev/null +++ b/src/core/agent_prompt.rs @@ -0,0 +1,123 @@ +//! Prompt builders that feed terminal context *back into* a running CLI coding +//! agent — the review-prompt / selection-range-prompt idea, sized to tty7: +//! take what the user is looking at (a selection in some +//! pane, the repo's `git diff`) and phrase it as one self-contained prompt to +//! paste into the agent's PTY. Pure string builders, unit-tested; the UI layer +//! owns finding the agent pane and writing the bytes. + +/// Cap on embedded context (selection or diff) so a pathological selection or +/// a giant diff can't flood the agent's input buffer. Anything longer is +/// truncated with an explicit note — the agent can always ask for more. +const MAX_CONTEXT_BYTES: usize = 24 * 1024; + +/// Truncate `text` to [`MAX_CONTEXT_BYTES`] on a char boundary, appending a +/// note when anything was cut. +fn capped(text: &str) -> String { + if text.len() <= MAX_CONTEXT_BYTES { + return text.to_string(); + } + let mut end = MAX_CONTEXT_BYTES; + while !text.is_char_boundary(end) { + end -= 1; + } + format!( + "{}\n[… truncated by tty7 — ask for the rest if needed]", + &text[..end] + ) +} + +/// A prompt asking the agent to look at terminal output the user selected +/// (a build error, a stack trace, a failing test). `cwd` locates the context. +pub fn build_selection_prompt(selection: &str, cwd: Option<&str>) -> Option { + let selection = selection.trim_end(); + if selection.trim().is_empty() { + return None; + } + let mut prompt = String::from( + "Here is terminal output I selected in another pane; please take a look and help me address it.", + ); + if let Some(cwd) = cwd.filter(|c| !c.is_empty()) { + prompt.push_str(&format!(" It came from a shell running in `{cwd}`.")); + } + prompt.push_str("\n\n```\n"); + prompt.push_str(&capped(selection)); + prompt.push_str("\n```"); + Some(prompt) +} + +/// A prompt asking the agent to review the working tree's diff. `diff` is the +/// combined `git diff` (+ `git diff --cached`) output, embedded so the agent +/// needn't re-run it; an empty diff yields `None` (nothing to review). +pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option { + let diff = diff.trim_end(); + if diff.trim().is_empty() { + return None; + } + let mut prompt = String::from( + "Please review the following uncommitted changes in this repository: point out bugs, \ + regressions, and anything that looks unintended. Keep it focused — this is a working diff, \ + not a style pass.", + ); + if let Some(cwd) = cwd.filter(|c| !c.is_empty()) { + prompt.push_str(&format!(" Repository: `{cwd}`.")); + } + prompt.push_str("\n\n```diff\n"); + prompt.push_str(&capped(diff)); + prompt.push_str("\n```"); + Some(prompt) +} + +/// The bytes that deliver `prompt` into an agent's PTY: a bracketed paste (so +/// multi-line prompts insert as one block instead of submitting line by line — +/// every recognized agent's TUI enables bracketed paste), followed by CR to +/// submit. ESC bytes inside the prompt are stripped, same as the clipboard +/// paste path, so embedded content can't fake the paste terminator. +pub fn submit_bytes(prompt: &str) -> Vec { + let mut bytes = b"\x1b[200~".to_vec(); + bytes.extend(prompt.bytes().filter(|&b| b != 0x1b)); + bytes.extend_from_slice(b"\x1b[201~\r"); + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selection_prompt_embeds_the_text_and_cwd() { + let p = build_selection_prompt("error[E0308]: mismatched types", Some("/work/tty7")) + .expect("non-empty selection builds"); + assert!(p.contains("error[E0308]")); + assert!(p.contains("/work/tty7")); + assert!(p.contains("```")); + // Empty / whitespace selections build nothing. + assert_eq!(build_selection_prompt(" \n", None), None); + } + + #[test] + fn diff_prompt_embeds_the_diff() { + let p = build_diff_review_prompt("--- a/x\n+++ b/x\n+added", None).unwrap(); + assert!(p.contains("```diff")); + assert!(p.contains("+added")); + assert_eq!(build_diff_review_prompt("", None), None); + } + + #[test] + fn oversized_context_is_truncated_with_a_note() { + let big = "x".repeat(MAX_CONTEXT_BYTES + 100); + let p = build_selection_prompt(&big, None).unwrap(); + assert!(p.len() < big.len() + 500); + assert!(p.contains("truncated by tty7")); + } + + #[test] + fn submit_bytes_bracket_and_sanitize() { + let bytes = submit_bytes("fix this\nplease"); + assert!(bytes.starts_with(b"\x1b[200~")); + assert!(bytes.ends_with(b"\x1b[201~\r")); + // Embedded ESC can't terminate the paste early. + let sneaky = submit_bytes("a\x1b[201~; rm -rf /\nb"); + let inner = &sneaky[6..sneaky.len() - 7]; + assert!(!inner.contains(&0x1b)); + } +} diff --git a/src/core/cli_agent.rs b/src/core/cli_agent.rs new file mode 100644 index 00000000..e9a5d039 --- /dev/null +++ b/src/core/cli_agent.rs @@ -0,0 +1,838 @@ +//! Third-party CLI coding-agent registry + detection. +//! +//! tty7 recognizes when a pane is running someone else's coding agent (Claude +//! Code, Codex, Gemini CLI, …) so the tab chip can brand it and desktop +//! notifications can say *which* agent finished or needs you. This is +//! deliberately *not* tty7's own agent: it only observes and enriches whatever +//! agent the user launched. +//! +//! Detection is command-based: the daemon already +//! reads the foreground process's `argv` for SSH-context sniffing, so we reuse +//! that to match the invoked command against a known agent. Matching is a pure +//! function over `argv` — [`CLIAgent::detect_from_argv`] — kept here in `core` +//! (framework-light, unit-tested) and called daemon-side, with the resulting +//! `Option` streamed to the client for the UI. +//! +//! The enum is serialized across the daemon↔client protocol, so its variants +//! are the wire contract; add new agents at the end. +//! +//! Beyond identity, this module also defines the *rich status* layer (a +//! second detection tier): agents whose hooks/plugins emit tty7's OSC 777 +//! sentinel events ([`AGENT_EVENT_SENTINEL`]) get a per-session state machine +//! ([`AgentSessionState`]: idle / working / waiting-for-you / done) plus the +//! native session id used for resume-after-restart. Everything here is pure +//! and unit-tested; the daemon sniffs the events and streams state changes to +//! the client. + +use std::collections::HashMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// A recognized third-party CLI coding agent. Ordering is the wire contract +/// (serialized in [`crate::daemon::protocol`]); append, never reorder. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CLIAgent { + Claude, + Codex, + Gemini, + Aider, + Amp, + OpenCode, + Copilot, + Cursor, + Goose, + Droid, + Pi, + Auggie, + Hermes, + Vibe, + Antigravity, + Grok, + Qwen, +} + +impl CLIAgent { + /// Every known agent, for iteration in detection and tests. + pub const ALL: [CLIAgent; 17] = [ + CLIAgent::Claude, + CLIAgent::Codex, + CLIAgent::Gemini, + CLIAgent::Aider, + CLIAgent::Amp, + CLIAgent::OpenCode, + CLIAgent::Copilot, + CLIAgent::Cursor, + CLIAgent::Goose, + CLIAgent::Droid, + CLIAgent::Pi, + CLIAgent::Auggie, + CLIAgent::Hermes, + CLIAgent::Vibe, + CLIAgent::Antigravity, + CLIAgent::Grok, + CLIAgent::Qwen, + ]; + + /// The command names that identify this agent — the launcher binary plus any + /// npm/pip package-dir aliases that show up in an interpreter-wrapped `argv` + /// (e.g. `node …/@anthropic-ai/claude-code/cli.js`, where the launcher is + /// `node` and only the `claude-code` path segment names the agent). All + /// lowercase; matched against extension-stripped path segments. + fn aliases(self) -> &'static [&'static str] { + match self { + CLIAgent::Claude => &["claude", "claude-code"], + CLIAgent::Codex => &["codex", "codex-cli"], + CLIAgent::Gemini => &["gemini", "gemini-cli"], + CLIAgent::Aider => &["aider", "aider-chat"], + CLIAgent::Amp => &["amp"], + CLIAgent::OpenCode => &["opencode"], + CLIAgent::Copilot => &["copilot"], + CLIAgent::Cursor => &["cursor-agent"], + CLIAgent::Goose => &["goose"], + CLIAgent::Droid => &["droid"], + CLIAgent::Pi => &["pi"], + CLIAgent::Auggie => &["auggie"], + CLIAgent::Hermes => &["hermes"], + CLIAgent::Vibe => &["vibe", "vibe-acp"], + CLIAgent::Antigravity => &["agy", "antigravity"], + CLIAgent::Grok => &["grok"], + CLIAgent::Qwen => &["qwen", "qwen-code"], + } + } + + /// Stable machine name (lowercase), used as the `agent` field of the OSC + /// event protocol and as the value side of user-defined detection rules in + /// `config.json` (`agent_commands: {"my-wrapper": "claude"}`). + pub fn slug(self) -> &'static str { + match self { + CLIAgent::Claude => "claude", + CLIAgent::Codex => "codex", + CLIAgent::Gemini => "gemini", + CLIAgent::Aider => "aider", + CLIAgent::Amp => "amp", + CLIAgent::OpenCode => "opencode", + CLIAgent::Copilot => "copilot", + CLIAgent::Cursor => "cursor", + CLIAgent::Goose => "goose", + CLIAgent::Droid => "droid", + CLIAgent::Pi => "pi", + CLIAgent::Auggie => "auggie", + CLIAgent::Hermes => "hermes", + CLIAgent::Vibe => "vibe", + CLIAgent::Antigravity => "antigravity", + CLIAgent::Grok => "grok", + CLIAgent::Qwen => "qwen", + } + } + + /// Look an agent up by its [`slug`](Self::slug) (case-insensitive). + pub fn from_slug(name: &str) -> Option { + let name = name.trim().to_ascii_lowercase(); + CLIAgent::ALL.into_iter().find(|a| a.slug() == name) + } + + /// Human-readable name for tab chips, notifications, and menus. + pub fn display_name(self) -> &'static str { + match self { + CLIAgent::Claude => "Claude Code", + CLIAgent::Codex => "Codex", + CLIAgent::Gemini => "Gemini", + CLIAgent::Aider => "Aider", + CLIAgent::Amp => "Amp", + CLIAgent::OpenCode => "OpenCode", + CLIAgent::Copilot => "Copilot", + CLIAgent::Cursor => "Cursor", + CLIAgent::Goose => "Goose", + CLIAgent::Droid => "Droid", + CLIAgent::Pi => "Pi", + CLIAgent::Auggie => "Auggie", + CLIAgent::Hermes => "Hermes", + CLIAgent::Vibe => "Vibe", + CLIAgent::Antigravity => "Antigravity", + CLIAgent::Grok => "Grok", + CLIAgent::Qwen => "Qwen Code", + } + } + + /// The shell command that resumes a previous session of this agent by its + /// native session id, or `None` for agents without a known resume flag. + /// The id is what the agent reported in its `session-start` event (see + /// [`AgentEvent`]); commands mirror cmux's per-agent resume table. + pub fn resume_command(self, session_id: &str) -> Option { + // Ids come from the agent's own events, but they still land on a shell + // command line — refuse anything that isn't a plain token so a + // malicious/corrupt id can't smuggle shell syntax. + if session_id.is_empty() + || !session_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') + { + return None; + } + match self { + CLIAgent::Claude => Some(format!("claude --resume {session_id}")), + CLIAgent::Codex => Some(format!("codex resume {session_id}")), + CLIAgent::Gemini => Some(format!("gemini --resume {session_id}")), + CLIAgent::OpenCode => Some(format!("opencode --session {session_id}")), + CLIAgent::Amp => Some(format!("amp threads continue {session_id}")), + CLIAgent::Cursor => Some(format!("cursor-agent --resume {session_id}")), + _ => None, + } + } + + /// Brand accent (0xRRGGBB) for the tab chip's agent dot. Chosen for legibility + /// on both light and dark themes rather than exact brand black/white — a pure + /// black or white dot vanishes against one theme, so vendors whose mark is + /// monochrome (Codex/OpenAI, Cursor) get a recognizable mid-tone hue instead. + pub fn accent_rgb(self) -> u32 { + match self { + CLIAgent::Claude => 0xD97757, // Claude terracotta + CLIAgent::Codex => 0x10A37F, // OpenAI green (black mark reads as this) + CLIAgent::Gemini => 0x4285F4, // Google blue + CLIAgent::Aider => 0x14B8A6, // teal + CLIAgent::Amp => 0xF34E3F, // Amp red + CLIAgent::OpenCode => 0x6E56CF, // violet + CLIAgent::Copilot => 0x8957E5, // GitHub purple + CLIAgent::Cursor => 0x9AA0A6, // Cursor is monochrome → neutral grey + CLIAgent::Goose => 0x9A8CFF, // periwinkle + CLIAgent::Droid => 0xF59E0B, // amber + CLIAgent::Pi => 0x0EA5E9, // sky + CLIAgent::Auggie => 0x16A34A, // Augment green + CLIAgent::Hermes => 0x8B5CF6, // violet + CLIAgent::Vibe => 0xFF7000, // Mistral orange + CLIAgent::Antigravity => 0x2563EB, // Google blue (darker than Gemini's) + CLIAgent::Grok => 0x64748B, // xAI is monochrome → slate + CLIAgent::Qwen => 0x7C3AED, // Qwen purple + } + } + + /// Asset path of this agent's brand glyph, resolved through the app's + /// [`crate::ui::assets`] source and rendered as a white silhouette on the + /// brand-colored avatar (gpui rasterizes SVGs to a tinted alpha mask, so the + /// mark's own fill is irrelevant — geometry only). Vendors we ship a brand + /// mark for point at `icons/agents/…`; the rest fall back to the generic + /// gpui-component `bot` glyph so every recognized agent still gets an avatar. + pub fn icon_path(self) -> &'static str { + match self { + CLIAgent::Claude => "icons/agents/claude.svg", + CLIAgent::Codex => "icons/agents/codex.svg", + CLIAgent::Gemini => "icons/agents/gemini.svg", + CLIAgent::Amp => "icons/agents/amp.svg", + CLIAgent::OpenCode => "icons/agents/opencode.svg", + CLIAgent::Copilot => "icons/agents/copilot.svg", + CLIAgent::Cursor => "icons/agents/cursor.svg", + CLIAgent::Goose => "icons/agents/goose.svg", + CLIAgent::Droid => "icons/agents/droid.svg", + // No brand mark bundled → generic robot glyph. + CLIAgent::Aider + | CLIAgent::Pi + | CLIAgent::Auggie + | CLIAgent::Hermes + | CLIAgent::Vibe + | CLIAgent::Antigravity + | CLIAgent::Grok + | CLIAgent::Qwen => "icons/bot.svg", + } + } + + /// Match a single extension-stripped, lowercased command token against the + /// registry. `None` when nothing matches. + fn match_token(token: &str) -> Option { + CLIAgent::ALL + .into_iter() + .find(|a| a.aliases().contains(&token)) + } + + /// Identify the coding agent a foreground `argv` is running, or `None`. + /// + /// The strategy is command-name detection: + /// 1. Strip any leading `VAR=value` environment assignments (`FOO=1 claude`). + /// 2. If the launcher's own basename matches a known agent, that's it — the + /// native-binary case (`claude`, `codex`, `gemini`, `aider`, …). + /// 3. Otherwise, if the launcher is a script *interpreter* (`node`, `bun`, + /// `python`, `npx`, …), scan the remaining path-like arguments for a + /// segment that names an agent — the npm/pip-wrapped case + /// (`node …/claude-code/cli.js`, `npx @anthropic-ai/claude-code`). + /// + /// The interpreter gate is what keeps `cat codex.md` or `vim aider.py` from + /// false-matching: a non-interpreter launcher only ever matches on its own + /// name, never on its arguments. + /// + /// The production caller (the daemon's foreground poll) goes through + /// [`detect_from_argv_with`](Self::detect_from_argv_with) to honor + /// user-defined rules; this rule-free form is the pure core the test suite + /// exercises. + #[cfg_attr(not(test), allow(dead_code))] + pub fn detect_from_argv(argv: &[String]) -> Option { + Self::detect_from_argv_with(argv, &HashMap::new()) + } + + /// [`detect_from_argv`](Self::detect_from_argv) extended with user-defined + /// rules (`config.json`'s `agent_commands`): a map from a command basename + /// to an agent [`slug`](Self::slug), so a personal wrapper (`"cc": + /// "claude"`) is branded like the agent it launches — a command allowlist + /// keyed by exact basename instead of regex. Custom rules apply to the + /// *launcher* only (never to + /// interpreter arguments) and lose to a built-in match on the same name. + pub fn detect_from_argv_with( + argv: &[String], + custom: &HashMap, + ) -> Option { + // 1. Skip leading environment assignments (`KEY=val`). A bare `env` prefix + // (`env claude`) is treated as an interpreter below so its target is + // scanned. + let mut rest = argv + .iter() + .map(String::as_str) + .skip_while(|t| is_env_assignment(t)); + + let launcher = rest.next()?; + let launcher_stem = base_stem(launcher); + + // 2. Native binary: the launcher itself is the agent — by the built-in + // registry first, then by a user-defined rule. + if let Some(agent) = CLIAgent::match_token(launcher_stem) { + return Some(agent); + } + if let Some(agent) = custom + .get(&launcher_stem.to_ascii_lowercase()) + .and_then(|slug| CLIAgent::from_slug(slug)) + { + return Some(agent); + } + + // 3. Interpreter wrapper: scan the script path / package arg it runs. + if is_interpreter(launcher_stem) { + for arg in rest { + // Only inspect path-like / package-like tokens (the script it + // runs), never bare flags or option values. + if arg.starts_with('-') { + continue; + } + for segment in arg.split('/') { + if let Some(agent) = + CLIAgent::match_token(&base_stem(segment).to_ascii_lowercase()) + { + return Some(agent); + } + } + } + } + + None + } +} + +/// A `KEY=value` shell environment assignment prefix (`FOO=bar cmd`). The `KEY` +/// must be a non-empty run of identifier chars before the first `=`. +fn is_env_assignment(token: &str) -> bool { + match token.split_once('=') { + Some((key, _)) => { + // A real env var starts with a letter/underscore and is otherwise + // alphanumerics/underscores — this rejects things like `a=b` paths or + // `--flag=val` that merely contain `=`. + let mut bytes = key.bytes(); + bytes + .next() + .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_') + && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_') + } + None => false, + } +} + +/// The final path component with a leading dir and a trailing script extension +/// stripped, lowercased-ready but case preserved (callers lowercase when they +/// match interpreter args). `/usr/bin/claude` → `claude`, `cli.js` → `cli`. +fn base_stem(token: &str) -> &str { + let name = Path::new(token) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(token); + // Strip one known script extension; leave unknown suffixes intact so + // `claude-code` stays whole. + for ext in [".js", ".mjs", ".cjs", ".ts", ".py", ".rb", ".sh"] { + if let Some(stem) = name.strip_suffix(ext) { + return stem; + } + } + name +} + +/// Whether a launcher basename is a script interpreter whose argument (rather +/// than the launcher itself) names the real program — so agent detection should +/// scan past it. Covers the common Node/Python/Ruby/`env`/`npx` wrappers agents +/// ship as. +fn is_interpreter(stem: &str) -> bool { + matches!( + stem.to_ascii_lowercase().as_str(), + "node" + | "nodejs" + | "bun" + | "deno" + | "npx" + | "pnpm" + | "yarn" + | "python" + | "python3" + | "ruby" + | "uv" + | "uvx" + | "env" + ) +} + +// --------------------------------------------------------------------------- +// Rich session status — the OSC event protocol + per-pane state machine. +// +// Identity detection above answers "*which* agent runs here"; this layer +// answers "what is it doing". Agent-side hooks (installed by +// `core::agent_hooks`, or hand-wired for any agent) emit an OSC 777 +// notification whose title is the [`AGENT_EVENT_SENTINEL`] and whose body is a +// small JSON event. The daemon sniffs those out of the PTY stream, folds them +// through [`AgentSessionState::apply_event`], and streams the state to the +// client (`DaemonMsg::AgentStatus`) for status dots, "needs your input" +// notifications, and session resume. It's a self-describing sentinel channel +// (OSC 777 + `tty7://cli-agent` sentinel + versioned JSON). +// --------------------------------------------------------------------------- + +/// The OSC 777 notification title that marks a payload as a tty7 agent event +/// rather than a user-facing notification: +/// `ESC ] 777;notify;tty7://cli-agent;{json} BEL`. +pub const AGENT_EVENT_SENTINEL: &str = "tty7://cli-agent"; + +/// What an agent session is doing right now, coarsely. `Waiting` is the state +/// the whole feature exists for: the agent stopped mid-turn and needs the user +/// (a permission prompt, a question) — the moment worth a notification and an +/// amber dot. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AgentStatus { + /// Session open, no turn in flight (freshly started, or the user hasn't + /// prompted since the last turn ended and was seen). + #[default] + Idle, + /// A turn is in flight (prompt submitted, tools running). + Working, + /// Stopped mid-turn on the user: permission request, question, or an + /// opaque "the agent pinged you" notification. + Waiting, + /// The turn finished; the result is sitting there waiting to be read. + Done, +} + +/// Per-pane agent session state, maintained daemon-side and mirrored to the +/// client. Exists only while an agent is detected in the pane's foreground. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentSessionState { + #[serde(default = "AgentSessionState::default_status")] + pub status: AgentStatus, + /// Human-readable context for `Waiting`/`Done` (e.g. "Claude needs your + /// permission to use Bash"), straight from the event. + #[serde(default)] + pub message: Option, + /// The agent's *native* session id (from its `session-start` event), the + /// key its own `--resume` flag takes — persisted for restore. + #[serde(default)] + pub session_id: Option, + /// Whether this state came from the rich sentinel channel (hooks + /// installed) rather than the opaque OSC 9/777 fallback. Rich state drives + /// turn-level notifications; fallback state only paints the dot (the + /// agent's own notification text was already toasted by the client). + #[serde(default)] + pub rich: bool, +} + +impl AgentStatus { + /// The status dot color (0xRRGGBB) shared by the tab chip and the sidebar, + /// or `None` for `Idle` (no dot — a resting agent is just its brand mark). + pub fn dot_rgb(self) -> Option { + match self { + AgentStatus::Idle => None, + AgentStatus::Working => Some(0x3B82F6), // blue: in flight + AgentStatus::Waiting => Some(0xF59E0B), // amber: needs you + AgentStatus::Done => Some(0x22C55E), // green: result ready + } + } +} + +impl AgentSessionState { + fn default_status() -> AgentStatus { + AgentStatus::Idle + } + + /// Fold one rich event into the state. Pure transition function — the + /// daemon owns *when* to call it and who to tell. + pub fn apply_event(&mut self, ev: &AgentEvent) { + self.rich = true; + if let Some(id) = &ev.session_id { + self.session_id = Some(id.clone()); + } + match ev.kind { + AgentEventKind::SessionStart => { + self.status = AgentStatus::Idle; + self.message = None; + } + AgentEventKind::PromptSubmit => { + self.status = AgentStatus::Working; + self.message = None; + } + // Explicit blocks from agents that distinguish them (Codex/OpenCode + // plugins): always the urgent "needs you" state. + AgentEventKind::PermissionRequest | AgentEventKind::QuestionAsked => { + self.status = AgentStatus::Waiting; + self.message = ev.message.clone(); + } + // Claude Code overloads its single Notification hook: it fires + // *mid-turn* for a permission/decision prompt (a genuine block worth + // the amber "needs you" state), but ALSO fires *between* turns as an + // idle "Claude is waiting for your input" reminder — which must not + // masquerade as a block. Escalate only when a turn is actually in + // flight; otherwise it's a passive nudge and the current state + // (typically Done, freshly replied) stands. Keyed on turn phase, not + // the message text, so it survives version/locale changes. + AgentEventKind::Notification => { + if self.status == AgentStatus::Working { + self.status = AgentStatus::Waiting; + self.message = ev.message.clone(); + } + } + AgentEventKind::Stop => { + self.status = AgentStatus::Done; + self.message = ev.message.clone(); + } + // The agent session ended but its id stays: Claude & friends can + // resume an *ended* session, which is exactly what restore does. + AgentEventKind::SessionEnd => { + self.status = AgentStatus::Idle; + self.message = None; + } + } + } +} + +/// The event vocabulary of the sentinel protocol (`"event"` in the JSON). +/// Deliberately a superset of what any one agent emits: Claude Code hooks map +/// onto session-start / prompt-submit / notification / stop / session-end, +/// while permission-request / question-asked are there for agents (Codex, +/// OpenCode plugins) that can distinguish them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AgentEventKind { + SessionStart, + PromptSubmit, + PermissionRequest, + QuestionAsked, + Notification, + Stop, + SessionEnd, +} + +/// One parsed sentinel event. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentEvent { + /// Which agent sent it, when the payload names one we know. Lets the event + /// brand a pane even where argv detection can't see the process (a wrapper + /// we don't recognize). + pub agent: Option, + pub kind: AgentEventKind, + pub session_id: Option, + pub message: Option, +} + +/// Parse a complete OSC payload (identifier included, e.g. +/// `777;notify;tty7://cli-agent;{"v":1,…}`) into an [`AgentEvent`]. `None` for +/// anything that isn't a well-formed sentinel event — including unknown +/// `event` values, so the protocol can grow without old daemons +/// mis-classifying new events. +pub fn parse_agent_event(payload: &[u8]) -> Option { + let rest = payload.strip_prefix(b"777;notify;")?; + let rest = rest.strip_prefix(AGENT_EVENT_SENTINEL.as_bytes())?; + let json = rest.strip_prefix(b";")?; + + #[derive(Deserialize)] + struct Wire { + // Protocol version; v1 is all that exists. Kept for forward evolution. + #[serde(default)] + #[allow(dead_code)] + v: u32, + #[serde(default)] + agent: Option, + event: String, + #[serde(default)] + session_id: Option, + #[serde(default)] + message: Option, + } + + let w: Wire = serde_json::from_slice(json).ok()?; + let kind = serde_json::from_value::(serde_json::Value::String(w.event)).ok()?; + let nonempty = |s: Option| s.filter(|s| !s.trim().is_empty()); + Some(AgentEvent { + agent: w.agent.as_deref().and_then(CLIAgent::from_slug), + kind, + session_id: nonempty(w.session_id), + message: nonempty(w.message), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn argv(parts: &[&str]) -> Vec { + parts.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn detects_native_binaries() { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["claude"])), + Some(CLIAgent::Claude) + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["/opt/homebrew/bin/codex", "--model", "o3"])), + Some(CLIAgent::Codex) + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["/usr/local/bin/gemini"])), + Some(CLIAgent::Gemini) + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["cursor-agent"])), + Some(CLIAgent::Cursor) + ); + } + + #[test] + fn strips_leading_env_assignments() { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["FOO=1", "BAR=baz", "claude"])), + Some(CLIAgent::Claude) + ); + } + + #[test] + fn detects_node_wrapped_claude_by_package_dir() { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&[ + "node", + "/Users/x/.npm/_npx/node_modules/@anthropic-ai/claude-code/cli.js", + ])), + Some(CLIAgent::Claude) + ); + } + + #[test] + fn detects_npx_package_form() { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["npx", "@anthropic-ai/claude-code"])), + Some(CLIAgent::Claude) + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["npx", "@google/gemini-cli"])), + Some(CLIAgent::Gemini) + ); + } + + #[test] + fn detects_python_wrapped_aider() { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&[ + "python3", + "/usr/lib/python3.12/site-packages/aider/__main__.py", + ])), + Some(CLIAgent::Aider) + ); + } + + #[test] + fn non_interpreter_does_not_match_on_arguments() { + // A file *named* like an agent, opened by an unrelated tool, must not + // trip detection — only interpreters have their args scanned. + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["cat", "codex.md"])), + None + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["vim", "claude-code/notes.txt"])), + None + ); + assert_eq!(CLIAgent::detect_from_argv(&argv(&["less", "aider"])), None); + } + + #[test] + fn unrelated_commands_are_none() { + assert_eq!(CLIAgent::detect_from_argv(&argv(&["zsh"])), None); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["node", "server.js"])), + None + ); + assert_eq!(CLIAgent::detect_from_argv(&argv(&[])), None); + } + + #[test] + fn every_agent_has_metadata() { + for a in CLIAgent::ALL { + assert!(!a.display_name().is_empty()); + assert!(!a.aliases().is_empty()); + assert!(a.accent_rgb() <= 0xFFFFFF); + assert_eq!(CLIAgent::from_slug(a.slug()), Some(a)); + } + } + + #[test] + fn detects_newer_agents_by_command() { + for (cmd, agent) in [ + ("auggie", CLIAgent::Auggie), + ("agy", CLIAgent::Antigravity), + ("vibe-acp", CLIAgent::Vibe), + ("grok", CLIAgent::Grok), + ("/usr/local/bin/qwen", CLIAgent::Qwen), + ("pi", CLIAgent::Pi), + ("hermes", CLIAgent::Hermes), + ] { + assert_eq!(CLIAgent::detect_from_argv(&argv(&[cmd])), Some(agent)); + } + } + + #[test] + fn custom_rules_map_wrappers_to_agents() { + let custom: HashMap = [("cc".to_string(), "claude".to_string())].into(); + assert_eq!( + CLIAgent::detect_from_argv_with(&argv(&["/home/x/bin/cc", "-c"]), &custom), + Some(CLIAgent::Claude) + ); + // A rule naming an unknown agent is ignored, not an error. + let bogus: HashMap = [("cc".to_string(), "hal9000".to_string())].into(); + assert_eq!( + CLIAgent::detect_from_argv_with(&argv(&["cc"]), &bogus), + None + ); + // Custom rules never scan interpreter arguments. + assert_eq!( + CLIAgent::detect_from_argv_with(&argv(&["node", "cc/cli.js"]), &custom), + None + ); + // Built-ins still win on their own names. + let shadow: HashMap = [("codex".to_string(), "claude".to_string())].into(); + assert_eq!( + CLIAgent::detect_from_argv_with(&argv(&["codex"]), &shadow), + Some(CLIAgent::Codex) + ); + } + + #[test] + fn parses_sentinel_events() { + let ev = parse_agent_event( + br#"777;notify;tty7://cli-agent;{"v":1,"agent":"claude","event":"permission-request","session_id":"abc-123","message":"Claude needs your permission to use Bash"}"#, + ) + .expect("well-formed sentinel event"); + assert_eq!(ev.agent, Some(CLIAgent::Claude)); + assert_eq!(ev.kind, AgentEventKind::PermissionRequest); + assert_eq!(ev.session_id.as_deref(), Some("abc-123")); + assert!(ev.message.as_deref().unwrap().contains("permission")); + + // A plain OSC 777 notification is NOT an event. + assert_eq!(parse_agent_event(b"777;notify;Build;done"), None); + // Unknown event names are dropped (forward evolution). + assert_eq!( + parse_agent_event(br#"777;notify;tty7://cli-agent;{"event":"quantum-leap"}"#), + None + ); + // Malformed JSON is dropped. + assert_eq!( + parse_agent_event(b"777;notify;tty7://cli-agent;{oops"), + None + ); + } + + #[test] + fn session_state_machine_follows_the_turn() { + let mut s = AgentSessionState::default(); + assert_eq!(s.status, AgentStatus::Idle); + + let ev = |kind, msg: Option<&str>, id: Option<&str>| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: id.map(String::from), + message: msg.map(String::from), + }; + + s.apply_event(&ev(AgentEventKind::SessionStart, None, Some("sid-1"))); + assert_eq!(s.status, AgentStatus::Idle); + assert_eq!(s.session_id.as_deref(), Some("sid-1")); + assert!(s.rich); + + s.apply_event(&ev(AgentEventKind::PromptSubmit, None, None)); + assert_eq!(s.status, AgentStatus::Working); + + // A Notification arriving MID-TURN (while Working) is a real block — + // a permission/decision prompt — so it escalates to Waiting. + s.apply_event(&ev( + AgentEventKind::Notification, + Some("Claude needs your permission"), + None, + )); + assert_eq!(s.status, AgentStatus::Waiting); + assert!(s.message.as_deref().unwrap().contains("permission")); + + s.apply_event(&ev(AgentEventKind::Stop, None, None)); + assert_eq!(s.status, AgentStatus::Done); + + // A Notification arriving BETWEEN turns (while Done) is Claude Code's + // idle "waiting for your input" nudge, NOT a block — it must not flip + // the finished-and-green session to amber "needs you". + s.apply_event(&ev( + AgentEventKind::Notification, + Some("Claude is waiting for your input"), + None, + )); + assert_eq!( + s.status, + AgentStatus::Done, + "an idle notification between turns must not fabricate a block" + ); + + // Session end goes idle but KEEPS the id — ended sessions resume. + s.apply_event(&ev(AgentEventKind::SessionEnd, None, None)); + assert_eq!(s.status, AgentStatus::Idle); + assert_eq!(s.session_id.as_deref(), Some("sid-1")); + } + + #[test] + fn resume_commands_are_shell_safe() { + assert_eq!( + CLIAgent::Claude.resume_command("abc-123").as_deref(), + Some("claude --resume abc-123") + ); + assert_eq!( + CLIAgent::Codex.resume_command("th_read.9").as_deref(), + Some("codex resume th_read.9") + ); + // No resume flag known → None. + assert_eq!(CLIAgent::Aider.resume_command("abc"), None); + // An id carrying shell syntax is refused outright. + assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /"), None); + assert_eq!(CLIAgent::Claude.resume_command("$(boom)"), None); + assert_eq!(CLIAgent::Claude.resume_command(""), None); + } + + #[test] + fn status_metadata_is_consistent() { + assert_eq!(AgentStatus::Idle.dot_rgb(), None); + for st in [ + AgentStatus::Working, + AgentStatus::Waiting, + AgentStatus::Done, + ] { + assert!(st.dot_rgb().is_some()); + } + // Wire form is kebab-case (shared with the JSON protocol). + assert_eq!( + serde_json::to_string(&AgentStatus::Waiting).unwrap(), + "\"waiting\"" + ); + } +} diff --git a/src/core/config.rs b/src/core/config.rs index d5ce8526..173f0d81 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -198,6 +198,22 @@ pub struct Config { /// rows. Entries for deleted profiles are harmless (never surfaced). #[serde(default)] pub ssh_profile_frecency: HashMap, + + // ── CLI coding agents ──────────────────────────────────────────────────── + /// User-defined agent-detection rules: a command basename → an agent slug + /// (`{"cc": "claude", "my-codex": "codex"}`), so personal wrappers get + /// branded like the agent they launch. Complements the built-in registry in + /// [`crate::core::cli_agent`]; built-ins win on their own names. The daemon + /// reads this once per process (restart the daemon to apply changes). + #[serde(default)] + pub agent_commands: HashMap, + /// On session restore, when a pane can't re-attach (the daemon lost it — + /// reboot, daemon restart) but it was running a coding agent whose native + /// session id we captured, type that agent's resume command into the fresh + /// shell (`claude --resume `, `codex resume `, …) so the + /// conversation continues where it left off. cmux-style; on by default. + #[serde(default = "default_true")] + pub restore_agent_sessions: bool, } /// One saved profile's usage record for palette frecency (see @@ -426,6 +442,8 @@ impl Default for Config { verify_host_keys: true, ssh_warn_on_close: false, ssh_profile_frecency: HashMap::new(), + agent_commands: HashMap::new(), + restore_agent_sessions: true, } } } @@ -649,6 +667,22 @@ pub fn extra_env() -> HashMap { Config::load().env } +/// User-defined agent-detection rules (`agent_commands`), keys lowercased, +/// cached once per process. The daemon consults this from its 0.5 s foreground +/// poll on every pane, so it must not re-read `config.json` each time; the +/// trade-off is that rule edits apply on the next daemon start (the GUI's +/// "Restart daemon" command counts). +pub fn agent_commands_cached() -> &'static HashMap { + static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); + CACHE.get_or_init(|| { + Config::load() + .agent_commands + .into_iter() + .map(|(k, v)| (k.to_ascii_lowercase(), v)) + .collect() + }) +} + /// Serde default for [`Config::keybinding_preset`]: the no-op `"default"` preset. fn default_preset() -> String { "default".to_string() diff --git a/src/core/mod.rs b/src/core/mod.rs index c2a38f47..44b7e97c 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -8,6 +8,9 @@ //! code. pub mod actions; +pub mod agent_hooks; +pub mod agent_prompt; +pub mod cli_agent; pub mod config; // SSH connection-manager data layer (WS1). Its public API is consumed by the // daemon-session, auth, forwarding, and UI workstreams, which land separately — diff --git a/src/core/osc.rs b/src/core/osc.rs index 78df7d92..871fd477 100644 --- a/src/core/osc.rs +++ b/src/core/osc.rs @@ -179,6 +179,48 @@ impl OscTokenizer { } } +/// Parse a buffered OSC payload (the bytes after `ESC ]`, e.g. `9;Build done` +/// or `777;notify;Title;Body`) into a `(title, body)` desktop notification, or +/// `None` if it isn't one. Shared by the client's notification toaster +/// (`terminal::remote`) and the daemon's agent-status sniffer (`daemon::pane`), +/// so ConEmu's OSC 9 subcommand quirks are handled in exactly one place. +/// +/// tty7's own agent-event sentinel (`777;notify;tty7://cli-agent;{json}` — see +/// [`crate::core::cli_agent::AGENT_EVENT_SENTINEL`]) parses as a notification +/// *shape*, but it is machine-to-machine traffic: callers that surface toasts +/// must check for it first (via [`crate::core::cli_agent::parse_agent_event`]) +/// rather than showing the raw JSON to the user. +pub fn parse_notification(payload: &[u8]) -> Option<(Option, String)> { + // OSC 9 ; — iTerm2 / growl style; title-less, body is the text. + if let Some(rest) = payload.strip_prefix(b"9;") { + // ConEmu overloads OSC 9 with numeric subcommands (`9;4;…` progress, + // `9;9;`, …); those aren't notifications, so skip a `;`/`` + // leading field. A real message rarely starts with a bare single digit. + let first = rest.split(|&b| b == b';').next().unwrap_or(rest); + if first.len() == 1 && first[0].is_ascii_digit() { + return None; + } + let body = String::from_utf8_lossy(rest).into_owned(); + return (!body.is_empty()).then_some((None, body)); + } + // OSC 777 ; notify ; ; <body> — urxvt style. + if let Some(rest) = payload.strip_prefix(b"777;notify;") { + let mut parts = rest.splitn(2, |&b| b == b';'); + let first = String::from_utf8_lossy(parts.next().unwrap_or(b"")).into_owned(); + let second = parts + .next() + .map(|b| String::from_utf8_lossy(b).into_owned()); + // With both fields present it's title + body; with only one it's a body-only + // notification (some senders omit the title). + let (title, body) = match second { + Some(body) if !body.is_empty() => (Some(first), body), + _ => (None, first), + }; + return (!body.is_empty()).then_some((title, body)); + } + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/core/session.rs b/src/core/session.rs index 2de9962a..854e9f1b 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -44,6 +44,16 @@ pub enum SessionPane { /// for sessions written before this field existed. #[serde(default)] ssh_spec: Option<Box<NativeSshSpec>>, + /// The coding agent this leaf was running at save time, plus its native + /// session id (from the agent's own `session-start` event). When the + /// pane can't re-attach on restore, these drive the cmux-style resume: + /// the fresh shell is handed the agent's resume command + /// (`claude --resume <id>`, …) so the conversation continues. `None` + /// for panes without an agent, agents without hooks, or old sessions. + #[serde(default)] + agent: Option<crate::core::cli_agent::CLIAgent>, + #[serde(default)] + agent_session_id: Option<String>, }, /// A split of two subtrees along `axis`, with `a` taking `ratio` of space. Split { @@ -173,6 +183,8 @@ mod tests { cwd: Some(PathBuf::from("/work")), pane_id: Some(7), ssh_spec: None, + agent: None, + agent_session_id: None, }, }, SessionTab { @@ -184,11 +196,15 @@ mod tests { cwd: None, pane_id: None, ssh_spec: None, + agent: None, + agent_session_id: None, }), b: Box::new(SessionPane::Leaf { cwd: Some(PathBuf::from("/tmp")), pane_id: Some(9), ssh_spec: None, + agent: None, + agent_session_id: None, }), }, }, @@ -211,6 +227,42 @@ mod tests { } } + #[test] + fn leaf_agent_resume_fields_round_trip_and_default() { + // Round trip: the agent + native session id survive serialization. + let leaf = SessionPane::Leaf { + cwd: None, + pane_id: None, + ssh_spec: None, + agent: Some(crate::core::cli_agent::CLIAgent::Claude), + agent_session_id: Some("abc-123".into()), + }; + let back: SessionPane = + serde_json::from_str(&serde_json::to_string(&leaf).unwrap()).unwrap(); + match back { + SessionPane::Leaf { + agent, + agent_session_id, + .. + } => { + assert_eq!(agent, Some(crate::core::cli_agent::CLIAgent::Claude)); + assert_eq!(agent_session_id.as_deref(), Some("abc-123")); + } + _ => panic!("expected leaf"), + } + // A session written before these fields existed decodes with `None`s. + let old: SessionPane = + serde_json::from_str(r#"{"Leaf":{"cwd":"/x","pane_id":3}}"#).unwrap(); + assert!(matches!( + old, + SessionPane::Leaf { + agent: None, + agent_session_id: None, + .. + } + )); + } + #[test] fn session_defaults_fill_missing_fields() { // An empty object → default (active 0, no tabs). @@ -242,6 +294,8 @@ mod tests { cwd: Some(PathBuf::from("/home/u")), pane_id: Some(1), ssh_spec: None, + agent: None, + agent_session_id: None, }, }], }; diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index 50bf77c5..8f6ce251 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -221,6 +221,14 @@ fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option<Pat // Advertise a widely-available terminfo + truecolor. cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); + // Mark the session as tty7's, for tooling that adapts to its host terminal + // — most importantly the `tty7 agent-hook` emitter, which stays silent + // without it so globally-installed agent hooks can't leak escape sequences + // into other terminals (see `core::agent_hooks`). + cmd.env( + crate::core::agent_hooks::TTY7_ENV_MARKER, + env!("CARGO_PKG_VERSION"), + ); // User-configured environment variables, injected last so they can override // the inherited environment (but not TERM/COLORTERM above, which reflect our // emulator's real capabilities). @@ -353,6 +361,15 @@ struct PaneState { shell: ShellState, /// Trusted foreground remote context from the local process table. remote: Option<RemoteContext>, + /// The third-party CLI coding agent running in the foreground, detected from + /// the foreground `argv` (same process-table poll as `remote`). `None` when + /// no known agent runs — see [`crate::core::cli_agent`]. + agent: Option<crate::core::cli_agent::CLIAgent>, + /// The rich agent-session status (idle/working/waiting/done + native + /// session id), folded from the sentinel OSC events the agent's hooks emit + /// (with an opaque OSC 9/777 fallback). Cleared when the agent exits. + /// See [`crate::core::cli_agent::AgentSessionState`]. + agent_session: Option<crate::core::cli_agent::AgentSessionState>, /// Last geometry the PTY was sized to (spawn size, then each `resize`). /// Reported to a re-attaching client as `DaemonMsg::Size` so its replay of /// the ring runs at the geometry the ring was recorded under. @@ -373,6 +390,18 @@ enum PaneBackend { NativeSsh(NativeSshBackend), } +/// The reader thread's two off-hot-path foreground probes, bundled so +/// [`DaemonPane::spawn_reader`] takes them as one argument. Both are +/// process-table reads (foreground process-group leader → `argv`) run together +/// on the reader's 0.5 s poll: `remote` classifies an SSH context, `agent` +/// classifies a third-party coding agent. Boxed rather than generic because +/// they're invoked at most twice a second — the indirection is free here and +/// keeps the reader's signature readable. +struct ForegroundProbes { + remote: Box<dyn Fn() -> Option<RemoteContext> + Send>, + agent: Box<dyn Fn() -> Option<crate::core::cli_agent::CLIAgent> + Send>, +} + /// The local-PTY backend: the same handles `DaemonPane` has always owned. struct PtyBackend { /// The PTY master. Kept for the pane's lifetime to `resize` it and to query the @@ -533,6 +562,8 @@ impl DaemonPane { cwd: spawn.initial_cwd, shell: ShellState::default(), remote: spawn.remote.clone(), + agent: None, + agent_session: None, size, alive: true, })); @@ -579,13 +610,17 @@ impl DaemonPane { // keyboard — see `foreground_command_running` / issue #26. let fg_master = master.clone(); let remote_master = master.clone(); + let agent_master = master.clone(); let reader = Self::spawn_reader( state, shutting_down, gate, reader_handle, move || foreground_command_running(&fg_master, shell_pid), - move || foreground_remote_context(&remote_master), + ForegroundProbes { + remote: Box::new(move || foreground_remote_context(&remote_master)), + agent: Box::new(move || foreground_agent(&agent_master)), + }, death, ); *pane.reader.lock().unwrap() = Some(reader); @@ -634,6 +669,10 @@ impl DaemonPane { cwd: None, shell: ShellState::default(), remote: Some(remote), + // A native-SSH pane has no local process group, so foreground-argv + // agent detection never runs for it. + agent: None, + agent_session: None, size, alive: true, })); @@ -679,7 +718,10 @@ impl DaemonPane { gate, reader_handle, || false, - || None, + ForegroundProbes { + remote: Box::new(|| None), + agent: Box::new(|| None), + }, death, ); *pane.reader.lock().unwrap() = Some(reader); @@ -727,6 +769,10 @@ impl DaemonPane { /// `Prompt` on change. On EOF it reports the death through `death` — marking /// the pane not-alive and sending `Exited`, keeping the ring for a later /// attach, or handing an unattached pane to `on_dead` (see [`DeathReporter`]). + /// + /// The two off-hot-path foreground probes (remote context + coding agent) + /// travel together in [`ForegroundProbes`]: both are process-table reads run + /// on the same 0.5 s poll, and bundling them keeps the signature at arity. fn spawn_reader( state: Arc<Mutex<PaneState>>, shutting_down: Arc<AtomicBool>, @@ -736,9 +782,13 @@ impl DaemonPane { // when a prompt mark arrives, to reject marks a foreground program emits — // see the call site and [`foreground_command_running`]. foreground_running: impl Fn() -> bool + Send + 'static, - foreground_remote: impl Fn() -> Option<RemoteContext> + Send + 'static, + probes: ForegroundProbes, death: Arc<DeathReporter>, ) -> JoinHandle<()> { + let ForegroundProbes { + remote: foreground_remote, + agent: foreground_agent_fn, + } = probes; std::thread::Builder::new() .name("tty7-daemon-pane-reader".to_string()) .spawn(move || { @@ -815,14 +865,19 @@ impl DaemonPane { } } - // SSH-context detection is a process-table query - // (sysctl/procfs). Keep it out of the state lock and - // off the per-chunk hot path; half-second freshness is - // enough for link hover/click state while keeping PTY - // drain latency predictable. - let remote = if std::time::Instant::now() >= next_remote_check { + // SSH-context + coding-agent detection are process-table + // queries (sysctl/procfs). Keep them out of the state lock + // and off the per-chunk hot path; half-second freshness is + // enough for link hover/click state and the agent tab chip + // while keeping PTY drain latency predictable. Both ride the + // one poll gate so we read the foreground process at most + // twice per interval. + let poll_now = std::time::Instant::now() >= next_remote_check; + if poll_now { next_remote_check = std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL; + } + let remote = if poll_now { // A native-SSH pane already carries its own remote // context; process-table detection must not clobber // it (this pane *is* SSH). Only a plain PTY pane gets @@ -837,6 +892,7 @@ impl DaemonPane { } else { None }; + let agent = poll_now.then(&foreground_agent_fn); let tr1 = trace.then(std::time::Instant::now); let mut st = state.lock().unwrap(); @@ -854,6 +910,9 @@ impl DaemonPane { if let Some(remote) = remote { apply_remote_context(&mut st, remote); } + if let Some(agent) = agent { + apply_agent(&mut st, agent); + } if let Some(tr1) = tr1 { tr_disp_t += tr1.elapsed(); } @@ -1394,6 +1453,12 @@ fn attach_subscriber(st: &mut PaneState, subscriber: Sender<DaemonMsg>) -> u64 { if st.remote.is_some() { let _ = subscriber.send(DaemonMsg::RemoteContext(st.remote.clone())); } + if st.agent.is_some() { + let _ = subscriber.send(DaemonMsg::Agent(st.agent)); + } + if st.agent_session.is_some() { + let _ = subscriber.send(DaemonMsg::AgentStatus(st.agent_session.clone())); + } // A dead pane's reader thread — the one that reports the child's exit — is // long gone, so replay its exit too: without this an attach racing the // child's death (it exited between the client's `List` and its `Attach`) @@ -1427,6 +1492,61 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) { }); } } + apply_agent_signals(st, signals.agent_events, signals.notification); +} + +/// Fold the chunk's agent signals into the pane's session state and push any +/// resulting change. Called with the state lock held. +/// +/// Two tiers: sentinel events (hooks installed) drive the full +/// state machine and may even *identify* the agent where argv detection can't +/// see through a wrapper; a plain OSC 9/777 notification is the no-hooks +/// fallback — it only means "the agent pinged you", so it marks the session +/// `Waiting` (non-rich) and never overrides live rich state. +fn apply_agent_signals( + st: &mut PaneState, + events: Vec<crate::core::cli_agent::AgentEvent>, + notification: Option<String>, +) { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + if events.is_empty() && notification.is_none() { + return; + } + let before = st.agent_session.clone(); + + for event in &events { + // An event naming an agent brands the pane even when the process-table + // poll can't (an unrecognized wrapper binary): identity via protocol. + if st.agent.is_none() && event.agent.is_some() { + st.agent = event.agent; + if let Some(sub) = &st.subscriber { + let _ = sub.send(DaemonMsg::Agent(st.agent)); + } + } + st.agent_session + .get_or_insert_with(AgentSessionState::default) + .apply_event(event); + } + + // Opaque fallback: only meaningful when we know an agent runs here, and + // never on top of rich state (the hooks channel owns it then). + if let Some(body) = notification + && st.agent.is_some() + && !st.agent_session.as_ref().is_some_and(|s| s.rich) + { + let sess = st + .agent_session + .get_or_insert_with(AgentSessionState::default); + sess.status = AgentStatus::Waiting; + sess.message = Some(body); + } + + if st.agent_session != before + && let Some(sub) = &st.subscriber + { + let _ = sub.send(DaemonMsg::AgentStatus(st.agent_session.clone())); + } } fn apply_remote_context(st: &mut PaneState, remote: Option<RemoteContext>) { @@ -1439,6 +1559,26 @@ fn apply_remote_context(st: &mut PaneState, remote: Option<RemoteContext>) { st.remote = remote; } +fn apply_agent(st: &mut PaneState, agent: Option<crate::core::cli_agent::CLIAgent>) { + if st.agent == agent { + return; + } + // The agent leaving the foreground ends its session: clear the rich state + // (and tell the client) so a stale "waiting" dot can't outlive the process. + // The poll can blip momentarily (an agent-spawned subcommand takes the + // foreground group), but events re-establish state on the next signal. + if agent.is_none() && st.agent_session.is_some() { + st.agent_session = None; + if let Some(sub) = &st.subscriber { + let _ = sub.send(DaemonMsg::AgentStatus(None)); + } + } + if let Some(sub) = &st.subscriber { + let _ = sub.send(DaemonMsg::Agent(agent)); + } + st.agent = agent; +} + /// Whether a foreground command — not the shell itself — currently owns the /// PTY. True while e.g. `ssh`, `vim`, or a nested shell runs; false when the /// shell sits idle at its own prompt (it is then the terminal's foreground @@ -1494,6 +1634,28 @@ fn foreground_remote_context(_master: &Mutex<Box<dyn MasterPty + Send>>) -> Opti None } +/// Identify the third-party CLI coding agent (Claude Code, Codex, …) owning the +/// PTY foreground, from its `argv`. Same process-table read as +/// [`foreground_remote_context`]; runs off the hot path on the 0.5 s poll. +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn foreground_agent( + master: &Mutex<Box<dyn MasterPty + Send>>, +) -> Option<crate::core::cli_agent::CLIAgent> { + let pid = master.lock().ok().and_then(|m| m.process_group_leader())?; + let argv = crate::daemon::remote::foreground_argv(pid)?; + crate::core::cli_agent::CLIAgent::detect_from_argv_with( + &argv, + crate::core::config::agent_commands_cached(), + ) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn foreground_agent( + _master: &Mutex<Box<dyn MasterPty + Send>>, +) -> Option<crate::core::cli_agent::CLIAgent> { + None +} + // --------------------------------------------------------------------------- // OSC sniffer (cwd + prompt). The byte-level OSC framing lives in // `core::osc::OscTokenizer` (shared with the client's notification scanner); @@ -1515,6 +1677,14 @@ struct ShellState { struct SniffSignals { cwd: Option<PathBuf>, shell: Option<ShellState>, + /// Sentinel agent events completed in this chunk, in stream order — each + /// one is a state-machine step, so unlike cwd/shell they must *all* apply + /// (a `stop` directly after a `notification` still means "done"). + agent_events: Vec<crate::core::cli_agent::AgentEvent>, + /// A plain (non-sentinel) OSC 9/777 desktop notification completed in this + /// chunk — the opaque "the agent pinged you" fallback signal for panes + /// whose agent has no hooks installed. Last body wins. + notification: Option<String>, } struct OscSniffer { @@ -1526,7 +1696,10 @@ struct OscSniffer { impl OscSniffer { fn new() -> Self { Self { - tok: OscTokenizer::new(&[b"7", b"133"]), + // 9 / 777 are the notification channels the agent-status layer + // rides (sentinel events + opaque fallback); the client sniffs the + // same two independently for its desktop toasts. + tok: OscTokenizer::new(&[b"7", b"133", b"9", b"777"]), shell: ShellState::default(), } } @@ -1543,6 +1716,14 @@ impl OscSniffer { } else if let Some(rest) = payload.strip_prefix(b"133;") { handle_osc133(shell, rest); signals.shell = Some(shell.clone()); + } else if let Some(event) = crate::core::cli_agent::parse_agent_event(payload) { + signals.agent_events.push(event); + } else if let Some((title, body)) = crate::core::osc::parse_notification(payload) { + // A sentinel-titled payload whose JSON failed to parse is + // protocol traffic, not a user notification — drop it. + if title.as_deref() != Some(crate::core::cli_agent::AGENT_EVENT_SENTINEL) { + signals.notification = Some(body); + } } }); signals @@ -1702,6 +1883,57 @@ fn proc_name(pid: i32) -> Option<String> { mod tests { use super::*; + /// End-to-end check of the *live* agent-detection chain this feature rides + /// on macOS/Linux: spawn a real PTY child whose `argv[0]` names a coding + /// agent (`exec -a codex …`), then follow the exact path `foreground_agent` + /// uses — read the PTY's foreground process-group leader, read its `argv` + /// from the process table, and run `detect_from_argv`. Guards against a + /// regression in the platform `process_group_leader` / `foreground_argv` + /// plumbing that the pure `detect_from_argv` unit tests can't see. + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn live_pty_child_argv_detects_the_agent() { + use portable_pty::{CommandBuilder, PtySize, native_pty_system}; + + let pty = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty"); + // `exec -a codex` replaces the shell with `cat`, giving it argv[0]=codex + // while it blocks on stdin — so it stays the PTY's foreground group long + // enough to observe. `cat` (not `sleep`) keeps it alive until the master + // is dropped and its stdin EOFs. Must be bash: `exec -a` is a bashism + // that dash (Ubuntu's /bin/sh) rejects. + let mut cmd = CommandBuilder::new("bash"); + cmd.args(["-c", "exec -a codex cat"]); + let mut child = pty.slave.spawn_command(cmd).expect("spawn child"); + let master = Mutex::new(pty.master); + + // Poll for the foreground group to become the child (not the transient + // `sh`), then detect. Bounded so a stuck spawn fails the test rather than + // hanging CI. + let mut detected = None; + for _ in 0..200 { + if let Some(agent) = foreground_agent(&master) { + detected = Some(agent); + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let _ = child.kill(); + let _ = child.wait(); + + assert_eq!( + detected, + Some(crate::core::cli_agent::CLIAgent::Codex), + "a live PTY child with argv[0]=codex must be detected as Codex" + ); + } + /// Spawn shell precedence: explicit override > configured > platform /// default (`None`). Locks the contract stated on [`choose_shell`]. #[test] @@ -2220,6 +2452,8 @@ mod tests { cwd: None, shell: ShellState::default(), remote: None, + agent: None, + agent_session: None, size: WinSize { cols: 80, rows: 24, @@ -2230,6 +2464,107 @@ mod tests { } } + /// The full daemon-side rich-status path: sentinel OSC events sniffed out + /// of the byte stream drive the pane's session state machine, identify the + /// agent when argv detection hasn't, and stream every change to the + /// subscriber — while a plain notification only fires the opaque fallback. + #[test] + fn sentinel_events_drive_agent_session_state() { + use crate::core::cli_agent::{AgentStatus, CLIAgent}; + + let mut st = test_state(true); + let (tx, rx) = mpsc::channel(); + st.subscriber = Some(tx); + + let mut sniffer = OscSniffer::new(); + let stream = concat!( + "\x1b]777;notify;tty7://cli-agent;", + r#"{"v":1,"agent":"claude","event":"session-start","session_id":"sid-9"}"#, + "\x07", + "\x1b]777;notify;tty7://cli-agent;", + r#"{"v":1,"agent":"claude","event":"prompt-submit"}"#, + "\x07", + ); + apply_signals(&mut st, sniffer.feed(stream.as_bytes())); + + // The event branded the pane (argv detection never ran here)… + assert_eq!(st.agent, Some(CLIAgent::Claude)); + // …and the state machine folded both events: idle → working, id kept. + let sess = st.agent_session.clone().expect("session state exists"); + assert_eq!(sess.status, AgentStatus::Working); + assert_eq!(sess.session_id.as_deref(), Some("sid-9")); + assert!(sess.rich); + + // The subscriber saw the identity and the (final) status. + assert!(matches!( + rx.try_recv(), + Ok(DaemonMsg::Agent(Some(CLIAgent::Claude))) + )); + assert!(matches!( + rx.try_recv(), + Ok(DaemonMsg::AgentStatus(Some(s))) if s.status == AgentStatus::Working + )); + + // A waiting event lands with its message. + let waiting = concat!( + "\x1b]777;notify;tty7://cli-agent;", + r#"{"event":"notification","message":"Claude needs your permission to use Bash"}"#, + "\x07", + ); + apply_signals(&mut st, sniffer.feed(waiting.as_bytes())); + assert_eq!( + st.agent_session.as_ref().unwrap().status, + AgentStatus::Waiting + ); + assert!(matches!( + rx.try_recv(), + Ok(DaemonMsg::AgentStatus(Some(s))) if s.message.as_deref().unwrap().contains("permission") + )); + + // The agent leaving the foreground clears the session (and says so). + apply_agent(&mut st, None); + assert!(st.agent_session.is_none()); + assert!(matches!(rx.try_recv(), Ok(DaemonMsg::AgentStatus(None)))); + assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Agent(None)))); + } + + /// The opaque fallback: with an agent detected but no hooks, a plain OSC 9 + /// notification marks the session waiting (non-rich); without an agent it + /// does nothing; and it never clobbers live rich state. + #[test] + fn opaque_notifications_only_fall_back_when_no_rich_state() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; + + // No agent → the notification is ignored (it's just a toast). + let mut st = test_state(true); + let mut sniffer = OscSniffer::new(); + apply_signals(&mut st, sniffer.feed(b"\x1b]9;Build finished\x07")); + assert!(st.agent_session.is_none()); + + // Agent detected, no hooks → waiting, non-rich, body kept. + st.agent = Some(CLIAgent::Codex); + apply_signals( + &mut st, + sniffer.feed(b"\x1b]9;Codex wants to run tests\x07"), + ); + let sess = st.agent_session.clone().unwrap(); + assert_eq!(sess.status, AgentStatus::Waiting); + assert!(!sess.rich); + + // Rich state present → the opaque ping is ignored. + st.agent_session = Some(AgentSessionState { + status: AgentStatus::Working, + message: None, + session_id: Some("sid".into()), + rich: true, + }); + apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07")); + assert_eq!( + st.agent_session.as_ref().unwrap().status, + AgentStatus::Working + ); + } + /// Attaching replays Size → Snapshot (→ Cwd) in order and installs the /// subscriber under a fresh epoch. #[test] @@ -2301,7 +2636,10 @@ mod tests { Arc::new(OutputGate::new()), Box::new(std::io::Cursor::new(b"tail".to_vec())), || false, // no PTY here → treat the shell as foreground - || None, + ForegroundProbes { + remote: Box::new(|| None), + agent: Box::new(|| None), + }, Arc::new(DeathReporter::new(move || { dead_flag.store(true, Ordering::SeqCst) })), @@ -2335,7 +2673,10 @@ mod tests { Arc::new(OutputGate::new()), Box::new(std::io::Cursor::new(Vec::new())), || false, - || None, + ForegroundProbes { + remote: Box::new(|| None), + agent: Box::new(|| None), + }, Arc::new(DeathReporter::new(move || dead_tx.send(()).unwrap())), ); handle.join().unwrap(); @@ -2358,7 +2699,10 @@ mod tests { Arc::new(OutputGate::new()), Box::new(std::io::Cursor::new(Vec::new())), || false, - || None, + ForegroundProbes { + remote: Box::new(|| None), + agent: Box::new(|| None), + }, Arc::new(DeathReporter::new(move || { dead_flag.store(true, Ordering::SeqCst) })), @@ -2414,6 +2758,25 @@ mod tests { assert!(dead_rx.try_recv().is_err(), "on_dead must fire only once"); } + /// Every spawned shell carries the `TTY7` marker, so the `tty7 agent-hook` + /// emitter fires (it stays silent without it). This is the env side of the + /// rich-status channel — a regression here silently breaks all hook-based + /// agent status, which no other test would catch. + #[test] + fn spawned_shell_carries_the_tty7_marker() { + let cmd = build_shell_command(None, &Some(PathBuf::from("/tmp"))) + .expect("build default shell command") + .0; + let tty7 = cmd + .get_env(crate::core::agent_hooks::TTY7_ENV_MARKER) + .and_then(|v| v.to_str()); + assert_eq!( + tty7, + Some(env!("CARGO_PKG_VERSION")), + "the daemon must inject TTY7 into every spawned shell" + ); + } + /// `apply_signals` writes sniffed cwd/shell state into the pane state. #[test] fn apply_signals_updates_state() { @@ -2424,7 +2787,7 @@ mod tests { &mut st, SniffSignals { cwd: Some(PathBuf::from("/tmp/x")), - shell: None, + ..SniffSignals::default() }, ); assert_eq!(st.cwd, Some(PathBuf::from("/tmp/x"))); @@ -2433,12 +2796,12 @@ mod tests { apply_signals( &mut st, SniffSignals { - cwd: None, shell: Some(ShellState { active: true, at_prompt: true, last_exit_code: Some(0), }), + ..SniffSignals::default() }, ); assert!(st.shell.active && st.shell.at_prompt); diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index 26e58683..d59b68a6 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -781,6 +781,17 @@ pub enum DaemonMsg { PaneList(Vec<PaneInfo>), /// The foreground remote context, or `None` when the pane is local / unknown. RemoteContext(Option<RemoteContext>), + /// The third-party CLI coding agent currently running in the foreground + /// (Claude Code, Codex, Gemini, …), or `None` when no known agent is running. + /// Detected daemon-side from the foreground `argv` — see + /// [`crate::core::cli_agent`]. + Agent(Option<crate::core::cli_agent::CLIAgent>), + /// The rich per-session agent status (idle / working / waiting / done + + /// native session id), sniffed daemon-side from the pane's OSC stream + /// (tty7's sentinel events, with an opaque OSC 9/777 fallback) — see + /// [`crate::core::cli_agent::AgentSessionState`]. `None` clears it (the + /// agent exited). + AgentStatus(Option<crate::core::cli_agent::AgentSessionState>), /// Reply to `EnsureLoopbackForward`. LoopbackForward(LoopbackForward), /// Reply to `ListLoopbackForwards` and `CloseLoopbackForward`. @@ -887,6 +898,10 @@ mod kind { // (15–19 reserved: WS3 auth extensions.) /// `ForwardList` — reply to the WS4 managed-forward messages. pub const FORWARD_LIST: u8 = 20; + /// `Agent` — the foreground CLI coding agent detected on a pane (or its clear). + pub const AGENT: u8 = 21; + /// `AgentStatus` — the pane's rich agent-session status (or its clear). + pub const AGENT_STATUS: u8 = 22; } /// Write one framed message: `[u32 LE len][u8 kind][payload]`. @@ -1141,6 +1156,8 @@ impl DaemonMsg { DaemonMsg::RemoteContext(remote) => { write_frame(w, kind::REMOTE_CONTEXT, &to_json(remote)?) } + DaemonMsg::Agent(agent) => write_frame(w, kind::AGENT, &to_json(agent)?), + DaemonMsg::AgentStatus(state) => write_frame(w, kind::AGENT_STATUS, &to_json(state)?), DaemonMsg::LoopbackForward(forward) => { write_frame(w, kind::LOOPBACK_FORWARD, &to_json(forward)?) } @@ -1194,6 +1211,8 @@ impl DaemonMsg { }, kind::PANE_LIST => DaemonMsg::PaneList(from_json(&payload)?), kind::REMOTE_CONTEXT => DaemonMsg::RemoteContext(from_json(&payload)?), + kind::AGENT => DaemonMsg::Agent(from_json(&payload)?), + kind::AGENT_STATUS => DaemonMsg::AgentStatus(from_json(&payload)?), kind::LOOPBACK_FORWARD => DaemonMsg::LoopbackForward(from_json(&payload)?), kind::LOOPBACK_FORWARD_LIST => DaemonMsg::LoopbackForwardList(from_json(&payload)?), kind::AUTH_PROMPT => { @@ -1467,6 +1486,16 @@ mod tests { target: "dev".into(), })), DaemonMsg::RemoteContext(None), + DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Claude)), + DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Codex)), + DaemonMsg::Agent(None), + DaemonMsg::AgentStatus(Some(crate::core::cli_agent::AgentSessionState { + status: crate::core::cli_agent::AgentStatus::Waiting, + message: Some("Claude needs your permission to use Bash".into()), + session_id: Some("abc-123".into()), + rich: true, + })), + DaemonMsg::AgentStatus(None), DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }), DaemonMsg::LoopbackForwardList(vec![LoopbackForwardInfo { id: LoopbackForwardId { diff --git a/src/main.rs b/src/main.rs index 9e1a4ed5..32aec0c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,10 +12,10 @@ mod ui; use crate::core::config::Config; use crate::ui::app::Tty7App; +use crate::ui::assets::Assets; use crate::ui::keymap; use gpui::*; use gpui_component::{ActiveTheme as _, Root, TitleBar}; -use gpui_component_assets::Assets; /// Register the bundled Hack monospace faces with gpui's text system so the /// default `font_family` ("Hack") renders identically on every machine, with no @@ -245,6 +245,21 @@ fn enrich_path_from_login_shell() { } fn main() { + // Agent-hook mode: `tty7 agent-hook <agent> <event>` is the tiny emitter + // Claude Code's hooks invoke (see `core::agent_hooks`). It reads the hook + // payload from stdin, writes one OSC sequence to the controlling terminal, + // and exits — never touching config, the daemon, or the GUI. Checked first + // so a hook can never accidentally boot a window. + { + let args: Vec<String> = std::env::args().skip(1).take(3).collect(); + if args.first().map(String::as_str) == Some("agent-hook") { + if let [_, agent, event] = args.as_slice() { + crate::core::agent_hooks::run_agent_hook(agent, event); + } + return; + } + } + // Resolve the config directory override (if any) up front, before any code // path touches config/session/history files (the daemon socket path resolves // under this dir too, so the order matters). diff --git a/src/terminal/git_status.rs b/src/terminal/git_status.rs new file mode 100644 index 00000000..234f9efe --- /dev/null +++ b/src/terminal/git_status.rs @@ -0,0 +1,130 @@ +//! A lightweight git snapshot for a pane's working directory — the current +//! branch and the working-tree diff size — rendered as the sidebar row's third +//! line (`⎇ feat/x +6 −5`): each session fronted with its branch and change +//! count. +//! +//! Deliberately shell-out simple: one `git` invocation per field, run on a +//! background thread by the caller (see [`crate::terminal::view`]) so the UI +//! never blocks on a slow repo. Read-only — `GIT_OPTIONAL_LOCKS=0` keeps status +//! polling from ever taking `index.lock` and fighting a real git command the +//! user is running. Returns `None` when the cwd isn't inside a git work tree, +//! so the sidebar simply omits the line. + +use std::path::Path; +use std::process::{Command, Stdio}; + +/// A pane's git snapshot: the branch it's on and how much the working tree has +/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from +/// `git diff --numstat HEAD` (tracked staged + unstaged changes); binary files +/// and untracked files don't contribute a line count. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GitStatus { + /// The branch name (`main`, `feat/x`), or a short commit sha when the HEAD + /// is detached. Never empty. + pub branch: String, + /// Lines added across the working tree vs `HEAD`. + pub added: u32, + /// Lines removed across the working tree vs `HEAD`. + pub removed: u32, +} + +/// Compute the git snapshot for `cwd`, or `None` when it isn't a git work tree +/// (or the path is gone). Blocking — call it on a background executor. +pub fn compute(cwd: &Path) -> Option<GitStatus> { + if !cwd.exists() { + return None; + } + let branch = branch_name(cwd)?; + let (added, removed) = diff_numstat(cwd).unwrap_or((0, 0)); + Some(GitStatus { + branch, + added, + removed, + }) +} + +/// The current branch name, or a short sha for a detached HEAD. Doubles as the +/// "is this a git repo" gate: both probes failing (not a work tree) yields +/// `None`. +fn branch_name(cwd: &Path) -> Option<String> { + // On a branch — even before the first commit — `symbolic-ref` names it. + if let Some(out) = git(cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) { + let name = out.trim(); + if !name.is_empty() { + return Some(name.to_string()); + } + } + // Detached HEAD (or a rebase/bisect): fall back to the short commit sha. + let sha = git(cwd, &["rev-parse", "--short", "HEAD"])?; + let sha = sha.trim(); + (!sha.is_empty()).then(|| sha.to_string()) +} + +/// Sum added/removed lines across the working tree vs `HEAD` from +/// `git diff --numstat HEAD`. Binary files (`-\t-`) contribute nothing. +fn diff_numstat(cwd: &Path) -> Option<(u32, u32)> { + let out = git(cwd, &["diff", "--numstat", "HEAD"])?; + let mut added = 0u32; + let mut removed = 0u32; + for line in out.lines() { + let mut fields = line.split('\t'); + if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) { + added += n; + } + if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) { + removed += n; + } + } + Some((added, removed)) +} + +/// Run `git -C <cwd> <args>` and return stdout on success, `None` on a +/// non-zero exit or a missing `git`. `GIT_OPTIONAL_LOCKS=0` makes the read +/// truly read-only; stdin is nulled so a misconfigured git can't block on a +/// prompt. +fn git(cwd: &Path, args: &[&str]) -> Option<String> { + let out = Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + String::from_utf8(out.stdout).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A tmp path that is not a git repo yields no status (and never panics). + #[test] + fn non_repo_is_none() { + let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz"); + let _ = std::fs::create_dir_all(&dir); + assert_eq!(compute(&dir), None); + } + + /// A path that doesn't exist is `None`, not a panic. + #[test] + fn missing_path_is_none() { + assert_eq!(compute(Path::new("/no/such/tty7/path/here")), None); + } + + /// This repo (the crate root is inside the tty7 work tree) reports a branch, + /// exercising the real `git` probe end-to-end. + #[test] + fn own_repo_has_a_branch() { + let here = env!("CARGO_MANIFEST_DIR"); + if let Some(status) = compute(Path::new(here)) { + assert!(!status.branch.is_empty()); + } + // If the crate is built outside a work tree (e.g. a vendored tarball), + // `None` is the correct answer and the assertion above is skipped. + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 0e55febc..3b64a032 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -21,6 +21,7 @@ pub mod element; pub mod fps; mod fuzzy; mod generator; +pub(crate) mod git_status; mod highlight; mod history; mod hold; @@ -36,4 +37,5 @@ mod typeahead; pub mod view; pub use remote::RemoteTerminal; +pub(crate) use remote::notify_desktop; pub use size::TermSize; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 5f71816b..c5d558a1 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -35,6 +35,7 @@ use alacritty_terminal::vte::ansi; use std::collections::VecDeque; +use crate::core::cli_agent::{AgentSessionState, CLIAgent}; use crate::core::osc::OscTokenizer; use crate::daemon::protocol::{ AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId, @@ -105,6 +106,8 @@ struct ReaderSignals { cwd: Arc<Mutex<Option<PathBuf>>>, shell: Arc<Mutex<ShellState>>, remote: Arc<Mutex<Option<RemoteContext>>>, + agent: Arc<Mutex<Option<CLIAgent>>>, + agent_session: Arc<Mutex<Option<AgentSessionState>>>, exited: Arc<AtomicBool>, child_exited: Arc<AtomicBool>, zle_reading: Arc<AtomicBool>, @@ -182,6 +185,15 @@ pub struct RemoteTerminal { /// arrives *after* an auto-supplied stored password means the server rejected /// it, so the sheet warns and offers to overwrite/clear the stale entry. auto_supplied_password: bool, + /// The third-party CLI coding agent running in the pane's foreground, last + /// reported by the daemon via `Agent` (detected from the foreground `argv`). + /// `None` when no known agent runs. Drives the tab avatar's brand mark — see + /// [`crate::core::cli_agent`]. + agent: Arc<Mutex<Option<CLIAgent>>>, + /// The agent's rich session status (idle/working/waiting/done + native + /// session id), last reported by the daemon via `AgentStatus`. Drives the + /// status dot, "needs your input" notifications, and session resume. + agent_session: Arc<Mutex<Option<AgentSessionState>>>, reader_thread: Option<JoinHandle<()>>, } @@ -300,6 +312,8 @@ impl RemoteTerminal { let cwd: Arc<Mutex<Option<PathBuf>>> = Arc::new(Mutex::new(None)); let shell_state: Arc<Mutex<ShellState>> = Arc::new(Mutex::new(ShellState::default())); let remote_context: Arc<Mutex<Option<RemoteContext>>> = Arc::new(Mutex::new(None)); + let agent: Arc<Mutex<Option<CLIAgent>>> = Arc::new(Mutex::new(None)); + let agent_session: Arc<Mutex<Option<AgentSessionState>>> = Arc::new(Mutex::new(None)); let exited_flag = Arc::new(AtomicBool::new(false)); let child_exited = Arc::new(AtomicBool::new(false)); let zle_reading = Arc::new(AtomicBool::new(false)); @@ -315,6 +329,8 @@ impl RemoteTerminal { cwd: cwd.clone(), shell: shell_state.clone(), remote: remote_context.clone(), + agent: agent.clone(), + agent_session: agent_session.clone(), exited: exited_flag.clone(), child_exited: child_exited.clone(), zle_reading: zle_reading.clone(), @@ -341,6 +357,8 @@ impl RemoteTerminal { ssh_phase, ssh_endpoint: None, auto_supplied_password: false, + agent, + agent_session, reader_thread: Some(reader_thread), }) } @@ -370,6 +388,8 @@ impl RemoteTerminal { cwd, shell, remote, + agent, + agent_session, exited: exited_flag, child_exited, zle_reading, @@ -625,6 +645,21 @@ impl RemoteTerminal { } proxy.send_event(AlacEvent::Wakeup); } + DaemonMsg::Agent(a) => { + flush_batch!(); + if let Ok(mut guard) = agent.lock() { + *guard = a; + } + } + DaemonMsg::AgentStatus(state) => { + flush_batch!(); + if let Ok(mut guard) = agent_session.lock() { + *guard = state; + } + // Status changes repaint the tab chip / sidebar + // dot even when the pane printed nothing. + proxy.send_event(AlacEvent::Wakeup); + } DaemonMsg::Exited { .. } => { // Child gone: apply what it printed last, then // mark the emulator exited and flip the shared @@ -835,6 +870,20 @@ impl RemoteTerminal { /// Whether zle is reading the keyboard right now (live `133;B` seen, no /// later mark). See the field docs; this is the gate for writing the /// typeahead wipe without it echoing into the scrollback. + /// The third-party CLI coding agent (Claude Code, Codex, …) running in the + /// pane's foreground, as last reported by the daemon, or `None`. Cheap cache + /// read — detection runs daemon-side. See [`crate::core::cli_agent`]. + pub fn foreground_agent(&self) -> Option<CLIAgent> { + self.agent.lock().ok().and_then(|g| *g) + } + + /// The rich agent-session status (idle/working/waiting/done + native + /// session id), as last reported by the daemon, or `None` when no agent + /// session is live. Cheap cache read — sniffing runs daemon-side. + pub fn agent_session(&self) -> Option<AgentSessionState> { + self.agent_session.lock().ok().and_then(|g| g.clone()) + } + pub fn zle_reading(&self) -> bool { self.zle_reading.load(Ordering::Relaxed) } @@ -1312,7 +1361,7 @@ fn stale_mode_resets(mode: TermMode) -> Vec<u8> { /// /// Note: `notify-rust`'s macOS backend uses the deprecated `NSUserNotification`, /// which is acceptable for a completion toast. -pub(super) fn notify_desktop(title: Option<&str>, body: &str) { +pub(crate) fn notify_desktop(title: Option<&str>, body: &str) { let summary = title.unwrap_or("tty7").to_string(); let body = body.to_string(); std::thread::spawn(move || { @@ -1377,36 +1426,22 @@ impl OscNotifyScanner { /// Parse a buffered OSC payload (the bytes after `ESC ]`, e.g. `9;Build done` or /// `777;notify;Title;Body`) into a `(title, body)` notification, or `None` if it -/// isn't a notification we surface. +/// isn't a notification we surface. The parsing itself lives in +/// [`crate::core::osc::parse_notification`] (shared with the daemon's agent +/// sniffer); this wrapper additionally drops tty7's own agent-event sentinel — +/// those payloads are machine-to-machine JSON for the daemon's state machine, +/// and toasting them would show raw JSON to the user. fn parse_osc_notification(payload: &[u8]) -> Option<(Option<String>, String)> { - // OSC 9 ; <text> — iTerm2 / growl style; title-less, body is the text. - if let Some(rest) = payload.strip_prefix(b"9;") { - // ConEmu overloads OSC 9 with numeric subcommands (`9;4;…` progress, - // `9;9;<cwd>`, …); those aren't notifications, so skip a `<digit>;`/`<digit>` - // leading field. A real message rarely starts with a bare single digit. - let first = rest.split(|&b| b == b';').next().unwrap_or(rest); - if first.len() == 1 && first[0].is_ascii_digit() { - return None; - } - let body = String::from_utf8_lossy(rest).into_owned(); - return (!body.is_empty()).then_some((None, body)); + if crate::core::cli_agent::parse_agent_event(payload).is_some() { + return None; } - // OSC 777 ; notify ; <title> ; <body> — urxvt style. - if let Some(rest) = payload.strip_prefix(b"777;notify;") { - let mut parts = rest.splitn(2, |&b| b == b';'); - let first = String::from_utf8_lossy(parts.next().unwrap_or(b"")).into_owned(); - let second = parts - .next() - .map(|b| String::from_utf8_lossy(b).into_owned()); - // With both fields present it's title + body; with only one it's a body-only - // notification (some senders omit the title). - let (title, body) = match second { - Some(body) if !body.is_empty() => (Some(first), body), - _ => (None, first), - }; - return (!body.is_empty()).then_some((title, body)); + let (title, body) = crate::core::osc::parse_notification(payload)?; + // A sentinel-titled payload whose JSON failed to parse is still not a + // user-facing notification; never toast it. + if title.as_deref() == Some(crate::core::cli_agent::AGENT_EVENT_SENTINEL) { + return None; } - None + Some((title, body)) } /// Open a fresh connection to the daemon's listening endpoint. The endpoint is @@ -2123,6 +2158,81 @@ mod tests { assert!(at, "at_prompt should become true after the Prompt report"); } + /// `foreground_agent` reflects the daemon's last `Agent` report — `None` + /// before any report, the detected agent after one, and back to `None` when + /// the agent exits (the daemon reports `Agent(None)`). + #[test] + fn foreground_agent_follows_daemon_agent_reports() { + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + + assert_eq!(term.foreground_agent(), None, "none before any report"); + + let poll = |want: Option<CLIAgent>| { + for _ in 0..200 { + if term.foreground_agent() == want { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + false + }; + + DaemonMsg::Agent(Some(CLIAgent::Claude)) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + assert!(poll(Some(CLIAgent::Claude)), "agent report should surface"); + + DaemonMsg::Agent(None).encode(&mut daemon_side).unwrap(); + daemon_side.flush().unwrap(); + assert!(poll(None), "agent exit should clear it"); + } + + /// `DaemonMsg::AgentStatus` frames must land in the client's session + /// cache (and a `None` clear it) — the reader half of the rich-status + /// channel the daemon's sniffer feeds. + #[test] + fn agent_session_follows_daemon_status_reports() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); + assert_eq!(term.agent_session(), None, "none before any report"); + + let poll = |want: &dyn Fn(Option<AgentSessionState>) -> bool| { + for _ in 0..200 { + if want(term.agent_session()) { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + false + }; + + DaemonMsg::AgentStatus(Some(AgentSessionState { + status: AgentStatus::Waiting, + message: Some("Claude needs your permission".into()), + session_id: Some("sid-1".into()), + rich: true, + })) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + assert!( + poll(&|s| s.is_some_and(|s| s.status == AgentStatus::Waiting + && s.session_id.as_deref() == Some("sid-1") + && s.rich)), + "status report should surface with message + session id" + ); + + DaemonMsg::AgentStatus(None) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + assert!(poll(&|s| s.is_none()), "a None report clears the session"); + } + /// The typeahead wipe (^U) may only be written once zle actually reads the /// keyboard; the client learns that from a *live* `133;B` (prompt end) in /// the output stream. `133;D` (command done, but precmd hooks still running diff --git a/src/terminal/view.rs b/src/terminal/view.rs index c0281d67..0f9e13ec 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -224,6 +224,42 @@ pub struct TerminalView { /// long-running commands completed while the window is in the background. running_since: Option<std::time::Instant>, running_title: String, + /// The coding agent (if any) detected during the current foreground-command + /// episode, captured so its completion notification can be branded ("Claude + /// Code finished" rather than a generic "command finished"). Set the moment + /// the daemon reports an agent while a command runs; cleared when it ends. + running_agent: Option<crate::core::cli_agent::CLIAgent>, + /// The rich agent status last seen by the poll, so transitions (working → + /// waiting, working → done) fire exactly one notification each and repaint + /// the status dot. + last_agent_status: Option<crate::core::cli_agent::AgentStatus>, + /// When the current rich turn entered `Working`, for the "finished after + /// Ns" copy on its `Done` notification. + agent_turn_started: Option<std::time::Instant>, + /// Whether this pane's agent ever reported over the rich sentinel channel. + /// While true, the coarse process-exit "agent finished" notification is + /// suppressed — the turn-level `stop` events already said it better. + agent_was_rich: bool, + /// Whether the agent's last finished turn (the green `Done` dot) is *unread* + /// — a turn ended that the user hasn't looked at since. Set when a new turn + /// finishes while this pane is unfocused; cleared the moment the pane gains + /// focus (you're looking at it). The tab avatar only paints the Done dot + /// while this is true, so a result you've already seen stops nagging. Blue + /// (working) / amber (waiting) are unaffected — they track live state. + agent_result_unread: bool, + /// The pane's last-computed git snapshot (branch + working-tree diff size), + /// shown as the sidebar row's third line. Computed off-thread by + /// [`refresh_git_status`](Self::refresh_git_status) on a cwd change or a + /// command finishing; `None` outside a git work tree (or before the first + /// probe lands). + git_status: Option<crate::terminal::git_status::GitStatus>, + /// The cwd `git_status` was last computed (or scheduled) for, so the poll + /// loop only reprobes when the working directory actually changes. + git_status_cwd: Option<std::path::PathBuf>, + /// Monotonic tag bumped on every git reprobe; a background result is dropped + /// unless it still matches, so a slow probe from a since-changed cwd can't + /// overwrite a fresher one (same guard as `completion_generation`). + git_status_gen: u64, /// The inline command line editor. Live only while the shell sits idle /// at its prompt (`input_active`): there the terminal keeps keyboard focus and /// we run our own line editor (so we own Tab / ↑ / ↓ for completion and @@ -454,6 +490,15 @@ fn notify_command_finished(label: &str, elapsed: std::time::Duration) { super::remote::notify_desktop(Some("tty7"), &body); } +/// Post a branded "the agent finished" notification — the coding-agent form of +/// [`notify_command_finished`], titled with the agent so it's obvious *which* +/// session came back. +fn notify_agent_finished(agent: crate::core::cli_agent::CLIAgent, elapsed: std::time::Duration) { + let secs = elapsed.as_secs(); + let body = format!("Finished after {secs}s"); + super::remote::notify_desktop(Some(agent.display_name()), &body); +} + /// Ring the OS system bell for the `Audible` bell mode. Returns `true` if a /// sound was actually requested, `false` on platforms without a portable beep /// (the caller then falls back to the visual flash so the bell is never silent). @@ -784,6 +829,9 @@ impl TerminalView { cx.on_focus_in(&focus_handle, window, |view, _window, cx| { view.focused = true; view.cursor_visible = true; + // Looking at the pane marks its finished turn read, so the tab + // avatar's green Done dot clears. + view.agent_result_unread = false; view.report_focus_change(true); cx.notify(); }), @@ -897,6 +945,14 @@ impl TerminalView { last_at_prompt: false, running_since: None, running_title: String::new(), + running_agent: None, + last_agent_status: None, + agent_turn_started: None, + agent_was_rich: false, + agent_result_unread: false, + git_status: None, + git_status_cwd: None, + git_status_gen: 0, cmd: CmdEditor::new(), typeahead: Typeahead::new(), hold: GapHold::new(), @@ -952,6 +1008,66 @@ impl TerminalView { self.terminal.remote_context() } + /// The coding agent running in this pane's foreground, or `None` when none + /// is. Identity comes from the daemon's foreground-`argv` detection (plus + /// the sentinel event channel, which can brand wrappers argv can't see + /// through). The tab avatar brands the pane with it. See + /// [`crate::core::cli_agent`]. + pub fn agent(&self) -> Option<crate::core::cli_agent::CLIAgent> { + self.terminal.foreground_agent() + } + + /// The agent's rich session status (idle / working / waiting / done + + /// native session id), when the pane's agent reports events over the + /// sentinel OSC channel (or the opaque notification fallback). Drives the + /// avatar's status dot, "needs your input" notifications, and resume. An + /// output-idle *guess* is deliberately still absent — agents are quietest + /// while thinking, so only agent-reported state is trusted. + pub fn agent_session(&self) -> Option<crate::core::cli_agent::AgentSessionState> { + self.terminal.agent_session() + } + + /// Whether this pane's finished turn (the green `Done` dot) is unread — a + /// turn ended that the user hasn't looked at since (see + /// [`agent_result_unread`](Self::agent_result_unread) field). Drives the + /// avatar dot's unread halo; the dot itself shows for any `Done`. + pub fn agent_result_unread(&self) -> bool { + self.agent_result_unread + } + + /// The pane's last-computed git snapshot (branch + working-tree diff), for + /// the sidebar row's branch line. `None` outside a git work tree or before + /// the first background probe lands. + pub fn git_status(&self) -> Option<crate::terminal::git_status::GitStatus> { + self.git_status.clone() + } + + /// The current grid selection as text, if any non-blank one exists — the + /// source for "Agent: Send Selection". + pub fn selection_text(&self) -> Option<String> { + self.terminal + .term + .lock() + .selection_to_string() + .filter(|t| !t.trim().is_empty()) + } + + /// Deliver a built prompt into this pane's PTY as a bracketed paste + CR — + /// the submit path for the agent context-feed commands. See + /// [`crate::core::agent_prompt::submit_bytes`]. + pub fn send_agent_prompt(&self, prompt: &str) { + self.terminal + .write(crate::core::agent_prompt::submit_bytes(prompt)); + } + + /// Type one command line + Enter into the pane's PTY, as if the user had. + /// Used by session restore to hand a fresh shell an agent resume command; + /// the bytes queue in the PTY until the (possibly still-starting) shell + /// reads them. + pub fn run_command_line(&self, cmd: &str) { + self.terminal.write(format!("{cmd}\r").into_bytes()); + } + /// The shell this pane was explicitly spawned with (new-tab dropdown pick), /// so splits can inherit it. `None` → the default shell. pub fn shell_spec(&self) -> Option<ShellSpec> { @@ -2268,34 +2384,193 @@ impl TerminalView { cx.notify(); } + // Whether the configured notification policy allows a post right now: + // never / only-when-unfocused / always. Shared by the command-finished, + // agent-finished, and agent-waiting notifications. + let notify_allowed = match cx.global::<Config>().notify_on_command_finish { + NotifyMode::Never => false, + NotifyMode::Unfocused => !window.is_window_active(), + NotifyMode::Always => true, + }; + // "Command finished" notification: a foreground command (not at prompt) - // that ran long and finished while the window was in the background. + // that ran long and finished while the window was in the background. When + // the command was a recognized coding agent, brand the notification with + // the agent instead of the generic "command finished" copy. let running = !at_prompt; + // While a command runs, latch the agent the daemon reports for it — the + // detection poll can land a beat after the command starts, so capture it + // whenever it appears rather than only at the start edge. + if running && self.running_agent.is_none() { + self.running_agent = self.terminal.foreground_agent(); + } + // A command finishing (back-to-prompt edge) may have edited files or + // switched branch, so reprobe git after it — captured before the match + // below clears `running_since`. + let cmd_finished = self.running_since.is_some() && !running; match (self.running_since, running) { (None, true) => { self.running_since = Some(std::time::Instant::now()); self.running_title = self.title.clone(); + self.running_agent = self.terminal.foreground_agent(); } (Some(start), false) => { let elapsed = start.elapsed(); let title = std::mem::take(&mut self.running_title); + let agent = self.running_agent.take(); self.running_since = None; - // Gate on the configured policy: never / only-when-unfocused / - // always. The configured long-command floor still applies - // regardless. - let cfg = cx.global::<Config>(); - let notify = match cfg.notify_on_command_finish { - NotifyMode::Never => false, - NotifyMode::Unfocused => !window.is_window_active(), - NotifyMode::Always => true, - }; - let threshold = std::time::Duration::from_secs(cfg.notify_threshold_secs); - if elapsed >= threshold && notify { - notify_command_finished(&title, elapsed); + if notify_allowed { + match agent { + // A rich-channel agent already announced each turn's + // end (`stop` events below); a second "finished" on + // process exit would be noise. + Some(_) if self.agent_was_rich => {} + // An agent session ends the moment it finishes — no + // duration floor: "Claude Code finished" is worth saying + // even for a quick turn you stepped away from. + Some(agent) => notify_agent_finished(agent, elapsed), + None => { + let threshold = std::time::Duration::from_secs( + cx.global::<Config>().notify_threshold_secs, + ); + if elapsed >= threshold { + notify_command_finished(&title, elapsed); + } + } + } } } _ => {} } + + let turn_finished = self.poll_agent_status(notify_allowed, cx); + + // Refresh the sidebar's git branch/diff line when the working directory + // changed (a `cd`), a command just finished, or an agent turn ended — + // an agent's session is one long foreground command, so its edits would + // otherwise stay invisible until it exits. All rare edges, so the + // off-thread `git` shell-out runs seldom, not every 300ms tick. + let cwd_now = self.cwd(); + if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished { + self.refresh_git_status(cwd_now, cx); + } + } + + /// Kick off an off-thread git probe for `cwd` and fold the result back on + /// the main thread, tagged with a generation so a stale probe (cwd changed + /// meanwhile) is dropped. Clears the status when there's no cwd (e.g. a + /// native-SSH pane pre-OSC-7, where a local `git` would be meaningless). + fn refresh_git_status(&mut self, cwd: Option<std::path::PathBuf>, cx: &mut Context<Self>) { + self.git_status_cwd = cwd.clone(); + let Some(cwd) = cwd else { + if self.git_status.take().is_some() { + cx.notify(); + } + return; + }; + self.git_status_gen += 1; + let generation = self.git_status_gen; + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { crate::terminal::git_status::compute(&cwd) }) + .await; + let _ = this.update(cx, |view, cx| { + // Drop a probe whose cwd has since been superseded. + if view.git_status_gen != generation { + return; + } + if view.git_status != result { + view.git_status = result; + cx.notify(); + } + }); + }) + .detach(); + } + + /// Fold the pane's rich agent status into turn-level notifications and the + /// status dot. Runs on the same cadence as the notification poll above. + /// + /// Only *transitions* act: entering `Waiting` says the agent needs you + /// (the reason attached), and a `Working → Done` edge says the turn + /// finished — with its duration when we saw it start. Non-rich (fallback) + /// state paints the dot but stays silent: the agent's own OSC notification + /// was already toasted by the reader thread, and echoing it would double + /// up. Attach replays land as a bare status with no observed transition + /// history, so a restored `Done` never re-notifies. + /// + /// Returns whether a turn just ended (a transition *into* `Done`) — the + /// caller uses it to reprobe git: an agent's whole session is one long + /// foreground command, so the back-to-prompt edge that normally refreshes + /// the branch/diff line never fires while it works. + fn poll_agent_status(&mut self, notify_allowed: bool, cx: &mut Context<Self>) -> bool { + use crate::core::cli_agent::AgentStatus; + + let session = self.terminal.agent_session(); + if session.as_ref().is_some_and(|s| s.rich) { + self.agent_was_rich = true; + } + if self.terminal.foreground_agent().is_none() && session.is_none() { + self.agent_was_rich = false; + } + + let status = session.as_ref().map(|s| s.status); + if status == self.last_agent_status { + return false; + } + let prev = std::mem::replace(&mut self.last_agent_status, status); + let turn_finished = status == Some(AgentStatus::Done) && prev != Some(AgentStatus::Done); + + // Read/unread for the green Done dot: a turn just finished is "unread" + // only if you weren't looking (focused pane = you watched it finish, so + // it's already read). Any non-Done status has no result to be unread. + match status { + Some(AgentStatus::Done) if prev != Some(AgentStatus::Done) => { + self.agent_result_unread = !self.focused; + } + Some(AgentStatus::Done) => {} + _ => self.agent_result_unread = false, + } + + let rich = session.as_ref().is_some_and(|s| s.rich); + let agent_name = self + .terminal + .foreground_agent() + .map(|a| a.display_name()) + .unwrap_or("Agent"); + match status { + Some(AgentStatus::Working) => { + self.agent_turn_started = Some(std::time::Instant::now()); + } + Some(AgentStatus::Waiting) if rich && notify_allowed => { + let body = session + .as_ref() + .and_then(|s| s.message.clone()) + .unwrap_or_else(|| "Waiting for your input".to_string()); + super::remote::notify_desktop(Some(agent_name), &body); + } + // Done only counts off an *observed* turn (working/waiting seen + // live), so an attach replay of old state stays quiet. + Some(AgentStatus::Done) + if rich + && notify_allowed + && matches!( + prev, + Some(AgentStatus::Working) | Some(AgentStatus::Waiting) + ) => + { + let body = match self.agent_turn_started.take() { + Some(start) => format!("Finished after {}s", start.elapsed().as_secs()), + None => "Turn finished".to_string(), + }; + super::remote::notify_desktop(Some(agent_name), &body); + } + _ => {} + } + // Status changed: repaint so the avatar dot / sidebar line track it. + cx.notify(); + turn_finished } /// True when the shell sits idle at its prompt: the PTY's foreground process diff --git a/src/ui/app.rs b/src/ui/app.rs index 44570ee7..f330a060 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -130,6 +130,75 @@ impl Tab { }; leaf.map(|l| l.read(cx).title.clone()).unwrap_or_default() } + + /// The git snapshot (branch + working-tree diff) of the tab's label-driving + /// terminal — the focused leaf with a `window`, else the first — for the + /// sidebar row's branch line (the branch and change count shown under the + /// title). `None` when that leaf isn't inside a git work tree, or before + /// its first probe lands. + pub(crate) fn git_status( + &self, + window: Option<&Window>, + cx: &App, + ) -> Option<crate::terminal::git_status::GitStatus> { + let leaf = match window { + Some(window) => self.pane.focused_or_first(window, cx), + None => self.pane.first_leaf(), + }?; + leaf.read(cx).git_status() + } + + /// The coding agent running in this tab, or `None`. Any leaf counts (a + /// split with a shell on the left and Claude on the right is an agent + /// tab); the first agent leaf in tree order wins. Drives the tab avatar's + /// brand mark. + pub(crate) fn agent(&self, cx: &App) -> Option<crate::core::cli_agent::CLIAgent> { + self.pane + .leaves() + .into_iter() + .find_map(|l| l.read(cx).agent()) + } + + /// The tab's most urgent agent status across its leaves — waiting beats + /// working beats done beats idle — or `None` when no leaf runs an agent. + /// The green `Done` state always shows (a finished turn stays visible until + /// the next one); [`agent_result_unread`](Self::agent_result_unread) then + /// says whether to emphasize it as unread. Drives the avatar dot and the + /// sidebar counts. + pub(crate) fn agent_status(&self, cx: &App) -> Option<crate::core::cli_agent::AgentStatus> { + use crate::core::cli_agent::AgentStatus; + let urgency = |s: AgentStatus| match s { + AgentStatus::Waiting => 3, + AgentStatus::Working => 2, + AgentStatus::Done => 1, + AgentStatus::Idle => 0, + }; + self.pane + .leaves() + .into_iter() + .filter(|l| l.read(cx).agent().is_some()) + .map(|l| { + l.read(cx) + .agent_session() + .map(|s| s.status) + .unwrap_or(AgentStatus::Idle) + }) + .max_by_key(|s| urgency(*s)) + } + + /// Whether the tab's shown status is an *unread* finished turn — a `Done` + /// the user hasn't looked at since. Drives the avatar dot's unread halo: + /// the green dot stays either way, the halo just says "new, come look." + /// Only meaningful when the shown status is `Done`. + pub(crate) fn agent_result_unread(&self, cx: &App) -> bool { + use crate::core::cli_agent::AgentStatus; + self.agent_status(cx) == Some(AgentStatus::Done) + && self.pane.leaves().into_iter().any(|l| { + let v = l.read(cx); + v.agent_session().map(|s| s.status) == Some(AgentStatus::Done) + && v.agent_result_unread() + }) + } } /// In-progress inline rename of a tab (double-click a tab label). Holds the @@ -2021,6 +2090,25 @@ impl Tty7App { } SaveQuickConnect(target) => self.open_ssh_profile_new_from_target(target, window, cx), OpenSshProfiles => self.open_settings_section(SettingsSection::Ssh, window, cx), + SendSelectionToAgent => self.send_selection_to_agent(window, cx), + SendGitDiffToAgent => self.send_git_diff_to_agent(window, cx), + InstallClaudeHooks => { + // Synchronous file edit; report the outcome as a toast either + // way. Installing over an existing install just refreshes the + // entries (e.g. after the tty7 binary moved) — say so. + let refreshed = crate::core::agent_hooks::claude_hooks_installed(); + match crate::core::agent_hooks::install_claude_hooks() { + Ok(summary) if refreshed => crate::terminal::notify_desktop( + Some("tty7"), + &format!("Refreshed: {summary}"), + ), + Ok(summary) => crate::terminal::notify_desktop(Some("tty7"), &summary), + Err(e) => crate::terminal::notify_desktop( + Some("tty7"), + &format!("Claude Code hook install failed: {e}"), + ), + } + } // Handled inside `PaletteView` (opens a sub-list); these never emit a // `Confirm` for this variant, so they never reach here. OpenThemePicker | OpenSshConnectInput => {} @@ -2028,6 +2116,108 @@ impl Tty7App { } } + // ----- Agent context feed (palette: "Agent: …") ------------------------- + + /// The pane the agent-feed commands deliver to: the first leaf running a + /// recognized coding agent, preferring the active tab, then any tab. `None` + /// when no agent runs anywhere. + fn agent_target_leaf(&self, cx: &App) -> Option<Entity<TerminalView>> { + let runs_agent = |leaf: &Entity<TerminalView>| leaf.read(cx).agent().is_some(); + if let Some(tab) = self.tabs.get(self.active) + && let Some(leaf) = tab.pane.leaves().into_iter().find(runs_agent) + { + return Some(leaf); + } + self.tabs + .iter() + .enumerate() + .filter(|(i, _)| *i != self.active) + .flat_map(|(_, t)| t.pane.leaves()) + .find(runs_agent) + } + + /// Deliver `prompt` into the agent pane's PTY and bring that pane's tab to + /// the front so the user sees the turn start. Toasts when no agent runs. + fn deliver_agent_prompt(&mut self, prompt: &str, window: &mut Window, cx: &mut Context<Self>) { + let Some(target) = self.agent_target_leaf(cx) else { + crate::terminal::notify_desktop( + Some("tty7"), + "No running coding agent found — start one (claude, codex, …) in a pane first.", + ); + return; + }; + target.read(cx).send_agent_prompt(prompt); + if let Some(i) = self + .tabs + .iter() + .position(|t| t.pane.leaves().contains(&target)) + { + self.activate(i, window, cx); + } + } + + /// "Agent: Send Selection" — the focused pane's selection, phrased as a + /// prompt, into the running agent's pane (the context-feed idea). + fn send_selection_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) { + let source = self + .tabs + .get(self.active) + .and_then(|t| t.pane.focused_or_first(window, cx)); + let (selection, cwd) = match &source { + Some(view) => (view.read(cx).selection_text(), view.read(cx).cwd()), + None => (None, None), + }; + let Some(selection) = selection else { + crate::terminal::notify_desktop( + Some("tty7"), + "Nothing selected — select some terminal output first.", + ); + return; + }; + let cwd = cwd.map(|c| c.to_string_lossy().into_owned()); + if let Some(prompt) = + crate::core::agent_prompt::build_selection_prompt(&selection, cwd.as_deref()) + { + self.deliver_agent_prompt(&prompt, window, cx); + } + } + + /// "Agent: Send Git Diff for Review" — the focused pane's repo diff + /// (unstaged + staged), phrased as a review prompt, into the agent's pane. + fn send_git_diff_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) { + let cwd = self + .tabs + .get(self.active) + .and_then(|t| t.pane.focused_or_first(window, cx)) + .and_then(|view| view.read(cx).cwd()); + let Some(cwd) = cwd else { + crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory."); + return; + }; + // Unstaged + staged, concatenated — "everything not yet committed", + // which is what a review pass wants. Both invocations are quick; the + // prompt builder caps runaway diffs. + let run = |args: &[&str]| { + std::process::Command::new("git") + .args(args) + .current_dir(&cwd) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default() + }; + let diff = format!("{}{}", run(&["diff"]), run(&["diff", "--cached"])); + let cwd_s = cwd.to_string_lossy().into_owned(); + match crate::core::agent_prompt::build_diff_review_prompt(&diff, Some(&cwd_s)) { + Some(prompt) => self.deliver_agent_prompt(&prompt, window, cx), + None => crate::terminal::notify_desktop( + Some("tty7"), + &format!("No uncommitted changes in {cwd_s} (or not a git repository)."), + ), + } + } + // ----- Settings tab (Cmd+,) ------------------------------------------- /// Toggle the settings overlay (Cmd+,). If it's already open, close it; @@ -3309,6 +3499,11 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { // reconnected on restore (FR-E4/C2); `None` for local panes. A // live pane reattaches by `pane_id` and never needs this. ssh_spec: view.ssh_spec(), + // The running agent + its native session id (when its hooks + // reported one), so a pane the daemon loses can resume the + // agent conversation instead of just reopening a shell. + agent: view.agent(), + agent_session_id: view.agent_session().and_then(|s| s.session_id), } } Pane::Split { @@ -3328,6 +3523,8 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { cwd: None, pane_id: None, ssh_spec: None, + agent: None, + agent_session_id: None, }, } } @@ -3390,6 +3587,8 @@ fn session_to_pane( cwd, pane_id, ssh_spec, + agent, + agent_session_id, } => { // Only restore the pane id when the daemon confirms it's still live; // a stale id (daemon restarted, pane killed) falls back to a spawn. @@ -3412,6 +3611,19 @@ fn session_to_pane( // A shell pick isn't persisted in the session, so a stale pane that // must respawn comes back on the default shell. let view = new_terminal(font_size, cwd.clone(), restore, None, window, cx); + // A pane that could NOT re-attach lost its running agent with the + // daemon; when we captured that agent's native session id, hand + // the fresh shell its resume command so the conversation picks up + // where it left off (cmux's auto-resume, config-gated). The bytes + // sit in the PTY input queue until the shell reads its first + // command — same mechanism as tmux send-keys at spawn. + if restore.is_none() + && cx.global::<Config>().restore_agent_sessions + && let (Some(agent), Some(id)) = (agent, agent_session_id) + && let Some(cmd) = agent.resume_command(id) + { + view.read(cx).run_command_line(&cmd); + } Pane::leaf(view) } SessionPane::Split { axis, ratio, a, b } => { diff --git a/src/ui/assets.rs b/src/ui/assets.rs new file mode 100644 index 00000000..ef3f0f1c --- /dev/null +++ b/src/ui/assets.rs @@ -0,0 +1,59 @@ +//! The app's [`gpui::AssetSource`]: tty7's own bundled icons layered over +//! gpui-component's icon set. +//! +//! gpui-component ships the generic UI glyphs (close, chevrons, `bot`, …) via +//! [`gpui_component_assets::Assets`]. tty7 adds a small set of third-party +//! coding-agent brand marks (`icons/agents/*.svg`) for the tab avatars — see +//! [`crate::core::cli_agent::CLIAgent::icon_path`]. Rather than fork the +//! upstream asset crate to carry app-specific brand art, this source resolves +//! tty7's icons first and delegates everything else downstream, so both sets +//! load through the single `AssetSource` gpui allows. + +use std::borrow::Cow; + +use gpui::{AssetSource, Result, SharedString}; + +/// tty7's asset source. Registered once in `main` via `with_assets`. +pub struct Assets; + +impl AssetSource for Assets { + fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> { + if let Some(bytes) = agent_icon(path) { + return Ok(Some(Cow::Borrowed(bytes))); + } + gpui_component_assets::Assets.load(path) + } + + fn list(&self, path: &str) -> Result<Vec<SharedString>> { + // Only gpui-component enumerates its icons; tty7's brand marks are + // referenced by explicit path, never listed, so the downstream set is + // the whole answer. + gpui_component_assets::Assets.list(path) + } +} + +/// The bytes of a bundled agent brand mark, or `None` if `path` isn't one of +/// ours. Kept as an explicit match (rather than `rust-embed`) because the set is +/// tiny and fixed, and `include_bytes!` needs no extra build dependency. +fn agent_icon(path: &str) -> Option<&'static [u8]> { + let bytes: &'static [u8] = match path { + // Flush `>_` prompt glyph for the plain-shell tab avatar (Lucide's + // unboxed `terminal`, which gpui-component doesn't bundle — it only + // ships the boxed `square-terminal`). + "icons/terminal.svg" => include_bytes!("../../assets/icons/terminal.svg"), + // Lucide's `git-branch`, for the sidebar row's branch line (gpui-component + // doesn't bundle a git glyph). + "icons/git-branch.svg" => include_bytes!("../../assets/icons/git-branch.svg"), + "icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"), + "icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"), + "icons/agents/gemini.svg" => include_bytes!("../../assets/icons/agents/gemini.svg"), + "icons/agents/amp.svg" => include_bytes!("../../assets/icons/agents/amp.svg"), + "icons/agents/opencode.svg" => include_bytes!("../../assets/icons/agents/opencode.svg"), + "icons/agents/copilot.svg" => include_bytes!("../../assets/icons/agents/copilot.svg"), + "icons/agents/cursor.svg" => include_bytes!("../../assets/icons/agents/cursor.svg"), + "icons/agents/goose.svg" => include_bytes!("../../assets/icons/agents/goose.svg"), + "icons/agents/droid.svg" => include_bytes!("../../assets/icons/agents/droid.svg"), + _ => return None, + }; + Some(bytes) +} diff --git a/src/ui/home.rs b/src/ui/home.rs index eb53a889..26704296 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -185,6 +185,8 @@ mod tests { cwd: cwd.map(PathBuf::from), pane_id: None, ssh_spec: None, + agent: None, + agent_session_id: None, } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2f994636..689013f0 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,6 +6,7 @@ //! depends back on `ui`. pub mod app; +pub mod assets; pub mod forwards; pub mod hints; pub mod home; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index b821049f..c3b2e796 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -60,6 +60,15 @@ pub enum CommandKind { ToggleSftp, /// Reconnect a dead native-SSH pane in place (WS6, FR-E4). RestartSshSession, + /// Send the focused pane's selection to a running CLI coding agent's pane + /// as a prompt (build error → agent, the review-feed idea). + SendSelectionToAgent, + /// Send the repo's uncommitted `git diff` (from the focused pane's cwd) to + /// a running CLI coding agent's pane as a review prompt. + SendGitDiffToAgent, + /// Wire tty7's Claude Code hooks into `~/.claude/settings.json` so panes + /// running `claude` report rich status (working / needs input / done). + InstallClaudeHooks, /// Opens the theme sub-list (a nested palette). Handled in `PaletteView`. OpenThemePicker, /// Opens a typed SSH connection sub-list. Handled in `PaletteView`. @@ -131,7 +140,10 @@ impl CommandKind { RestartDaemon => "RestartDaemon", ToggleSftp => "ToggleSftp", RestartSshSession => "RestartSshSession", - FindInTerminal + SendSelectionToAgent + | SendGitDiffToAgent + | InstallClaudeHooks + | FindInTerminal | OpenThemePicker | OpenSshConnectInput | OpenSshConnect(_) @@ -214,6 +226,12 @@ impl Command { Command::new("Open Settings", OpenSettings), Command::new("Reset Font Size", ResetFontSize), Command::new("Restart Daemon…", RestartDaemon), + Command::new("Agent: Send Selection", SendSelectionToAgent) + .with_subtitle("selection → running coding agent"), + Command::new("Agent: Send Git Diff for Review", SendGitDiffToAgent) + .with_subtitle("git diff → running coding agent"), + Command::new("Agent: Install Claude Code Hooks", InstallClaudeHooks) + .with_subtitle("rich status + resume for claude"), ] } diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 520ac22b..a94d7e35 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -71,13 +71,66 @@ impl Tty7App { .min_h_0() .overflow_y_scroll() .p_1p5() + // Tight row-to-row spacing so the tabs read as one list, not a set + // of far-apart cards (each row already has its own inner padding). .gap_0p5(); for (i, tab) in self.tabs.iter().enumerate() { let is_active = i == active; let label = self.tab_label(tab, i, Some(window), cx); - // SSH status dot (PRD FR-E2). + // No status/cwd text line under the title: the avatar's status dot + // already carries working/waiting/done, and the title + git branch + // line carry the location — a "Working…" or cwd line would just be + // noise. The row is title + (optional) branch line, nothing else. + // Leading avatar inputs: the SSH connection-status colour (PRD + // FR-E2) and the coding agent running in the tab, if any — the + // avatar brands the row by whichever applies. let ssh_dot = self.tab_ssh_dot(tab, cx); + let agent = tab.agent(cx); + let agent_status = tab.agent_status(cx); + let agent_unread = tab.agent_result_unread(cx); + // Third line (when the pane's cwd is inside a git work tree): the + // branch, then the working-tree diff as green `+N` / red `−N` + // badges — a per-session branch row. Built here so the row can + // grow to fit it (a non-repo pane keeps the compact two-line row). + let git_line = tab.git_status(Some(window), cx).map(|g| { + let mut line = h_flex() + .w_full() + .items_center() + .gap_1p5() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child( + gpui::svg() + .path("icons/git-branch.svg") + .flex_shrink_0() + .size(px(11.)) + .text_color(cx.theme().muted_foreground), + ) + // Branch name flexes and truncates; the badges stay pinned + // to the right (a long branch ellipsizes, the counts don't). + .child(div().flex_1().min_w_0().truncate().child(g.branch.clone())); + // Diff counts in plain (not bold) coloured text — a quiet + // green/red readout, not a loud badge, so a big `+1590` doesn't + // dominate the row. + if g.added > 0 { + line = line.child( + div() + .flex_shrink_0() + .text_color(cx.theme().success) + .child(format!("+{}", g.added)), + ); + } + if g.removed > 0 { + line = line.child( + div() + .flex_shrink_0() + .text_color(cx.theme().danger) + .child(format!("−{}", g.removed)), + ); + } + line + }); // Filter by the search box; matching is on the visible label. The row // keeps its real index `i`, so activate/close/move still hit the right // tab even when the list is narrowed. @@ -105,17 +158,25 @@ impl Tty7App { .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child(Input::new(&input).appearance(false)) .into_any_element(), - None => div() + None => v_flex() .id(("sidebar-label", i)) .flex_1() .min_w_0() - // Ellipsis-truncate so a long label degrades gracefully in the - // fixed-width rail rather than hard-clipping mid-glyph. - .truncate() - .text_sm() - // Active row carries a hair more weight, matching the chip. - .when(is_active, |d| d.font_weight(FontWeight::MEDIUM)) - .child(label) + // A touch of air between the title and branch lines. + .gap(px(2.5)) + // Title line — ellipsis-truncate so a long label degrades + // gracefully in the fixed-width rail rather than hard-clipping. + .child( + div() + .w_full() + .truncate() + .text_sm() + // Active row carries a hair more weight, matching the chip. + .when(is_active, |d| d.font_weight(FontWeight::MEDIUM)) + .child(label), + ) + // Branch + diff line, when the pane sits in a git repo. + .children(git_line) // Single click activates; double click starts a rename. .on_mouse_down( MouseButton::Left, @@ -148,11 +209,17 @@ impl Tty7App { // hover without touching siblings (same trick as the chip). .group(SharedString::from(format!("tab-row-{i}"))) .w_full() - .h(px(34.)) + // Size to content with a small, uniform vertical padding rather + // than forcing a fixed height: a one-line shell tab is a short + // row, a two/three-line agent tab is a taller one. The *padding* + // is what stays consistent, so rows read as harmonious even + // though a single-line tab no longer gets padded out into a big + // half-empty box. + .py_1p5() .items_center() .justify_between() - .gap_1p5() - .pl_3() + .gap_2() + .pl_2() .pr_1p5() .rounded_lg() // Sidebar-surface token scheme (gpui-component's Sidebar @@ -182,10 +249,8 @@ impl Tty7App { this.activate(i, window, cx); }), ) - // Leading SSH status dot when this tab hosts an SSH session. - .when_some(ssh_dot, |c, color| { - c.child(div().flex_shrink_0().size(px(6.)).rounded_full().bg(color)) - }) + // Leading avatar: agent brand mark, SSH status, or shell glyph. + .child(self.tab_avatar(agent, agent_status, agent_unread, ssh_dot, 22., cx)) .child(label_region) // Trailing slot: while the shortcut hints are armed it shows the // row's ⌘N switch digit; otherwise the close affordance — always diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 97d32f48..7b09ac13 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -28,7 +28,7 @@ const KEEP_SEGMENTS: usize = 3; /// Abbreviate a leading `$HOME` to `~` (an integrated shell usually already /// does this, but absolute paths from other shells won't be). Borrows when /// there's nothing to rewrite. -fn abbreviate_home(path: &str) -> std::borrow::Cow<'_, str> { +pub(crate) fn abbreviate_home(path: &str) -> std::borrow::Cow<'_, str> { use std::borrow::Cow; if path.starts_with('~') { return Cow::Borrowed(path); @@ -147,6 +147,127 @@ impl Render for DragTab { } impl Tty7App { + /// The status dot pinned to an agent avatar's bottom-right corner: a solid + /// `rgb` disc with a surface-colored separator ring so it reads as sitting + /// on the badge. When `unread` (a finished turn you haven't looked at), the + /// dot gains a crisp outer ring of the same hue — the dot's separator ring + /// becomes the gap, so it reads as a clean target (core · gap · ring), a + /// sharper "come look" than a soft halo. `size` is the avatar edge. + fn status_dot(rgb: u32, unread: bool, size: f32, cx: &App) -> gpui::AnyElement { + let d = (size * 0.42).max(7.); + let bg = cx.theme().background; + // The read dot: a solid disc with a 2px surface-colored separator ring, + // so its *green core* is `d - 4`. The unread variant keeps that exact + // core and only wraps it in a hairline gap + ring — so switching read↔ + // unread never changes the dot's apparent size, just adds a thin target + // rim. (The earlier version accidentally grew the core, which read as a + // much bigger dot.) + if unread { + let core = (d - 4.0).max(3.0); // identical green core to the read dot + let gap = 0.9; // hairline surface-colored gap + let ring = (d * 0.10).max(0.85); // ~0.9px same-hue outer rim + let inner = core + gap * 2.0; // green core + gap + let outer = inner + ring * 2.0; // + outer ring + let inner_dot = div() + .size(px(inner)) + .rounded_full() + .border_1() + .border_color(bg) + .bg(gpui::rgb(rgb)); + // Center the outer disc on the read dot's center (same corner point). + div() + .absolute() + .right(px(-(outer - d) / 2.0 - d * 0.22)) + .bottom(px(-(outer - d) / 2.0 - d * 0.22)) + .size(px(outer)) + .rounded_full() + .flex() + .items_center() + .justify_center() + .bg(gpui::rgb(rgb)) + .child(inner_dot) + .into_any_element() + } else { + div() + .absolute() + .right(px(-(d * 0.22))) + .bottom(px(-(d * 0.22))) + .size(px(d)) + .rounded_full() + .border_2() + .border_color(bg) + .bg(gpui::rgb(rgb)) + .into_any_element() + } + } + + /// The leading avatar for a tab row/chip: a rounded badge that brands the + /// tab by what's running in it — each session fronted with an icon. A + /// recognized coding agent gets its brand mark — a white silhouette + /// (gpui tints SVGs as an alpha mask) on the vendor accent; an SSH pane gets + /// a terminal glyph ringed in its connection-status colour; a plain shell + /// gets a neutral terminal glyph. An agent's live status rides the corner as + /// a [`status_dot`](Self::status_dot). `size` is the badge's edge in px. + pub(crate) fn tab_avatar( + &self, + agent: Option<crate::core::cli_agent::CLIAgent>, + status: Option<crate::core::cli_agent::AgentStatus>, + unread: bool, + ssh: Option<gpui::Hsla>, + size: f32, + cx: &App, + ) -> gpui::AnyElement { + let base = div() + .flex_shrink_0() + .size(px(size)) + .flex() + .items_center() + .justify_center(); + // A circle for every kind — the brand mark / glyph sits + // small and centred with generous padding rather than filling the badge. + match agent { + Some(agent) => { + // The agent's live status as a small dot pinned to the badge's + // bottom-right corner (blue working / amber waiting / green + // done), ringed in the surface color so it reads as sitting on + // the badge rather than clipped by it. Idle (or unknown) draws + // no dot — a resting agent is just its brand mark. An *unread* + // finished turn adds a translucent same-hue halo around the dot + // — a soft "come look" that clears (back to a plain dot) once + // you view the pane, without ever hiding the done state. + let dot = status + .and_then(|s| s.dot_rgb()) + .map(|rgb| Self::status_dot(rgb, unread, size, cx)); + base.relative() + .rounded_full() + .bg(gpui::rgb(agent.accent_rgb())) + .child( + gpui::svg() + .path(agent.icon_path()) + .size(px(size * 0.54)) + .text_color(gpui::white()), + ) + .when_some(dot, |b, dot| b.child(dot)) + .into_any_element() + } + None => base + .rounded_full() + // A clearly-visible neutral disc (a neutral grey shell badge), not a + // near-transparent tint — so the avatar column reads as a column. + .bg(cx.theme().muted) + .when_some(ssh, |d, c| d.border_2().border_color(c)) + .child( + // A flush `>_` prompt (not the boxed `square-terminal`) so it + // fills the badge at the same visual weight as a brand mark. + gpui::svg() + .path("icons/terminal.svg") + .size(px(size * 0.56)) + .text_color(cx.theme().foreground.opacity(0.65)), + ) + .into_any_element(), + } + } + /// The display label for a tab: the user-set name if present, otherwise the /// focused terminal's title (shortened), falling back to /// "Session N" when there's no title yet. Pass `window` so the label tracks @@ -308,6 +429,12 @@ impl Tty7App { let label = self.tab_label(tab, i, Some(window), cx); // SSH status dot (PRD FR-E2): coloured by the pane's connection phase. let ssh_dot = self.tab_ssh_dot(tab, cx); + // A coding agent running in this tab (Claude Code, Codex, …) fronts + // its chip with the vendor brand mark so it's recognizable at a + // glance across a crowded strip. + let agent = tab.agent(cx); + let agent_status = tab.agent_status(cx); + let agent_unread = tab.agent_result_unread(cx); // Inline rename input for this tab, if it's the one being renamed. let rename_input = self @@ -436,9 +563,21 @@ impl Tty7App { .when_some(ssh_dot, |c, color| { c.child(div().flex_shrink_0().size(px(6.)).rounded_full().bg(color)) }) - // Clickable / editable label region. No leading context glyph — - // the label carries the whole chip, so a row of tabs reads as - // plain text rather than icon-per-chip busy. + // Leading agent brand avatar, when a coding agent runs in this + // tab — the vendor mark on its accent. Only agents get an avatar + // here: ordinary shells stay text-only so the strip reads as + // tabs, not icon-per-chip busy. + .when_some(agent, |chip, agent| { + chip.child(self.tab_avatar( + Some(agent), + agent_status, + agent_unread, + None, + 18., + cx, + )) + }) + // Clickable / editable label region. .child(label_region) // Trailing slot: normally the close affordance — always shown on // the active tab; on the others it stays out of the way