/** * session-stream-pane.ts — Streaming token renderer component. * * Renders a live-updating conversation stream from session events, * with word wrapping, scrollback, and auto-scroll behaviour. * Follows the same patterns as ConversationViewer. */ import { type Component, matchesKey, type TUI, truncateToWidth, wrapTextWithAnsi, } from "@mariozechner/pi-tui"; import type { AgentSessionEvent, SessionEventStream } from "../session-events.js"; import type { Theme } from "./agent-widget.js"; /** Minimum viewport lines for meaningful rendering. */ const MIN_VIEWPORT = 5; /** * Accumulated message entry for rendering. */ interface DisplayEntry { role: "user" | "assistant" | "tool" | "status" | "separator"; text: string; } /** * Streaming conversation pane with scrollback. */ export class SessionStreamPane implements Component { private entries: DisplayEntry[] = []; private scrollOffset = 0; private autoScroll = true; private unsubscribe: (() => void) | null = null; private lastInnerW = 0; private closed = false; constructor( private tui: TUI,events: SessionEventStream, private theme: Theme,agentInfo: { type: string; description: string }, ) { this.unsubscribe = events.subscribe((event: AgentSessionEvent) => { if (this.closed) return; this.handleEvent(event); this.tui.requestRender(); }); // Initial status message this.entries.push({ role: "status", text: `◉ ${agentInfo.type} — ${agentInfo.description}`, }); } /** * Handle an incoming session event and update display entries. */ private handleEvent(event: AgentSessionEvent): void { switch (event.type) { case "token": // Append to last assistant entry or create one this.appendToLastAssistant(event.text); break; case "tool_start": this.entries.push({ role: "tool", text: `╭─ Tool: ${event.toolName} ─────────────────────────────╮`, }); if (event.args) { const argsText = typeof event.args === "string" ? event.args : JSON.stringify(event.args).slice(0, 200); this.entries.push({ role: "tool", text: `│ ${argsText}` }); } break; case "tool_end": this.entries.push({ role: "tool", text: `│ ═ Result: ${event.toolName}` }); if (event.output) { const out = event.output.length > 400 ? event.output.slice(0, 400) + "..." : event.output; this.entries.push({ role: "tool", text: `│ ${out}` }); } this.entries.push({ role: "tool", text: "╰─────────────────────────────────────────────────╯", }); break; case "message": // Full message sync — handled by token/tool events above break; case "status": this.entries.push({ role: "status", text: `◆ Status: ${event.status}`, }); break; case "error": this.entries.push({ role: "status", text: `✕ Error: ${event.error.message}`, }); break; case "turn_end": this.entries.push({ role: "separator", text: "" }); break; default: break; } } /** * Append text to the last assistant entry, or create a new one. */ private appendToLastAssistant(text: string): void { // Check if last entry is assistant — merge text if (this.entries.length > 0) { const last = this.entries[this.entries.length - 1]; if (last.role === "assistant") { last.text += text; return; } } this.entries.push({ role: "assistant", text }); } // ---- Component Interface ---- render(width: number): string[] { if (this.closed) return []; if (width < 6) return []; // too narrow const _th = this.theme; const innerW = Math.max(1, width - 4); this.lastInnerW = innerW; const contentLines = this.buildContentLines(innerW); const viewportHeight = this.viewportHeight(); const maxScroll = Math.max(0, contentLines.length - viewportHeight); if (this.autoScroll) { this.scrollOffset = maxScroll; } const visibleStart = Math.min(this.scrollOffset, maxScroll); const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight); const lines: string[] = []; for (let i = 0; i < viewportHeight; i++) { lines.push(visible[i] ?? ""); } return lines; } handleInput(data: string): void { if (this.closed) return; const totalLines = this.buildContentLines(this.lastInnerW).length; const viewportHeight = this.viewportHeight(); const maxScroll = Math.max(0, totalLines - viewportHeight); if (matchesKey(data, "up")) { this.scrollOffset = Math.max(0, this.scrollOffset - 1); this.autoScroll = this.scrollOffset >= maxScroll; } else if (matchesKey(data, "down")) { this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1); this.autoScroll = this.scrollOffset >= maxScroll; } else if (matchesKey(data, "pageUp")) { this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight); this.autoScroll = false; } else if (matchesKey(data, "pageDown")) { this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight); this.autoScroll = this.scrollOffset >= maxScroll; } else if (matchesKey(data, "home")) { this.scrollOffset = 0; this.autoScroll = false; } else if (matchesKey(data, "end")) { this.scrollOffset = maxScroll; this.autoScroll = true; } } invalidate(): void { // No cached state to clear } dispose(): void { this.closed = true; if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; } this.entries = []; } // ---- Private ---- private viewportHeight(): number { return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - 8); } /** * Build rendered content lines from accumulated entries. */ private buildContentLines(width: number): string[] { if (width <= 0) return []; const th = this.theme; const lines: string[] = []; if (this.entries.length <= 1) { lines.push(th.fg("dim", "(waiting for agent output...)")); return lines; } for (let i = 1; i < this.entries.length; i++) { const entry = this.entries[i]; switch (entry.role) { case "user": lines.push(th.fg("accent", "[User]")); for (const line of wrapTextWithAnsi(entry.text, width)) { lines.push(line); } break; case "assistant": for (const line of wrapTextWithAnsi(entry.text, width)) { lines.push(line); } break; case "tool": { // Dimmed tool call rendering const isBorder = entry.text.startsWith("╭") || entry.text.startsWith("╰"); const isEnd = entry.text.startsWith("╰") || entry.text.startsWith("│ ═"); if (isBorder || isEnd) { lines.push(th.fg("muted", truncateToWidth(entry.text, width))); } else { lines.push(th.fg("dim", truncateToWidth(entry.text, width))); } break; } case "status": lines.push(th.fg("muted", truncateToWidth(entry.text, width))); break; case "separator": // Thin separator for turn boundaries break; } } // Streaming indicator if (this.entries.length > 0) { const last = this.entries[this.entries.length - 1]; if (last.role === "assistant" && !last.text.endsWith("\n")) { lines.push(th.fg("accent", "▍ ") + th.fg("dim", "streaming...")); } } return lines.map(l => truncateToWidth(l, width)); } }