diff --git a/CHANGELOG.md b/CHANGELOG.md index 791895a9..f987e91e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to tty7 are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Pi is a first-class agent, not a fallback one** — Pi panes drew the generic + robot glyph every unbranded agent shares, so a Pi tab was indistinguishable + from an Aider or Qwen one in the sidebar, the tab chip and the tray menu. They + now carry their own avatar on the existing sky accent, status dot unchanged. + The mark is Pi's own, from pi.dev, rescaled to tty7's 24x24 icon grid — its + `prefers-color-scheme` stylesheet dropped, since these avatars are tinted by + the app. Restoring a Pi pane also resumes its conversation now: the tty7 + extension reports Pi's session id, and the resume command is + `pi --session ` (Pi's `--resume` is a boolean that only opens the + interactive picker), with `--session` / `--session-id` / `--fork` / + `--resume` / `-r` / `--continue` / `-c` stripped off the replayed launch + flags so the restored id wins. A pane launched with `--no-session` is not + resumed at all — that pane never wrote a session to disk, and reopening one + would override the choice to keep it ephemeral. (#225) + ## [26.7.6] - 2026-07-28 ### Added diff --git a/assets/icons/agents/pi.svg b/assets/icons/agents/pi.svg new file mode 100644 index 00000000..89ed68a6 --- /dev/null +++ b/assets/icons/agents/pi.svg @@ -0,0 +1,11 @@ + + + + + diff --git a/src/core/agent_hooks.rs b/src/core/agent_hooks.rs index 99eb018e..66739afc 100644 --- a/src/core/agent_hooks.rs +++ b/src/core/agent_hooks.rs @@ -1032,6 +1032,14 @@ export const Tty7Presence = async ({{ $ }}) => {{ /// extensions from per-directory `index.ts` files; this one forwards Pi's /// lifecycle events to the emitter. Inert outside tty7 (both the TS guard and /// the emitter check `TTY7`). +/// +/// Every forwarded event carries Pi's own session id, read off the read-only +/// session manager on the handler's context — that id is what +/// [`crate::core::cli_agent::CLIAgent::resume_command`] feeds to `pi --session +/// ` when a restored pane relaunches its conversation. The context is only +/// reachable from a handler, so the load-time presence ping stays bare and +/// `session_start` (fired for startup / new / resume / fork) is what actually +/// reports the id, including when the user switches sessions mid-pane. fn pi_extension_ts() -> Option { let exe = serde_json::to_string(&std::env::current_exe().ok()?.display().to_string()).ok()?; Some(format!( @@ -1041,20 +1049,42 @@ import {{ spawnSync }} from "node:child_process"; const EXE = {exe}; -function emit(event: string): void {{ +/** The slice of Pi's handler context we read — structural, so this bridge does + * not depend on the context type staying exported. */ +type SessionCtx = {{ sessionManager?: {{ getSessionId?(): string | undefined }} }}; + +function emit(event: string, ctx?: SessionCtx): void {{ try {{ - spawnSync(EXE, ["agent-hook", "pi", event], {{ stdio: ["ignore", "ignore", "ignore"] }}); + let payload = ""; + try {{ + const id = ctx?.sessionManager?.getSessionId?.(); + if (id) payload = JSON.stringify({{ session_id: id }}); + }} catch {{}} + const args = ["agent-hook", "pi", event]; + // Nothing to send → leave stdin closed rather than handing the emitter a + // pipe it has to read to EOF. + if (payload) {{ + spawnSync(EXE, args, {{ input: payload, stdio: ["pipe", "ignore", "ignore"] }}); + }} else {{ + spawnSync(EXE, args, {{ stdio: ["ignore", "ignore", "ignore"] }}); + }} }} catch {{}} }} export default function (pi: ExtensionAPI) {{ if (!process.env["TTY7"]) return; - // Extension load = the agent is running in this pane; Pi has no separate - // session-start event. + // Extension load = the agent is running in this pane. No context here yet, + // so the id rides on session_start instead. emit("session-start"); - pi.on("agent_start", () => emit("prompt-submit")); - pi.on("agent_end", () => emit("stop")); - pi.on("session_shutdown", () => emit("session-end")); + pi.on("agent_start", (_event, ctx) => emit("prompt-submit", ctx)); + pi.on("agent_end", (_event, ctx) => emit("stop", ctx)); + pi.on("session_shutdown", (_event, ctx) => emit("session-end", ctx)); + // Last, and guarded: the three above already worked, so a Pi build that + // rejects this event name must not take them — or the whole extension — + // down with it. + try {{ + pi.on("session_start", (_event, ctx) => emit("session-start", ctx)); + }} catch {{}} }} "# )) @@ -1280,6 +1310,14 @@ mod tests { assert!(pi.contains("agent-hook pi")); assert!(pi.contains(&exe)); assert!(pi.contains(r#"process.env["TTY7"]"#)); + // Pi's session id is what `pi --session ` resumes with, and it only + // reaches tty7 if the bridge pipes a payload instead of ignoring stdin + // — which is how this integration shipped, silently costing Pi panes + // their resume. + assert!(pi.contains("getSessionId")); + assert!(pi.contains("session_id")); + assert!(pi.contains(r#"stdio: ["pipe", "ignore", "ignore"]"#)); + assert!(pi.contains(r#"pi.on("session_start""#)); let grok = grok_hooks_json().expect("grok content builds"); let parsed: serde_json::Value = serde_json::from_str(&grok).expect("valid JSON"); diff --git a/src/core/cli_agent.rs b/src/core/cli_agent.rs index 70fa4b79..94a7fc35 100644 --- a/src/core/cli_agent.rs +++ b/src/core/cli_agent.rs @@ -183,6 +183,11 @@ impl CLIAgent { { return None; } + // A pane the user launched as deliberately ephemeral has nothing on + // disk to come back to, whatever id the agent reported. + if launch_argv.is_some_and(|argv| self.opts_out_of_sessions(argv)) { + return None; + } // The user's launch flags, pre-joined with a leading space so they // splice into the format strings below; empty when none survive. let flags = launch_argv @@ -214,10 +219,32 @@ impl CLIAgent { // 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}")), + // Pi's `--resume`/`-r` is a *boolean* that opens the interactive + // session picker and `--continue`/`-c` just takes the newest + // session; the flag that targets one by id is `--session + // ` ("Use specific session file or partial UUID"). Its + // ids are uuidv7, so they clear the token gate above. + CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")), _ => None, } } + /// Whether `argv` launched the agent with session persistence turned off, + /// which makes the pane unresumable: nothing was written to disk, so a + /// replayed id would point at a session file that never existed *and* + /// would quietly undo the user's opt-out. Distinct from the stale flags in + /// [`Self::replay_flags`], which name a different session and merely have + /// to lose to the injected id. + fn opts_out_of_sessions(self, argv: &[String]) -> bool { + let ephemeral: &[&str] = match self { + // Pi still mints an in-memory session id under `--no-session` — it + // only skips the write — so tty7 does observe an id to replay. + CLIAgent::Pi => &["--no-session"], + _ => &[], + }; + argv.iter().any(|t| ephemeral.contains(&t.as_str())) + } + /// The launch-flag tail of `argv` worth replaying on a resume command, or /// `None` to resume bare. Deliberately conservative: anything ambiguous /// falls back to no flags rather than a corrupted command line. @@ -271,6 +298,23 @@ impl CLIAgent { // `--last` targets "the most recent session" and would contradict // the explicit id we inject. CLIAgent::Codex => &["--last"], + // Pi's ways of picking a session all fight the `--session ` we + // inject: `--session`/`--session-id` name a different one, + // `--fork` would branch instead of continue, and the boolean + // `-r`/`-c` re-open the picker or the newest session. + // `--session-dir` is *not* here — it says where sessions live, so + // the id we inject needs it to still be there. `--no-session` is + // not here either: it isn't stale, it means there is nothing to + // resume at all (see [`Self::opts_out_of_sessions`]). + CLIAgent::Pi => &[ + "--session", + "--session-id", + "--fork", + "--resume", + "-r", + "--continue", + "-c", + ], // 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), @@ -383,9 +427,9 @@ impl CLIAgent { CLIAgent::Goose => "icons/agents/goose.svg", CLIAgent::Droid => "icons/agents/droid.svg", CLIAgent::Grok => "icons/agents/grok.svg", + CLIAgent::Pi => "icons/agents/pi.svg", // No brand mark bundled → generic robot glyph. CLIAgent::Aider - | CLIAgent::Pi | CLIAgent::Auggie | CLIAgent::Hermes | CLIAgent::Vibe @@ -951,6 +995,32 @@ mod tests { assert_eq!(CLIAgent::Grok.accent_rgb(), 0x000000); } + /// The fallback robot glyph is a placeholder, not a resting state: an agent + /// may only sit on it while no mark is bundled, and a bundled mark may not + /// silently fall back off. Pinning the exact fallback set makes either + /// direction a deliberate edit here rather than something noticed in the UI. + #[test] + fn only_the_unbranded_agents_use_the_fallback_glyph() { + let fallback: Vec<&str> = CLIAgent::ALL + .into_iter() + .filter(|a| a.icon_path() == "icons/bot.svg") + .map(CLIAgent::slug) + .collect(); + assert_eq!( + fallback, + ["aider", "auggie", "hermes", "vibe", "antigravity", "qwen"] + ); + // Everything else names a bundled mark under the agents directory. + for a in CLIAgent::ALL { + let path = a.icon_path(); + assert!( + path == "icons/bot.svg" || path == format!("icons/agents/{}.svg", a.slug()), + "{} points at an unexpected {path}", + a.display_name() + ); + } + } + #[test] fn detects_newer_agents_by_command() { for (cmd, agent) in [ @@ -1241,6 +1311,14 @@ mod tests { CLIAgent::Codex.resume_command("th_read.9", None).as_deref(), Some("codex resume th_read.9") ); + // Pi targets a session by id through `--session`, not through its + // boolean `--resume` (which only opens the picker). + assert_eq!( + CLIAgent::Pi + .resume_command("0199c3f2-1b0e-7c3a-9f21-6d4b8e2a5c17", None) + .as_deref(), + Some("pi --session 0199c3f2-1b0e-7c3a-9f21-6d4b8e2a5c17") + ); // No resume flag known → None. assert_eq!(CLIAgent::Aider.resume_command("abc", None), None); // An id carrying shell syntax is refused outright. @@ -1362,6 +1440,60 @@ mod tests { .as_deref(), Some("codex resume id-3 --yolo") ); + // Pi: mode flags replay, but every way of naming a *different* session + // is stripped so the injected id wins — including the boolean picker + // flags. + assert_eq!( + CLIAgent::Pi + .resume_command("id-a", Some(&argv(&["pi", "--model", "opus"]))) + .as_deref(), + Some("pi --model opus --session id-a") + ); + assert_eq!( + CLIAgent::Pi + .resume_command( + "id-b", + Some(&argv(&[ + "pi", + "--session", + "old-id", + "--fork", + "old", + "-c", + "--model", + "opus" + ])) + ) + .as_deref(), + Some("pi --model opus --session id-b") + ); + // `--no-session` is the user asking for an ephemeral pane: Pi mints an + // id but never writes the file, so resuming it would open an empty + // session *and* override the opt-out — no resume command at all. + assert_eq!( + CLIAgent::Pi.resume_command( + "id-x", + Some(&argv(&["pi", "--no-session", "--model", "opus"])) + ), + None + ); + // `--session-dir` says where sessions live — the injected id needs it, + // so it is deliberately *not* stripped. + assert_eq!( + CLIAgent::Pi + .resume_command( + "id-c", + Some(&argv(&[ + "pi", + "--session-dir", + "/w/.sessions", + "--fork", + "old" + ])) + ) + .as_deref(), + Some("pi --session-dir /w/.sessions --session id-c") + ); // No token names the agent (custom wrapper rule) → bare. assert_eq!( CLIAgent::Claude diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 2d88903f..43361596 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -185,6 +185,12 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { // 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"), + // Pi's own mark, from pi.dev — the one file that arrives with theme + // logic attached (`logo-auto.svg` carries a `prefers-color-scheme` + // style block). The geometry is kept as published and rescaled to the + // 24x24 grid; the CSS is dropped, since these avatars are tinted by the + // app and no other mark here brings a stylesheet. Details in the SVG. + "icons/agents/pi.svg" => include_bytes!("../../assets/icons/agents/pi.svg"), _ => return None, }; Some(bytes) diff --git a/src/ui/tray/icon.rs b/src/ui/tray/icon.rs index ae11b045..a32ea73b 100644 --- a/src/ui/tray/icon.rs +++ b/src/ui/tray/icon.rs @@ -339,11 +339,17 @@ mod tests { assert_ne!(normal.data, attention.data); } - /// The avatar renders for a branded agent, an unbranded (bot-fallback) - /// agent, and with/without the status dot. + /// Every avatar renders, with and without the status dot. Run over the + /// whole roster rather than one branded and one fallback agent, because + /// this is the only test that puts the bundled SVGs through resvg: the + /// asset-source test next door proves the bytes resolve, not that they + /// parse into visible geometry. Vendor marks arrive in whatever shape the + /// vendor publishes — stylesheets, nested groups, features usvg quietly + /// drops — and a mark that parses to nothing shows up as a bare accent + /// disc, which nothing else here would catch. #[test] fn agent_avatar_renders_brand_and_fallback() { - for agent in [CLIAgent::Claude, CLIAgent::Qwen] { + for agent in CLIAgent::ALL { let idle = agent_avatar(agent, AgentStatus::Idle).unwrap(); let waiting = agent_avatar(agent, AgentStatus::Waiting).unwrap(); assert_eq!((idle.width(), idle.height()), (32, 32)); @@ -351,6 +357,22 @@ mod tests { assert_eq!(idle.pixel(0, 0).unwrap().alpha(), 0); // …and the center is covered (disc + glyph). assert!(idle.pixel(16, 16).unwrap().alpha() > 0); + // The glyph actually drew something. The disc under it is a flat + // accent fill, so every opaque pixel shares one colour unless the + // white mark landed on top — one colour means resvg handed back an + // empty canvas, which is what a silently-unsupported SVG feature + // looks like from here. + let shades: std::collections::HashSet<_> = idle + .pixels() + .iter() + .filter(|p| p.alpha() == 0xFF) + .map(|p| (p.red(), p.green(), p.blue())) + .collect(); + assert!( + shades.len() > 1, + "{} rendered as a bare disc — its glyph drew nothing", + agent.display_name() + ); // The status dot changes the bottom-right corner. assert_ne!(idle.data(), waiting.data()); }