/** * session-status-bar.ts — Status bar component for the live session TUI. * * Shows animated spinner, agent type, description, elapsed time, * tool use count, and turn count in a single compact line. */ import { type Component, type TUI, truncateToWidth, } from "@mariozechner/pi-tui"; import type { AgentSessionEvent, SessionEventStream } from "../session-events.js"; import type { Theme } from "./agent-widget.js"; import { SPINNER } from "./agent-widget.js"; // ---- Constants ---- /** Tick interval for elapsed time updates (ms). */ const ELAPSED_TICK_MS = 1000; /** Spinner frame interval (ms). */ const SPINNER_TICK_MS = 200; // ---- StatusBar Component ---- /** * Status bar showing live agent session stats. */ export class SessionStatusBar implements Component { private spinnerIdx = 0; private elapsed = 0; private toolCount = 0; private turnCount = 0; private status: string = "running"; private startTime: number; private timerHandle: ReturnType | null = null; private spinnerHandle: ReturnType | null = null; private unsubscribe: (() => void) | null = null; private closed = false; constructor( private tui: TUI,events: SessionEventStream, private theme: Theme, private record: { type: string; description: string; createdAt?: number; startedAt?: number }, ) { this.startTime = record.startedAt ?? record.createdAt ?? Date.now(); this.startTimers(); this.unsubscribe = events.subscribe((event: AgentSessionEvent) => { if (this.closed) return; this.handleEvent(event); this.tui.requestRender(); }); } /** * Handle session events to update counters. */ private handleEvent(event: AgentSessionEvent): void { switch (event.type) { case "tool_start": this.toolCount++; break; case "turn_end": this.turnCount++; break; case "status": this.status = event.status; break; default: break; } } /** * Start timers for elapsed time and spinner animation. */ private startTimers(): void { if (this.timerHandle) clearInterval(this.timerHandle); if (this.spinnerHandle) clearInterval(this.spinnerHandle); this.timerHandle = setInterval(() => { if (this.closed) return; this.elapsed = Math.floor((Date.now() - this.startTime) / 1000); this.tui.requestRender(); }, ELAPSED_TICK_MS); this.spinnerHandle = setInterval(() => { if (this.closed) return; this.spinnerIdx = (this.spinnerIdx + 1) % SPINNER.length; this.tui.requestRender(); }, SPINNER_TICK_MS); } // ---- Component Interface ---- render(width: number): string[] { if (this.closed || width < 20) return []; const th = this.theme; const spinner = SPINNER[this.spinnerIdx]; // Status icon let statusIcon: string; let statusColor: string; switch (this.status) { case "running": statusIcon = spinner; statusColor = "accent"; break; case "completed": statusIcon = "✓"; statusColor = "success"; break; case "error": case "aborted": case "stopped": statusIcon = "✕"; statusColor = "error"; break; default: statusIcon = "◌"; statusColor = "dim"; break; } // Agent name + description const name = this.record.type; const desc = this.record.description ?? ""; // Stats const elapsedStr = this.formatElapsed(this.elapsed); const toolStr = `${this.toolCount} tool${this.toolCount === 1 ? "" : "s"}`; const turnStr = `${this.turnCount} turn${this.turnCount === 1 ? "" : "s"}`; // Build content const iconPart = th.fg(statusColor, statusIcon); const namePart = th.bold(name); const descPart = desc ? ` ${th.fg("muted", "·")} ${th.fg("dim", truncateToWidth(desc, Math.max(10, Math.floor(width * 0.3))))}` : ""; const statsPart = th.fg("dim", ` ${th.fg("dim", "·")} ${elapsedStr} ${th.fg("dim", "·")} ${toolStr} ${th.fg("dim", "·")} ${turnStr}`); const full = `${iconPart} ${namePart}${descPart}${statsPart}`; const padded = truncateToWidth(full, width); return [padded]; } handleInput(_data: string): void { // No input handling needed } invalidate(): void { // No cached state } dispose(): void { this.closed = true; if (this.timerHandle) { clearInterval(this.timerHandle); this.timerHandle = null; } if (this.spinnerHandle) { clearInterval(this.spinnerHandle); this.spinnerHandle = null; } if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; } } // ---- Private ---- /** * Format elapsed seconds into a human-readable duration string. */ private formatElapsed(seconds: number): string { if (seconds < 60) return `${seconds}s`; const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}m ${s}s`; } }