feat(agents): make Pi a first-class agent with icon and resume (#240)

* feat(agents): make Pi 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 (#225). Auditing the rest of the registry
turned up two more places Pi was on a default rather than handled.

The avatar. Repo practice, from the most recent addition (eced0af,
Grok): take the vendor's mark where it is usable as a 16px silhouette,
otherwise lobehub/lobe-icons' transcription (MIT) with the notice inside
the SVG. Neither applies here — lobe-icons' "Pi" is Inflection AI's
chatbot, a different product whose trademark has no business on this
agent, and Pi itself (earendil-works/pi, MIT) ships no symbol to
transcribe. So this is tty7's own geometric Greek pi on the same 24x24
grid, ~3.3 stroke weight and rounded terminals as the bundled marks,
drawn as a filled silhouette because gpui rasterizes these to a tinted
alpha mask. The letter is not a trademark; nothing is vendored in. The
sky accent and the status dot are unchanged.

Resume. Pi's `--resume`/`-r` is a *boolean* that opens the interactive
picker and `--continue`/`-c` just takes the newest session; the flag
that targets one by id is `--session <path|id>` (its own flag table,
packages/coding-agent/src/cli/args.ts). So the resume command is
`pi --session <id>`, and the stale-flag list gains an arm for the five
ways to name a different session — `--session`, `--session-id`,
`--fork`, `-r`/`-c`, and `--no-session`, which would turn saving off
entirely. `--session-dir` is deliberately not stripped: it says where
sessions live, so the injected id needs it to survive.

None of which pays off unless tty7 knows an id, and it did not — the
generated Pi extension spawned the emitter with stdin ignored, so every
event arrived with session_id: None and resume_command was never
reached. The bridge now reads Pi's id off ctx.sessionManager
.getSessionId() (exposed on the read-only session manager Pi hands each
handler) and pipes it in as the emitter's JSON payload, on session_start
— which also fires for /resume, --fork and new sessions, so a mid-pane
switch re-reports instead of going stale. The load-time presence ping
stays bare; no context exists yet.

Left alone deliberately: aliases, slug, display name, accent and the
hook install/uninstall integration were already correct, and every
render site (tab chip, sidebar row, tray menu, notifications) is generic
over icon_path/accent_rgb/display_name — no other agent changes.

Guard tests, following what the module already does: the fallback set is
pinned by slug so neither adding a mark nor regressing to bot.svg can
pass unnoticed, the Pi resume form and its flag stripping are asserted,
and the Pi bridge is checked for the stdin plumbing whose absence was
the silent half of this bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* no-mistakes(review): skip resume for Pi panes launched with --no-session

* no-mistakes(document): align Pi changelog flag list with stripped flags

* fix(agents): use Pi's own mark for the avatar, not tty7 artwork

The Pi avatar shipped as original artwork on the claim that Pi publishes
no symbol. That claim was wrong: Pi's mark is at pi.dev/logo-auto.svg.
Swap the drawing for the published one and correct the provenance notes
that repeated the claim (the SVG header, the asset-source arm and the
changelog entry).

The published file is the one mark in this set that arrives with a
stylesheet — an 800x800 box whose `prefers-color-scheme` block swaps
black for white. usvg renders it anyway (it applies the base rule and
ignores the media query), so this is normalisation rather than a fix:
geometry rescaled to the 24x24 grid the rest of the set uses, class and
media query dropped for the flat sentinel fill, since gpui and the tray
both tint these as alpha masks. At this size the mark lands on an exact
4x4 grid of 6-unit cells, so the rescale is lossless.

The tray's avatar test now walks the whole roster instead of one branded
and one fallback agent, and asserts the disc came back with more than one
opaque colour. It is the only test that runs the bundled SVGs through
resvg — the asset-source test proves the bytes resolve, not that they
parse into visible geometry — and a mark that parses to nothing renders
as a bare accent disc that nothing else would catch.

Refs #225

* no-mistakes(document): correct Pi changelog icon-grid claim

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
l0ng-ai
2026-07-28 21:34:02 +08:00
committed by GitHub
co-authored by l0ng-ai Claude Opus 5
parent 1996f2ae12
commit 6d70939824
6 changed files with 239 additions and 11 deletions
+19
View File
@@ -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 <id>` (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
+11
View File
@@ -0,0 +1,11 @@
<!-- Pi's own brand mark, from https://pi.dev/logo-auto.svg.
Normalized to tty7's 24x24 icon format; geometry unchanged. The published
file is an 800x800 box carrying a `prefers-color-scheme` <style> block that
swaps black for white — no other mark in this set ships theme-conditional
CSS, and the avatars are tinted by the app regardless, so the class and
media query are dropped for the flat sentinel fill the rest of the set
uses. The mark lands on an exact 4x4 grid of 6-unit cells at this size. -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0H18V12H12V18H6V24H0V0ZM6 6V12H12V6H6Z" fill="#FF0000"/>
<path d="M18 12H24V24H18V12Z" fill="#FF0000"/>
</svg>

After

Width:  |  Height:  |  Size: 795 B

+45 -7
View File
@@ -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
/// <id>` 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<String> {
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 <id>` 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");
+133 -1
View File
@@ -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 <id-or-title>`; 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
// <path|id>` ("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 <id>` 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
+6
View File
@@ -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)
+25 -3
View File
@@ -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());
}