import * as fs from "node:fs"; import { matchesKey } from "@earendil-works/pi-tui"; import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts"; import type { TeamRunManifest } from "../state/types.ts"; import { resolveRealContainedPath } from "../utils/safe-paths.ts"; import { truncate, truncateToVisualLines, truncateToVisualLinesTail } from "../utils/visual.ts"; import type { InteractiveComponent } from "./component.ts"; import { canopyLine, formatHint, overflowHint, RAIL, railLine } from "./rail.ts"; import { renderDiff } from "./render-diff.ts"; import { colorForStatus, iconForStatus, type RunStatus } from "./status-colors.ts"; import { highlightCode, highlightJson } from "./syntax-highlight.ts"; import type { CrewTheme } from "./theme-adapter.ts"; import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts"; import { DEFAULT_TRANSCRIPT_TAIL_BYTES, getTranscriptCacheEntry, readTranscriptLinesCached } from "./transcript-cache.ts"; // PR-G3 (UI-3): alias the shared InteractiveComponent contract (render + // invalidate + handleInput). Structurally identical to the previous local // declaration — no behavior change. DurableTextViewer / DurableTranscriptViewer // also have an extra `dispose()`, which is allowed (superset). type Component = InteractiveComponent; type TranscriptTheme = CrewTheme; interface ReadRunTranscriptResult { title: string; path: string; lines: string[]; bytesRead: number; size: number; truncated: boolean; } // FIND-06: short-TTL cache keyed by (runId, taskId, readOptions) so the ~500ms // render tick of `DurableTranscriptViewer` does not re-stat / re-read / re-parse // the underlying transcript on every call. The deeper `transcriptCache` only // reuses parsed lines when mtime/size are stable; during an actively-written // run, the file's mtime changes on every tick and the inner cache is // invalidated. This outer cache caps the per-tick re-parse cost at one parse // per ~500ms even while the file is growing. const READ_RUN_TRANSCRIPT_TTL_MS = 500; const READ_RUN_TRANSCRIPT_CACHE_MAX = 32; interface ReadRunTranscriptCacheEntry { parsedAt: number; result: ReadRunTranscriptResult; } const readRunTranscriptCache = new Map(); function readRunTranscriptCacheKey( manifest: TeamRunManifest, taskId: string | undefined, readOptions: { full: boolean; maxTailBytes: number }, ): string { return `${manifest.runId}|${taskId ?? ""}|${readOptions.full ? "full" : "tail"}|${readOptions.maxTailBytes}`; } function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } function textFromContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((part) => { const obj = asRecord(part); if (!obj) return ""; if (typeof obj.text === "string") return obj.text; if (typeof obj.content === "string") return obj.content; if (typeof obj.name === "string") return `[tool:${obj.name}]`; return ""; }) .filter(Boolean) .join("\n"); } function isLikelyDiff(text: string): boolean { const lines = text.split(/\r?\n/); const matched = lines.filter((line) => /^[-+\s]\d+\s/.test(line)).length; return matched >= 2 && (text.includes("-") || text.includes("+")); } function highlightCodeBlocks(input: string, theme: TranscriptTheme): string[] { const codeBlockRegex = /```(\S+)?\n([\s\S]*?)```/g; const lines: string[] = []; let index = 0; let match: RegExpExecArray | null; while ((match = codeBlockRegex.exec(input)) !== null) { if (match.index > index) lines.push(...input.slice(index, match.index).split(/\r?\n/)); const lang = match[1]?.trim(); const block = match[2] ?? ""; const highlighted = highlightCode(block, lang, theme); if (highlighted) { lines.push(...highlighted.split(/\r?\n/)); } index = match.index + match[0].length; } if (index < input.length) lines.push(...input.slice(index).split(/\r?\n/)); return lines.filter((line) => line.length > 0); } export function formatTranscriptEvent(event: unknown, themeLike: unknown = undefined): string[] { const theme = asCrewTheme(themeLike); const obj = asRecord(event); if (!obj) return [String(event)]; const type = typeof obj.type === "string" ? obj.type : undefined; const toolName = typeof obj.toolName === "string" ? obj.toolName : typeof obj.name === "string" ? obj.name : undefined; const content = textFromContent(obj.content); if (type && /tool/i.test(type)) { const result = asRecord(obj.result); const isError = obj.isError === true || result?.isError === true; const isPartial = obj.isPartial === true; const status: RunStatus = isError ? "failed" : isPartial ? "running" : "completed"; const header = theme.fg( colorForStatus(status), `${iconForStatus(status, { runningGlyph: "⋯" })} [Tool${toolName ? `: ${toolName}` : ""}] ${type}`, ); const text = (content || (typeof obj.text === "string" ? obj.text : typeof obj.result === "string" ? obj.result : "")).trim(); if (!text) return [header, "(no output)"]; if (isLikelyDiff(text)) { return [header, renderDiff(text, { theme })]; } if (text.startsWith("{") && text.endsWith("}")) { return [header, ...highlightJson(text, theme).split(/\r?\n/).filter(Boolean)]; } if (text.includes("```") && text.includes("```")) { return [header, ...highlightCodeBlocks(text, theme)]; } return [ header, ...text .split(/\r?\n/) .filter(Boolean) .map((line) => theme.fg("muted", line)), ]; } const message = asRecord(obj.message); if (message) { const role = typeof message.role === "string" ? message.role : "message"; const text = textFromContent(message.content); if (text.trim()) { const label = role === "assistant" ? "Assistant" : role === "user" ? "User" : role; const header = `[${label}]:`; const lines = text.split(/\r?\n/); if (text.includes("```") && text.includes("```")) { return [theme.fg("accent", header), ...highlightCodeBlocks(text, theme)]; } if (lines.length > 1) { const block = lines.map((line) => (role === "assistant" ? theme.bold(line) : line)).join("\n"); return [theme.fg("accent", header), ...block.split(/\r?\n/).filter(Boolean)]; } return [theme.fg("accent", header), ...lines.filter(Boolean)]; } } if (type) { const text = content || (typeof obj.text === "string" ? obj.text : ""); return text.trim() ? [theme.fg("muted", `[${type}]: ${text.trim()}`)] : [`[${type}]`]; } return [JSON.stringify(event)]; } export function formatTranscriptText(text: string, themeLike: unknown = undefined): string[] { const lines: string[] = []; for (const raw of text.split(/\r?\n/).filter(Boolean)) { try { const parsed = JSON.parse(raw); lines.push(...formatTranscriptEvent(parsed, themeLike)); } catch { lines.push(raw); } } return lines.length ? lines : ["(no transcript content)"]; } export function readRunTranscript( manifest: TeamRunManifest, taskId?: string, options: { full?: boolean; maxTailBytes?: number } = {}, ): ReadRunTranscriptResult { const now = Date.now(); const readOptions = { full: options.full === true, maxTailBytes: options.maxTailBytes ?? DEFAULT_TRANSCRIPT_TAIL_BYTES, }; const cacheKey = readRunTranscriptCacheKey(manifest, taskId, readOptions); const cached = readRunTranscriptCache.get(cacheKey); if (cached && now - cached.parsedAt < READ_RUN_TRANSCRIPT_TTL_MS) { return cached.result; } const agents = readCrewAgents(manifest); const agent = taskId ? agents.find((item) => item.taskId === taskId || item.id === taskId) : (agents.find((item) => item.transcriptPath) ?? agents[0]); const selectedTaskId = agent?.taskId ?? taskId ?? "unknown"; let transcriptPath = ""; try { transcriptPath = agentOutputPath(manifest, selectedTaskId); } catch { try { transcriptPath = agentOutputPath(manifest, "unknown"); } catch { // Both fallbacks failed — transcript will be empty. transcriptPath = ""; } } if (agent?.transcriptPath) { try { const safeTranscriptPath = resolveRealContainedPath(manifest.artifactsRoot, agent.transcriptPath); if (fs.existsSync(safeTranscriptPath)) transcriptPath = safeTranscriptPath; } catch { // Ignore untrusted transcript paths from mutable agent state and fall back to durable agent output. } } const lines = readTranscriptLinesCached(transcriptPath, (text) => formatTranscriptText(text), now, readOptions); const entry = getTranscriptCacheEntry(transcriptPath, readOptions); const result: ReadRunTranscriptResult = { title: `${manifest.runId}:${selectedTaskId}`, path: transcriptPath, lines: lines.length ? lines : ["(no transcript content)"], bytesRead: entry?.bytesRead ?? 0, size: entry?.size ?? 0, truncated: entry?.truncated ?? false, }; // Map preserves insertion order; the first key is the oldest entry. Evict // before insert so the cache stays at most READ_RUN_TRANSCRIPT_CACHE_MAX. while (readRunTranscriptCache.size >= READ_RUN_TRANSCRIPT_CACHE_MAX) { const oldest = readRunTranscriptCache.keys().next().value; if (oldest === undefined) break; readRunTranscriptCache.delete(oldest); } readRunTranscriptCache.set(cacheKey, { parsedAt: now, result }); return result; } interface ViewerState { theme: TranscriptTheme; autoScroll: boolean; lastHeight: number; scroll: number; // PERF (2026-08-24): full-wrap cache for the scroll-up path. ViewerState is // built per render, so these are per-render scratch: the tail branch is the // hot path (bottom-pinned ~500ms re-render) and never needs the full wrap; // renders that fall to the else branch pay one full wrap each — the same // price the pre-optimization code paid on EVERY render. fullVisual: string[] | null; sourceLen: number; } /** * Overlay footer hints in the ONE rail hint format (`formatHint`): keys `label` * pairs joined by ` · `, close action LAST, `Esc` spelled via `keyToken`. * The bound keys/actions are unchanged — `handleInput` still reads j/k, PgUp/ * PgDn, g/G, a, f and q/Esc exactly as before. */ const VIEWER_HINTS = formatHint([ [["j", "k"], "scroll"], [["pageup", "pagedown"], "page"], ["g/G", "top/bottom"], ["a", "auto"], ["f", "full/tail"], [["q", "escape"], "close"], ]); function renderViewerBase(state: ViewerState, width: number, lines: string[], title: string, subtitle: string): string[] { const inner = Math.max(20, width - 4); // PERF (2026-08-24): transcripts grow to thousands of lines; wrapping every // grapheme of the whole tail to display 16 rows was the largest CPU sink in // the UI. Bottom-pinned (autoScroll) renders use the tail window — O(visible // rows); scrolling up pays one full wrap per render (what the old code paid // on EVERY render) and can page through the whole transcript. const bodyText = lines.join("\n"); let visualLines: string[]; let skippedCount: number; let maxScroll: number; let tailWindow = false; if (state.autoScroll && (state.fullVisual === null || state.sourceLen !== lines.length)) { const tail = truncateToVisualLinesTail(bodyText, state.lastHeight, inner); visualLines = tail.visualLines; skippedCount = tail.skippedCount; maxScroll = skippedCount; // bottom-pinned: everything above the window state.fullVisual = null; state.sourceLen = lines.length; tailWindow = true; } else { if (state.fullVisual === null || state.sourceLen !== lines.length) { // MAX_SAFE_INTEGER is never a real allocation: truncateToVisualLines // returns early while visualLines.length <= limit (always true), so // slice(-limit) never runs — this is just "wrap everything". const full = truncateToVisualLines(bodyText, Number.MAX_SAFE_INTEGER, inner); state.fullVisual = full.visualLines; state.sourceLen = lines.length; } visualLines = state.fullVisual!; skippedCount = 0; maxScroll = Math.max(0, visualLines.length - state.lastHeight); } if (state.autoScroll) state.scroll = maxScroll; state.scroll = Math.min(state.scroll, maxScroll); // The tail window IS the bottom-pinned view: state.scroll holds the offset // into the FULL wrap (skippedCount lines above), so slicing the ≤ lastHeight // window at it would render an empty body. Display the window directly. const visible = tailWindow ? visualLines : visualLines.slice(state.scroll, state.scroll + state.lastHeight); // Tail-branch total is a lower bound: skippedCount counts SOURCE lines and // each may wrap to several visual lines. "≥" flags that (short transcripts // and the full-wrap branch keep the exact count). const totalLines = tailWindow ? skippedCount + visible.length : visualLines.length; const totalLabel = tailWindow && skippedCount > 0 ? `≥ ${totalLines}` : `${totalLines}`; // L-plural (2026-10-08): "1 lines" grammar slip live-caught in the transcript // viewer footer — pluralize only from 2 (and keep "≥" prefix intact). const linesWord = totalLines === 1 ? "line" : "lines"; const statusLine = `${totalLabel} ${linesWord} · ${totalLines ? Math.round(((state.scroll + visible.length) / totalLines) * 100) : 100}% · auto-scroll ${state.autoScroll ? "on" : "off"}`; // RAIL frame (design system §2.E): `┏ NAME ▸ SUBJECT` canopy, `┃` body rows, // `┗ ` close. The rounded `╭─╮│╰─╯` box is retired. This // viewer is a standalone overlay that paints its own frame, so the rail // glyphs are correct here (dashboard PANES must not draw them). const budget = Math.max(18, width - 2); const row = (text: string) => railLine(RAIL.body, "border", text, state.theme, budget); const linesOut: string[] = [ canopyLine({ word: "TRANSCRIPT", subject: title, theme: state.theme, budget, right: subtitle }), row(state.theme.fg("dim", VIEWER_HINTS)), ]; if (skippedCount > 0) { // ONE overflow dialect (`▲ n above` / `▼ m below`) instead of the legacy // `… (n lines truncated above` (which also never closed its paren). linesOut.push(row(state.theme.fg("muted", overflowHint(skippedCount, 0, state.theme)))); } for (const visibleLine of visible) linesOut.push(row(visibleLine)); linesOut.push(railLine(RAIL.close, "border", state.theme.fg("dim", statusLine), state.theme, budget)); return linesOut.map((line) => truncate(line, width)); } export class DurableTextViewer implements Component { private scroll = 0; private lastHeight = 16; private autoScroll = true; // PERF (2026-08-24): whether the PREVIOUS render was bottom-pinned. The // tail branch tracks `scroll` in source-line units, which is not a valid // index into the full wrap — the first manual render after a pinned one // clamps to the exact visual bottom instead of slicing at that stale value. private prevAutoScroll = true; private title: string; private subtitle: string; private lines: string[]; private theme: TranscriptTheme; private done: (result: undefined) => void; private readonly unsubscribeTheme: () => void; constructor(title: string, subtitle: string, lines: string[], theme: unknown, done: (result: undefined) => void) { this.title = title; this.subtitle = subtitle; this.lines = lines.length ? lines : ["(empty)"]; this.theme = asCrewTheme(theme); this.done = done; this.unsubscribeTheme = subscribeThemeChange(theme, () => this.invalidate()); } invalidate(): void { /* no-op */ } dispose(): void { this.unsubscribeTheme(); } handleInput(data: string): void { if (matchesKey(data, "q") || matchesKey(data, "escape")) { this.done(undefined); return; } const maxScroll = Math.max(0, this.lines.length - this.lastHeight); if (data === "k" || data === "\u001b[A") { this.scroll = Math.max(0, this.scroll - 1); this.autoScroll = false; } else if (data === "j" || data === "\u001b[B") { this.scroll = Math.min(maxScroll, this.scroll + 1); this.autoScroll = this.scroll >= maxScroll; } else if (data === "\u001b[5~") { this.scroll = Math.max(0, this.scroll - this.lastHeight); this.autoScroll = false; } else if (data === "\u001b[6~") { this.scroll = Math.min(maxScroll, this.scroll + this.lastHeight); this.autoScroll = this.scroll >= maxScroll; } else if (data === "g" || data === "\u001b[H") { this.scroll = 0; this.autoScroll = false; this.prevAutoScroll = false; // explicit top — do not pin to bottom } else if (data === "G" || data === "\u001b[F") { this.scroll = maxScroll; this.autoScroll = true; } else if (data === "a") { this.autoScroll = !this.autoScroll; } } render(width: number): string[] { // Leaving the pinned state (k/PgUp/a): the stale source-unit scroll // would slice the full wrap in the wrong place — clamp to its bottom. if (!this.autoScroll && this.prevAutoScroll) this.scroll = Number.MAX_SAFE_INTEGER; const state: ViewerState = { theme: this.theme, autoScroll: this.autoScroll, lastHeight: this.lastHeight, scroll: this.scroll, fullVisual: null, sourceLen: 0, }; const rendered = renderViewerBase(state, width, this.lines, this.title, this.subtitle); // Write the clamped/updated scroll back so handleInput's scroll math // starts from the position the user actually saw. this.scroll = state.scroll; this.prevAutoScroll = this.autoScroll; return rendered; } } export class DurableTranscriptViewer implements Component { private scroll = 0; private lastHeight = 16; private autoScroll = true; // See DurableTextViewer.prevAutoScroll — same pinned→manual transition guard. private prevAutoScroll = true; private manifest: TeamRunManifest; private theme: TranscriptTheme; private done: (result: undefined) => void; private taskId?: string; private fullTranscript = false; private maxTailBytes: number; private readonly unsubscribeTheme: () => void; // UI-9: transcript content is read once when the viewer opens and then // reused for per-keypress scroll math, so handleInput no longer performs a // synchronous fs read on every keystroke. The cache is refreshed on the // explicit full/tail toggle ("f") and kept fresh by the periodic render() // call (which still reads, TTL-cached at ~500ms). `readTranscript` defaults // to the module function and is overridable for tests. private readTranscript: typeof readRunTranscript; private cached: ReadRunTranscriptResult; constructor( manifest: TeamRunManifest, theme: unknown, done: (result: undefined) => void, taskId?: string, options: { maxTailBytes?: number; readTranscript?: typeof readRunTranscript } = {}, ) { this.manifest = manifest; this.theme = asCrewTheme(theme); this.done = done; this.taskId = taskId; this.maxTailBytes = options.maxTailBytes ?? DEFAULT_TRANSCRIPT_TAIL_BYTES; this.readTranscript = options.readTranscript ?? readRunTranscript; this.unsubscribeTheme = subscribeThemeChange(theme, () => this.invalidate()); this.cached = this.readTranscript(this.manifest, this.taskId, { full: this.fullTranscript, maxTailBytes: this.maxTailBytes, }); } invalidate(): void { /* no-op */ } dispose(): void { this.unsubscribeTheme(); } handleInput(data: string): void { if (matchesKey(data, "q") || matchesKey(data, "escape")) { this.done(undefined); return; } const content = this.cached.lines; const maxScroll = Math.max(0, content.length - this.lastHeight); if (data === "k" || data === "\u001b[A") { this.scroll = Math.max(0, this.scroll - 1); this.autoScroll = false; } else if (data === "j" || data === "\u001b[B") { this.scroll = Math.min(maxScroll, this.scroll + 1); this.autoScroll = this.scroll >= maxScroll; } else if (data === "\u001b[5~") { this.scroll = Math.max(0, this.scroll - this.lastHeight); this.autoScroll = false; } else if (data === "\u001b[6~") { this.scroll = Math.min(maxScroll, this.scroll + this.lastHeight); this.autoScroll = this.scroll >= maxScroll; } else if (data === "g" || data === "\u001b[H") { this.scroll = 0; this.autoScroll = false; this.prevAutoScroll = false; // explicit top — do not pin to bottom } else if (data === "G" || data === "\u001b[F") { this.scroll = maxScroll; this.autoScroll = true; } else if (data === "a") { this.autoScroll = !this.autoScroll; } else if (data === "f") { this.fullTranscript = !this.fullTranscript; this.scroll = 0; this.autoScroll = !this.fullTranscript; this.prevAutoScroll = this.autoScroll; // explicit reset — no bottom pin // The full/tail toggle changes the read options, so refresh the cache // with a single explicit read rather than per keystroke. this.cached = this.readTranscript(this.manifest, this.taskId, { full: this.fullTranscript, maxTailBytes: this.maxTailBytes, }); } } render(width: number): string[] { const data = this.readTranscript(this.manifest, this.taskId, { full: this.fullTranscript, maxTailBytes: this.maxTailBytes, }); // Keep the per-keypress cache in sync with the latest rendered content // (the read is TTL-cached at ~500ms, so this stays cheap on each tick). this.cached = data; // Leaving the pinned state (k/PgUp/a): the stale source-unit scroll // would slice the full wrap in the wrong place — clamp to its bottom. if (!this.autoScroll && this.prevAutoScroll) this.scroll = Number.MAX_SAFE_INTEGER; const state: ViewerState = { theme: this.theme, autoScroll: this.autoScroll, lastHeight: this.lastHeight, scroll: this.scroll, fullVisual: null, sourceLen: 0, }; const rendered = renderViewerBase( state, width, data.lines, "pi-crew transcript", `${data.title} · ${data.truncated ? `tail ${Math.round(data.bytesRead / 1024)}KB/${Math.round(data.size / 1024)}KB` : `full ${Math.round(data.size / 1024)}KB`} · f ${this.fullTranscript ? "tail" : "full"}`, ); // Write the clamped/updated scroll back (see DurableTextViewer.render). this.scroll = state.scroll; this.prevAutoScroll = this.autoScroll; return rendered; } }