From c76ef87d021440c62f3d3b0a96ae94594701c66c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:17:05 +0800 Subject: [PATCH 1/2] feat(agents): wire the rich status channel into Grok Build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grok Build exposes a Claude Code-shaped hook surface, so tty7 can now install into it and give grok panes live session status and resume-after-restart, not just a brand chip. - Owned hook file at ~/.grok/hooks/tty7.json (grok loads every JSON file there; global hooks need no folder-trust grant), so the user's own hooks are never touched. - Read camelCase payload keys: grok's envelope sends sessionId, and without it restore loses the id --resume needs. - Relabel events that arrive through grok's Claude-compat scan of ~/.claude/settings.json, keyed on the GROK_HOOK_EVENT var its hook runner injects — otherwise a grok pane reports Claude Code, and having both integrations installed emits every turn under two identities. - Resume via `grok --resume `, stripping the flags that would fight the injected id (--resume/--load/--continue/--session-id/--fork-session) or relocate the session (--worktree/--worktree-ref). Notification is subscribed with a matcher for elicitation_dialog only. Grok dispatches its permission_prompt notification before the permission system decides, so it fires on essentially every tool call, auto-approved ones included; escalating that to the amber "needs you" state would flash the pane and fire a desktop notification on every tool a turn runs. --- docs/features.md | 2 +- docs/features.zh-CN.md | 2 +- src/core/agent_hooks.rs | 216 ++++++++++++++++++++++++++++++++++++---- src/core/cli_agent.rs | 52 ++++++++++ 4 files changed, 251 insertions(+), 21 deletions(-) diff --git a/docs/features.md b/docs/features.md index 8786b07e..625cb074 100644 --- a/docs/features.md +++ b/docs/features.md @@ -31,7 +31,7 @@ Codex, Gemini CLI, Aider, Amp, OpenCode, and ~10 more) and adds around 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` -- **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 +- **Status dot** — working (blue) / needs your input (amber) / done (green), driven by agent-reported events over an OSC channel; Settings → Agents installs the hooks that feed it (Claude Code, Codex, Copilot CLI, OpenCode, Pi, Grok Build) - **Notifications** — "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 on restore, carrying the original launch flags (`claude --dangerously-skip-permissions --resume …`) (`restore_agent_sessions`, on by default) diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md index a7ef88ab..11325ca7 100644 --- a/docs/features.zh-CN.md +++ b/docs/features.zh-CN.md @@ -30,7 +30,7 @@ tty7 能识别 pane 里跑着的第三方 coding agent(Claude Code、Codex、G Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包裹或替代 agent 本身。 - **品牌头像** —— 标签 chip / 侧栏行显示每个 pane 跑的是哪个 agent;自定义包装命令可通过 `config.json` 的 `agent_commands` 映射 -- **状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在命令面板运行 *Agent: Install Claude Code Hooks* 一键接通 Claude Code +- **状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在 设置 → Agents 一键装好对应 hooks(Claude Code、Codex、Copilot CLI、OpenCode、Pi、Grok Build) - **通知** —— agent 卡在等你批准的那一刻弹 "needs your permission…",每轮结束弹 "finished after Ns",遵循你的通知策略 - **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N −M`),`cd` 或命令跑完时自动刷新 - **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话,并带上原始启动 flags(`claude --dangerously-skip-permissions --resume …`;`restore_agent_sessions`,默认开启) diff --git a/src/core/agent_hooks.rs b/src/core/agent_hooks.rs index ddce9d08..99eb018e 100644 --- a/src/core/agent_hooks.rs +++ b/src/core/agent_hooks.rs @@ -4,10 +4,10 @@ //! The rich agent-status channel ([`crate::core::cli_agent`]) needs the agent //! itself to say what it's doing. Each supported agent exposes that //! differently — Claude Code and Codex take a declarative hooks map, Copilot -//! auto-loads JSON hook files from a directory, OpenCode loads JS plugins, Pi -//! loads TS extensions — but every integration bottoms out in the same tiny -//! emitter: `tty7 agent-hook ` reads the hook's JSON payload -//! from stdin and writes one sentinel OSC 777 sequence to the controlling +//! and Grok auto-load JSON hook files from a directory, OpenCode loads JS +//! plugins, Pi loads TS extensions — but every integration bottoms out in the +//! same tiny emitter: `tty7 agent-hook ` reads the hook's JSON +//! payload from stdin and writes one sentinel OSC 777 sequence to the controlling //! terminal, where tty7's daemon-side sniffer picks it up and folds it into //! the pane's session state. //! @@ -24,6 +24,12 @@ use crate::core::cli_agent::AGENT_EVENT_SENTINEL; /// write escape sequences into terminals that aren't tty7. pub const TTY7_ENV_MARKER: &str = "TTY7"; +/// Env var Grok Build's hook runner injects into every hook process it spawns. +/// Its presence identifies *who ran us*, which matters because grok also scans +/// `~/.claude/settings.json` for hooks (its Claude-compat layer) — see +/// [`run_agent_hook`]. +const GROK_HOOK_ENV: &str = "GROK_HOOK_EVENT"; + /// 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; @@ -46,6 +52,7 @@ pub fn run_agent_hook(agent: &str, event: &str) { if std::env::var_os(TTY7_ENV_MARKER).is_none() { return; } + let agent = effective_agent(agent, std::env::var_os(GROK_HOOK_ENV).is_some()); // Hook payload: the agent writes JSON ({"session_id": …, "message": …, …}) // and closes stdin. Absent/malformed input still emits the bare event — // the state machine works without ids or messages. A tty stdin means the @@ -82,18 +89,35 @@ fn detach_console() { #[cfg(unix)] fn detach_console() {} +/// The agent slug an invocation really speaks for. Normally the one the +/// installed hook passed, but Grok Build reads `~/.claude/settings.json` as +/// well as its own hooks directory (a deliberate Claude-compat layer), so a +/// tty7 Claude Code integration also fires inside grok panes. Grok's hook +/// runner stamps every hook process with [`GROK_HOOK_ENV`], so those events are +/// relabeled to the agent that actually ran them — otherwise a grok pane +/// reports "Claude Code", and a user with both integrations installed emits +/// each turn under two identities instead of one deduplicated stream. +fn effective_agent(agent: &str, ran_by_grok: bool) -> &str { + if ran_by_grok { "grok" } else { agent } +} + /// The sentinel event one hook invocation maps onto, or `None` to stay silent. -/// Most hooks pass their event through; the exception is Copilot's single -/// `notification` hook, which fires for *every* notification type — only -/// permission/elicitation prompts are the amber "needs you" moment, so those -/// are escalated to `permission-request` and everything else is dropped -/// rather than parroted as a block. +/// Most hooks pass their event through; the exceptions are the single catch-all +/// `notification` hook Copilot and Grok expose, which fires for *every* +/// notification type. Only the types that always mean a real block escalate to +/// `permission-request`; everything else is dropped rather than parroted as one. +/// +/// The two agents draw that line differently. Copilot's `permission_prompt` +/// only fires when it is actually asking, so it counts; grok dispatches the +/// same type *before* its permission system decides — on essentially every tool +/// call, auto-approved ones included — so only `elicitation_dialog` (grok +/// asking the user a question) survives there. Grok's completions and errors +/// (`task_complete`, `agent_error`) are never blocks for either. fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> { - if agent == "copilot" && event == "notification" { - if stdin_json.contains("permission_prompt") || stdin_json.contains("elicitation_dialog") { - return Some("permission-request"); - } - return None; + if matches!(agent, "copilot" | "grok") && event == "notification" { + let blocks = stdin_json.contains("elicitation_dialog") + || (agent == "copilot" && stdin_json.contains("permission_prompt")); + return blocks.then_some("permission-request"); } Some(event) } @@ -110,9 +134,19 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { "agent": agent, "event": event, }); - for key in ["session_id", "message", "cwd"] { + // The sentinel body is always snake_case, but the payloads are not: Claude + // Code & friends send `session_id`, while Grok Build's envelope is + // camelCase throughout (`sessionId`). Read both spellings of each field so + // one vendor's convention doesn't cost us the session id that `--resume` + // needs. + for (key, alias) in [ + ("session_id", "sessionId"), + ("message", "message"), + ("cwd", "cwd"), + ] { if let Some(v) = payload .get(key) + .or_else(|| payload.get(alias)) .and_then(|v| v.as_str()) .filter(|v| !v.is_empty()) { @@ -319,15 +353,19 @@ pub enum HookAgent { OpenCode, /// A tty7-owned TS extension in `~/.pi/agent/extensions/tty7/`. Pi, + /// A tty7-owned hook file in `~/.grok/hooks/` (Grok Build loads every JSON + /// file there, and global hooks need no folder-trust grant). + Grok, } impl HookAgent { - pub const ALL: [HookAgent; 5] = [ + pub const ALL: [HookAgent; 6] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::Copilot, HookAgent::OpenCode, HookAgent::Pi, + HookAgent::Grok, ]; /// The `agent` slug in hook commands and sentinel events — matches @@ -339,6 +377,7 @@ impl HookAgent { HookAgent::Copilot => "copilot", HookAgent::OpenCode => "opencode", HookAgent::Pi => "pi", + HookAgent::Grok => "grok", } } @@ -350,6 +389,7 @@ impl HookAgent { HookAgent::Copilot => "Copilot CLI", HookAgent::OpenCode => "OpenCode", HookAgent::Pi => "Pi", + HookAgent::Grok => "Grok Build", } } @@ -386,6 +426,12 @@ impl HookAgent { .join("tty7") .join("index.ts"), ), + HookAgent::Grok => Some( + home_dir()? + .join(".grok") + .join("hooks") + .join(OWNED_FILE_STEM_JSON), + ), } } @@ -421,7 +467,7 @@ pub fn hooks_state(agent: HookAgent) -> HooksState { match agent { HookAgent::Claude => hook_map_state(&path, agent, CLAUDE_HOOK_EVENTS), HookAgent::Codex => hook_map_state(&path, agent, CODEX_HOOK_EVENTS), - HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi => { + HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi | HookAgent::Grok => { let Some(expected) = owned_file_content(agent) else { return HooksState::NotInstalled; }; @@ -457,7 +503,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result { ), }) } - HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi => { + HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi | HookAgent::Grok => { let content = owned_file_content(agent) .ok_or_else(|| anyhow::anyhow!("cannot resolve tty7's own executable path"))?; owned_file_install(&path, &content, &agent.marker())?; @@ -475,7 +521,7 @@ pub fn uninstall_hooks(agent: HookAgent) -> anyhow::Result { .ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?; match agent { HookAgent::Claude | HookAgent::Codex => hook_map_uninstall(&path, agent), - HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi => { + HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi | HookAgent::Grok => { owned_file_uninstall(&path, &agent.marker()) } } @@ -603,6 +649,39 @@ const CODEX_HOOK_EVENTS: &[(&str, &str)] = &[ ("Stop", "stop"), ]; +/// Seconds grok gives one tty7 hook before killing it. Set explicitly for two +/// reasons: it keeps `Stop` off grok's 600-second gate default (that budget is +/// for hooks that run test suites; ours writes a few bytes and returns), and it +/// leaves headroom for the first hook of a session, which pays the cold-start +/// cost of paging in the tty7 binary. A killed hook only loses that one event — +/// the session id arrives again on the next `UserPromptSubmit`. +const GROK_HOOK_TIMEOUT_SECS: u32 = 10; + +/// Grok Build's hook events — Claude Code's vocabulary, because grok mirrors it +/// deliberately (it even reads `~/.claude/settings.json`) — plus the matcher +/// regex each subscription is narrowed by (grok tests it against the event's +/// own discriminator: the notification type on `Notification`, the tool name on +/// tool events, …). Written as an owned file rather than merged into a shared +/// one; see [`grok_hooks_json`]. +/// +/// `Notification` is the one narrowed subscription. Grok dispatches its +/// `permission_prompt` notification *before* the permission system decides, so +/// it fires on essentially every tool call, auto-approved ones included — +/// escalating that to the amber "needs you" state would flash the pane (and +/// fire a desktop notification) on every tool a turn runs. `elicitation_dialog` +/// — grok's ask-the-user question — is the type that always means a real block, +/// so it is the only one subscribed. The emitter re-checks this (see +/// [`effective_event`]), which is what covers the same events arriving through +/// grok's Claude-compat scan, where the matcher isn't ours to set. +const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[ + ("SessionStart", "session-start", None), + ("UserPromptSubmit", "prompt-submit", None), + ("Notification", "notification", Some("elicitation_dialog")), + ("PostToolUse", "tool-complete", None), + ("Stop", "stop", None), + ("SessionEnd", "session-end", None), +]; + fn hook_map_state(path: &Path, agent: HookAgent, events: &[(&str, &str)]) -> HooksState { let Ok(text) = std::fs::read_to_string(path) else { return HooksState::NotInstalled; @@ -789,6 +868,7 @@ fn owned_file_content(agent: HookAgent) -> Option { HookAgent::Copilot => copilot_hooks_json(), HookAgent::OpenCode => opencode_plugin_js(), HookAgent::Pi => pi_extension_ts(), + HookAgent::Grok => grok_hooks_json(), HookAgent::Claude | HookAgent::Codex => None, } } @@ -875,6 +955,31 @@ fn copilot_hooks_json() -> Option { serde_json::to_string_pretty(&root).ok() } +/// Grok Build hook file (`~/.grok/hooks/tty7.json`). Grok loads every JSON file +/// in that directory and global hooks are always trusted (project hooks need a +/// folder-trust grant; ours don't), so tty7 owns its own file and never touches +/// the user's. Both the schema and the event names are Claude Code's — grok +/// mirrors them deliberately — so this is the same wiring as +/// [`CLAUDE_HOOK_EVENTS`] in owned-file form; see [`GROK_HOOK_EVENTS`] for the +/// table and why `Notification` carries a matcher. +fn grok_hooks_json() -> Option { + let mut hooks = serde_json::Map::new(); + for (event, sentinel, matcher) in GROK_HOOK_EVENTS { + let mut group = serde_json::json!({ + "hooks": [{ + "type": "command", + "command": hook_command(HookAgent::Grok, sentinel)?, + "timeout": GROK_HOOK_TIMEOUT_SECS, + }] + }); + if let Some(matcher) = matcher { + group["matcher"] = serde_json::Value::String((*matcher).to_string()); + } + hooks.insert((*event).to_string(), serde_json::json!([group])); + } + serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok() +} + /// OpenCode plugin (`~/.config/opencode/plugins/tty7.js`). OpenCode has no /// declarative hooks — its extensibility surface is JS plugins auto-loaded /// from that directory — so the plugin bridges its events onto the same @@ -985,6 +1090,31 @@ mod tests { 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); + + // Grok's envelope is camelCase throughout; the same fields must land in + // the snake_case sentinel body, or restore loses the id `--resume` needs. + let seq = build_hook_sequence( + "grok", + "session-start", + r#"{"hookEventName":"session_start","sessionId":"g-42","cwd":"/w"}"#, + ); + let ev = parse_agent_event(&seq[2..seq.len() - 1]).expect("daemon parses the grok event"); + assert_eq!(ev.agent, Some(CLIAgent::Grok)); + assert_eq!(ev.session_id.as_deref(), Some("g-42")); + assert_eq!(ev.cwd.as_deref(), Some(std::path::Path::new("/w"))); + } + + /// Grok reads `~/.claude/settings.json` too, so a tty7 Claude Code + /// integration fires inside grok panes. Those invocations must speak as + /// grok — otherwise the pane reports the wrong agent, and having both + /// integrations installed emits every turn twice under two identities. + #[test] + fn grok_run_hooks_are_relabeled_to_grok() { + assert_eq!(effective_agent("claude", true), "grok"); + assert_eq!(effective_agent("grok", true), "grok"); + // Outside grok's hook runner nothing is rewritten. + assert_eq!(effective_agent("claude", false), "claude"); + assert_eq!(effective_agent("grok", false), "grok"); } /// Every event name any installer writes must be one the daemon's parser @@ -997,6 +1127,7 @@ mod tests { .iter() .chain(CODEX_HOOK_EVENTS) .map(|(_, e)| *e) + .chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e)) .collect(); // Owned-file integrations embed their events in generated source. events.extend([ @@ -1037,12 +1168,37 @@ mod tests { effective_event("copilot", "notification", r#"{"type":"turn_summary"}"#), None ); + // Grok's catch-all Notification hook is filtered harder: only its + // ask-the-user question is reliably a block. `permission_prompt` fires + // ahead of the permission decision — verified against grok 0.2.112, + // where an auto-approved `list_dir` emitted one — so escalating it + // would flash amber on every tool call. + assert_eq!( + effective_event( + "grok", + "notification", + r#"{"notificationType":"elicitation_dialog","message":"User question requested"}"# + ), + Some("permission-request") + ); + for noisy in ["permission_prompt", "task_complete", "agent_error"] { + assert_eq!( + effective_event( + "grok", + "notification", + &format!(r#"{{"notificationType":"{noisy}"}}"#) + ), + None, + "grok {noisy} is not a block" + ); + } // Other agents and events pass through untouched. assert_eq!( effective_event("claude", "notification", "{}"), Some("notification") ); assert_eq!(effective_event("copilot", "stop", "{}"), Some("stop")); + assert_eq!(effective_event("grok", "stop", "{}"), Some("stop")); } /// The controlling-tty fallback (`ancestor_tty_device`) is what makes the @@ -1124,6 +1280,28 @@ mod tests { assert!(pi.contains("agent-hook pi")); assert!(pi.contains(&exe)); assert!(pi.contains(r#"process.env["TTY7"]"#)); + + let grok = grok_hooks_json().expect("grok content builds"); + let parsed: serde_json::Value = serde_json::from_str(&grok).expect("valid JSON"); + for (event, sentinel, matcher) in GROK_HOOK_EVENTS { + let group = &parsed["hooks"][*event][0]; + let cmd = group["hooks"][0]["command"] + .as_str() + .unwrap_or_else(|| panic!("grok {event} carries a command")); + assert!( + cmd.ends_with(&format!("agent-hook grok {sentinel}")), + "grok {event} runs the emitter with {sentinel}, got {cmd}" + ); + // A narrowed subscription must carry its matcher — without it grok + // fires the hook for every notification type, which is the amber + // flash this integration exists to avoid. + assert_eq!( + group.get("matcher").and_then(|m| m.as_str()), + *matcher, + "grok {event} matcher" + ); + } + assert!(grok.contains(&exe)); } /// Owned-file lifecycle against a scratch path: install → Installed, diff --git a/src/core/cli_agent.rs b/src/core/cli_agent.rs index f58fb9a5..291e2d71 100644 --- a/src/core/cli_agent.rs +++ b/src/core/cli_agent.rs @@ -211,6 +211,9 @@ impl CLIAgent { // Copilot CLI: `copilot --resume ` (`-r` shorthand) — // the one hooks-covered agent that was missing from this table. CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")), + // Grok Build: `grok --resume `; a UUID-shaped value + // always takes the id path, which is what its hooks report. + CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")), _ => None, } } @@ -268,6 +271,27 @@ impl CLIAgent { // `--last` targets "the most recent session" and would contradict // the explicit id we inject. CLIAgent::Codex => &["--last"], + // Beyond the session-targeting flags (`--load` is grok's hidden + // alias for `--resume`; `--session-id` names a *new* session and + // `--fork-session` would branch off the one we mean to continue), + // the worktree pair goes too: `--worktree` with no value mints a + // fresh git worktree on every relaunch, and `--worktree-ref` + // requires `--worktree`, so leaving it behind would make grok + // reject the resume outright. + CLIAgent::Grok => &[ + "--resume", + "-r", + "--load", + "--continue", + "-c", + "--session-id", + "-s", + "--fork-session", + "--worktree", + "-w", + "--worktree-ref", + "--ref", + ], _ => &[], }; let mut i = 0; @@ -1371,6 +1395,34 @@ mod tests { CLIAgent::Copilot.resume_command("s-9", None).as_deref(), Some("copilot --resume s-9") ); + // Grok: mode flags survive, and every way of naming another session is + // stripped so the injected id is the only target left. + assert_eq!( + CLIAgent::Grok + .resume_command("g-2", Some(&argv(&["grok", "--model", "grok-code"]))) + .as_deref(), + Some("grok --model grok-code --resume g-2") + ); + assert_eq!( + CLIAgent::Grok + .resume_command( + "g-2", + Some(&argv(&["grok", "--resume", "g-1", "--fork-session"])) + ) + .as_deref(), + Some("grok --resume g-2") + ); + // `--worktree` would mint a fresh git worktree on every restore, and + // `--worktree-ref` can't survive without it. + assert_eq!( + CLIAgent::Grok + .resume_command( + "g-3", + Some(&argv(&["grok", "-w", "--worktree-ref", "main", "--yolo"])) + ) + .as_deref(), + Some("grok --yolo --resume g-3") + ); } #[test] From eced0af754c20033c3d2e9ed334be4ae96abf1d9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:17:24 +0800 Subject: [PATCH 2/2] feat(agents): give Grok its brand avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grok was drawing the generic robot glyph on a slate disc — the fallback picked back when no usable mark was bundled. xAI publishes its symbol only as a ~2:1 landscape lockup that bleeds off its own canvas; traced and fitted to a 24x24 box it is unreadable as a 16px silhouette, which is what the tab chip and sidebar render. So the bundled mark is lobehub/lobe-icons' square transcription (MIT), drawn for exactly this avatar use. Its notice rides in the SVG. The slate accent goes with it. That mid-tone exists for vendors whose monochrome mark is grey or a gradient (Cursor), because a white field vanishes on a light theme; a black field has no such problem — it stays darker than even the darkest theme background and the white mark carries the badge. Grok brands in black, like Codex, so it keeps that. Adds a guard test: every CLIAgent::icon_path must resolve through the asset source. A brand mark means touching two files, and forgetting the registration costs the agent its avatar silently. --- assets/icons/agents/grok.svg | 5 +++++ src/core/cli_agent.rs | 16 +++++++++++----- src/ui/assets.rs | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 assets/icons/agents/grok.svg diff --git a/assets/icons/agents/grok.svg b/assets/icons/agents/grok.svg new file mode 100644 index 00000000..82745602 --- /dev/null +++ b/assets/icons/agents/grok.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/core/cli_agent.rs b/src/core/cli_agent.rs index 291e2d71..70fa4b79 100644 --- a/src/core/cli_agent.rs +++ b/src/core/cli_agent.rs @@ -338,8 +338,11 @@ impl CLIAgent { /// 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 monochrome vendors get - /// a recognizable mid-tone hue instead; Codex keeps its black field. + /// *white* field vanishes against a light theme, so vendors whose mark is a + /// grey or gradient monochrome (Cursor) get a recognizable mid-tone hue + /// instead. A black field is a different case: it stays darker than even the + /// darkest theme background and the white mark on it carries the badge, so + /// vendors who actually brand in black (Codex, Grok) keep it. pub fn accent_rgb(self) -> u32 { match self { CLIAgent::Claude => 0xD97757, // Claude terracotta @@ -357,7 +360,7 @@ impl CLIAgent { 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::Grok => 0x000000, // xAI brands in black CLIAgent::Qwen => 0x7C3AED, // Qwen purple } } @@ -379,6 +382,7 @@ impl CLIAgent { CLIAgent::Cursor => "icons/agents/cursor.svg", CLIAgent::Goose => "icons/agents/goose.svg", CLIAgent::Droid => "icons/agents/droid.svg", + CLIAgent::Grok => "icons/agents/grok.svg", // No brand mark bundled → generic robot glyph. CLIAgent::Aider | CLIAgent::Pi @@ -386,7 +390,6 @@ impl CLIAgent { | CLIAgent::Hermes | CLIAgent::Vibe | CLIAgent::Antigravity - | CLIAgent::Grok | CLIAgent::Qwen => "icons/bot.svg", } } @@ -940,9 +943,12 @@ mod tests { } } + /// The two vendors who actually brand in black keep the black field rather + /// than the mid-tone substitute monochrome marks otherwise get. #[test] - fn codex_avatar_uses_its_black_brand_field() { + fn black_branded_avatars_keep_their_brand_field() { assert_eq!(CLIAgent::Codex.accent_rgb(), 0x000000); + assert_eq!(CLIAgent::Grok.accent_rgb(), 0x000000); } #[test] diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 05b12d72..d2dd4cbf 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -151,6 +151,11 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "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"), + // The one mark not taken from the vendor directly: xAI publishes its + // symbol only as a ~2:1 landscape lockup that turns to mush as a 16px + // silhouette, so this is lobehub/lobe-icons' square transcription (MIT), + // drawn for exactly this avatar use. Its notice rides in the SVG. + "icons/agents/grok.svg" => include_bytes!("../../assets/icons/agents/grok.svg"), _ => return None, }; Some(bytes) @@ -181,6 +186,21 @@ mod tests { } } + /// Every agent avatar must resolve to real bytes. Adding a brand mark means + /// touching two files — the SVG and the arm above — and forgetting the + /// second one costs the agent its avatar with nothing to show for it. + #[test] + fn every_agent_icon_resolves() { + for agent in crate::core::cli_agent::CLIAgent::ALL { + let path = agent.icon_path(); + assert!( + Assets.load(path).unwrap().is_some(), + "{} points at {path}, which nothing serves", + agent.display_name() + ); + } + } + /// A `stock/` path for a glyph tty7 never overrode still has to resolve — /// the prefix is a bypass, not a separate asset set. #[test]