/** * Subagent card UI design constraints: * - Lives above the editor so long-running subagents remain a live dashboard * instead of scrollback chat records. * - Focus overlays stay separate from chat records because they display * mutable runtime state and can be disposed without rewriting history. * - Pi's TUI throws on overflow, so every rendered line must be clamped to * terminal width and renders are requested only from explicit state changes. */ import { Container, truncateToWidth, type Component, type TUI, visibleWidth } from "@mariozechner/pi-tui"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { getFrameBackgroundMode, renderMarkdownLines, resolveFrameBgColor, wrapLineWithBg, } from "../../vera-theme/src/public"; import type { BackgroundJob } from "./background"; import { agentLabel, formatUsage, FOREGROUND_RETAIN_MS, iconFor, renderTimelineItem, type ForegroundRun, type SubagentRuntimeState, } from "./tool"; import type { SubagentRunDetails } from "./types"; export type CardsMode = "all" | "background" | "off"; const MAX_CARDS = 9; // matches the 1..9 shortcut range const CARD_TASK_CLIP = 60; const OVERLAY_TIMELINE_LINES = 12; const OVERLAY_OUTPUT_LINES = 16; // When the subagent has finished, the final report is what the user came // to read, so allow more room before truncating. Anything beyond is still // reachable by scrolling. const OVERLAY_FINAL_OUTPUT_LINES = 60; interface CardData { /** Stable key for diffing; foreground uses toolCallId, background uses jobId. */ id: string; /** Display label for the agent ("worker", "explorer", etc). */ agentName: string; /** Full task string (single-line clipped at render time). */ task: string; startedAt: number; /** When set, card has finished and will be dropped after FOREGROUND_RETAIN_MS. */ completedAt?: number; /** Kind of run, drives badge and overlay header. */ kind: "foreground" | "background"; /** Latest known details snapshot (may be undefined for very early states). */ details?: SubagentRunDetails; } // ── Width helpers ─────────────────────────────────────────────────────── /** * Force a line to *exactly* `target` visible columns: pad with spaces when * shorter, truncate (no ellipsis) when longer. Pi's TUI throws hard when a * rendered line exceeds terminal width, so every line we emit must be * clamped — not just padded. */ function clampToWidth(s: string, target: number): string { return truncateToWidth(s, target, "", true); } function padToWidth(s: string, target: number): string { const w = visibleWidth(s); if (w >= target) return s; return s + " ".repeat(target - w); } // ── State collection ──────────────────────────────────────────────────── function isJobFinished(status: BackgroundJob["status"]): boolean { return status === "done" || status === "error" || status === "aborted"; } function jobToCard(job: BackgroundJob): CardData { return { id: `bg:${job.id}`, agentName: job.definition?.name ?? job.details.agent?.name ?? "worker", task: job.details.task, startedAt: job.details.startedAt ?? job.createdAt, completedAt: isJobFinished(job.status) ? job.updatedAt : undefined, kind: "background", details: job.details, }; } function foregroundToCard(run: ForegroundRun): CardData { return { id: `fg:${run.toolCallId}`, agentName: run.agentName, task: run.task, startedAt: run.startedAt, completedAt: run.completedAt, kind: "foreground", details: run.details, }; } /** * Collect cards in display order: oldest first by startedAt. Drops foreground * entries past their retention window as a side effect (state mutation). */ /** * Look up a card by its id (`bg:` or `fg:`) directly * from the live state. Used by the overlay so each render reads fresh * data instead of a stale snapshot taken when the user pressed Alt+N. */ function getCardById(state: SubagentRuntimeState, id: string): CardData | undefined { if (id.startsWith("bg:")) { const jobId = id.slice(3); const job = state.background.jobs.get(jobId); return job ? jobToCard(job) : undefined; } if (id.startsWith("fg:")) { const toolCallId = id.slice(3); const run = state.foregroundRuns.get(toolCallId); return run ? foregroundToCard(run) : undefined; } return undefined; } function collectCards(state: SubagentRuntimeState, mode: CardsMode, now: number): CardData[] { if (mode === "off") return []; // Janitor: drop foreground entries past retention window. const expiredKeys: string[] = []; for (const [key, run] of state.foregroundRuns) { if (run.completedAt !== undefined && now - run.completedAt > FOREGROUND_RETAIN_MS) { expiredKeys.push(key); } } for (const key of expiredKeys) state.foregroundRuns.delete(key); const cards: CardData[] = []; for (const job of state.background.jobs.values()) { if (isJobFinished(job.status) && now - job.updatedAt > FOREGROUND_RETAIN_MS) continue; cards.push(jobToCard(job)); } if (mode === "all") { for (const run of state.foregroundRuns.values()) { cards.push(foregroundToCard(run)); } } cards.sort((a, b) => a.startedAt - b.startedAt); return cards.slice(0, MAX_CARDS); } // ── Card rendering ────────────────────────────────────────────────────── function fmtDuration(ms: number | undefined): string { if (ms === undefined || !Number.isFinite(ms) || ms < 0) return ""; if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`; } function statusGlyph(card: CardData, theme: any): string { const phase = card.details?.phase; if (phase === "done") return theme.fg("success", "+"); if (phase === "error") return theme.fg("error", "x"); return theme.fg("warning", "*"); // running / queued / unknown } function clipTask(task: string, max: number): string { const firstLine = String(task ?? "").split(/\r?\n/)[0] ?? ""; const trimmed = firstLine.trim(); if (trimmed.length <= max) return trimmed; return trimmed.slice(0, max - 1) + "…"; } function mapCardStatus(card: CardData): string { const phase = card.details?.phase; if (phase === "done") return "success"; if (phase === "error") return "error"; return "pending"; } /** * Render one card as 2 lines (top + bottom border, content folded in). * * Cell-accounted layout: * ╭ ⎵ topLeft ⎵ dashes ⎵ topRight ⎵ ╮ total = 6 + T + D + R * ╰ ⎵ bottomLeft ⎵ dashes ⎵ ╯ total = 5 + B + F * * (⎵ marks single space separators.) Each line is clamped to `width` as a * hard safety net. */ function renderCard(card: CardData, slot: number, theme: any, width: number, now: number): string[] { const dim = (s: string) => theme.fg("borderMuted", s); const sep = dim(" · "); const slotLabel = theme.fg("dim", `Alt+${slot}`); // Top row segments. const slotBadge = theme.fg("muted", `(${slot})`); const agentText = theme.fg("text", card.agentName); const taskText = theme.fg("dim", clipTask(card.task, CARD_TASK_CLIP)); const elapsed = card.completedAt ? card.completedAt - card.startedAt : now - card.startedAt; const elapsedText = theme.fg("muted", fmtDuration(elapsed)); const topLeft = `${slotBadge} ${agentText}${sep}${taskText}`; // Use ASCII "~" for duration marker — emoji and clock glyphs (⏱ ⏲ etc.) // are wide and varies across terminals, breaking width math. const topRight = `${theme.fg("dim", "~")} ${elapsedText} ${slotLabel}`; const topLeftVis = visibleWidth(topLeft); const topRightVis = visibleWidth(topRight); // Bottom row segments. const glyph = statusGlyph(card, theme); const phaseLabel = card.details?.phase === "done" ? theme.fg("success", "done") : card.details?.phase === "error" ? theme.fg("error", "error") : card.kind === "background" && card.details?.phase === undefined ? theme.fg("muted", "queued") : theme.fg("warning", "running"); const tools = card.details?.timeline.filter((it) => it.kind === "tool" && it.phase === "start").length ?? 0; const toolsLabel = tools > 0 ? theme.fg("muted", `${tools} tool${tools === 1 ? "" : "s"}`) : ""; const usage = card.details ? formatUsage(card.details.usage, card.details.model) : ""; const usageLabel = usage ? theme.fg("dim", usage) : ""; const bgBadge = card.kind === "background" ? theme.fg("muted", "[bg]") : ""; const bottomParts = [glyph, phaseLabel, toolsLabel, usageLabel, bgBadge].filter((p) => p.length > 0); const bottomLeft = bottomParts.join(" "); const bottomLeftVis = visibleWidth(bottomLeft); // Top: ╭ · topLeft · dashes · topRight · ╮ total = 6 + T + D + R const topGap = Math.max(1, width - 6 - topLeftVis - topRightVis); const top = dim("╭") + " " + topLeft + " " + dim("─".repeat(topGap)) + " " + topRight + " " + dim("╮"); // Bottom: ╰ · bottomLeft · dashes · ╯ total = 5 + B + F const bottomGap = Math.max(1, width - 5 - bottomLeftVis); const bottom = dim("╰") + " " + bottomLeft + " " + dim("─".repeat(bottomGap)) + " " + dim("╯"); return [clampToWidth(top, width), clampToWidth(bottom, width)]; } // ── Widget component ──────────────────────────────────────────────────── class SubagentCardsComponent extends Container implements Component { private cardsCache: CardData[] = []; constructor( private readonly state: SubagentRuntimeState, private mode: CardsMode, private readonly theme: any, ) { super(); } setMode(mode: CardsMode): void { this.mode = mode; } /** Read-only snapshot used by the shortcut handler to look up a card by slot. */ getCardAt(slot: number): CardData | undefined { return this.cardsCache[slot - 1]; } override render(width: number): string[] { const now = Date.now(); const cards = collectCards(this.state, this.mode, now); this.cardsCache = cards; if (cards.length === 0) return []; const lines: string[] = []; for (let i = 0; i < cards.length; i++) { const cardLines = renderCard(cards[i], i + 1, this.theme, width, now); lines.push(...cardLines); } return lines; } } // ── Detail overlay ────────────────────────────────────────────────────── /** * Build the overlay component for one card. Static snapshot at open time; * does not poll for updates (the user can close and reopen for fresh data). * Keeping it static avoids the partial-update jitter cycle entirely. */ function buildOverlayLines(card: CardData, theme: any, width: number): string[] { const dim = (s: string) => theme.fg("borderMuted", s); const sideL = dim("│") + " "; const sideR = " " + dim("│"); const inner = Math.max(4, width - 4); // width - sideL(2) - sideR(2) // Strip newlines AND tabs, then clamp/pad to exactly `inner` cells before // adding borders. Without newline stripping, multi-line content from the // child agent would get pushed as a single "row" that the terminal then // breaks across multiple physical rows — desyncing Pi's diff renderer. // Tabs need stripping for a different reason: pi-tui's `visibleWidth` // counts TAB as 3 cells, but its `graphemeWidth` (used by overlay // compositing's `sliceWithWidth` / `extractSegments`) counts TAB as 1. // When this overlay is composited over a base line, the mismatch makes // the composer over-pad by 2 cells per TAB, pushing the rendered line // past terminal width and tripping pi-tui's hard width check. const lines: string[] = []; const pushRow = (rawContent: string) => { const single = rawContent.replace(/[\r\n\t]+/g, " "); const fitted = clampToWidth(single, inner); // pad+truncate to exactly inner lines.push(sideL + fitted + sideR); }; const pushDivider = () => lines.push(dim("├" + "─".repeat(width - 2) + "┤")); const details = card.details; // ── Top border ── // Layout: ╭ · titleLeft · dashes · closeHint · ╮ total = 6 + T + D + S const titleLeft = `${theme.fg("text", "subagent")} ${theme.fg("muted", `(${card.kind})`)}`; const closeHint = theme.fg("dim", "↑↓ PgUp/Dn Esc"); const titleVis = visibleWidth(titleLeft); const closeVis = visibleWidth(closeHint); const topGap = Math.max(1, width - 6 - titleVis - closeVis); lines.push( clampToWidth( dim("╭") + " " + titleLeft + " " + dim("─".repeat(topGap)) + " " + closeHint + " " + dim("╮"), width, ), ); // ── Header section ── pushRow(`${theme.fg("muted", "Agent")} ${theme.fg("text", card.agentName)}`); if (details?.jobId) pushRow(`${theme.fg("muted", "Job")} ${theme.fg("dim", details.jobId)}`); if (details?.model) pushRow(`${theme.fg("muted", "Model")} ${theme.fg("dim", details.model)}`); if (details?.cwd) pushRow(`${theme.fg("muted", "CWD")} ${theme.fg("dim", details.cwd)}`); const elapsed = card.completedAt ? card.completedAt - card.startedAt : Date.now() - card.startedAt; pushRow(`${theme.fg("muted", "Duration")} ${theme.fg("dim", fmtDuration(elapsed))}`); // ── Task section ── pushDivider(); pushRow(theme.fg("muted", "Task")); for (const ln of renderMarkdownLines(card.task ?? "", theme, inner)) { pushRow(ln); } if (details) { // Section ordering depends on phase: // running → Timeline first (live activity), output below if streaming. // done/err → Final output / Error first (the report Vera received), // Timeline pushed to the bottom for after-the-fact inspection. // // Once the subagent has finished, the user's primary interest is // "what did it return?", not "what tools did it call along the way". const isRunning = details.phase === "running"; const isError = details.phase === "error"; const output = (details.finalText || details.liveText || "").trim(); const renderTimeline = () => { pushDivider(); const tools = details.timeline.filter((it) => it.kind === "tool" && it.phase === "start").length; const heading = isRunning ? "Timeline" : "Timeline (history)"; pushRow(`${theme.fg("muted", heading)} ${theme.fg("dim", `(${tools} tool${tools === 1 ? "" : "s"})`)}`); const items = details.timeline.slice(-OVERLAY_TIMELINE_LINES); if (items.length === 0) pushRow(theme.fg("dim", "(no events yet)")); for (const item of items) pushRow(renderTimelineItem(item, theme)); }; const renderError = () => { pushDivider(); pushRow(theme.fg("muted", "Error")); if (details.error) { for (const ln of details.error.split(/\r?\n/)) pushRow(theme.fg("error", ln)); } }; const renderOutput = (heading: string, max: number) => { pushDivider(); pushRow(theme.fg("muted", heading)); const allLines = renderMarkdownLines(output, theme, inner); const outLines = allLines.slice(0, max); for (const ln of outLines) pushRow(ln); if (allLines.length > max) { pushRow(theme.fg("dim", `… (${allLines.length - max} more lines)`)); } }; const renderUsage = () => { const usage = formatUsage(details.usage, details.model, details.durationMs); if (usage) { pushDivider(); pushRow(`${theme.fg("muted", "Usage")} ${theme.fg("dim", usage)}`); } }; if (isRunning) { // Live: timeline up top, output if streaming, then usage. renderTimeline(); if (output) renderOutput("Live output", OVERLAY_OUTPUT_LINES); renderUsage(); } else { // Finished: foreground the report. Larger window since this is the // primary thing the user wants to read. if (isError) renderError(); if (output) renderOutput("Final output", OVERLAY_FINAL_OUTPUT_LINES); renderUsage(); // Timeline at the bottom — reachable via End / PgDn for after-the-fact // inspection, but no longer competes with the report for attention. renderTimeline(); } } // ── Bottom border ── lines.push(dim("╰" + "─".repeat(width - 2) + "╯")); const mode = getFrameBackgroundMode(); const bgColor = resolveFrameBgColor(mode, mapCardStatus(card)); if (bgColor) { return lines.map((ln) => wrapLineWithBg(ln, bgColor, theme)); } return lines; } /** * Live-reading overlay: re-reads the card from the runtime state on every * render call so the user sees the subagent's progress in real time. Pi's * TUI re-renders all components (including overlays) whenever * `tui.requestRender()` fires — which the cards widget already triggers on * every state change — so we get live updates for free. * * Tracks vertical scroll offset so overlong overlays can be paged. * `render(width)` returns lines starting at `scrollOffset`; Pi composites * those into the overlay region and truncates anything beyond maxHeight. */ class OverlayComponent implements Component { private scrollOffset = 0; private lastTotalLines = 0; constructor( private readonly getCard: () => CardData | undefined, private readonly theme: any, private readonly tui: TUI, ) {} /** * Approximate viewport height. The overlay is created with maxHeight=85% * of terminal rows; we use the same fraction to compute how many lines * are likely visible at once. Used to bound scrollOffset so the user * can't scroll past the content into a region of "only the bottom border". */ private viewportHeight(): number { const rows = this.tui.terminal?.rows ?? 24; return Math.max(4, Math.floor(rows * 0.85)); } /** * Maximum allowed scrollOffset: lets the user scroll until the LAST line * of content is at the bottom of the viewport, but no further. So the * viewport always shows real content, never a sea of empty rows. */ private maxScrollOffset(): number { return Math.max(0, this.lastTotalLines - this.viewportHeight()); } render(width: number): string[] { const card = this.getCard(); if (!card) { return ["(card no longer available)"]; } const allLines = buildOverlayLines(card, this.theme, width); this.lastTotalLines = allLines.length; // Clamp scroll offset to the valid range now that we know totalLines. const max = this.maxScrollOffset(); if (this.scrollOffset > max) this.scrollOffset = max; if (this.scrollOffset < 0) this.scrollOffset = 0; return allLines.slice(this.scrollOffset); } scrollBy(delta: number): void { const next = this.scrollOffset + delta; const max = this.maxScrollOffset(); this.scrollOffset = Math.min(max, Math.max(0, next)); } scrollToTop(): void { this.scrollOffset = 0; } scrollToBottom(): void { this.scrollOffset = this.maxScrollOffset(); } invalidate(): void {} } /** * Wraps the live overlay with key handling: Esc / Enter close, arrow keys * and Page keys scroll. Each scroll triggers `tui.requestRender()` so the * new viewport is drawn immediately. */ class ClosableOverlay implements Component { constructor( private readonly inner: OverlayComponent, private readonly done: (result: void) => void, private readonly tui: TUI, ) {} render(width: number): string[] { return this.inner.render(width); } invalidate(): void { this.inner.invalidate?.(); } handleInput(data: string): void { if (data === "\x1b" || data === "\r" || data === "\n") { this.done(undefined); return; } if (data === "\x1b[A") { // allow-ansi: keyboard-input this.inner.scrollBy(-1); this.tui.requestRender(); return; } if (data === "\x1b[B") { // allow-ansi: keyboard-input this.inner.scrollBy(1); this.tui.requestRender(); return; } if (data === "\x1b[5~") { // allow-ansi: keyboard-input this.inner.scrollBy(-10); this.tui.requestRender(); return; } if (data === "\x1b[6~") { // allow-ansi: keyboard-input this.inner.scrollBy(10); this.tui.requestRender(); return; } if (data === "\x1b[H" || data === "\x1b[1~") { // allow-ansi: keyboard-input this.inner.scrollToTop(); this.tui.requestRender(); return; } if (data === "\x1b[F" || data === "\x1b[4~") { // allow-ansi: keyboard-input this.inner.scrollToBottom(); this.tui.requestRender(); return; } } } // ── Public installer ──────────────────────────────────────────────────── const WIDGET_KEY = "vera-subagent-cards"; export interface SubagentCardsOptions { mode: CardsMode; } /** * Wire the cards widget + shortcut handlers. Idempotent: calling twice * replaces prior wiring (the widget by key, shortcuts by re-registration). */ export function installSubagentCards( pi: ExtensionAPI, state: SubagentRuntimeState, options: SubagentCardsOptions, ): void { if (options.mode === "off") return; let widgetComponent: SubagentCardsComponent | undefined; let widgetTui: TUI | undefined; const requestRender = () => widgetTui?.requestRender(); // Both change sources must trigger re-render. state.foregroundOnChange = requestRender; const prevBgOnChange = state.background.onChange; state.background.onChange = () => { prevBgOnChange?.(); requestRender(); }; // Register shortcuts AT MODULE LOAD TIME, not inside session_start. // setupExtensionShortcuts() runs once during interactive-mode init and // snapshots `extensionRunner.getShortcuts(...)` into its closure; anything // registered after that snapshot is invisible to the input dispatcher. // Tools / commands work because they go through different registries that // re-read on each call. Shortcuts don't. // // The handler closure captures `widgetComponent` by reference (let), so // even though the widget is created later in session_start, when a key is // pressed the handler reads the current value. // Alt+1 .. Alt+9 — emit "\x1b1" .. "\x1b9" (Esc + digit) in virtually // every terminal, unlike Ctrl+Alt+digit which most non-Kitty terminals // do not emit at all. const SHORTCUTS = [ "alt+1", "alt+2", "alt+3", "alt+4", "alt+5", "alt+6", "alt+7", "alt+8", "alt+9", ] as const; for (let n = 1; n <= SHORTCUTS.length; n++) { const slot = n; pi.registerShortcut(SHORTCUTS[n - 1], { description: `Open subagent card ${slot} detail`, handler: async (shortcutCtx: ExtensionContext) => { if (!shortcutCtx.hasUI) return; const card = widgetComponent?.getCardAt(slot); if (!card) { shortcutCtx.ui.notify(`No subagent card at slot ${slot}.`, "info"); return; } // Capture the card id and rebuild a getter that re-reads from // state on every render. Card.id is `bg:` for background // jobs and `fg:` for foreground; getCardById uses // that prefix to dispatch back to the right store. Static // snapshot would freeze — we want live updates. const cardId = card.id; await shortcutCtx.ui.custom( (tui, theme, _kb, done) => { const getCard = (): CardData | undefined => getCardById(state, cardId); const overlay = new OverlayComponent(getCard, theme, tui); return new ClosableOverlay(overlay, done, tui); }, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", }, }, ); }, }); } pi.on("session_start", async (_event, ctx) => { if (!ctx.hasUI) return; ctx.ui.setWidget( WIDGET_KEY, (tui, theme) => { widgetTui = tui; widgetComponent = new SubagentCardsComponent(state, options.mode, theme); return widgetComponent; }, { placement: "aboveEditor" }, ); }); pi.on("session_shutdown", async () => { widgetTui = undefined; widgetComponent = undefined; }); }