diff --git a/extensions/shared/agent-session-page.test.ts b/extensions/shared/agent-session-page.test.ts new file mode 100644 index 00000000..1041d408 --- /dev/null +++ b/extensions/shared/agent-session-page.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { + KeybindingsManager, + Theme, +} from "@earendil-works/pi-coding-agent"; +import { initTheme } from "@earendil-works/pi-coding-agent"; +import { visibleWidth, type TUI } from "@earendil-works/pi-tui"; +import { + AgentSessionPage, + type AgentSessionPageState, +} from "./agent-session-page.ts"; + +initTheme("dark", false); + +const theme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, +} as Theme; + +const keybindings = { + matches(data: string, binding: string) { + return data === binding.replace("tui.editor.cursor", "").toLowerCase(); + }, + getKeys(binding: string) { + return [binding.split(".").at(-1)?.toLowerCase() ?? binding]; + }, +} as unknown as KeybindingsManager; + +function tui(rows: number) { + return { terminal: { rows }, requestRender() {} } as unknown as TUI; +} + +function state(): AgentSessionPageState { + return { + id: "child-1", + title: "child session", + status: "running", + metadata: ["model", "12%/100k"], + document: { + items: [ + { kind: "user", text: "Inspect the page" }, + { kind: "assistant", parts: [{ type: "text", text: "## Result" }] }, + ], + }, + }; +} + +test("writable and read-only children use one full-terminal page", () => { + const direct = new AgentSessionPage(tui(18), theme, keybindings, { + getState: state, + close() {}, + send() {}, + }); + const workflow = new AgentSessionPage(tui(18), theme, keybindings, { + getState: state, + close() {}, + }); + + const directLines = direct.render(60); + const workflowLines = workflow.render(60); + for (const lines of [directLines, workflowLines]) { + assert.equal(lines.length, 18); + assert.ok(lines.every((line) => visibleWidth(line) <= 60)); + assert.match(lines.join("\n"), /> Inspect the page/); + assert.match(lines.join("\n"), /Result/); + assert.doesNotMatch(lines.join("\n"), /╭|╮|Transcript/); + } +}); + +test("a read-only Workflow child returns left without stealing a parent session", () => { + let closed = 0; + const page = new AgentSessionPage(tui(18), theme, keybindings, { + getState: state, + close: () => { + closed += 1; + }, + }); + + page.handleInput("left"); + assert.equal(closed, 1); +}); diff --git a/extensions/shared/agent-session-page.ts b/extensions/shared/agent-session-page.ts new file mode 100644 index 00000000..bc0b8b0b --- /dev/null +++ b/extensions/shared/agent-session-page.ts @@ -0,0 +1,328 @@ +import type { + KeybindingsManager, + Theme, +} from "@earendil-works/pi-coding-agent"; +import type { Component, Focusable, TUI } from "@earendil-works/pi-tui"; +import { + Input, + Key, + matchesKey, + truncateToWidth, + visibleWidth, +} from "@earendil-works/pi-tui"; +import { + AgentTranscriptRenderer, + type AgentTranscriptDocument, +} from "./agent-transcript.ts"; +import { hintLine, type ScreenHint } from "./screen-chrome.ts"; +import { spinnerFrame } from "./spinner.ts"; +import { sanitizeTerminalText } from "./terminal-text.ts"; +import { TranscriptViewport } from "./transcript-viewport.ts"; + +const SCROLL_STEP = 6; + +export type AgentSessionPageStatus = "running" | "done" | "error" | "uncertain"; + +export interface AgentSessionPageState { + readonly id: string; + readonly title: string; + readonly status: AgentSessionPageStatus; + readonly document: AgentTranscriptDocument; + readonly metadata?: ReadonlyArray; + readonly errorText?: string; + readonly emptyText?: string; +} + +export interface AgentSessionPageSource { + getState(): AgentSessionPageState | undefined; + close(): void; + send?(text: string): void; + abort?(): void; +} + +function safeLine(value: string) { + return sanitizeTerminalText(value).replace(/\s+/g, " ").trim(); +} + +function configuredKeys( + keybindings: KeybindingsManager, + binding: Parameters[0], +) { + return keybindings.getKeys(binding).join("/") || "unbound"; +} + +function stateGlyph(state: AgentSessionPageState, theme: Theme, now: number) { + switch (state.status) { + case "running": + return theme.fg("warning", spinnerFrame(now)); + case "done": + return theme.fg("success", "✓"); + case "error": + return theme.fg("error", "✗"); + case "uncertain": + return theme.fg("warning", "?"); + } +} + +/** + * One full-screen child-session page shared by Direct and Workflow adapters. + * The source owns lifecycle facts; this module owns only page rendering, + * reading position, optional input, and navigation back to the parent view. + */ +export class AgentSessionPage implements Component, Focusable { + private tui: TUI; + private theme: Theme; + private keybindings: KeybindingsManager; + private source: AgentSessionPageSource; + private input = new Input(); + private renderer = new AgentTranscriptRenderer(); + private viewport = new TranscriptViewport(); + private rowCount = 0; + private viewportSize = 1; + + private _focused = false; + get focused() { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.input.focused = value; + } + + constructor( + tui: TUI, + theme: Theme, + keybindings: KeybindingsManager, + source: AgentSessionPageSource, + ) { + this.tui = tui; + this.theme = theme; + this.keybindings = keybindings; + this.source = source; + this.input.onSubmit = (value: string) => { + const text = value.trim(); + if (!text || !this.source.send) return; + this.input.setValue(""); + this.source.send(text); + this.viewport.scrollToEnd(this.rowCount, this.viewportSize); + this.tui.requestRender(); + }; + } + + handleInput(data: string) { + const state = this.source.getState(); + if ( + this.source.abort && + state?.status === "running" && + this.keybindings.matches(data, "app.clear") + ) { + this.source.abort(); + return; + } + if ( + this.keybindings.matches(data, "app.interrupt") || + this.keybindings.matches(data, "tui.select.cancel") + ) { + this.source.close(); + return; + } + + const writable = Boolean(this.source.send); + const inputEmpty = !writable || this.input.getValue().length === 0; + if ( + inputEmpty && + (this.keybindings.matches(data, "tui.editor.cursorLeft") || + (!writable && data === "h")) + ) { + this.source.close(); + return; + } + if ( + this.keybindings.matches(data, "tui.editor.cursorUp") || + (!writable && data === "k") + ) { + this.viewport.scrollBy(-SCROLL_STEP, this.rowCount, this.viewportSize); + this.tui.requestRender(); + return; + } + if ( + this.keybindings.matches(data, "tui.editor.cursorDown") || + (!writable && data === "j") + ) { + this.viewport.scrollBy(SCROLL_STEP, this.rowCount, this.viewportSize); + this.tui.requestRender(); + return; + } + if ( + this.keybindings.matches(data, "tui.editor.pageUp") || + matchesKey(data, Key.ctrl("u")) + ) { + this.viewport.scrollBy( + -this.viewportSize, + this.rowCount, + this.viewportSize, + ); + this.tui.requestRender(); + return; + } + if ( + this.keybindings.matches(data, "tui.editor.pageDown") || + matchesKey(data, Key.ctrl("d")) + ) { + this.viewport.scrollBy( + this.viewportSize, + this.rowCount, + this.viewportSize, + ); + this.tui.requestRender(); + return; + } + if (inputEmpty && (matchesKey(data, Key.home) || data === "g")) { + this.viewport.scrollToTop(this.rowCount, this.viewportSize); + this.tui.requestRender(); + return; + } + if (inputEmpty && (matchesKey(data, Key.end) || data === "G")) { + this.viewport.scrollToEnd(this.rowCount, this.viewportSize); + this.tui.requestRender(); + return; + } + if (writable) { + this.input.handleInput(data); + this.tui.requestRender(); + } + } + + private rule(width: number, left = "", right = "") { + const available = Math.max(1, width); + const leftWidth = visibleWidth(left); + const rightWidth = visibleWidth(right); + if (!right || leftWidth + rightWidth + 2 > available) { + return truncateToWidth( + left + "─".repeat(Math.max(0, available - leftWidth)), + available, + ); + } + return ( + left + "─".repeat(Math.max(1, available - leftWidth - rightWidth)) + right + ); + } + + render(width: number) { + const state = this.source.getState(); + const height = Math.max(1, this.tui.terminal.rows || 30); + if (!state) { + const border = this.theme.fg( + "borderAccent", + "─".repeat(Math.max(1, width)), + ); + const lines = [ + border, + this.theme.fg("dim", "child is no longer tracked"), + ]; + while (lines.length < height - 1) lines.push(""); + lines.push(border); + return lines; + } + + const now = Date.now(); + const title = safeLine(state.title) || state.id; + const headerLeft = + this.theme.fg("borderAccent", "─ ") + + stateGlyph(state, this.theme, now) + + " " + + this.theme.fg("accent", this.theme.bold(title)) + + this.theme.fg("borderAccent", " "); + const metadata = (state.metadata ?? []) + .map((item) => (item ? safeLine(item) : "")) + .filter(Boolean) + .map((item) => this.theme.fg("muted", item)); + const dot = this.theme.fg("dim", " · "); + while ( + metadata.length > 1 && + visibleWidth(headerLeft) + visibleWidth(metadata.join(dot)) + 2 > width + ) { + metadata.shift(); + } + + const writable = Boolean(this.source.send); + const chromeRows = writable ? 5 : 4; + const errorRows = state.errorText ? 1 : 0; + const bodyHeight = Math.max(1, height - chromeRows); + const transcriptCapacity = Math.max(1, bodyHeight - errorRows); + const transcript = this.renderer.render(state.document, width, this.theme, { + now, + }); + this.rowCount = transcript.length; + this.viewportSize = transcriptCapacity; + this.viewport.reconcile(transcript.length, transcriptCapacity); + + const lines = [ + this.rule( + width, + truncateToWidth( + headerLeft, + Math.max(1, width - visibleWidth(metadata.join(dot)) - 2), + ), + metadata.join(dot), + ), + ]; + const body: string[] = []; + if (state.errorText) { + body.push( + truncateToWidth( + this.theme.fg("error", `error: ${safeLine(state.errorText)}`), + width, + ), + ); + } + const visible = transcript.slice( + this.viewport.scrollTop, + this.viewport.scrollTop + transcriptCapacity, + ); + if (visible.length === 0) { + body.push(this.theme.fg("dim", state.emptyText ?? "waiting for output…")); + } else { + body.push(...visible); + } + while (body.length < bodyHeight) body.push(""); + lines.push(...body.slice(0, bodyHeight)); + + lines.push( + this.rule( + width, + this.theme.fg("borderAccent", "─"), + this.viewport.followingEnd + ? "" + : this.theme.fg( + "dim", + `↓ ${this.viewport.linesBelow(transcript.length, transcriptCapacity)}`, + ), + ), + ); + if (writable) lines.push(...this.input.render(width)); + + const keys = (binding: Parameters[0]) => + configuredKeys(this.keybindings, binding); + const hints: ScreenHint[] = []; + if (writable) hints.push([keys("tui.input.submit"), "send"]); + hints.push([keys("app.interrupt"), "back"]); + if (this.source.abort) hints.push([keys("app.clear"), "abort run"]); + hints.push( + [ + `${keys("tui.editor.cursorUp")}/${keys("tui.editor.cursorDown")}`, + "scroll", + ], + [`${keys("tui.editor.pageUp")}/${keys("tui.editor.pageDown")}`, "page"], + ["g/G", "top/bottom"], + ); + lines.push(hintLine(this.theme, hints, width)); + lines.push(this.rule(width, this.theme.fg("borderAccent", "─"))); + return lines.slice(0, height); + } + + invalidate() { + this.input.invalidate(); + this.renderer.invalidate(); + } +} diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index 6eb83456..1c887ad7 100644 --- a/extensions/subagents/src/ui/takeover.ts +++ b/extensions/subagents/src/ui/takeover.ts @@ -12,13 +12,8 @@ import type { Theme, } from "@earendil-works/pi-coding-agent"; import type { Component, Focusable, TUI } from "@earendil-works/pi-tui"; -import { - Input, - Key, - matchesKey, - truncateToWidth, - visibleWidth, -} from "@earendil-works/pi-tui"; +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { AgentSessionPage } from "../../../shared/agent-session-page.ts"; import { hintLine, panelFrame, @@ -27,13 +22,11 @@ import { import { sanitizeTerminalText } from "../../../shared/terminal-text.ts"; import { formatElapsed, type SubagentSnapshot } from "../domain.ts"; import { formatContextUtilization } from "../../../shared/context-utilization.ts"; -import { TranscriptViewport } from "../../../shared/transcript-viewport.ts"; import type { SubagentReadModel } from "../manager.ts"; import { - buildTranscriptLines, SPINNER_INTERVAL_MS, spinnerFrame, - TranscriptRenderer, + subagentTranscriptDocument, } from "./transcript.ts"; export function sanitizeSubagentDisplayLine(value: string) { @@ -90,7 +83,7 @@ export async function openSubagentTakeover( new TakeoverView(tui, theme, keybindings, id, view, done, options), { overlay: true, - overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" }, + overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" }, }, ); } @@ -394,34 +387,18 @@ export class SubagentDashboard implements Component { // --- Takeover view ------------------------------------------------------------ -const TRANSCRIPT_SCROLL_STEP = 6; - export class TakeoverView implements Component, Focusable { - private tui: TUI; - private theme: Theme; - private keybindings: KeybindingsManager; - private id: string; - private view: SubagentReadModel; - private done: (value: null) => void; - private options?: TakeoverOptions; - - private input = new Input(); - private transcriptRenderer = new TranscriptRenderer(); - private transcriptViewport = new TranscriptViewport(); - private transcriptRowCount = 0; - private transcriptViewportSize = 1; + private page: AgentSessionPage; private unsubscribe: () => void; private renderTimer?: ReturnType; private ticker?: ReturnType; private closed = false; - private _focused = false; - get focused(): boolean { - return this._focused; + get focused() { + return this.page.focused; } set focused(value: boolean) { - this._focused = value; - this.input.focused = value; + this.page.focused = value; } constructor( @@ -433,53 +410,50 @@ export class TakeoverView implements Component, Focusable { done: (value: null) => void, options?: TakeoverOptions, ) { - this.tui = tui; - this.theme = theme; - this.keybindings = keybindings; - this.id = id; - this.view = view; - this.done = done; - this.options = options; + this.page = new AgentSessionPage(tui, theme, keybindings, { + getState: () => { + const snap = view.get(id); + if (!snap) return undefined; + return { + id: snap.id, + title: snap.title, + status: snap.status, + document: subagentTranscriptDocument(snap), + metadata: [ + options?.badge, + snap.meta.modelLabel, + formatContextUtilization(snap.usage), + formatElapsed(snap), + ], + errorText: snap.errorText, + }; + }, + close: () => this.close(done), + send: (text) => view.requestSend(id, text), + abort: () => view.requestAbort(id), + }); this.unsubscribe = view.subscribeTo(id, () => { - this.refreshTicker(); - this.scheduleRender(); + this.refreshTicker(view.get(id), tui); + this.scheduleRender(tui); }); - this.refreshTicker(); - this.input.onSubmit = (value: string) => { - const text = value.trim(); - if (!text) return; - this.input.setValue(""); - this.view.requestSend(this.id, text); - this.transcriptViewport.scrollToEnd( - this.transcriptRowCount, - this.transcriptViewportSize, - ); - this.tui.requestRender(); - }; + this.refreshTicker(view.get(id), tui); } - private snap(): SubagentSnapshot | undefined { - return this.view.get(this.id); - } - - private refreshTicker() { + private refreshTicker(snap: SubagentSnapshot | undefined, tui: TUI) { if (this.ticker) clearInterval(this.ticker); this.ticker = undefined; - if (this.snap()?.status === "running") { - this.ticker = setInterval( - () => this.tui.requestRender(), - SPINNER_INTERVAL_MS, - ); + if (snap?.status === "running") { + this.ticker = setInterval(() => tui.requestRender(), SPINNER_INTERVAL_MS); } } - private scheduleRender() { + private scheduleRender(tui: TUI) { if (this.renderTimer) return; // Streaming can emit an event per token. Limit terminal repaints so this // view cannot starve input handling or make the child look frozen. this.renderTimer = setTimeout(() => { this.renderTimer = undefined; - if (!this.closed) this.tui.requestRender(); + if (!this.closed) tui.requestRender(); }, 50); } @@ -493,8 +467,8 @@ export class TakeoverView implements Component, Focusable { return true; } - private close() { - if (this.cleanup()) this.done(null); + private close(done: (value: null) => void) { + if (this.cleanup()) done(null); } dispose(): void { @@ -502,234 +476,14 @@ export class TakeoverView implements Component, Focusable { } handleInput(data: string): void { - if (this.keybindings.matches(data, "app.clear")) { - const snap = this.snap(); - if (snap?.status === "running") this.view.requestAbort(this.id); - return; - } - if ( - this.keybindings.matches(data, "app.interrupt") || - this.keybindings.matches(data, "tui.select.cancel") - ) { - this.close(); - return; - } - if (this.keybindings.matches(data, "tui.editor.cursorUp")) { - this.transcriptViewport.scrollBy( - -TRANSCRIPT_SCROLL_STEP, - this.transcriptRowCount, - this.transcriptViewportSize, - ); - this.tui.requestRender(); - return; - } - if (this.keybindings.matches(data, "tui.editor.cursorDown")) { - this.transcriptViewport.scrollBy( - TRANSCRIPT_SCROLL_STEP, - this.transcriptRowCount, - this.transcriptViewportSize, - ); - this.tui.requestRender(); - return; - } - if (this.keybindings.matches(data, "tui.editor.pageUp")) { - this.transcriptViewport.scrollBy( - -this.transcriptViewportSize, - this.transcriptRowCount, - this.transcriptViewportSize, - ); - this.tui.requestRender(); - return; - } - if (this.keybindings.matches(data, "tui.editor.pageDown")) { - this.transcriptViewport.scrollBy( - this.transcriptViewportSize, - this.transcriptRowCount, - this.transcriptViewportSize, - ); - this.tui.requestRender(); - return; - } - const transcriptNavigation = this.input.getValue().length === 0; - if (transcriptNavigation && (matchesKey(data, Key.home) || data === "g")) { - this.transcriptViewport.scrollToTop( - this.transcriptRowCount, - this.transcriptViewportSize, - ); - this.tui.requestRender(); - return; - } - if (transcriptNavigation && (matchesKey(data, Key.end) || data === "G")) { - this.transcriptViewport.scrollToEnd( - this.transcriptRowCount, - this.transcriptViewportSize, - ); - this.tui.requestRender(); - return; - } - this.input.handleInput(data); - this.tui.requestRender(); - } - - private viewportHeight(): number { - const rows = this.tui.terminal.rows || 30; - // Top rule, transcript rule, input, key hints, and bottom rule are five - // chrome rows. The overlay leaves Pi's final footer row visible. - return Math.max(1, rows - 6); - } - - private rule(width: number, left = "", right = "") { - const fill = "─"; - const available = Math.max(1, width); - const leftWidth = visibleWidth(left); - const rightWidth = visibleWidth(right); - if (!right || leftWidth + rightWidth + 2 > available) { - return truncateToWidth( - left + fill.repeat(Math.max(0, available - leftWidth)), - available, - ); - } - return ( - left + - fill.repeat(Math.max(1, available - leftWidth - rightWidth)) + - right - ); + this.page.handleInput(data); } render(width: number): string[] { - const theme = this.theme; - const now = Date.now(); - const lines: string[] = []; - const snap = this.snap(); - - if (!snap) { - const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width))); - lines.push( - border, - theme.fg("dim", `${this.id} is no longer tracked`), - border, - ); - return lines; - } - - const title = sanitizeSubagentDisplayLine(snap.title) || snap.id; - const headerLeft = - theme.fg("borderAccent", "─ ") + - statusGlyph(snap, theme, now) + - " " + - theme.fg("accent", theme.bold(title)) + - theme.fg("borderAccent", " "); - const utilization = formatContextUtilization(snap.usage); - const metadata = [ - ...(this.options?.badge - ? [theme.fg("muted", sanitizeSubagentDisplayLine(this.options.badge))] - : []), - theme.fg( - "muted", - sanitizeSubagentDisplayLine(snap.meta.modelLabel ?? "?") || "?", - ), - ...(utilization ? [theme.fg("muted", utilization)] : []), - theme.fg("muted", formatElapsed(snap)), - ]; - const dot = theme.fg("dim", " · "); - while ( - metadata.length > 1 && - visibleWidth(headerLeft) + visibleWidth(metadata.join(dot)) + 2 > width - ) { - metadata.shift(); - } - lines.push( - this.rule( - width, - truncateToWidth( - headerLeft, - Math.max(1, width - visibleWidth(metadata.join(dot)) - 2), - ), - metadata.join(dot), - ), - ); - - // Fixed-height transcript viewport. Errors consume a row, but scroll state - // is represented by the following rule so its height never changes. - // `now` is shared with the header glyph so both spinners show one frame. - const transcript = buildTranscriptLines( - snap, - width, - theme, - this.transcriptRenderer, - { now }, - ); - const viewport = this.viewportHeight(); - const errorRows = snap.errorText ? 1 : 0; - const transcriptCapacity = Math.max(1, viewport - errorRows); - this.transcriptRowCount = transcript.length; - this.transcriptViewportSize = transcriptCapacity; - this.transcriptViewport.reconcile(transcript.length, transcriptCapacity); - - const body: string[] = []; - if (snap.errorText) { - body.push( - truncateToWidth( - theme.fg( - "error", - `error: ${sanitizeSubagentDisplayLine(snap.errorText)}`, - ), - width, - ), - ); - } - const visible = transcript.slice( - this.transcriptViewport.scrollTop, - this.transcriptViewport.scrollTop + transcriptCapacity, - ); - if (visible.length === 0) body.push(theme.fg("dim", "waiting for output…")); - else body.push(...visible); - while (body.length < viewport) body.push(""); - lines.push(...body.slice(0, viewport)); - - lines.push( - this.rule( - width, - theme.fg("borderAccent", "─"), - this.transcriptViewport.followingEnd - ? "" - : theme.fg( - "dim", - `↓ ${this.transcriptViewport.linesBelow(transcript.length, transcriptCapacity)}`, - ), - ), - ); - lines.push(...this.input.render(width)); - const keys = (binding: Parameters[0]) => - configuredKeys(this.keybindings, binding); - const editing: ScreenHint[] = [ - [keys("tui.input.submit"), "send"], - [keys("app.interrupt"), "back"], - [keys("app.clear"), "abort run"], - [ - `${keys("tui.editor.cursorUp")}/${keys("tui.editor.cursorDown")}`, - "scroll", - ], - ]; - // Same drop-the-page-hint fallback as before, measured on the styled line - // so the keys-brighter-than-labels styling cannot change what fits. - const full = hintLine( - theme, - [ - ...editing, - [`${keys("tui.editor.pageUp")}/${keys("tui.editor.pageDown")}`, "page"], - ], - width, - ); - lines.push( - visibleWidth(full) <= width ? full : hintLine(theme, editing, width), - ); - lines.push(this.rule(width, theme.fg("borderAccent", "─"))); - return lines; + return this.page.render(width); } invalidate(): void { - this.input.invalidate(); - this.transcriptRenderer.invalidate(); + this.page.invalidate(); } } diff --git a/extensions/subagents/takeover.test.ts b/extensions/subagents/takeover.test.ts index 5ffa0fa0..33749a2c 100644 --- a/extensions/subagents/takeover.test.ts +++ b/extensions/subagents/takeover.test.ts @@ -217,7 +217,7 @@ test("takeover scroll indicator lives in its rule without changing overlay heigh ); try { const pinned = view.render(80); - assert.equal(pinned.length, 19); + assert.equal(pinned.length, 20); assert.doesNotMatch(pinned.join("\n"), /↓ \d+/); view.handleInput("tui.editor.pageUp"); diff --git a/extensions/workflows/dashboard.test.ts b/extensions/workflows/dashboard.test.ts index 50dc7df0..fa6110f3 100644 --- a/extensions/workflows/dashboard.test.ts +++ b/extensions/workflows/dashboard.test.ts @@ -435,8 +435,10 @@ test("direct workflow navigation drills right and returns left through every lev dashboard.handleInput("right"); const transcript = dashboard.render(120).join("\n"); - assert.match(transcript, /Transcript/); + assert.equal(transcript.split("\n").length, 30); + assert.match(transcript, /writer/); assert.match(transcript, /git status/); + assert.doesNotMatch(transcript, /╭|╮|Transcript/); assert.doesNotMatch(transcript, /clipboard|\u001b/); dashboard.handleInput("left"); diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index 01c1e805..705d6248 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -18,12 +18,8 @@ import { getAgentDir, type KeybindingsManager, } from "@earendil-works/pi-coding-agent"; -import { - Key, - matchesKey, - type TUI, - truncateToWidth, -} from "@earendil-works/pi-tui"; +import { type TUI, truncateToWidth } from "@earendil-works/pi-tui"; +import { AgentSessionPage } from "../shared/agent-session-page.ts"; import { contextPercent } from "../shared/context-utilization.ts"; import { fitNavigationSides } from "../shared/below-editor-navigation.ts"; import { @@ -34,7 +30,6 @@ import { } from "../shared/screen-chrome.ts"; import { SPINNER_INTERVAL_MS, spinnerFrame } from "../shared/spinner.ts"; import { sanitizeTerminalText } from "../shared/terminal-text.ts"; -import { TranscriptViewport } from "../shared/transcript-viewport.ts"; import { isAcceptanceLedger } from "./acceptance.ts"; import { projectWorkflowGraph } from "./graph-projection.ts"; import { @@ -68,11 +63,10 @@ import { workflowGraphRecords, } from "./model.ts"; import { writeFileAtomic } from "./serialization.ts"; -import { WorkflowTranscriptRenderer } from "./transcript.ts"; +import { WorkflowTranscriptAdapter } from "./transcript.ts"; const NOTICE_TTL_MS = 4000; const MIN_HEIGHT = 10; -const TRANSCRIPT_SCROLL_STEP = 20; function wrapSelection(index: number, delta: number, length: number): number { if (length === 0) return 0; @@ -682,10 +676,7 @@ export class WorkflowDashboard { private phaseIndex = 0; private agentIndex = 0; private detailFocus: DetailFocus = "phases"; - private transcriptViewport = new TranscriptViewport(); - private transcriptRowCount = 0; - private transcriptViewportSize = 1; - private transcriptRenderer = new WorkflowTranscriptRenderer(); + private transcriptPage?: AgentSessionPage; private current?: RunEntry; private openedDirectly = false; private notice?: string; @@ -774,10 +765,11 @@ export class WorkflowDashboard { this.disposed = true; if (this.timer) clearInterval(this.timer); this.timer = undefined; + this.transcriptPage = undefined; } invalidate() { - this.transcriptRenderer.invalidate(); + this.transcriptPage?.invalidate(); } private refresh() { @@ -949,70 +941,27 @@ export class WorkflowDashboard { } else if (left || cancel) { this.detailFocus = "phases"; } else if ((right || confirm) && this.selectedAgent()) { - this.transcriptViewport = new TranscriptViewport(); - this.view = "transcript"; + this.openTranscriptPage(); } } if (data === "s") this.saveReport(); if (data === "x") this.abortRun(this.current); } else { - const scrollStep = - data === "j" || data === "k" ? TRANSCRIPT_SCROLL_STEP : 1; - const pageStep = Math.max(1, this.transcriptViewportSize - 2); - if (up) { - this.transcriptViewport.scrollBy( - -scrollStep, - this.transcriptRowCount, - this.transcriptViewportSize, - ); - } else if (down) { - this.transcriptViewport.scrollBy( - scrollStep, - this.transcriptRowCount, - this.transcriptViewportSize, - ); - } else if (matchesKey(data, Key.ctrl("u"))) { - this.transcriptViewport.scrollBy( - -pageStep, - this.transcriptRowCount, - this.transcriptViewportSize, - ); - } else if (matchesKey(data, Key.ctrl("d"))) { - this.transcriptViewport.scrollBy( - pageStep, - this.transcriptRowCount, - this.transcriptViewportSize, - ); - } else if (data === "g" || matchesKey(data, Key.home)) { - this.transcriptViewport.scrollToTop( - this.transcriptRowCount, - this.transcriptViewportSize, - ); - } else if (data === "G" || matchesKey(data, Key.end)) { - this.transcriptViewport.scrollToEnd( - this.transcriptRowCount, - this.transcriptViewportSize, - ); - } else if (cancel || left) { - this.view = "detail"; - this.detailFocus = "agents"; - } + this.transcriptPage?.handleInput(data); + this.refreshTimer(); + return; } this.refreshTimer(); this.tui.requestRender(); } render(width: number): string[] { + if (this.view === "transcript" && this.transcriptPage) { + return this.transcriptPage.render(width); + } const height = Math.max(MIN_HEIGHT, this.tui.terminal.rows - 1); let lines: string[]; - if (this.view === "transcript" && this.current && this.selectedAgent()) { - lines = this.renderTranscript( - this.current.details, - this.selectedAgent()!, - width, - height, - ); - } else if (this.view === "detail" && this.current) { + if (this.view === "detail" && this.current) { lines = this.renderDetail(this.current.details, width, height); } else { lines = this.renderList(width, height); @@ -1020,6 +969,52 @@ export class WorkflowDashboard { return lines.map((line) => truncateToWidth(line, width, "")); } + private openTranscriptPage() { + const transcriptAdapter = new WorkflowTranscriptAdapter(); + this.view = "transcript"; + this.transcriptPage = new AgentSessionPage( + this.tui, + this.theme, + this.keybindings, + { + getState: () => { + const details = this.current?.details; + const agent = this.selectedAgent(); + if (!details || !agent) return undefined; + return { + id: agent.callId ?? `agent-${agent.index}`, + title: agent.label, + status: agent.state, + document: transcriptAdapter.document( + agent.transcript, + agent.worktreePath, + ), + metadata: [ + `${details.name ?? details.runId} · ${agent.phase ?? "unphased"}`, + agent.model, + agentContext(agent), + agent.acceptance + ? `acceptance:${agent.acceptance.status}` + : undefined, + formatElapsed(agent.startedAt, agent.finishedAt), + ], + errorText: agent.error, + emptyText: + "transcript unavailable (this run predates transcript capture)", + }; + }, + close: () => { + this.transcriptPage = undefined; + this.view = "detail"; + this.detailFocus = "agents"; + this.refreshTimer(); + this.tui.requestRender(); + }, + }, + ); + this.tui.requestRender(); + } + /** Bordered panel with a title in the top border, padded to exact height. */ private panel( title: string, @@ -1352,87 +1347,6 @@ export class WorkflowDashboard { lines.push(this.hintLine(hints, width)); return lines; } - - private renderTranscript( - details: WorkflowDetails, - agent: AgentRecord, - width: number, - height: number, - ): string[] { - const theme = this.theme; - const lines: string[] = []; - const right = theme.fg( - "dim", - [ - agent.model, - agentContext(agent), - agent.acceptance ? `acceptance:${agent.acceptance.status}` : undefined, - formatElapsed(agent.startedAt, agent.finishedAt), - ] - .filter(Boolean) - .join(" · ") + " ", - ); - lines.push( - fitNavigationSides( - ` ${stateGlyph(agent.state, theme, Date.now())} ${theme.bold(theme.fg("accent", agent.label))}`, - right, - width, - ), - ); - lines.push( - fitNavigationSides( - ` ${theme.fg("muted", `${details.name ?? details.runId} · ${agent.phase ?? "unphased"}`)}`, - theme.fg("dim", `${agent.transcript.length} entries `), - width, - ), - ); - - const panelHeight = height - 3; - const bodyHeight = Math.max(1, panelHeight - 2); - const rows = - agent.transcript.length === 0 - ? [ - theme.fg( - "dim", - " transcript unavailable (this run predates transcript capture)", - ), - ] - : this.transcriptRenderer.render( - agent.transcript, - agent.worktreePath, - width - 2, - theme, - { now: Date.now() }, - ); - this.transcriptRowCount = rows.length; - this.transcriptViewportSize = bodyHeight; - this.transcriptViewport.reconcile(rows.length, bodyHeight); - const visible = rows.slice( - this.transcriptViewport.scrollTop, - this.transcriptViewport.scrollTop + bodyHeight, - ); - const linesBelow = this.transcriptViewport.linesBelow( - rows.length, - bodyHeight, - ); - const position = - rows.length > bodyHeight - ? `Transcript · ${this.transcriptViewport.scrollTop + 1}-${Math.min(rows.length, this.transcriptViewport.scrollTop + bodyHeight)}/${rows.length}${this.transcriptViewport.followingEnd ? "" : ` · ↓ ${linesBelow}`}` - : "Transcript"; - lines.push(...this.panel(position, visible, width, panelHeight)); - lines.push( - this.hintLine( - [ - ["j/k", "scroll"], - ["ctrl-u/d", "page"], - ["g/G", "top/bottom"], - ["h/left/esc", "back"], - ], - width, - ), - ); - return lines; - } } /** @@ -1487,7 +1401,7 @@ export async function showWorkflowDashboard( }, { overlay: true, - overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" }, + overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" }, }, ); } diff --git a/extensions/workflows/transcript.ts b/extensions/workflows/transcript.ts index 31c63cdf..d101d443 100644 --- a/extensions/workflows/transcript.ts +++ b/extensions/workflows/transcript.ts @@ -79,7 +79,7 @@ function buildWorkflowTranscriptDocument( } /** Preserve the shared renderer's identity cache between Workflow repaint ticks. */ -class WorkflowTranscriptAdapter { +export class WorkflowTranscriptAdapter { private previousEntries?: ReadonlyArray; private previousCwd?: string; private previousDocument?: AgentTranscriptDocument;