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/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..70fa4b79 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;
@@ -314,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
@@ -333,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
}
}
@@ -355,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
@@ -362,7 +390,6 @@ impl CLIAgent {
| CLIAgent::Hermes
| CLIAgent::Vibe
| CLIAgent::Antigravity
- | CLIAgent::Grok
| CLIAgent::Qwen => "icons/bot.svg",
}
}
@@ -916,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]
@@ -1371,6 +1401,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]
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]