/** * pi-hud — HUD-style footer + bordered editor for pi-coding-agent * * Inspired by claude-hud (https://github.com/jarrodwatts/claude-hud). * Loaded by pi's jiti runtime — no build step required. * * Editor (border shell, installed via setEditorComponent): * Top border (LEFT-aligned, rounded): ╭─ π [model] [thinkingLevel] | phase | ponytail | goal ──╮ * First content row gets an inline `❯ ` prompt (wrapped rows pad to * align); all rows wrapped with │ on each side. * Bottom border (LEFT-aligned, rounded, full width): ╰─ [cwd] | [git] | [duration] ──╯ * handleInput fully delegated to super.handleInput — IME / paste / slash / * history / model cycling all keep working. * * Footer (1 line, persists in pi's footer slot via setFooter): * Q0 | ctx ████░░░░ 45% (12.3k) | $0.023 | ↑12k ↓2k | ↻ R W | Δcost * * Both surfaces read from a single StatusHub singleton (defined below) so the * editor border and the footer never disagree about model / phase / goal. */ import type { AssistantMessage } from "@earendil-works/pi-ai"; import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { existsSync, readFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { queueFileFor } from "./_lib/queue"; // ============================================================================ // Config (~/.pi/agent/pi-hud/config.json — created on demand, optional) // ============================================================================ const PI_ROOT = process.env.PI_HOME || join(process.env.HOME || "", ".pi"); const CONFIG_PATH = join(PI_ROOT, "agent/pi-hud/config.json"); const SETTINGS_PATH = join( process.env.PI_HOME || join(process.env.HOME || "", ".pi"), "agent/settings.json", ); interface Config { enabled: boolean; showModel: boolean; showCwd: boolean; showContext: boolean; showCost: boolean; showGit: boolean; showDuration: boolean; showLastTool: boolean; showToolCounts: boolean; /** Show pending follow-up queue count and next item. */ showQueue?: boolean; /** Show thinking level next to the model name. */ showThinking?: boolean; /** Show ponytail mode indicator when active. */ showPonytail?: boolean; /** Show the running/idle/retrying/compacting/error state on line 3. */ showRunState?: boolean; /** Show cache hit rate next to the context bar. */ showCacheHit?: boolean; /** Show compaction counter next to the context bar. */ showCompactionCount?: boolean; /** Show per-turn cost (computed from the latest assistant usage). */ showTurnCost?: boolean; /** Show the active pi-goal objective on line 1. */ showGoal?: boolean; /** Manual context window override when Pi's runtime doesn't know the model's true size. */ contextWindowOverride?: number; } const DEFAULTS: Config = { enabled: true, showModel: true, showCwd: true, showContext: true, showCost: true, showGit: true, showDuration: true, showLastTool: true, showToolCounts: true, showQueue: true, showThinking: true, showPonytail: true, showRunState: true, showCacheHit: true, showCompactionCount: true, showTurnCost: true, showGoal: true, // contextWindowOverride: undefined by default — pi-hud will discover from ctx }; let config: Config = { ...DEFAULTS }; function loadConfig(): void { try { if (existsSync(CONFIG_PATH)) { const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8")); config = { ...DEFAULTS, ...raw }; } else { config = { ...DEFAULTS }; } } catch { config = { ...DEFAULTS }; } } loadConfig(); // ============================================================================ // pi-goal integration // ============================================================================ // // Goal state can arrive via two paths depending on which pi-goal package the // host is running: // // 1. The host-built goal mode (lives inside @oh-my-pi/pi-coding-agent) emits // a `goal_updated` session event on every create / resume / pause / drop / // complete and on every per-tool budget tick. Handled by the listener at // the bottom of this file. // // 2. The `@narumitw/pi-goal` npm package records goal state via // `pi.appendEntry("goal-state", { goal })` instead of emitting an event, // so we scan the session log ourselves and mirror the latest entry into // `currentGoal`. That scanner is `applyPersistedGoal` below. // // We mirror the minimal slice we need for the HUD; the runtime remains the // source of truth for accounting / budget enforcement. type GoalStatus = "active" | "paused" | "budget-limited" | "complete" | "dropped"; interface GoalLite { objective: string; status: GoalStatus; tokenBudget?: number; tokensUsed: number; } // ponytail: `currentGoal` and `footerRenderTrigger` were module-level state. // Both moved into StatusHub below; the singleton resets them on session_start. /** Accept either `objective` (host-built goal mode) or `text` * (`@narumitw/pi-goal`) so the bridge works for both transports. */ function pickObjectiveText(g: any): string { if (!g) return ""; if (typeof g.objective === "string" && g.objective) return g.objective; if (typeof g.text === "string" && g.text) return g.text; return ""; } /** Map pi-goal's `budget_limited` / `usage_limited` / `queued` / `blocked` * onto the HUD's `GoalStatus` union (which only knows `budget-limited`). */ function normalizeStatus(s: unknown): GoalStatus { const v = String(s ?? "").toLowerCase().replace(/_/g, "-"); if ( v === "active" || v === "paused" || v === "budget-limited" || v === "complete" || v === "dropped" ) return v; if (v === "queued" || v === "blocked") return "paused"; if (v === "usage-limited") return "budget-limited"; return "active"; } /** * Mirror the most recent `goal-state` custom entry from the session log into * StatusHub.currentGoal. Used as a fallback when the active pi-goal package * writes state via `pi.appendEntry` instead of emitting a `goal_updated` * event. * * Called on session_start, on every turn_end, and every second from the * render tick so a fresh goal-set after the HUD was already running still * shows up on line 1. */ function applyPersistedGoal(ctx: any, hub: StatusHub) { const sm = ctx?.sessionManager; if (!sm) return; const entries: any[] = sm.getBranch?.() ?? sm.getEntries?.() ?? []; for (let i = entries.length - 1; i >= 0; i--) { const e = entries[i]; if (e?.type === "custom" && e.customType === "goal-state") { const g = e.data?.goal; const text = pickObjectiveText(g); hub.setCurrentGoal( text ? { objective: text, status: normalizeStatus(g?.status), tokenBudget: g?.tokenBudget, tokensUsed: g?.tokensUsed ?? 0, } : null, ); return; } } } function truncateObjective(obj: string, max = 28): string { const flat = obj.replace(/\s+/g, " ").trim(); return truncateToWidth(flat, max, "…"); } /** Compact tag for line 1. Returns null when there is nothing worth showing. */ function goalTag(): string | null { if (!currentGoal) return null; const g = currentGoal; if (g.status === "complete" || g.status === "dropped") return null; const obj = truncateObjective(g.objective); if (g.status === "paused") return `⏸ ${obj}`; if (g.status === "budget-limited") return `🎯 ${obj} ⚠`; return `🎯 ${obj}`; } function goalColor(status: GoalStatus, theme: { fg: (k: string, s: string) => string }): string { const tag = goalTag(); if (!tag) return ""; if (status === "paused") return theme.fg("muted", tag); if (status === "budget-limited") return theme.fg("warning", tag); return theme.fg("accent", tag); } // ============================================================================ // Helpers (pure, testable) // ============================================================================ function fmtN(n: number): string { if (!n) return "0"; if (n < 1000) return `${n}`; if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; return `${(n / 1_000_000).toFixed(2)}M`; } function fmtCost(c: number): string { if (!c) return "$0.00"; if (c < 0.001) return "$<0.001"; if (c < 1) return `$${c.toFixed(3)}`; return `$${c.toFixed(2)}`; } function fmtDuration(ms: number): string { const s = Math.max(0, Math.floor(ms / 1000)); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); if (m < 60) return `${m}m`; const h = Math.floor(m / 60); return `${h}h${m % 60}m`; } function pctColor(pct: number, theme: any): (s: string) => string { if (pct >= 85) return (s: string) => theme.fg("error", s); if (pct >= 70) return (s: string) => theme.fg("warning", s); return (s: string) => theme.fg("success", s); } interface TurnStats { cost: number; input: number; output: number; cacheRead: number; cacheWrite: number; turnCount: number; compactionCount: number; } /** Pull the latest completed assistant message from the branch — used to * compute cache hit rate and per-turn cost without re-walking every entry. */ function lastAssistantUsage(ctx: any): { cost: number; input: number; output: number; cacheRead: number; cacheWrite: number; } | null { try { for (let i = ctx.sessionManager.getBranch().length - 1; i >= 0; i--) { const e = ctx.sessionManager.getBranch()[i]; if (e?.type !== "message") continue; const m = e.message; if (!m || m.role !== "assistant") continue; const u = (m as AssistantMessage).usage || {}; return { cost: (u as any).cost?.total || 0, input: u.input || 0, output: u.output || 0, cacheRead: u.cacheRead || 0, cacheWrite: u.cacheWrite || 0, }; } } catch { /* branch may be unavailable */ } return null; } function branchStats(ctx: any): TurnStats { const stats: TurnStats = { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turnCount: 0, compactionCount: 0 }; try { for (const e of ctx.sessionManager.getBranch()) { if (e?.type === "compaction") { stats.compactionCount++; continue; } if (e?.type !== "message") continue; const m = e.message; if (!m || m.role !== "assistant") continue; const u = (m as AssistantMessage).usage || {}; stats.input += u.input || 0; stats.output += u.output || 0; stats.cacheRead += u.cacheRead || 0; stats.cacheWrite += u.cacheWrite || 0; stats.cost += (u as any).cost?.total || 0; stats.turnCount++; } } catch { /* session may have no branch yet */ } return stats; } function getCwdDisplay(cwd: string, maxLen = 36): string { if (!cwd) return ""; if (cwd.length <= maxLen) return cwd; const parts = cwd.split("/").filter(Boolean); if (parts.length <= 2) return "~" + cwd.slice(-(maxLen - 1)); return "~/" + parts.slice(-2).join("/"); } interface GitStatus { branch: string; ahead: number; behind: number; staged: number; modified: number; untracked: number; } let gitStatusCache: { cwd: string; at: number; value: GitStatus } | undefined; function getGitStatus(cwd: string, fallbackBranch: string): GitStatus { const now = Date.now(); if (gitStatusCache && gitStatusCache.cwd === cwd && now - gitStatusCache.at < 750) return gitStatusCache.value; const empty: GitStatus = { branch: fallbackBranch, ahead: 0, behind: 0, staged: 0, modified: 0, untracked: 0 }; try { const result = spawnSync("git", ["-C", cwd, "status", "--porcelain=v1", "--branch"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); if (result.status !== 0) { gitStatusCache = { cwd, at: now, value: empty }; return empty; } const lines = String(result.stdout || "").split("\n").filter(Boolean); const header = lines.shift() || ""; const branch = header.replace(/^## /, "").split("...")[0] || fallbackBranch; const tracking = header.match(/\[ahead (\d+)(?:, behind (\d+))?\]|\[behind (\d+)\]/); const value: GitStatus = { branch, ahead: Number(tracking?.[1] || 0), behind: Number(tracking?.[2] || tracking?.[3] || 0), staged: 0, modified: 0, untracked: 0, }; for (const line of lines) { if (line.startsWith("??")) { value.untracked++; continue; } const index = line[0]; const worktree = line[1]; if (index && index !== " ") value.staged++; if (worktree && worktree !== " ") value.modified++; } gitStatusCache = { cwd, at: now, value }; return value; } catch { gitStatusCache = { cwd, at: now, value: empty }; return empty; } } function formatGitStatus(status: GitStatus): string { const parts = [status.branch || "detached"]; if (status.ahead) parts.push(`↑${status.ahead}`); if (status.behind) parts.push(`↓${status.behind}`); if (status.staged) parts.push(`+${status.staged}`); if (status.modified) parts.push(`~${status.modified}`); if (status.untracked) parts.push(`?${status.untracked}`); if (parts.length === 1) parts.push("clean"); return parts.join(" "); } interface QueueStatus { count: number; next: string; } /** Cached by file path so multiple concurrent sessions don't bleed. */ const queueStatusCache = new Map(); function getQueueStatus(sessionFile: string | undefined): QueueStatus { const file = queueFileFor(sessionFile); const now = Date.now(); const cached = queueStatusCache.get(file); if (cached && now - cached.at < 750) return cached.value; try { const parsed = JSON.parse(readFileSync(file, "utf8")); const items = Array.isArray(parsed) ? parsed : []; const value: QueueStatus = { count: items.length, next: items.length > 0 && typeof items[0]?.text === "string" ? items[0].text.replace(/[\r\n\t]+/g, " ").trim() : "", }; queueStatusCache.set(file, { at: now, value }); return value; } catch { const value: QueueStatus = { count: 0, next: "" }; queueStatusCache.set(file, { at: now, value }); return value; } } // ============================================================================ // Mode indicators (ponytail, etc.) // ============================================================================ /** * Detect active "modes" by inspecting ~/.pi/agent/settings.json + env. * Cheap — read once per render, file is small. Returns a list of short tags. * * ponytail: detected by: * 1. PONYTAIL env var (highest priority — explicit arm; any of lite/full/ultra) * 2. settings.json packages list contains "npm:@dietrichgebert/ponytail" or "ponytail" * → assume default level "full" (the documented default in the skill). * 3. PONYTAIL_DEFAULT_MODE env var — overrides the default when #2 fires. */ function detectPonytail(): { active: boolean; level: string } { const envLevel = process.env.PONYTAIL; if (envLevel && envLevel !== "off" && envLevel !== "0") { return { active: true, level: envLevel }; } try { if (existsSync(SETTINGS_PATH)) { const raw = JSON.parse(readFileSync(SETTINGS_PATH, "utf8")); const pkgs: string[] = raw?.packages ?? []; const hit = pkgs.some((p) => /ponytail/i.test(String(p))); if (hit) { // Package loaded but env unset — fall back to the skill's documented default. // Override with PONYTAIL_DEFAULT_MODE if set. const def = process.env.PONYTAIL_DEFAULT_MODE; const level = def && def !== "off" && def !== "0" ? def : "full"; return { active: true, level }; } } } catch { /* ignore */ } return { active: false, level: "" }; } // ============================================================================ // Tool-arg summarization (cheap preview for "running Bash …" etc.) // ============================================================================ /** Reduce a tool's args to a short one-line preview. Best-effort: unknown * shapes fall back to a JSON.stringify preview truncated to budget. */ // ponytail: `previewToolArgs` used to feed the `◐ Bash ` part of the // footer L3 run-state row. After moving L3's phase badge onto the top // border and dropping the rest of L3, this helper has no caller. // Recover from git if you ever restore a "current tool" indicator. // ============================================================================ // Renderer (builds the 3-line output) // ============================================================================ interface RunState { phase: "idle" | "running" | "retrying" | "compacting" | "error"; phaseLabel: string; phaseAt: number; currentToolName: string | null; currentToolArgs: unknown; currentToolStartedAt: number; lastToolEndedAt: number; lastToolWasError: boolean; turnStartedAt: number | null; errorMessage: string | null; } // ============================================================================ // StatusHub — single source of truth for session state. // ============================================================================ // // pi-hud used to keep `startedAt` / `toolCounts` / `runState` / `currentGoal` / // `retryAttempt` as module-level `let` bindings, mutated by event handlers and // read by renderLines. With the bordered editor added in this package, two // factories (footer + editor) both need the same snapshot — so the state // moved into a singleton with a publish/subscribe interface. Each factory // subscribes and calls `tui.requestRender()` on publish. // // `getInstance()` is lazy so the very first `session_start` (which fires // before any factory exists) still gets a usable hub. All mutators call // `publish()` so subscribers see consistent state. class StatusHub { private static _instance: StatusHub | undefined; static getInstance(): StatusHub { if (!StatusHub._instance) StatusHub._instance = new StatusHub(); return StatusHub._instance; } private startedAt: number = Date.now(); private toolCounts: Map = new Map(); private runState: RunState = { phase: "idle", phaseLabel: "idle", phaseAt: Date.now(), currentToolName: null, currentToolArgs: undefined, currentToolStartedAt: 0, lastToolName: null, lastToolEndedAt: 0, lastToolWasError: false, turnStartedAt: null, errorMessage: null, }; private retryAttempt: number = 0; private currentGoal: GoalLite | null = null; private subscribers: Set<() => void> = new Set(); subscribe(cb: () => void): () => void { this.subscribers.add(cb); return () => { this.subscribers.delete(cb); }; } publish(): void { for (const cb of this.subscribers) { try { cb(); } catch { /* swallow — one bad subscriber must not break others */ } } } /** Reset all session-scoped fields. Called on session_start. */ sessionStart(): void { this.startedAt = Date.now(); this.toolCounts = new Map(); this.runState = { phase: "idle", phaseLabel: "idle", phaseAt: Date.now(), currentToolName: null, currentToolArgs: undefined, currentToolStartedAt: 0, lastToolName: null, lastToolEndedAt: 0, lastToolWasError: false, turnStartedAt: null, errorMessage: null, }; this.retryAttempt = 0; this.currentGoal = null; this.publish(); } getStartedAt(): number { return this.startedAt; } getToolCounts(): Map { return this.toolCounts; } getRunState(): RunState { return this.runState; } getRetryAttempt(): number { return this.retryAttempt; } getCurrentGoal(): GoalLite | null { return this.currentGoal; } setPhase(phase: RunState["phase"], errorMessage: string | null = null): void { this.runState.phase = phase; this.runState.phaseAt = Date.now(); if (errorMessage) this.runState.errorMessage = errorMessage; this.publish(); } resetError(): void { if (this.runState.phase === "error") this.runState.phase = "idle"; this.runState.errorMessage = null; this.publish(); } clearTurn(): void { this.runState.turnStartedAt = null; if (this.runState.phase === "running") this.setPhase("idle"); this.publish(); } beginTurn(): void { this.runState.turnStartedAt = Date.now(); this.setPhase("running"); } toolStart(name: string, args: unknown): void { this.runState.currentToolName = name; this.runState.currentToolArgs = args; this.runState.currentToolStartedAt = Date.now(); this.toolCounts.set(name, (this.toolCounts.get(name) || 0) + 1); this.publish(); } toolEnd(name: string | undefined, isError: boolean): void { this.runState.lastToolName = name || this.runState.currentToolName; this.runState.lastToolEndedAt = Date.now(); this.runState.lastToolWasError = isError; this.runState.currentToolName = null; this.runState.currentToolArgs = undefined; this.runState.currentToolStartedAt = 0; this.publish(); } setRetryAttempt(n: number): void { this.retryAttempt = n; // Caller usually pairs this with setPhase; no extra publish. } setCurrentGoal(g: GoalLite | null): void { this.currentGoal = g; this.publish(); } } function thinkingTag(level: string | undefined, model: any): string | null { if (!level) return null; // "off" means no reasoning — show nothing (would be misleading). if (level === "off") return null; // Only show for reasoning-capable models (avoid noise). if (model && (model as any).reasoning === false) return null; return level; // full word: minimal / low / medium / high / xhigh / max } /** Format a phase indicator with a short token for inline use. */ function phaseBadge(phase: RunState["phase"], theme: any): string { switch (phase) { case "running": return theme.fg("accent", "● thinking"); case "retrying": return theme.fg("warning", "↻ retry"); case "compacting": return theme.fg("warning", "↻ compact"); case "error": return theme.fg("error", "✘ error"); default: return theme.fg("dim", "○ idle"); } } function renderLines( ctx: any, theme: any, branch: string, hub: StatusHub, width: number, extensionStatuses: ReadonlyMap, ): string[] { if (!config.enabled) return [""]; let tokens = 0; let win = 0; try { const u = ctx.getContextUsage(); tokens = u?.tokens || 0; win = u?.contextWindow || 0; } catch {} // Fallback chain for window size: // 1. ctx.getContextUsage().contextWindow (Pi runtime, if it knows the model) // 2. ctx.model.contextWindow (also Pi-side) // 3. config.contextWindowOverride (manual, for unregistered models like MiniMax-M3) // 4. 200_000 (legacy last-resort default) if (!win && ctx.model) { win = (ctx.model as any)?.contextWindow || 0; } const winFallbacked = !win && !!config.contextWindowOverride; if (!win) { win = config.contextWindowOverride || 200_000; } const pct = win ? Math.min(100, Math.round((tokens / win) * 100)) : 0; const stats = branchStats(ctx); const last = lastAssistantUsage(ctx); // ----- Single line: stats ----- // L1 (identity) moved to the editor's bottom border, L3 (run state) // lives on the editor's top border. Footer is now stats-only: queue // + ctx bar + cost + tokens + cache + turn cost. const l2: string[] = []; if (config.showQueue !== false) { const queue = getQueueStatus(ctx?.sessionManager?.getSessionFile?.()); const next = queue.next ? ` next:${truncateToWidth(queue.next, 24, "…")}` : ""; l2.push(theme.fg(queue.count > 0 ? "warning" : "muted", `Q${queue.count}${next}`)); l2.push(theme.fg("dim", "|")); } if (config.showContext) { const color = pctColor(pct, theme); const filled = Math.max(0, Math.min(10, Math.round((pct / 100) * 10))); const bar = "█".repeat(filled) + "░".repeat(10 - filled); const winLabel = winFallbacked ? `~${fmtN(win)}` : fmtN(win); l2.push(color(`ctx ${bar} ${pct}%`)); l2.push(theme.fg("dim", `${fmtN(tokens)}|${winLabel}`)); if (config.showCompactionCount !== false && stats.compactionCount > 0) { l2.push(theme.fg("muted", `|↻${stats.compactionCount}`)); } if (config.showCacheHit !== false && last && last.input + last.cacheRead + last.cacheWrite > 0) { const prompt = last.input + last.cacheRead + last.cacheWrite; const hit = prompt > 0 ? Math.round((last.cacheRead / prompt) * 100) : 0; l2.push(theme.fg("muted", `CH${hit}%`)); } } if (config.showCost) { l2.push(theme.fg("dim", "|")); l2.push(theme.fg("warning", fmtCost(stats.cost))); } l2.push(theme.fg("dim", `|↑${fmtN(stats.input)} ↓${fmtN(stats.output)}`)); if (last && (last as any).reasoning) { l2.push(theme.fg("dim", "|")); l2.push(theme.fg("muted", `🧠${fmtN((last as any).reasoning)}`)); } // Cache read / write — skipped when both are zero to avoid noise. if (stats.cacheRead > 0 || stats.cacheWrite > 0) { l2.push(theme.fg("dim", "|")); l2.push(theme.fg("muted", `↻ R${fmtN(stats.cacheRead)} W${fmtN(stats.cacheWrite)}`)); } if (config.showTurnCost && last) { l2.push(theme.fg("dim", "|")); l2.push(theme.fg("muted", `Δ${fmtCost(last.cost)}`)); } // Queue is intentionally near the start so the overall HUD width // truncation cannot leave a misleading tail of the prompt visible. const l2str = l2.length ? l2.join(" ") : ""; return [truncateToWidth(l2str, width)]; } // ============================================================================ // BorderedEditor — CustomEditor subclass that paints a status line on the // top border while delegating all input handling to super. // ============================================================================ // // Layout: calls super.render with width-2 so the inner editor lays text // out in the column count between the side borders. Then wraps each // content row with `│` and paints `┌…─┐` on top, `└…─┘` on bottom, with // status rows (phase / ponytail / goal on top, model / cwd / git / // duration on bottom) read from StatusHub + ctx. Scroll indicators on // top or bottom get replaced by the styled borders — ponytail: ok for // typical sessions, fix when scroll becomes a real workflow. // // handleInput forwards everything to super.handleInput so escape, Ctrl+D, // paste-image, model cycling, history navigation, slash commands, IME // composition (when Kitty keyboard protocol isn't forcing raw mode) all // keep working without per-key dispatch logic here. // // ponytail: editor border deliberately omits cost / tokens / turn stats — // those live in the footer (stats-only). Border carries identity + runtime // state, footer carries usage. The split keeps each surface readable on // narrow terminals where a single 3-line footer would overflow. class BorderedEditor extends CustomEditor { private hub: StatusHub; private unsubscribe: () => void; // Full Theme with .fg — the EditorTheme passed via super() only has // borderColor + selectList. ui.theme is a getter that returns the // current full theme, so we read it lazily each render. private ui: any; // pi passes ctx to setEditorComponent's factory closure but NOT to the // editor instance itself; we capture it once in the constructor so the // bottom border can read model / cwd / thinkingLevel without going // through pi internals. private ctx: any; constructor( tui: any, theme: any, keybindings: any, hub: StatusHub, ui: any, ctx: any, ) { super(tui, theme, keybindings, { paddingX: 0 }); this.ui = ui; this.hub = hub; this.ctx = ctx; // hub publishes on every state mutation; we forward to tui.requestRender. this.unsubscribe = hub.subscribe(() => tui.requestRender()); } override handleInput(data: string): void { super.handleInput(data); } override render(width: number): string[] { // Reserve 1 column on each side for `│` borders, plus 2 columns for // the inline prompt row (`❯ ` on the first line, ` ` on wrapped // lines so the text column stays aligned). super.render lays text // out in (width-4) columns. Too narrow → fall back to plain render. if (width < 6) return super.render(width); const innerWidth = width - 4; const inner = super.render(innerWidth); if (inner.length === 0) return inner; const top = formatTopBorder( this.hub, this.ctx, width, this.ui.theme, this.borderColor, ); const bottom = formatBottomBorder( this.ctx, this.hub, width, this.ui.theme, this.borderColor, ); if (inner.length === 1) return [top, bottom]; const middle = inner.slice(1, -1); // Inline prompt: the `❯` char anchors the first line of text like a // real shell prompt; wrapped lines pad with 2 spaces to keep the // text column aligned. ponytail: when the editor is scrolled, the // first VISIBLE line gets the `❯` — acceptable, scrolling a prompt // box is rare. const sideBordered = middle.map((line, idx) => { const lead = idx === 0 ? this.ui.theme.fg("accent", "❯ ") : " "; return this.borderColor("│") + lead + line + this.borderColor("│"); }); return [top, ...sideBordered, bottom]; } dispose(): void { this.unsubscribe(); } } function formatTopBorder( hub: StatusHub, ctx: any, width: number, theme: any, borderColor: (s: string) => string, ): string { const parts: string[] = []; // ponytail: the `❯` prompt char moved OFF this border and onto the // first content row (inline prompt) — it looked like an unanchored // decoration because the text starts at column 1 while `❯` sat at // column 4 here. Top border is now pure runtime state. if (ctx) { const provider = (ctx.model?.provider as string) || ""; const modelId = (ctx.model?.id as string) || "no-model"; const modelDisplay = provider ? `${provider}/${modelId}` : modelId; parts.push(theme.fg("dim", "π")); parts.push(theme.fg("accent", modelDisplay)); // Thinking level lives next to the model on the top border (user // moved it from bottom — bottom shouldn't carry anything that's // already on top). if (config.showThinking !== false) { const t = thinkingTag(ctx.thinkingLevel, ctx.model); if (t) parts.push(theme.fg("muted", t)); } } parts.push(phaseBadge(hub.getRunState().phase, theme)); const pony = detectPonytail(); if (pony.active) { parts.push(theme.fg("warning", `🐴:${pony.level}`)); } const goal = hub.getCurrentGoal(); if (goal && goal.status !== "complete" && goal.status !== "dropped") { const truncated = truncateObjective(goal.objective, 24); const tag = goal.status === "paused" ? theme.fg("muted", `⏸ ${truncated}`) : goal.status === "budget-limited" ? theme.fg("warning", `🎯 ${truncated} ⚠`) : theme.fg("accent", `🎯 ${truncated}`); parts.push(tag); } // -4 reserves room for: ╭ gap gap ╮ (or ╰ gap gap ╯ on bottom). // -CORNER_DASHES additionally reserves the dashes between each corner // and the text — 0 felt visually underweight when left-aligned, so the // user asked for "slightly longer" runs of `─` after each corner. const CORNER_DASHES = 2; const innerText = truncateToWidth( parts.join(" "), Math.max(0, width - 4 - CORNER_DASHES), ); const innerWidth = visibleWidth(innerText); const padRight = Math.max(0, width - innerWidth - 4 - CORNER_DASHES); // ponytail: text hugs the left edge of the box (was centered — looked // like a title, but it's actually a status row, so left-align reads as // "sitting on the rim" rather than "floating in the middle"). The fixed // CORNER_DASHES run gives the line visual definition without re-centering. return ( borderColor("╭" + "─".repeat(CORNER_DASHES) + " ") + innerText + borderColor(" " + "─".repeat(padRight) + "╮") ); } // Mirror of formatTopBorder — paints the identity line on the box's // bottom edge: cwd | git | duration. l1 used to live in the footer; // moving it here lets the footer shrink to stats-only and keeps the // box self-contained for "what session am I in". // // ponytail: model + thinking level intentionally live ONLY on the top // border. The user moved them up after seeing them duplicated on both // borders — keeping them on a single surface is cleaner than splitting // them across two. cwd / git / duration stay here because they're // per-session identity, not per-request runtime state. // // ponytail: skips 🐴:level and 🎯 goal — those already live on the top // border, so including them again would just be visual noise. // // ponytail: bottom border mirrors the top's structure — rounded `╰` // left, rounded `╯` right, text in the middle, trailing dashes filling // the gap. The user asked for this twice (once initially, then again // after briefly trying without trailing dashes / right corner and // reverting). ponytail notes the history in case it gets revisited. function formatBottomBorder( ctx: any, hub: StatusHub, width: number, theme: any, borderColor: (s: string) => string, ): string { const parts: string[] = []; const cwd = (ctx.cwd as string) || process.cwd(); // First section gets no leading separator; subsequent sections prepend // `|`. Previously the model display sat at the front with a hardcoded // leading `|` from the section that came after it — once model is // removed, the first section's separator has nothing to separate from. if (config.showCwd) { parts.push(theme.fg("dim", getCwdDisplay(cwd))); } if (config.showGit) { const git = getGitStatus(cwd, ""); if (git.branch) { if (parts.length > 0) parts.push(theme.fg("dim", "|")); parts.push(theme.fg("accent", formatGitStatus(git))); } } if (config.showDuration) { if (parts.length > 0) parts.push(theme.fg("dim", "|")); parts.push(theme.fg("dim", fmtDuration(Date.now() - hub.getStartedAt()))); } // Reserve 4 cols for `╰ gap gap ╯` — user reverted an earlier request // to drop the trailing dashes + right corner; the bottom border is // full-width with both corners, mirroring the top border's structure. // CORNER_DASHES adds the same `─` run after the left corner as the top // border — user wanted visual symmetry between top and bottom rows. const CORNER_DASHES = 2; const innerTextMaxWidth = Math.max(0, width - 4 - CORNER_DASHES); const innerText = parts.length ? truncateToWidth(parts.join(" "), innerTextMaxWidth) : ""; const innerWidth = visibleWidth(innerText); const padRight = Math.max(0, width - innerWidth - 4 - CORNER_DASHES); return ( borderColor("╰" + "─".repeat(CORNER_DASHES) + " ") + innerText + borderColor(" " + "─".repeat(padRight) + "╯") ); } // Re-export StatusHub for tests. pi's loader only consumes the default export; // the named export is harmless. export { StatusHub }; // ============================================================================ // Extension // ============================================================================ export default function (pi: ExtensionAPI) { const hub = StatusHub.getInstance(); let footerActive = false; let editorActive = false; // ponytail: `lastStatuses` stays per-factory — it comes from the // footerData argument pi passes to the footer factory and only the // footer reads it; the editor border doesn't need extension statuses. let lastStatuses: ReadonlyMap = new Map(); function enableFooter(ctx: any) { if (footerActive) return; footerActive = true; ctx.ui.setFooter((tui: any, theme: any, footerData: any) => { const unsubBranch = footerData.onBranchChange(() => tui.requestRender()); const unsubHub = hub.subscribe(() => tui.requestRender()); const tick = setInterval(() => { tui.requestRender(); applyPersistedGoal(ctx, hub); }, 1_000); let disposed = false; return { dispose() { if (disposed) return; disposed = true; unsubBranch(); unsubHub(); clearInterval(tick); footerActive = false; }, invalidate() {}, render(width: number): string[] { // Auto-shutoff if config changed to disabled under us if (!config.enabled) { ctx.ui.setFooter(undefined); disableEditor(ctx); return [""]; } let branch = ""; try { branch = footerData.getGitBranch?.() || ""; } catch {} try { lastStatuses = footerData.getExtensionStatuses?.() ?? new Map(); } catch { lastStatuses = new Map(); } return renderLines(ctx, theme, branch, hub, width, lastStatuses); }, }; }); } function enableEditor(ctx: any) { if (editorActive) return; editorActive = true; ctx.ui.setEditorComponent((tui: any, theme: any, keybindings: any) => { return new BorderedEditor(tui, theme, keybindings, hub, ctx.ui, ctx); }); } function disableFooter(ctx: any) { if (footerActive) { try { ctx.ui.setFooter(undefined); } catch {} footerActive = false; } disableEditor(ctx); } function disableEditor(ctx: any) { if (!editorActive) return; try { ctx.ui.setEditorComponent(undefined); } catch {} editorActive = false; } // ---- /hud command ---- pi.registerCommand("hud", { description: "Toggle pi-hud (use: /hud on|off|reload)", handler: async (args, ctx) => { const arg = (args || "").trim().toLowerCase(); if (arg === "on") { loadConfig(); config.enabled = true; enableFooter(ctx); ctx.ui.notify("pi-hud: on", "info"); } else if (arg === "off") { config.enabled = false; disableFooter(ctx); ctx.ui.notify("pi-hud: off", "info"); } else if (arg === "reload") { loadConfig(); if (config.enabled) enableFooter(ctx); else disableFooter(ctx); ctx.ui.notify( `pi-hud: config reloaded (enabled=${config.enabled})`, "info", ); } else { // No arg -> toggle config.enabled = !config.enabled; if (config.enabled) enableFooter(ctx); else disableFooter(ctx); ctx.ui.notify(`pi-hud: ${config.enabled ? "on" : "off"}`, "info"); } }, }); // ---- Event subscriptions ---- pi.on("session_start", async (_event, ctx) => { hub.sessionStart(); applyPersistedGoal(ctx, hub); loadConfig(); if (config.enabled) { enableFooter(ctx); enableEditor(ctx); } }); pi.on("session_shutdown", async (_event, ctx) => { disableFooter(ctx); }); pi.on("session_before_compact", async (_event, _ctx) => { hub.setPhase("compacting"); }); pi.on("session_compact", async (_event: any, _ctx) => { // Compaction finishes just before this fires; revert to idle (the // ongoing turn continues under `turn_start`, which will re-enter // running). fall back to idle if the reason was overflow / error. if (hub.getRunState().phase === "compacting") hub.setPhase("idle"); }); pi.on("agent_start", async (_event, _ctx) => { hub.setPhase("running"); hub.setRetryAttempt(0); hub.getRunState().errorMessage = null; }); pi.on("agent_end", async (_event: any, _ctx) => { // If we ended because of a provider error, the message's stopReason // is captured by `message_end` before agent_end fires; we trust the // earlier setPhase call there. const phase = hub.getRunState().phase; if (phase === "running" || phase === "retrying" || phase === "compacting") { hub.setPhase("idle"); } hub.getRunState().turnStartedAt = null; hub.getRunState().currentToolName = null; }); pi.on("turn_start", async (_event, _ctx) => { hub.beginTurn(); }); pi.on("turn_end", async (_event, ctx) => { hub.clearTurn(); applyPersistedGoal(ctx, hub); }); pi.on("after_provider_response", async (event: any, _ctx) => { if (event?.status === 429 || (event?.status && event.status >= 500)) { hub.setPhase("retrying"); } }); pi.on("message_end", async (event: any, _ctx) => { const m = event?.message; if (!m || m.role !== "assistant") return; if (m.stopReason === "error") { hub.setPhase("error", (m as any).errorMessage ?? "provider error"); } else if ( hub.getRunState().phase === "error" || hub.getRunState().phase === "retrying" ) { // Recovered: a fresh assistant message arrived without error. hub.resetError(); hub.setPhase("running"); } }); pi.on("tool_execution_start", async (event: any, _ctx) => { if (event?.toolName) { hub.toolStart(event.toolName as string, event.args ?? event.input); } }); pi.on("tool_execution_end", async (event: any, _ctx) => { hub.toolEnd(event?.toolName as string, !!event?.isError); }); // pi-goal: mirror the latest goal so the HUD can show it on line 1. // The host emits this on every create / resume / pause / drop / complete // and on every per-tool budget tick. `event.goal === null` means cleared. pi.on("goal_updated", async (event: any, _ctx) => { const g = event?.goal; const text = pickObjectiveText(g); hub.setCurrentGoal( text ? { objective: text, status: normalizeStatus(g?.status), tokenBudget: g?.tokenBudget, tokensUsed: g?.tokensUsed ?? 0, } : null, ); }); }