From 5afb8fbffb6ff0eafdb46ec32a25a6686b3a9f2b Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 24 Aug 2026 18:10:04 +0800 Subject: [PATCH] feat(tui): unify child transcript rendering --- extensions/file-mutation-display/render.ts | 260 +-------- extensions/shared/agent-transcript.ts | 447 +++++++++++++++ extensions/shared/tool-activity.ts | 364 ++++++++++++ extensions/shared/transcript-viewport.test.ts | 65 +++ extensions/shared/transcript-viewport.ts | 46 ++ extensions/subagents/src/ui/takeover.ts | 89 ++- extensions/subagents/src/ui/transcript.ts | 528 +----------------- extensions/subagents/takeover.test.ts | 72 +++ extensions/subagents/transcript.test.ts | 115 +++- extensions/workflows/dashboard.test.ts | 225 ++++++++ extensions/workflows/dashboard.ts | 214 ++++--- extensions/workflows/transcript.test.ts | 142 +++++ extensions/workflows/transcript.ts | 137 +++++ 13 files changed, 1792 insertions(+), 912 deletions(-) create mode 100644 extensions/shared/agent-transcript.ts create mode 100644 extensions/shared/tool-activity.ts create mode 100644 extensions/shared/transcript-viewport.test.ts create mode 100644 extensions/shared/transcript-viewport.ts create mode 100644 extensions/workflows/transcript.test.ts create mode 100644 extensions/workflows/transcript.ts diff --git a/extensions/file-mutation-display/render.ts b/extensions/file-mutation-display/render.ts index 7d6919af..3fff9956 100644 --- a/extensions/file-mutation-display/render.ts +++ b/extensions/file-mutation-display/render.ts @@ -1,5 +1,3 @@ -import { isAbsolute, relative } from "node:path"; -import { stripVTControlCharacters } from "node:util"; import type { AgentToolResult, Theme, @@ -7,7 +5,7 @@ import type { } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, type Component } from "@earendil-works/pi-tui"; import type { TSchema } from "typebox"; -import { spinnerFrame } from "../shared/spinner.ts"; +import { toolActivityText } from "../shared/tool-activity.ts"; type ActivityStatus = "pending" | "success" | "error"; @@ -23,12 +21,6 @@ type ActivityRenderState = { }; }; -type ActivityRow = { - verb: string; - target: string; - detail?: string; -}; - const HORIZONTAL_PADDING = " "; const emptyComponent: Component = { @@ -36,20 +28,6 @@ const emptyComponent: Component = { invalidate() {}, }; -function record(value: unknown): Record { - return value !== null && typeof value === "object" - ? (value as Record) - : {}; -} - -function string(value: unknown) { - return typeof value === "string" ? value : ""; -} - -function number(value: unknown) { - return typeof value === "number" ? value : undefined; -} - function textOutput(result: AgentToolResult | undefined) { return ( result?.content @@ -59,228 +37,6 @@ function textOutput(result: AgentToolResult | undefined) { ); } -function resultCount(result: AgentToolResult | undefined) { - return textOutput(result) - .split(/\r?\n/) - .filter((line) => line.trim().length > 0 && !line.trim().startsWith("[")) - .length; -} - -function grepMatchCount(result: AgentToolResult | undefined) { - const output = textOutput(result).trim(); - if (!output || output === "No matches found") return 0; - return output.split(/\r?\n/).filter((line) => /^.+:\d+:/.test(line)).length; -} - -function itemCount( - result: AgentToolResult | undefined, - emptyMessage: string, -) { - const output = textOutput(result).trim(); - return !output || output === emptyMessage ? 0 : resultCount(result); -} - -function plural(count: number, singular: string) { - const pluralForm = - singular === "match" - ? "matches" - : singular === "entry" - ? "entries" - : `${singular}s`; - return `${count} ${count === 1 ? singular : pluralForm}`; -} - -function editStats(details: unknown) { - const diff = string(record(details).diff); - if (!diff) return undefined; - let additions = 0; - let removals = 0; - for (const line of diff.split(/\r?\n/)) { - if (line.startsWith("+") && !line.startsWith("+++")) additions += 1; - if (line.startsWith("-") && !line.startsWith("---")) removals += 1; - } - return { additions, removals }; -} - -function range(args: Record) { - const offset = number(args.offset); - const limit = number(args.limit); - if (offset === undefined && limit === undefined) return ""; - const start = offset ?? 1; - return limit === undefined ? `:${start}-` : `:${start}-${start + limit - 1}`; -} - -function displayPath(path: string, cwd: string) { - if (!isAbsolute(path)) return path; - const local = relative(cwd, path); - if (local === "") return "."; - return local.startsWith("..") || isAbsolute(local) ? path : local; -} - -function activityRow( - name: string, - argsValue: unknown, - result: AgentToolResult | undefined, - cwd: string, -): ActivityRow { - const args = record(argsValue); - const path = displayPath(string(args.path) || ".", cwd); - switch (name) { - case "read": - return { verb: "Read", target: `${path}${range(args)}` }; - case "bash": - return { - verb: "Ran", - target: string(args.command).replace(/\s+/g, " ").trim(), - }; - case "write": { - const content = string(args.content); - const lines = - content.length === 0 - ? 0 - : content.replace(/\r?\n$/, "").split(/\r?\n/).length; - return { verb: "Wrote", target: path, detail: plural(lines, "line") }; - } - case "edit": - return { verb: "Edited", target: path }; - case "grep": - return { - verb: "Searched", - target: string(args.pattern), - detail: `in ${path} ${plural(grepMatchCount(result), "match")}`, - }; - case "find": - return { - verb: "Searched", - target: string(args.pattern), - detail: `in ${path} ${plural(itemCount(result, "No files found matching pattern"), "result")}`, - }; - case "ls": - return { - verb: "Listed", - target: path, - detail: plural(itemCount(result, "(empty directory)"), "entry"), - }; - default: - return { verb: name, target: "" }; - } -} - -function pendingVerb(name: string) { - switch (name) { - case "read": - return "Reading"; - case "bash": - return "Running"; - case "write": - return "Writing"; - case "edit": - return "Editing"; - case "grep": - case "find": - return "Searching"; - case "ls": - return "Listing"; - default: - return "Running"; - } -} - -function activityIcon(name: string) { - switch (name) { - case "read": - return "\ueaa4"; // Nerd Fonts Codicon: book - case "bash": - return "\uea85"; // Nerd Fonts Codicon: terminal - case "write": - case "edit": - return "\uea73"; // Nerd Fonts Codicon: edit - case "grep": - case "find": - return "\uea6d"; // Nerd Fonts Codicon: search - case "ls": - return "\uea83"; // Nerd Fonts Codicon: folder - default: - return "✓"; - } -} - -function errorSummary(result: AgentToolResult | undefined) { - const lines = textOutput(result) - .split(/\r?\n/) - .map((line) => stripVTControlCharacters(line).trim()) - .filter(Boolean); - return ( - [...lines] - .reverse() - .find((line) => - /(?:command (?:exited|timed out|aborted)|error|denied|failed)/i.test( - line, - ), - ) ?? lines[0] - ); -} - -function duration( - state: NonNullable["openpiActivity"]>, -) { - if (state.startedAt === undefined) return undefined; - const seconds = Math.floor( - ((state.endedAt ?? Date.now()) - state.startedAt) / 1000, - ); - return seconds > 0 ? `${seconds}s` : undefined; -} - -function activityText( - name: string, - args: unknown, - state: NonNullable["openpiActivity"]>, - theme: Theme, - cwd: string, -) { - const row = activityRow(name, args, state.result, cwd); - const elapsed = duration(state); - const verbText = ( - state.status === "pending" - ? pendingVerb(name) - : state.status === "error" - ? "Failed" - : row.verb - ).padEnd(8); - const verb = theme.fg( - state.status === "error" - ? "error" - : state.status === "success" - ? "muted" - : "toolTitle", - verbText, - ); - if (state.status === "pending") { - const detail = elapsed ? ` · ${elapsed}` : ""; - return `${theme.fg("warning", spinnerFrame(Date.now()))} ${verb} ${row.target}${theme.fg("dim", detail)}`; - } - if (state.status === "error") { - const summary = errorSummary(state.result); - const detail = [elapsed, summary].filter(Boolean).join(" · "); - return `${theme.fg("error", "✕")} ${verb} ${row.target}${detail ? theme.fg("dim", ` · ${detail}`) : ""}`; - } - const parts: string[] = []; - if (name === "edit") { - // Kimi-style diff stats: additions green, removals red. - const stats = editStats(state.result?.details); - if (stats) { - parts.push( - `${theme.fg("success", `+${stats.additions}`)} ${theme.fg("error", `-${stats.removals}`)}`, - ); - } - } else if (row.detail) { - parts.push(theme.fg("dim", row.detail)); - } - if (elapsed) parts.push(theme.fg("dim", elapsed)); - const detail = parts.join(theme.fg("dim", " · ")); - return `${theme.fg("dim", activityIcon(name))} ${verb} ${theme.fg("muted", row.target)}${detail ? ` ${detail}` : ""}`; -} - function activityComponent( name: string, args: unknown, @@ -296,7 +52,19 @@ function activityComponent( state.status === "success" ? theme.fg("muted", "…") : "…"; return [ `${HORIZONTAL_PADDING}${truncateToWidth( - activityText(name, args, state, theme, cwd), + toolActivityText( + { + name, + args, + output: textOutput(state.result), + details: state.result?.details, + status: state.status, + cwd, + startedAt: state.startedAt, + endedAt: state.endedAt, + }, + theme, + ), contentWidth, ellipsis, )}${HORIZONTAL_PADDING}`, diff --git a/extensions/shared/agent-transcript.ts b/extensions/shared/agent-transcript.ts new file mode 100644 index 00000000..79c2e8b9 --- /dev/null +++ b/extensions/shared/agent-transcript.ts @@ -0,0 +1,447 @@ +/** Shared operator-facing agent transcript rendering. */ + +import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent"; +import { + Markdown, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type DefaultTextStyle, + type MarkdownOptions, +} from "@earendil-works/pi-tui"; +import { sanitizeTerminalText } from "./terminal-text.ts"; +import { + parseToolArgsPreview, + renderToolActivityLine, + type ToolActivityStatus, +} from "./tool-activity.ts"; + +export type AgentTranscriptPart = + | { readonly type: "text"; readonly text: string } + | { + readonly type: "thinking"; + readonly text: string; + readonly redacted?: boolean; + } + | { + readonly type: "toolCall"; + readonly toolId: string; + readonly name: string; + readonly argsPreview?: string; + }; + +export type AgentTranscriptItem = + | { readonly kind: "user"; readonly text: string } + | { + readonly kind: "assistant"; + readonly parts: ReadonlyArray; + } + | { + readonly kind: "toolResult"; + readonly toolId: string; + readonly name: string; + readonly isError: boolean; + readonly outputPreview?: string; + }; + +export interface AgentTranscriptDocument { + readonly items: ReadonlyArray; + readonly cwd?: string; + readonly liveAssistant?: { readonly text: string; readonly thinking: string }; + readonly liveTools?: ReadonlyArray<{ + readonly toolId: string; + readonly name: string; + readonly argsPreview?: string; + readonly outputPreview?: string; + readonly done?: boolean; + readonly isError?: boolean; + }>; + readonly queued?: ReadonlyArray<{ + readonly text: string; + readonly kind: "steer" | "follow-up"; + }>; +} + +const MAX_CACHED_WIDTHS_PER_ITEM = 2; + +/** + * Strip raw ANSI codes, expand tabs, and drop control chars. Terminal-expanded + * tabs (and stray escapes) make lines wider than the width we declare to the + * TUI, which desyncs the renderer and smears the overlay. + */ +export function sanitizeText(text: string): string { + return sanitizeTerminalText(text); +} + +function transcriptMarkdownTheme() { + const theme = getMarkdownTheme(); + return { + ...theme, + // Markdown normalizes unordered lists to "- "; use a display bullet so + // transcript list syntax is never confused with unrendered source. + listBullet: (text: string) => + theme.listBullet(text.replace(/^(?:[-+*]) /, "• ")), + }; +} + +function renderMarkdown( + text: string, + width: number, + defaultTextStyle?: DefaultTextStyle, + options?: MarkdownOptions, +) { + const clean = sanitizeText(text).trim(); + if (!clean) return []; + const markdown = new Markdown( + clean, + 0, + 0, + transcriptMarkdownTheme(), + defaultTextStyle, + options, + ); + return markdown + .render(Math.max(1, width)) + .map((line) => truncateToWidth(line, width)); +} + +function renderUserText(theme: Theme, text: string, width: number) { + const lines = renderMarkdown( + text, + Math.max(1, width - 2), + { color: (content: string) => theme.fg("userMessageText", content) }, + { preserveOrderedListMarkers: true, preserveBackslashEscapes: true }, + ); + return lines.map((line, index) => + truncateToWidth( + (index === 0 ? theme.fg("accent", "> ") : " ") + line, + width, + ), + ); +} + +function renderThinking(theme: Theme, text: string, width: number) { + const reasoning = sanitizeText(text).trim(); + if (!reasoning) return []; + const out: string[] = []; + const prefix = theme.fg("dim", "~ "); + const defaultTextStyle = { + color: (content: string) => theme.fg("muted", content), + italic: true, + } satisfies DefaultTextStyle; + const lines = renderMarkdown( + reasoning, + Math.max(1, width - 2), + defaultTextStyle, + ); + for (let i = 0; i < lines.length; i++) { + out.push(truncateToWidth((i === 0 ? prefix : " ") + lines[i], width)); + } + return out; +} + +export type ToolPhase = "live" | "ok" | "error" | "pending"; + +function activityStatus(phase: ToolPhase): ToolActivityStatus { + if (phase === "error") return "error"; + if (phase === "ok") return "success"; + return "pending"; +} + +function renderToolLine( + theme: Theme, + phase: ToolPhase, + name: string, + argsPreview: string | undefined, + outputPreview: string | undefined, + width: number, + now: number, + cwd?: string, +) { + const { args, fallback } = parseToolArgsPreview(argsPreview); + return renderToolActivityLine( + { + name, + args, + argsFallback: fallback, + output: outputPreview, + status: activityStatus(phase), + cwd, + }, + theme, + width, + now, + ); +} + +function renderAssistantItem( + theme: Theme, + item: Extract, + width: number, + tools: ReadonlyMap, + now: number, + cwd?: string, +) { + const out: string[] = []; + for (const part of item.parts) { + if (part.type === "text") { + out.push(...renderMarkdown(part.text, width)); + } else if (part.type === "thinking") { + out.push( + ...renderThinking( + theme, + part.redacted ? "[redacted reasoning]" : part.text, + width, + ), + ); + } else if (part.type === "toolCall") { + const state = tools.get(part.toolId) ?? { phase: "pending" }; + // A live tool is rendered by the live block, which owns the spinner and + // the streaming output; rendering the call here too would show the same + // command twice and make the block reflow when the tool settles. + if (state.phase === "live") continue; + out.push( + renderToolLine( + theme, + state.phase, + part.name, + part.argsPreview, + state.result?.outputPreview, + width, + now, + cwd, + ), + ); + } + } + return out; +} + +function renderToolResultItem( + theme: Theme, + item: Extract, + width: number, + paired: boolean, + now: number, + cwd?: string, +) { + if (paired) return []; + return [ + renderToolLine( + theme, + item.isError ? "error" : "ok", + item.name, + undefined, + item.outputPreview, + width, + now, + cwd, + ), + ]; +} + +function hasEarlierToolCall( + transcript: ReadonlyArray, + resultIndex: number, + toolId: string, +) { + for (let index = resultIndex - 1; index >= 0; index--) { + const candidate = transcript[index]; + if (candidate?.kind !== "assistant") continue; + if ( + candidate.parts.some( + (part) => part.type === "toolCall" && part.toolId === toolId, + ) + ) { + return true; + } + } + return false; +} + +function renderTranscriptItem( + theme: Theme, + item: AgentTranscriptItem, + width: number, + context: ItemContext, + now: number, + cwd?: string, +) { + if (item.kind === "user") return renderUserText(theme, item.text, width); + if (item.kind === "assistant") { + return renderAssistantItem(theme, item, width, context.tools, now, cwd); + } + return renderToolResultItem(theme, item, width, context.paired, now, cwd); +} + +interface ItemContext { + readonly tools: ReadonlyMap; + readonly paired: boolean; + /** Cache discriminator: identity plus width is not enough on its own. */ + readonly token: string; +} + +interface ToolRenderState { + readonly phase: ToolPhase; + readonly result?: Extract; +} + +/** + * An item's rendering depends on its neighbours (does a call have its result + * yet?) and on live state (is the call still running?), so the cache key has to + * carry that context or a stale glyph would outlive the phase it described. + */ +function itemContext( + transcript: ReadonlyArray, + index: number, + liveIds: ReadonlySet, +): ItemContext { + const item = transcript[index]!; + if (item.kind === "user") + return { tools: new Map(), paired: false, token: "" }; + if (item.kind === "toolResult") { + const paired = hasEarlierToolCall(transcript, index, item.toolId); + return { tools: new Map(), paired, token: paired ? "p" : "o" }; + } + + const tools = new Map(); + for (const part of item.parts) { + if (part.type !== "toolCall") continue; + if (liveIds.has(part.toolId)) { + tools.set(part.toolId, { phase: "live" }); + continue; + } + const result = findResult(transcript, index, part.toolId); + tools.set( + part.toolId, + result + ? { phase: result.isError ? "error" : "ok", result } + : { phase: "pending" }, + ); + } + return { + tools, + paired: false, + token: [...tools].map(([id, state]) => `${id}:${state.phase}`).join(","), + }; +} + +/** The result for a call, if it has already landed later in the transcript. */ +function findResult( + transcript: ReadonlyArray, + callIndex: number, + toolId: string, +) { + for (let index = callIndex + 1; index < transcript.length; index++) { + const candidate = transcript[index]; + if (candidate?.kind === "toolResult" && candidate.toolId === toolId) { + return candidate; + } + } + return undefined; +} + +/** + * Caches finalized transcript items by identity and width. Live state remains + * uncached because it changes on every stream tick; callers clear this cache + * from their component's invalidate() when Pi changes theme. + */ +export class AgentTranscriptRenderer { + private itemCache = new WeakMap>(); + + render( + document: AgentTranscriptDocument, + width: number, + theme: Theme, + options?: { readonly now?: number }, + ) { + const out: string[] = []; + const now = options?.now ?? Date.now(); + const liveTools = document.liveTools ?? []; + const liveIds = new Set(liveTools.map((tool) => tool.toolId)); + + for (let index = 0; index < document.items.length; index++) { + const item = document.items[index]; + const context = itemContext(document.items, index, liveIds); + const key = `${width}|${context.token}`; + const cached = this.itemCache.get(item)?.get(key); + const lines = + cached ?? + renderTranscriptItem(theme, item, width, context, now, document.cwd); + if (!cached) { + const widths = this.itemCache.get(item) ?? new Map(); + if (widths.size >= MAX_CACHED_WIDTHS_PER_ITEM) { + const oldestWidth = widths.keys().next().value; + if (oldestWidth !== undefined) widths.delete(oldestWidth); + } + widths.set(key, lines); + this.itemCache.set(item, widths); + } + if (lines.length > 0) { + if (out.length > 0 && !context.paired) out.push(""); + out.push(...lines); + } + } + while (out.length > 0 && out[out.length - 1] === "") out.pop(); + + // Live streaming assistant buffers (cleared when the finalized message lands). + if (document.liveAssistant) { + const { thinking, text } = document.liveAssistant; + const before = out.length; + if (out.length > 0) out.push(""); + if (thinking.trim()) out.push(...renderThinking(theme, thinking, width)); + if (text.trim()) out.push(...renderMarkdown(text, width)); + if (out.length === before + 1) out.pop(); + } + + // Live tool executions. The manager drops a live entry when its ToolEnd + // lands, and the transcript's call line then takes over with the settled + // glyph in the same column, so the block never reflows. + for (const tool of liveTools) { + if (out.length > 0) out.push(""); + const phase: ToolPhase = tool.done + ? tool.isError + ? "error" + : "ok" + : "live"; + out.push( + renderToolLine( + theme, + phase, + tool.name, + tool.argsPreview, + tool.outputPreview, + width, + now, + document.cwd, + ), + ); + } + + // Queued steering/follow-up messages: show them immediately so Enter + // visibly acknowledges the user's input instead of appearing to do nothing. + for (const message of document.queued ?? []) { + if (out.length > 0) out.push(""); + const prefix = theme.fg("warning", `> [queued ${message.kind}] `); + const wrapped = wrapTextWithAnsi( + sanitizeText(message.text), + Math.max(1, width - visibleWidth(prefix)), + ); + for (let i = 0; i < wrapped.length; i++) { + out.push( + truncateToWidth( + (i === 0 ? prefix : " ".repeat(visibleWidth(prefix))) + + theme.fg("muted", wrapped[i]), + width, + ), + ); + } + } + + return out; + } + + invalidate() { + this.itemCache = new WeakMap(); + } +} diff --git a/extensions/shared/tool-activity.ts b/extensions/shared/tool-activity.ts new file mode 100644 index 00000000..d9c27350 --- /dev/null +++ b/extensions/shared/tool-activity.ts @@ -0,0 +1,364 @@ +import { isAbsolute, relative } from "node:path"; +import { stripVTControlCharacters } from "node:util"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { truncateToWidth } from "@earendil-works/pi-tui"; +import { spinnerFrame } from "./spinner.ts"; +import { sanitizeTerminalText } from "./terminal-text.ts"; + +export type ToolActivityStatus = "pending" | "success" | "error"; + +export interface ToolActivity { + readonly name: string; + readonly args?: unknown; + readonly argsFallback?: string; + readonly output?: string; + readonly details?: unknown; + readonly status: ToolActivityStatus; + readonly cwd?: string; + readonly startedAt?: number; + readonly endedAt?: number; +} + +interface ActivityRow { + readonly verb: string; + readonly target: string; + readonly detail?: string; +} + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function string(value: unknown) { + return typeof value === "string" ? sanitizeTerminalText(value) : ""; +} + +function number(value: unknown) { + return typeof value === "number" ? value : undefined; +} + +function compact(value: string) { + return sanitizeTerminalText(value).replace(/\r?\n/g, " ↵ ").trim(); +} + +export function parseToolArgsPreview(preview?: string) { + const clean = sanitizeTerminalText(preview ?? "").trim(); + const fallback = compact(clean); + if (!fallback) return { args: undefined, fallback: undefined }; + try { + const args: unknown = JSON.parse(clean); + return { args, fallback }; + } catch { + return { args: undefined, fallback }; + } +} + +function displayPath(value: string, cwd?: string) { + if (!isAbsolute(value) || !cwd) return value; + const local = relative(cwd, value); + if (local === "") return "."; + return local.startsWith("..") || isAbsolute(local) ? value : local; +} + +function resultCount(output: string) { + return output + .split(/\r?\n/) + .filter((line) => line.trim().length > 0 && !line.trim().startsWith("[")) + .length; +} + +function grepMatchCount(output: string) { + const text = output.trim(); + if (!text || text === "No matches found") return 0; + return text.split(/\r?\n/).filter((line) => /^.+:\d+:/.test(line)).length; +} + +function itemCount(output: string, emptyMessage: string) { + const text = output.trim(); + return !text || text === emptyMessage ? 0 : resultCount(output); +} + +function plural(count: number, singular: string) { + const pluralForm = + singular === "match" + ? "matches" + : singular === "entry" + ? "entries" + : `${singular}s`; + return `${count} ${count === 1 ? singular : pluralForm}`; +} + +function editStats(details: unknown) { + const diff = string(record(details).diff); + if (!diff) return undefined; + let additions = 0; + let removals = 0; + for (const line of diff.split(/\r?\n/)) { + if (line.startsWith("+") && !line.startsWith("+++")) additions += 1; + if (line.startsWith("-") && !line.startsWith("---")) removals += 1; + } + return { additions, removals }; +} + +function range(args: Record) { + const offset = number(args.offset); + const limit = number(args.limit); + if (offset === undefined && limit === undefined) return ""; + const start = offset ?? 1; + return limit === undefined ? `:${start}-` : `:${start}-${start + limit - 1}`; +} + +function canonicalName(name: string) { + const lower = sanitizeTerminalText(name).toLowerCase(); + if (lower === "rg") return "grep"; + if (lower === "fd") return "find"; + return lower; +} + +function activityRow(activity: ToolActivity): ActivityRow { + const name = canonicalName(activity.name); + const args = record(activity.args); + const fallback = compact(activity.argsFallback ?? ""); + const output = sanitizeTerminalText(activity.output ?? ""); + const path = displayPath(string(args.path) || ".", activity.cwd); + switch (name) { + case "read": + return { verb: "Read", target: `${path}${range(args)}` }; + case "bash": + return { + verb: "Ran", + target: + string(args.command).replace(/\s+/g, " ").trim() || fallback || name, + }; + case "write": { + const content = string(args.content); + const lines = + content.length === 0 + ? 0 + : content.replace(/\r?\n$/, "").split(/\r?\n/).length; + return { + verb: "Wrote", + target: string(args.path) ? path : fallback, + ...(content ? { detail: plural(lines, "line") } : {}), + }; + } + case "edit": + return { verb: "Edited", target: string(args.path) ? path : fallback }; + case "grep": { + const pattern = string(args.pattern) || fallback; + return { + verb: "Searched", + target: pattern, + ...(output + ? { detail: `in ${path} ${plural(grepMatchCount(output), "match")}` } + : string(args.path) + ? { detail: `in ${path}` } + : {}), + }; + } + case "find": { + const pattern = string(args.pattern) || fallback; + return { + verb: "Searched", + target: pattern, + ...(output + ? { + detail: `in ${path} ${plural(itemCount(output, "No files found matching pattern"), "result")}`, + } + : string(args.path) + ? { detail: `in ${path}` } + : {}), + }; + } + case "ls": + return { + verb: "Listed", + target: string(args.path) ? path : fallback || ".", + ...(output + ? { detail: plural(itemCount(output, "(empty directory)"), "entry") } + : {}), + }; + default: + return { + verb: sanitizeTerminalText(activity.name), + target: fallback || name, + }; + } +} + +function pendingVerb(name: string) { + switch (canonicalName(name)) { + case "read": + return "Reading"; + case "bash": + return "Running"; + case "write": + return "Writing"; + case "edit": + return "Editing"; + case "grep": + case "find": + return "Searching"; + case "ls": + return "Listing"; + default: + return "Running"; + } +} + +function activityIcon(name: string) { + switch (canonicalName(name)) { + case "read": + return "\ueaa4"; + case "bash": + return "\uea85"; + case "write": + case "edit": + return "\uea73"; + case "grep": + case "find": + return "\uea6d"; + case "ls": + return "\uea83"; + default: + return "✓"; + } +} + +function errorSummary(output?: string) { + const lines = sanitizeTerminalText(output ?? "") + .split(/\r?\n/) + .map((line) => stripVTControlCharacters(line).trim()) + .filter(Boolean); + return ( + [...lines] + .reverse() + .find((line) => + /(?:command (?:exited|timed out|aborted)|error|denied|failed)/i.test( + line, + ), + ) ?? lines[0] + ); +} + +function elapsed(activity: ToolActivity, now: number) { + if (activity.startedAt === undefined) return undefined; + const seconds = Math.floor( + ((activity.endedAt ?? now) - activity.startedAt) / 1000, + ); + return seconds > 0 ? `${seconds}s` : undefined; +} + +export function toolActivityText( + activity: ToolActivity, + theme: Theme, + now = Date.now(), +) { + const row = activityRow(activity); + const duration = elapsed(activity, now); + const verbText = ( + activity.status === "pending" + ? pendingVerb(activity.name) + : activity.status === "error" + ? "Failed" + : row.verb + ).padEnd(8); + const verb = theme.fg( + activity.status === "error" + ? "error" + : activity.status === "success" + ? "muted" + : "toolTitle", + verbText, + ); + + if (activity.status === "pending") { + const detail = duration ? ` · ${duration}` : ""; + return `${theme.fg("warning", spinnerFrame(now))} ${verb} ${row.target}${theme.fg("dim", detail)}`; + } + if (activity.status === "error") { + const summary = errorSummary(activity.output); + const detail = [duration, summary].filter(Boolean).join(" · "); + return `${theme.fg("error", "✕")} ${verb} ${row.target}${detail ? theme.fg("dim", ` · ${detail}`) : ""}`; + } + + const parts: string[] = []; + if (canonicalName(activity.name) === "edit") { + const stats = editStats(activity.details); + if (stats) { + parts.push( + `${theme.fg("success", `+${stats.additions}`)} ${theme.fg("error", `-${stats.removals}`)}`, + ); + } + } else if (row.detail) { + parts.push(theme.fg("dim", row.detail)); + } + if (duration) parts.push(theme.fg("dim", duration)); + const detail = parts.join(theme.fg("dim", " · ")); + return `${theme.fg("dim", activityIcon(activity.name))} ${verb} ${theme.fg("muted", row.target)}${detail ? ` ${detail}` : ""}`; +} + +export function renderToolActivityLine( + activity: ToolActivity, + theme: Theme, + width: number, + now = Date.now(), +) { + const ellipsis = activity.status === "success" ? theme.fg("muted", "…") : "…"; + return truncateToWidth( + toolActivityText(activity, theme, now), + width, + ellipsis, + ); +} + +/** Historical Direct helper retained without owning a second formatter. */ +export function summarizeToolArgs( + name: string, + argsPreview?: string, + cwd?: string, +) { + if (!argsPreview) return undefined; + const fallback = compact(argsPreview); + if (!fallback || fallback === "{}") return undefined; + + let args: Record | undefined; + try { + const parsed: unknown = JSON.parse(argsPreview); + if ( + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) + ) { + args = parsed as Record; + } + } catch { + return fallback; + } + if (!args) return fallback; + + const field = (key: string) => { + const value = args[key]; + return typeof value === "string" && value.length > 0 + ? sanitizeTerminalText(value).replace(/\r?\n/g, " ↵ ") + : undefined; + }; + const path = () => { + const value = field("path"); + return value ? displayPath(value, cwd) : undefined; + }; + const tool = canonicalName(name); + if (tool === "bash") return field("command") ?? fallback; + if (tool === "read" || tool === "write" || tool === "edit") { + return path() ?? fallback; + } + if (tool === "grep" || tool === "find") { + const pattern = field("pattern"); + const searchPath = path(); + if (pattern && searchPath) return `${pattern} · ${searchPath}`; + return pattern ?? searchPath ?? fallback; + } + return fallback; +} diff --git a/extensions/shared/transcript-viewport.test.ts b/extensions/shared/transcript-viewport.test.ts new file mode 100644 index 00000000..4354327d --- /dev/null +++ b/extensions/shared/transcript-viewport.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { TranscriptViewport } from "./transcript-viewport.ts"; + +test("following transcript stays pinned as rows append", () => { + const viewport = new TranscriptViewport(); + + viewport.reconcile(20, 5); + assert.equal(viewport.scrollTop, 15); + assert.equal(viewport.followingEnd, true); + + viewport.reconcile(23, 5); + assert.equal(viewport.scrollTop, 18); + assert.equal(viewport.linesBelow(23, 5), 0); +}); + +test("manual upward scrolling preserves the absolute reading anchor", () => { + const viewport = new TranscriptViewport(); + viewport.reconcile(20, 5); + + viewport.scrollBy(-4, 20, 5); + assert.equal(viewport.scrollTop, 11); + assert.equal(viewport.followingEnd, false); + + viewport.reconcile(23, 5); + assert.equal(viewport.scrollTop, 11); + assert.equal(viewport.linesBelow(23, 5), 7); +}); + +test("reaching the bottom or explicitly ending restores follow", () => { + const viewport = new TranscriptViewport(); + viewport.reconcile(20, 5); + viewport.scrollBy(-6, 20, 5); + + viewport.scrollBy(99, 20, 5); + assert.equal(viewport.scrollTop, 15); + assert.equal(viewport.followingEnd, true); + + viewport.scrollToTop(20, 5); + assert.equal(viewport.scrollTop, 0); + assert.equal(viewport.followingEnd, false); + + viewport.scrollToEnd(20, 5); + assert.equal(viewport.scrollTop, 15); + assert.equal(viewport.followingEnd, true); +}); + +test("paused viewport clamps safely across shrink and resize without resuming", () => { + const viewport = new TranscriptViewport(); + viewport.reconcile(30, 6); + viewport.scrollBy(-3, 30, 6); + assert.equal(viewport.scrollTop, 21); + + viewport.reconcile(12, 6); + assert.equal(viewport.scrollTop, 6); + assert.equal(viewport.followingEnd, false); + + viewport.reconcile(12, 9); + assert.equal(viewport.scrollTop, 3); + assert.equal(viewport.followingEnd, false); + + viewport.reconcile(0, 9); + assert.equal(viewport.scrollTop, 0); + assert.equal(viewport.followingEnd, false); +}); diff --git a/extensions/shared/transcript-viewport.ts b/extensions/shared/transcript-viewport.ts new file mode 100644 index 00000000..e63a2bf2 --- /dev/null +++ b/extensions/shared/transcript-viewport.ts @@ -0,0 +1,46 @@ +function maxScrollTop(rowCount: number, viewportSize: number) { + return Math.max(0, rowCount - Math.max(1, viewportSize)); +} + +function clamp(value: number, maximum: number) { + return Math.max(0, Math.min(value, maximum)); +} + +/** + * Pure transcript viewport state with the same follow-to-end contract as Pi's + * ScrollView. Operators may pause following without new rows moving their + * absolute reading anchor; reaching the end explicitly resumes following. + */ +export class TranscriptViewport { + scrollTop = 0; + followingEnd = true; + + reconcile(rowCount: number, viewportSize: number) { + const maximum = maxScrollTop(rowCount, viewportSize); + this.scrollTop = this.followingEnd + ? maximum + : clamp(this.scrollTop, maximum); + } + + scrollBy(delta: number, rowCount: number, viewportSize: number) { + this.reconcile(rowCount, viewportSize); + const maximum = maxScrollTop(rowCount, viewportSize); + this.scrollTop = clamp(this.scrollTop + delta, maximum); + this.followingEnd = this.scrollTop === maximum && delta > 0; + } + + scrollToTop(rowCount: number, viewportSize: number) { + this.scrollTop = 0; + this.followingEnd = false; + this.reconcile(rowCount, viewportSize); + } + + scrollToEnd(rowCount: number, viewportSize: number) { + this.followingEnd = true; + this.reconcile(rowCount, viewportSize); + } + + linesBelow(rowCount: number, viewportSize: number) { + return maxScrollTop(rowCount, viewportSize) - this.scrollTop; + } +} diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index f9db05a1..6eb83456 100644 --- a/extensions/subagents/src/ui/takeover.ts +++ b/extensions/subagents/src/ui/takeover.ts @@ -12,7 +12,13 @@ import type { Theme, } from "@earendil-works/pi-coding-agent"; import type { Component, Focusable, TUI } from "@earendil-works/pi-tui"; -import { Input, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { + Input, + Key, + matchesKey, + truncateToWidth, + visibleWidth, +} from "@earendil-works/pi-tui"; import { hintLine, panelFrame, @@ -21,6 +27,7 @@ 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, @@ -400,8 +407,9 @@ export class TakeoverView implements Component, Focusable { private input = new Input(); private transcriptRenderer = new TranscriptRenderer(); - /** Scroll offset in lines from the bottom of the transcript. 0 = pinned to bottom. */ - private scrollOffset = 0; + private transcriptViewport = new TranscriptViewport(); + private transcriptRowCount = 0; + private transcriptViewportSize = 1; private unsubscribe: () => void; private renderTimer?: ReturnType; private ticker?: ReturnType; @@ -442,7 +450,10 @@ export class TakeoverView implements Component, Focusable { if (!text) return; this.input.setValue(""); this.view.requestSend(this.id, text); - this.scrollOffset = 0; + this.transcriptViewport.scrollToEnd( + this.transcriptRowCount, + this.transcriptViewportSize, + ); this.tui.requestRender(); }; } @@ -452,10 +463,14 @@ export class TakeoverView implements Component, Focusable { } private refreshTicker() { - const interval = - this.snap()?.status === "running" ? SPINNER_INTERVAL_MS : 1000; if (this.ticker) clearInterval(this.ticker); - this.ticker = setInterval(() => this.tui.requestRender(), interval); + this.ticker = undefined; + if (this.snap()?.status === "running") { + this.ticker = setInterval( + () => this.tui.requestRender(), + SPINNER_INTERVAL_MS, + ); + } } private scheduleRender() { @@ -500,27 +515,54 @@ export class TakeoverView implements Component, Focusable { return; } if (this.keybindings.matches(data, "tui.editor.cursorUp")) { - this.scrollOffset += TRANSCRIPT_SCROLL_STEP; + this.transcriptViewport.scrollBy( + -TRANSCRIPT_SCROLL_STEP, + this.transcriptRowCount, + this.transcriptViewportSize, + ); this.tui.requestRender(); return; } if (this.keybindings.matches(data, "tui.editor.cursorDown")) { - this.scrollOffset = Math.max( - 0, - this.scrollOffset - TRANSCRIPT_SCROLL_STEP, + this.transcriptViewport.scrollBy( + TRANSCRIPT_SCROLL_STEP, + this.transcriptRowCount, + this.transcriptViewportSize, ); this.tui.requestRender(); return; } if (this.keybindings.matches(data, "tui.editor.pageUp")) { - this.scrollOffset += this.viewportHeight(); + this.transcriptViewport.scrollBy( + -this.transcriptViewportSize, + this.transcriptRowCount, + this.transcriptViewportSize, + ); this.tui.requestRender(); return; } if (this.keybindings.matches(data, "tui.editor.pageDown")) { - this.scrollOffset = Math.max( - 0, - this.scrollOffset - this.viewportHeight(), + 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; @@ -620,8 +662,9 @@ export class TakeoverView implements Component, Focusable { const viewport = this.viewportHeight(); const errorRows = snap.errorText ? 1 : 0; const transcriptCapacity = Math.max(1, viewport - errorRows); - const maxOffset = Math.max(0, transcript.length - transcriptCapacity); - if (this.scrollOffset > maxOffset) this.scrollOffset = maxOffset; + this.transcriptRowCount = transcript.length; + this.transcriptViewportSize = transcriptCapacity; + this.transcriptViewport.reconcile(transcript.length, transcriptCapacity); const body: string[] = []; if (snap.errorText) { @@ -635,10 +678,9 @@ export class TakeoverView implements Component, Focusable { ), ); } - const end = transcript.length - this.scrollOffset; const visible = transcript.slice( - Math.max(0, end - Math.max(1, viewport - body.length)), - end, + this.transcriptViewport.scrollTop, + this.transcriptViewport.scrollTop + transcriptCapacity, ); if (visible.length === 0) body.push(theme.fg("dim", "waiting for output…")); else body.push(...visible); @@ -649,7 +691,12 @@ export class TakeoverView implements Component, Focusable { this.rule( width, theme.fg("borderAccent", "─"), - this.scrollOffset > 0 ? theme.fg("dim", `↓ ${this.scrollOffset}`) : "", + this.transcriptViewport.followingEnd + ? "" + : theme.fg( + "dim", + `↓ ${this.transcriptViewport.linesBelow(transcript.length, transcriptCapacity)}`, + ), ), ); lines.push(...this.input.render(width)); diff --git a/extensions/subagents/src/ui/transcript.ts b/extensions/subagents/src/ui/transcript.ts index fd733a19..5d2301e5 100644 --- a/extensions/subagents/src/ui/transcript.ts +++ b/extensions/subagents/src/ui/transcript.ts @@ -1,424 +1,35 @@ -/** - * Transcript rendering for the takeover view: turns a SubagentSnapshot's - * normalized transcript + live state into width-bounded TUI lines. The domain - * stream stays normalized and bounded; this renderer only formats its previews. - */ - -import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent"; +import type { Theme } from "@earendil-works/pi-coding-agent"; import { - Markdown, - truncateToWidth, - visibleWidth, - wrapTextWithAnsi, - type DefaultTextStyle, - type MarkdownOptions, -} from "@earendil-works/pi-tui"; -import { sanitizeTerminalText } from "../../../shared/terminal-text.ts"; -import type { SubagentSnapshot, TranscriptItem } from "../domain.ts"; - -const MAX_CACHED_WIDTHS_PER_ITEM = 2; + AgentTranscriptRenderer, + type AgentTranscriptDocument, +} from "../../../shared/agent-transcript.ts"; +import type { SubagentSnapshot } from "../domain.ts"; -// The spinner lives in shared/ so strips outside this extension animate in -// step; the re-export keeps this module's historical import surface intact. -import { spinnerFrame } from "../../../shared/spinner.ts"; +export { sanitizeText } from "../../../shared/agent-transcript.ts"; export { SPINNER_FRAMES, SPINNER_INTERVAL_MS, spinnerFrame, } from "../../../shared/spinner.ts"; +export { summarizeToolArgs } from "../../../shared/tool-activity.ts"; +export type { ToolPhase } from "../../../shared/agent-transcript.ts"; -/** - * Strip raw ANSI codes, expand tabs, and drop control chars. Terminal-expanded - * tabs (and stray escapes) make lines wider than the width we declare to the - * TUI, which desyncs the renderer and smears the overlay. - */ -export function sanitizeText(text: string): string { - return sanitizeTerminalText(text); -} - -function singleLinePreview(text: string) { - // Keep meaningful whitespace inside parsed commands and paths; only fold - // physical line breaks so a preview remains one terminal row. - return sanitizeText(text).replace(/\r?\n/g, " ↵ "); -} - -function compactPreview(text: string) { - return singleLinePreview(text).trim(); -} - -function stringField(value: Record, field: string) { - const candidate = value[field]; - return typeof candidate === "string" && candidate.length > 0 - ? singleLinePreview(candidate) - : undefined; -} - -function parsedArgs(preview: string) { - try { - const value: unknown = JSON.parse(preview); - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; - } catch { - return undefined; - } -} - -/** Turn common tool arguments into a useful, bounded summary without retaining raw args. */ -export function summarizeToolArgs( - name: string, - argsPreview?: string, - cwd?: string, -) { - if (!argsPreview) return undefined; - - const fallback = compactPreview(argsPreview); - if (!fallback || fallback === "{}") return undefined; - - const args = parsedArgs(fallback); - if (!args) return fallback; - - const path = (field: string) => { - const value = stringField(args, field); - return value ? relativeToCwd(value, cwd) : undefined; - }; - - const tool = name.toLowerCase(); - if (tool === "bash") return stringField(args, "command") ?? fallback; - if (tool === "read" || tool === "write" || tool === "edit") { - return path("path") ?? fallback; - } - if (tool === "rg" || tool === "fd") { - const pattern = stringField(args, "pattern"); - const searchPath = path("path"); - if (pattern && searchPath) return `${pattern} · ${searchPath}`; - return pattern ?? searchPath ?? fallback; - } - return fallback; -} - -/** Absolute paths inside the child's own checkout read as noise; relativize. */ -function relativeToCwd(path: string, cwd?: string) { - if (!cwd) return path; - if (path === cwd) return "."; - const prefix = cwd.endsWith("/") ? cwd : `${cwd}/`; - return path.startsWith(prefix) ? path.slice(prefix.length) : path; -} - -function transcriptMarkdownTheme() { - const theme = getMarkdownTheme(); - return { - ...theme, - // Markdown normalizes unordered lists to "- "; use a display bullet so - // transcript list syntax is never confused with unrendered source. - listBullet: (text: string) => - theme.listBullet(text.replace(/^(?:[-+*]) /, "• ")), - }; -} - -function renderMarkdown( - text: string, - width: number, - defaultTextStyle?: DefaultTextStyle, - options?: MarkdownOptions, -) { - const clean = sanitizeText(text).trim(); - if (!clean) return []; - const markdown = new Markdown( - clean, - 0, - 0, - transcriptMarkdownTheme(), - defaultTextStyle, - options, - ); - return markdown - .render(Math.max(1, width)) - .map((line) => truncateToWidth(line, width)); -} - -function renderUserText(theme: Theme, text: string, width: number) { - const lines = renderMarkdown( - text, - Math.max(1, width - 2), - { color: (content: string) => theme.fg("userMessageText", content) }, - { preserveOrderedListMarkers: true, preserveBackslashEscapes: true }, - ); - return lines.map((line, index) => - truncateToWidth( - (index === 0 ? theme.fg("accent", "> ") : " ") + line, - width, - ), - ); -} - -function renderThinking(theme: Theme, text: string, width: number) { - const reasoning = sanitizeText(text).trim(); - if (!reasoning) return []; - const out: string[] = []; - const prefix = theme.fg("dim", "~ "); - const defaultTextStyle = { - color: (content: string) => theme.fg("muted", content), - italic: true, - } satisfies DefaultTextStyle; - const lines = renderMarkdown( - reasoning, - Math.max(1, width - 2), - defaultTextStyle, - ); - for (let i = 0; i < lines.length; i++) { - out.push(truncateToWidth((i === 0 ? prefix : " ") + lines[i], width)); - } - return out; -} - -function renderToolBody( - theme: Theme, - name: string, - argsPreview?: string, - cwd?: string, -) { - const toolName = sanitizeText(name); - const preview = summarizeToolArgs(toolName, argsPreview, cwd); - // The `$` form only earns its prompt when there is a command to show; a bare - // `$ ` would read as an empty shell line. - if (toolName === "bash" && preview) return theme.fg("dim", `$ ${preview}`); - return ( - theme.fg("toolTitle", toolName) + - (preview ? theme.fg("dim", ` ${preview}`) : "") - ); -} - -function firstOutputPreview(outputPreview?: string) { - return ( - sanitizeText(outputPreview ?? "") - .split("\n") - .find((line) => line.trim()) ?? "" - ); -} - -/** - * One execution owns exactly one glyph column, on its command line. Output - * lines are plain indented text so a block keeps identical columns from the - * moment the command starts to the moment it settles. - */ -export type ToolPhase = "live" | "ok" | "error" | "pending"; - -function phaseGlyph(theme: Theme, phase: ToolPhase, now: number) { - switch (phase) { - case "live": - return theme.fg("warning", spinnerFrame(now)); - case "ok": - return theme.fg("success", "✓"); - case "error": - return theme.fg("error", "✗"); - case "pending": - return theme.fg("dim", "·"); - } -} - -/** Command line: ` $ cmd` for bash, ` name args` otherwise. */ -function renderToolLine( - theme: Theme, - phase: ToolPhase, - name: string, - argsPreview: string | undefined, - width: number, - now: number, - cwd?: string, -) { - return truncateToWidth( - `${phaseGlyph(theme, phase, now)} ${renderToolBody(theme, name, argsPreview, cwd)}`, - width, - ); -} - -/** Output line: indented under the command, no second glyph. */ -function renderOutputLine( - theme: Theme, - isError: boolean, - outputPreview: string, - width: number, - cwd?: string, -) { - // Tool output echoes the absolute search path back (fd/rg print what they - // were given); inside the child's own checkout the relative form is enough. - const text = cwd ? outputPreview.split(`${cwd}/`).join("") : outputPreview; - const preview = text || "(no output)"; - const content = isError - ? theme.fg(text ? "error" : "dim", preview) - : theme.fg("dim", preview); - return truncateToWidth(` ${content}`, width); -} - -function renderAssistantItem( - theme: Theme, - item: Extract, - width: number, - phases: ReadonlyMap, - now: number, - cwd?: string, -) { - const out: string[] = []; - for (const part of item.parts) { - if (part.type === "text") { - out.push(...renderMarkdown(part.text, width)); - } else if (part.type === "thinking") { - out.push( - ...renderThinking( - theme, - part.redacted ? "[redacted reasoning]" : part.text, - width, - ), - ); - } else if (part.type === "toolCall") { - const phase = phases.get(part.toolId) ?? "pending"; - // A live tool is rendered by the live block, which owns the spinner and - // the streaming output; rendering the call here too would show the same - // command twice and make the block reflow when the tool settles. - if (phase === "live") continue; - out.push( - renderToolLine( - theme, - phase, - part.name, - part.argsPreview, - width, - now, - cwd, - ), - ); - } - } - return out; -} - -function renderToolResultItem( - theme: Theme, - item: Extract, - width: number, - paired: boolean, - now: number, - cwd?: string, -) { - const preview = firstOutputPreview(item.outputPreview); - // An orphan result (its call is not the previous item) still needs a glyph: - // there is no command line above it to carry one. - if (!paired) { - return [ - renderToolLine( - theme, - item.isError ? "error" : "ok", - item.name, - undefined, - width, - now, - ), - ...(preview - ? [renderOutputLine(theme, item.isError, preview, width, cwd)] - : []), - ]; - } - return [renderOutputLine(theme, item.isError, preview, width, cwd)]; -} - -function isPairedToolResult( - previous: TranscriptItem | undefined, - current: TranscriptItem, -) { - if ( - !previous || - previous.kind !== "assistant" || - current.kind !== "toolResult" - ) { - return false; - } - const lastPart = previous.parts[previous.parts.length - 1]; - return lastPart?.type === "toolCall" && lastPart.toolId === current.toolId; -} - -function renderTranscriptItem( - theme: Theme, - item: TranscriptItem, - width: number, - context: ItemContext, - now: number, - cwd?: string, -) { - if (item.kind === "user") return renderUserText(theme, item.text, width); - if (item.kind === "assistant") { - return renderAssistantItem(theme, item, width, context.phases, now, cwd); - } - return renderToolResultItem(theme, item, width, context.paired, now, cwd); -} - -interface ItemContext { - readonly phases: ReadonlyMap; - readonly paired: boolean; - /** Cache discriminator: identity plus width is not enough on its own. */ - readonly token: string; -} - -/** - * An item's rendering depends on its neighbours (does a call have its result - * yet?) and on live state (is the call still running?), so the cache key has to - * carry that context or a stale glyph would outlive the phase it described. - */ -function itemContext( - transcript: ReadonlyArray, - index: number, - liveIds: ReadonlySet, -): ItemContext { - const item = transcript[index]!; - if (item.kind === "user") - return { phases: new Map(), paired: false, token: "" }; - if (item.kind === "toolResult") { - const paired = isPairedToolResult(transcript[index - 1], item); - return { phases: new Map(), paired, token: paired ? "p" : "o" }; - } - - const phases = new Map(); - for (const part of item.parts) { - if (part.type !== "toolCall") continue; - if (liveIds.has(part.toolId)) { - phases.set(part.toolId, "live"); - continue; - } - const result = findResult(transcript, index, part.toolId); - phases.set( - part.toolId, - result ? (result.isError ? "error" : "ok") : "pending", - ); - } +/** Thin projection from Direct Subagent state to the shared UI document. */ +export function subagentTranscriptDocument( + snap: SubagentSnapshot, +): AgentTranscriptDocument { return { - phases, - paired: false, - token: [...phases].map(([id, phase]) => `${id}:${phase}`).join(","), + items: snap.transcript, + cwd: snap.cwd, + ...(snap.liveAssistant ? { liveAssistant: snap.liveAssistant } : {}), + ...(snap.liveTools.length > 0 ? { liveTools: snap.liveTools } : {}), + ...(snap.queued.length > 0 ? { queued: snap.queued } : {}), }; } -/** The result for a call, if it has already landed later in the transcript. */ -function findResult( - transcript: ReadonlyArray, - callIndex: number, - toolId: string, -) { - for (let index = callIndex + 1; index < transcript.length; index++) { - const candidate = transcript[index]; - if (candidate?.kind === "toolResult" && candidate.toolId === toolId) { - return candidate; - } - } - return undefined; -} - -/** - * Caches finalized transcript items by identity and width. Live state remains - * uncached because it changes on every stream tick; callers clear this cache - * from their component's invalidate() when Pi changes theme. - */ +/** Compatibility wrapper around the shared renderer's historical Direct API. */ export class TranscriptRenderer { - private itemCache = new WeakMap>(); + private renderer = new AgentTranscriptRenderer(); render( snap: SubagentSnapshot, @@ -426,106 +37,19 @@ export class TranscriptRenderer { theme: Theme, options?: { readonly now?: number }, ) { - const out: string[] = []; - const now = options?.now ?? Date.now(); - const liveIds = new Set(snap.liveTools.map((tool) => tool.toolId)); - - for (let index = 0; index < snap.transcript.length; index++) { - const item = snap.transcript[index]; - const context = itemContext(snap.transcript, index, liveIds); - const key = `${width}|${context.token}`; - const cached = this.itemCache.get(item)?.get(key); - const lines = - cached ?? - renderTranscriptItem(theme, item, width, context, now, snap.cwd); - if (!cached) { - const widths = this.itemCache.get(item) ?? new Map(); - if (widths.size >= MAX_CACHED_WIDTHS_PER_ITEM) { - const oldestWidth = widths.keys().next().value; - if (oldestWidth !== undefined) widths.delete(oldestWidth); - } - widths.set(key, lines); - this.itemCache.set(item, widths); - } - if (lines.length > 0) { - if ( - out.length > 0 && - !isPairedToolResult(snap.transcript[index - 1], item) - ) { - out.push(""); - } - out.push(...lines); - } - } - while (out.length > 0 && out[out.length - 1] === "") out.pop(); - - // Live streaming assistant buffers (cleared when the finalized message lands). - if (snap.liveAssistant) { - const { thinking, text } = snap.liveAssistant; - const before = out.length; - if (out.length > 0) out.push(""); - if (thinking.trim()) out.push(...renderThinking(theme, thinking, width)); - if (text.trim()) out.push(...renderMarkdown(text, width)); - if (out.length === before + 1) out.pop(); - } - - // Live tool executions. The manager drops a live entry when its ToolEnd - // lands, and the transcript's call line then takes over with the settled - // glyph in the same column, so the block never reflows. - for (const tool of snap.liveTools) { - if (out.length > 0) out.push(""); - const phase: ToolPhase = tool.done - ? tool.isError - ? "error" - : "ok" - : "live"; - out.push( - renderToolLine( - theme, - phase, - tool.name, - tool.argsPreview, - width, - now, - snap.cwd, - ), - ); - const preview = firstOutputPreview(tool.outputPreview); - if (preview) - out.push( - renderOutputLine(theme, !!tool.isError, preview, width, snap.cwd), - ); - } - - // Queued steering/follow-up messages: show them immediately so Enter - // visibly acknowledges the user's input instead of appearing to do nothing. - for (const message of snap.queued) { - if (out.length > 0) out.push(""); - const prefix = theme.fg("warning", `> [queued ${message.kind}] `); - const wrapped = wrapTextWithAnsi( - sanitizeText(message.text), - Math.max(1, width - visibleWidth(prefix)), - ); - for (let i = 0; i < wrapped.length; i++) { - out.push( - truncateToWidth( - (i === 0 ? prefix : " ".repeat(visibleWidth(prefix))) + - theme.fg("muted", wrapped[i]), - width, - ), - ); - } - } - - return out; + return this.renderer.render( + subagentTranscriptDocument(snap), + width, + theme, + options, + ); } invalidate() { - this.itemCache = new WeakMap(); + this.renderer.invalidate(); } } -/** Render a subagent's conversation as width-bounded lines. */ export function buildTranscriptLines( snap: SubagentSnapshot, width: number, diff --git a/extensions/subagents/takeover.test.ts b/extensions/subagents/takeover.test.ts index a2324a08..5ffa0fa0 100644 --- a/extensions/subagents/takeover.test.ts +++ b/extensions/subagents/takeover.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { stripVTControlCharacters } from "node:util"; import type { KeybindingsManager, Theme, @@ -228,3 +229,74 @@ test("takeover scroll indicator lives in its rule without changing overlay heigh view.dispose(); } }); + +test("takeover pauses on an absolute reading anchor and resumes at the end", () => { + const transcript = Array.from({ length: 40 }, (_, index) => ({ + kind: "assistant" as const, + parts: [{ type: "text" as const, text: `output ${index}` }], + })); + const running = snap("run", "running", { transcript }); + const view = new TakeoverView( + tui(20), + theme, + keys, + "run", + model([running]), + () => {}, + ); + try { + view.render(80); + view.handleInput("tui.editor.pageUp"); + const paused = view.render(80); + const anchor = paused.find((line) => /output \d+/.test(line)); + assert.ok(anchor); + + transcript.push( + ...Array.from({ length: 5 }, (_, index) => ({ + kind: "assistant" as const, + parts: [{ type: "text" as const, text: `output ${40 + index}` }], + })), + ); + const appended = view.render(80); + assert.equal( + appended.find((line) => /output \d+/.test(line)), + anchor, + ); + assert.match(appended.join("\n"), /↓ \d+/); + + view.handleInput("G"); + assert.match(view.render(80).join("\n"), /output 44/); + assert.doesNotMatch(view.render(80).join("\n"), /↓ \d+/); + + transcript.push({ + kind: "assistant", + parts: [{ type: "text", text: "output 45" }], + }); + assert.match(view.render(80).join("\n"), /output 45/); + } finally { + view.dispose(); + } +}); + +test("Home and End edit non-empty input instead of moving the transcript", () => { + const view = new TakeoverView( + tui(20), + theme, + keys, + "run", + model([snap("run")]), + () => {}, + ); + try { + view.handleInput("abc"); + view.handleInput("\u001b[H"); + view.handleInput("X"); + assert.match(stripVTControlCharacters(view.render(80).join("\n")), /Xabc/); + + view.handleInput("\u001b[F"); + view.handleInput("Y"); + assert.match(stripVTControlCharacters(view.render(80).join("\n")), /XabcY/); + } finally { + view.dispose(); + } +}); diff --git a/extensions/subagents/transcript.test.ts b/extensions/subagents/transcript.test.ts index 7978cb8b..be2b7480 100644 --- a/extensions/subagents/transcript.test.ts +++ b/extensions/subagents/transcript.test.ts @@ -90,6 +90,33 @@ test("takeover transcript renders finalized and live assistant Markdown within i assert.ok(lines.every((line) => visibleWidth(line) <= 24)); }); +test("shared transcript renders fenced code and CJK deterministically at narrow widths", () => { + const value = snapshot({ + transcript: [ + { kind: "user", text: "请检查这个非常长的中文文件名是否正确" }, + { + kind: "assistant", + parts: [ + { + type: "text", + text: '结果:\n\n```ts\nconst 状态 = "完成";\n```', + }, + ], + }, + ], + }); + + const first = buildTranscriptLines(value, 12, theme); + const second = buildTranscriptLines(value, 12, theme); + const rendered = plain(first); + + assert.deepEqual(second, first); + assert.match(rendered, /请检查|中文|结果|const|状态|完成/); + assert.match(rendered, /```ts/); + assert.ok(first.length > 4); + assert.ok(first.every((line) => visibleWidth(line) <= 12)); +}); + test("thinking renders Markdown but preserves redaction", () => { const rendered = plain( buildTranscriptLines( @@ -200,13 +227,10 @@ test("tool call and output lines drop the child cwd prefix", () => { theme, ); - assert.deepEqual(lines, [ - "✓ fd *.mjs · scripts", - " scripts/benchmark-arm-selection.mjs", - ]); + assert.deepEqual(lines, [" Searched *.mjs in scripts 1 result"]); }); -test("bash tool calls use shell prompts while other tools go bare", () => { +test("pending tool calls use the parent activity verbs", () => { const rendered = plain( buildTranscriptLines( snapshot({ @@ -235,11 +259,11 @@ test("bash tool calls use shell prompts while other tools go bare", () => { ), ); - assert.match(rendered, /^· \$ git status --porcelain/m); - assert.match(rendered, /· read src\/index\.ts/); + assert.match(rendered, /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Running {2}git status --porcelain/m); + assert.match(rendered, /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Reading {2}src\/index\.ts/m); }); -test("adjacent tool results form one block with a success glyph", () => { +test("settled tools use one semantic activity row", () => { const lines = buildTranscriptLines( snapshot({ transcript: [ @@ -267,9 +291,51 @@ test("adjacent tool results form one block with a success glyph", () => { theme, ); - // One glyph per execution, on the command line; the output sits under it. - assert.deepEqual(lines, ["✓ $ printf ok", " ok"]); - assert.ok(!lines.slice(0, -1).some((line) => line === "")); + assert.deepEqual(lines, [" Ran printf ok"]); +}); + +test("parallel tool results reuse their earlier command lines", () => { + const lines = buildTranscriptLines( + snapshot({ + transcript: [ + { + kind: "assistant", + parts: [ + { + type: "toolCall", + toolId: "read-a", + name: "read", + argsPreview: '{"path":"a.ts"}', + }, + { + type: "toolCall", + toolId: "read-b", + name: "read", + argsPreview: '{"path":"b.ts"}', + }, + ], + }, + { + kind: "toolResult", + toolId: "read-a", + name: "read", + isError: false, + outputPreview: "alpha", + }, + { + kind: "toolResult", + toolId: "read-b", + name: "read", + isError: false, + outputPreview: "beta", + }, + ], + }), + 80, + theme, + ); + + assert.deepEqual(lines, [" Read a.ts", " Read b.ts"]); }); test("tool errors and empty results use status glyphs", () => { @@ -298,9 +364,9 @@ test("tool errors and empty results use status glyphs", () => { ); // Orphan results (no call above them) keep a glyph of their own. - assert.match(rendered, /✗ bash/); + assert.match(rendered, /✕ Failed\s+bash/); assert.match(rendered, /command failed/); - assert.match(rendered, /✓ bash/); + assert.match(rendered, / Ran\s+bash/); }); test("a running tool becomes settled without reflowing", () => { @@ -353,12 +419,13 @@ test("a running tool becomes settled without reflowing", () => { { now: 0 }, ); - // The command appears exactly once while running, and only the glyph changes - // when the tool settles: same line count, same columns. - assert.deepEqual(running, ["⠋ $ git status", " clean"]); - assert.deepEqual(settled, ["✓ $ git status", " clean"]); - assert.equal(running[0]?.slice(1), settled[0]?.slice(1)); - assert.equal(running[1], settled[1]); + // The command appears exactly once and keeps the same target column. + assert.deepEqual(running, ["⠋ Running git status"]); + assert.deepEqual(settled, [" Ran git status"]); + assert.equal( + running[0]?.indexOf("git status"), + settled[0]?.indexOf("git status"), + ); }); test("the spinner advances between frames instead of freezing in the cache", () => { @@ -427,8 +494,8 @@ test("cached items are keyed by width and by tool phase", () => { theme, { now: 0 }, ); - assert.match(wide[0]!, /^·/); - assert.match(settled[0]!, /^✓/); + assert.match(wide[0]!, /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); + assert.match(settled[0]!, /^/); }); test("spinnerFrame is deterministic and advances every 120ms", () => { @@ -505,15 +572,15 @@ test("cached finalized transcript output is rebuilt after invalidation", () => { assert.match( plain(buildTranscriptLines(cached, 80, taggedTheme("first"), renderer)), - /\[first:dim\]\$ npm test/, + /\[first:toolTitle\]Running\s+npm test/, ); assert.match( plain(buildTranscriptLines(cached, 80, taggedTheme("second"), renderer)), - /\[first:dim\]\$ npm test/, + /\[first:toolTitle\]Running\s+npm test/, ); renderer.invalidate(); assert.match( plain(buildTranscriptLines(cached, 80, taggedTheme("second"), renderer)), - /\[second:dim\]\$ npm test/, + /\[second:toolTitle\]Running\s+npm test/, ); }); diff --git a/extensions/workflows/dashboard.test.ts b/extensions/workflows/dashboard.test.ts index 497e81d5..50dc7df0 100644 --- a/extensions/workflows/dashboard.test.ts +++ b/extensions/workflows/dashboard.test.ts @@ -80,6 +80,53 @@ test("persisted nonterminal invocation facts are projected as uncertain", () => assert.equal(restored?.agents[0]?.invocation?.outcome, "uncertain"); }); +test("persisted transcripts retain exact tool call identities", () => { + const details = normalizePersistedWorkflowDetails("wf_tools", { + status: "completed", + startedAt: 10, + finishedAt: 20, + phases: [], + agents: [ + { + index: 0, + label: "worker", + state: "completed", + transcript: [ + { + role: "tool", + name: "read", + toolCallId: "call-a", + text: "a.ts", + }, + { + role: "tool", + name: "read", + toolCallId: "call-b", + text: "b.ts", + }, + { + role: "toolResult", + name: "read", + toolCallId: "call-b", + text: "beta", + }, + { + role: "toolResult", + name: "read", + toolCallId: "call-a", + text: "alpha", + }, + ], + }, + ], + }); + + assert.deepEqual( + details?.agents[0]?.transcript.map((entry) => entry.toolCallId), + ["call-a", "call-b", "call-b", "call-a"], + ); +}); + test("stale recovery reconciles the run and every active agent", () => { const details = normalizePersistedWorkflowDetails("wf_stale", { status: "running", @@ -405,6 +452,98 @@ test("direct workflow navigation drills right and returns left through every lev } }); +test("Workflow transcript follows, pauses on its top row, and resumes", () => { + const transcript = Array.from({ length: 40 }, (_, index) => ({ + role: "assistant" as const, + text: `line ${index}`, + })); + const details: WorkflowDetails = { + runId: "wf_1234567890ab", + sessionId: SESSION, + name: "follow", + background: false, + status: "running", + startedAt: Date.now() - 1_000, + phases: [{ title: "Work" }], + currentPhase: "Work", + agents: [ + { + index: 1, + label: "worker", + phase: "Work", + state: "running", + startedAt: Date.now() - 900, + preview: "", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + turns: 1, + }, + transcript, + }, + ], + }; + writeRun(details.runId, details.startedAt); + const dashboard = new WorkflowDashboard( + { + terminal: { rows: 20 }, + requestRender() {}, + } as unknown as TUI, + { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + } as unknown as Theme, + { + matches(data: string, binding: string) { + return data === binding.replace("tui.editor.cursor", "").toLowerCase(); + }, + getKeys: () => ["esc"], + } as unknown as KeybindingsManager, + () => new Map([[details.runId, details]]), + SESSION, + new Set(), + 0, + () => {}, + details.runId, + ); + + try { + dashboard.handleInput("right"); + dashboard.handleInput("right"); + const pinned = dashboard.render(80).join("\n"); + assert.match(pinned, /line 39/); + assert.doesNotMatch(pinned, /line 0\b/); + + dashboard.handleInput("k"); + const paused = dashboard.render(80); + const anchor = paused.find((line) => /line \d+/.test(line)); + assert.ok(anchor); + assert.match(paused.join("\n"), /↓ \d+/); + + transcript.push( + ...Array.from({ length: 5 }, (_, index) => ({ + role: "assistant" as const, + text: `line ${40 + index}`, + })), + ); + assert.equal( + dashboard.render(80).find((line) => /line \d+/.test(line)), + anchor, + ); + + dashboard.handleInput("G"); + const resumed = dashboard.render(80).join("\n"); + assert.match(resumed, /line 44/); + assert.doesNotMatch(resumed, /↓ \d+/); + } finally { + dashboard.dispose(); + } +}); + test("live workflow dashboard repaints on the shared spinner cadence", (t) => { t.mock.timers.enable({ apis: ["setInterval"] }); writeRun("wf_123abc", Date.now()); @@ -450,6 +589,92 @@ test("live workflow dashboard repaints on the shared spinner cadence", (t) => { } }); +test("settled child transcript does not repaint for another live run", (t) => { + t.mock.timers.enable({ apis: ["setInterval"] }); + const settledId = "wf_aa1050"; + const liveId = "wf_bb1050"; + writeRun(settledId, Date.now() - 2_000, Date.now() - 1_000); + writeRun(liveId, Date.now() - 500); + const settled: WorkflowDetails = { + runId: settledId, + sessionId: SESSION, + name: "settled", + background: false, + status: "completed", + startedAt: Date.now() - 2_000, + finishedAt: Date.now() - 1_000, + phases: [{ title: "Work" }], + agents: [ + { + index: 1, + label: "done", + phase: "Work", + state: "done", + startedAt: Date.now() - 2_000, + finishedAt: Date.now() - 1_000, + preview: "complete", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + turns: 1, + }, + transcript: [{ role: "assistant", text: "complete" }], + }, + ], + }; + const live: WorkflowDetails = { + runId: liveId, + sessionId: SESSION, + name: "live", + background: false, + status: "running", + startedAt: Date.now() - 500, + phases: [], + agents: [], + }; + let renders = 0; + const dashboard = new WorkflowDashboard( + { + terminal: { rows: 20 }, + requestRender() { + renders += 1; + }, + } as unknown as TUI, + { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme, + { + matches(data: string, binding: string) { + return data === binding.replace("tui.editor.cursor", "").toLowerCase(); + }, + getKeys: () => ["esc"], + } as unknown as KeybindingsManager, + () => + new Map([ + [settledId, settled], + [liveId, live], + ]), + SESSION, + new Set(), + 0, + () => {}, + settledId, + ); + try { + dashboard.handleInput("right"); + dashboard.handleInput("right"); + renders = 0; + t.mock.timers.tick(SPINNER_INTERVAL_MS * 2); + assert.equal(renders, 0); + } finally { + dashboard.dispose(); + } +}); + function saveReport(runId: string) { writeRun(runId, Date.now() - 1_000); const tui = { diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index 95d356a7..01c1e805 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -23,7 +23,6 @@ import { matchesKey, type TUI, truncateToWidth, - wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import { contextPercent } from "../shared/context-utilization.ts"; import { fitNavigationSides } from "../shared/below-editor-navigation.ts"; @@ -35,6 +34,7 @@ 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,6 +68,7 @@ import { workflowGraphRecords, } from "./model.ts"; import { writeFileAtomic } from "./serialization.ts"; +import { WorkflowTranscriptRenderer } from "./transcript.ts"; const NOTICE_TTL_MS = 4000; const MIN_HEIGHT = 10; @@ -261,6 +262,10 @@ function normalizeTranscript(value: unknown): TranscriptEntry[] { typeof entry.name === "string" ? sanitizeLine(entry.name, 160) || undefined : undefined, + toolCallId: + typeof entry.toolCallId === "string" + ? sanitizeLine(entry.toolCallId, 1_024) || undefined + : undefined, isError: entry.isError === true, timestamp: typeof entry.timestamp === "number" ? entry.timestamp : undefined, @@ -677,15 +682,16 @@ export class WorkflowDashboard { private phaseIndex = 0; private agentIndex = 0; private detailFocus: DetailFocus = "phases"; - private transcriptScroll = 0; + private transcriptViewport = new TranscriptViewport(); private transcriptRowCount = 0; private transcriptViewportSize = 1; + private transcriptRenderer = new WorkflowTranscriptRenderer(); private current?: RunEntry; private openedDirectly = false; private notice?: string; private noticeAt = 0; private disposed = false; - private timer: ReturnType; + private timer?: ReturnType; private tui: TUI; private theme: Theme; private keybindings: KeybindingsManager; @@ -736,25 +742,43 @@ export class WorkflowDashboard { this.noticeAt = Date.now(); } } + this.refreshTimer(); + } + + private refreshTimer() { + const active = Boolean( + this.notice || + (this.view === "list" + ? this.entries.some( + (entry) => entry.live && entry.details.status === "running", + ) + : this.view === "detail" + ? this.current?.live && this.current.details.status === "running" + : this.current?.live && this.selectedAgent()?.state === "running"), + ); + if (!active) { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + return; + } + if (this.timer) return; this.timer = setInterval(() => { - if ( - this.entries.some((e) => e.live) || - this.current?.live || - this.notice - ) { - this.refresh(); - this.tui.requestRender(); - } + this.refresh(); + this.tui.requestRender(); + this.refreshTimer(); }, SPINNER_INTERVAL_MS); } dispose() { if (this.disposed) return; this.disposed = true; - clearInterval(this.timer); + if (this.timer) clearInterval(this.timer); + this.timer = undefined; } - invalidate() {} + invalidate() { + this.transcriptRenderer.invalidate(); + } private refresh() { const selected = this.entries[this.listIndex]?.runId; @@ -819,11 +843,12 @@ export class WorkflowDashboard { const target = path.join(runsDir(), entry.runId, "report.md"); try { writeFileAtomic(target, buildWorkflowReport(entry.details)); - this.notice = `saved ${shortenHome(target)}`; + this.setNotice(`saved ${shortenHome(target)}`); } catch (error) { - this.notice = `save failed: ${error instanceof Error ? error.message : String(error)}`; + this.setNotice( + `save failed: ${error instanceof Error ? error.message : String(error)}`, + ); } - this.noticeAt = Date.now(); } /** Request cancellation of a run by id, surfacing the outcome as a notice. */ @@ -842,6 +867,7 @@ export class WorkflowDashboard { private setNotice(text: string) { this.notice = text; this.noticeAt = Date.now(); + this.refreshTimer(); } handleInput(data: string) { @@ -923,43 +949,56 @@ export class WorkflowDashboard { } else if (left || cancel) { this.detailFocus = "phases"; } else if ((right || confirm) && this.selectedAgent()) { - this.transcriptScroll = 0; + this.transcriptViewport = new TranscriptViewport(); this.view = "transcript"; } } if (data === "s") this.saveReport(); if (data === "x") this.abortRun(this.current); } else { - const maxScroll = Math.max( - 0, - this.transcriptRowCount - this.transcriptViewportSize, - ); const scrollStep = data === "j" || data === "k" ? TRANSCRIPT_SCROLL_STEP : 1; const pageStep = Math.max(1, this.transcriptViewportSize - 2); if (up) { - this.transcriptScroll = Math.max(0, this.transcriptScroll - scrollStep); + this.transcriptViewport.scrollBy( + -scrollStep, + this.transcriptRowCount, + this.transcriptViewportSize, + ); } else if (down) { - this.transcriptScroll = Math.min( - maxScroll, - this.transcriptScroll + scrollStep, + this.transcriptViewport.scrollBy( + scrollStep, + this.transcriptRowCount, + this.transcriptViewportSize, ); } else if (matchesKey(data, Key.ctrl("u"))) { - this.transcriptScroll = Math.max(0, this.transcriptScroll - pageStep); + this.transcriptViewport.scrollBy( + -pageStep, + this.transcriptRowCount, + this.transcriptViewportSize, + ); } else if (matchesKey(data, Key.ctrl("d"))) { - this.transcriptScroll = Math.min( - maxScroll, - this.transcriptScroll + pageStep, + 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 (data === "g") { - this.transcriptScroll = 0; - } else if (data === "G") { - this.transcriptScroll = maxScroll; } else if (cancel || left) { this.view = "detail"; this.detailFocus = "agents"; } } + this.refreshTimer(); this.tui.requestRender(); } @@ -1314,53 +1353,6 @@ export class WorkflowDashboard { return lines; } - private transcriptRows(agent: AgentRecord, width: number): string[] { - const theme = this.theme; - const rows: string[] = []; - if (agent.transcript.length === 0) { - return [ - theme.fg( - "dim", - " transcript unavailable (this run predates transcript capture)", - ), - ]; - } - - for (const entry of agent.transcript) { - // Tool calls are one-liners: the arrow shows direction, the accent name - // says what ran, and the arguments collapse to a single compact line - // instead of a vertical JSON block. - if (entry.role === "tool") { - const name = entry.name ? sanitizeLine(entry.name, 160) : "unknown"; - const args = compactInlineJson(entry.text); - rows.push( - ` ${theme.fg("muted", "→")} ${theme.fg("accent", name)}${args ? theme.fg("dim", ` ${args}`) : ""}`, - ); - continue; - } - const label = transcriptLabel(entry); - const color = transcriptColor(entry); - const marker = entry.role === "toolResult" ? "←" : "●"; - rows.push( - ` ${theme.fg(color, marker)} ${theme.bold(theme.fg(color, label))}`, - ); - const contentWidth = Math.max(8, width - 4); - const styled = theme.fg( - entry.role === "thinking" || entry.role === "toolResult" - ? "dim" - : entry.isError - ? "error" - : "text", - sanitizeTerminalText(entry.text), - ); - for (const line of wrapTextWithAnsi(styled, contentWidth)) { - rows.push(` ${line}`); - } - rows.push(""); - } - return rows; - } - private renderTranscript( details: WorkflowDetails, agent: AgentRecord, @@ -1397,18 +1389,35 @@ export class WorkflowDashboard { const panelHeight = height - 3; const bodyHeight = Math.max(1, panelHeight - 2); - const rows = this.transcriptRows(agent, width - 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; - const maxScroll = Math.max(0, rows.length - bodyHeight); - this.transcriptScroll = Math.min(this.transcriptScroll, maxScroll); + this.transcriptViewport.reconcile(rows.length, bodyHeight); const visible = rows.slice( - this.transcriptScroll, - this.transcriptScroll + bodyHeight, + this.transcriptViewport.scrollTop, + this.transcriptViewport.scrollTop + bodyHeight, + ); + const linesBelow = this.transcriptViewport.linesBelow( + rows.length, + bodyHeight, ); const position = rows.length > bodyHeight - ? `Transcript · ${this.transcriptScroll + 1}-${Math.min(rows.length, this.transcriptScroll + bodyHeight)}/${rows.length}` + ? `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( @@ -1438,39 +1447,6 @@ function displayError(error: string) { return clean; } -function transcriptLabel(entry: TranscriptEntry): string { - if (entry.role === "user") return "user"; - if (entry.role === "assistant") return "assistant"; - if (entry.role === "thinking") return "thinking"; - const name = entry.name ? sanitizeLine(entry.name, 160) : "unknown"; - return name; -} - -/** - * Tool arguments arrive pretty-printed over many lines; the transcript shows - * them inline. Non-JSON text passes through flattened. - */ -function compactInlineJson(text: string) { - const flat = sanitizeTerminalText(text).trim(); - if (!flat) return ""; - try { - return JSON.stringify(JSON.parse(flat)); - } catch { - return flat.replace(/\s+/g, " "); - } -} - -function transcriptColor( - entry: TranscriptEntry, -): "accent" | "success" | "dim" | "warning" | "error" | "muted" { - if (entry.isError) return "error"; - if (entry.role === "user") return "accent"; - if (entry.role === "assistant") return "success"; - if (entry.role === "thinking") return "dim"; - if (entry.role === "tool") return "warning"; - return "muted"; -} - function groupGlyph(group: PhaseGroup, theme: Theme) { if (group.agents.length === 0) return theme.fg("dim", "○"); if (group.agents.some((a) => a.state === "running")) diff --git a/extensions/workflows/transcript.test.ts b/extensions/workflows/transcript.test.ts new file mode 100644 index 00000000..30d80eb6 --- /dev/null +++ b/extensions/workflows/transcript.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { initTheme, type Theme } from "@earendil-works/pi-coding-agent"; +import type { SubagentSnapshot } from "../subagents/src/domain.ts"; +import { + buildTranscriptLines, + subagentTranscriptDocument, +} from "../subagents/src/ui/transcript.ts"; +import type { TranscriptEntry } from "./model.ts"; +import { + WorkflowTranscriptRenderer, + workflowTranscriptDocument, +} from "./transcript.ts"; + +initTheme("dark", false); + +const theme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, +} as Theme; + +function directSnapshot(): SubagentSnapshot { + return { + id: "sa-1", + origin: "model", + backend: "pi", + title: "shared fixture", + prompt: "shared fixture", + cwd: "/repo", + status: "done", + createdAt: 0, + settledAt: 1, + meta: { backend: "pi" }, + usage: {}, + transcript: [ + { kind: "user", text: "请检查 **状态**" }, + { + kind: "assistant", + parts: [ + { type: "text", text: "先列出:\n\n- one\n- two" }, + { type: "thinking", text: "核对 `git status`" }, + { + type: "toolCall", + toolId: "call-1", + name: "bash", + argsPreview: '{"command":"git status"}', + }, + ], + }, + { + kind: "toolResult", + toolId: "call-1", + name: "bash", + isError: false, + outputPreview: "clean", + }, + ], + liveTools: [], + queued: [], + finalText: "先列出", + turns: 1, + }; +} + +function workflowEntries(): TranscriptEntry[] { + return [ + { role: "user", text: "请检查 **状态**" }, + { role: "assistant", text: "先列出:\n\n- one\n- two" }, + { role: "thinking", text: "核对 `git status`" }, + { + role: "tool", + name: "bash", + toolCallId: "call-1", + text: '{"command":"git status"}', + }, + { + role: "toolResult", + name: "bash", + toolCallId: "call-1", + text: "clean", + }, + ]; +} + +test("Direct and Workflow adapters render one equivalent conversation body", () => { + const direct = directSnapshot(); + const workflow = workflowEntries(); + + assert.deepEqual( + subagentTranscriptDocument(direct), + workflowTranscriptDocument(workflow, direct.cwd), + ); + assert.deepEqual( + buildTranscriptLines(direct, 36, theme, undefined, { now: 0 }), + new WorkflowTranscriptRenderer().render(workflow, direct.cwd, 36, theme, { + now: 0, + }), + ); +}); + +test("old Workflow transcript entries without call ids remain renderable", () => { + const lines = new WorkflowTranscriptRenderer().render( + [ + { role: "tool", name: "read", text: '{"path":"/repo/a.ts"}' }, + { role: "toolResult", name: "read", text: "contents" }, + ], + "/repo", + 40, + theme, + { now: 0 }, + ); + + assert.deepEqual(lines, [" Read a.ts"]); +}); + +test("explicit results consume pending calls before legacy id fallback", () => { + const lines = new WorkflowTranscriptRenderer().render( + [ + { + role: "tool", + name: "read", + toolCallId: "explicit", + text: '{"path":"a.ts"}', + }, + { + role: "toolResult", + name: "read", + toolCallId: "explicit", + text: "alpha", + }, + { role: "tool", name: "read", text: '{"path":"b.ts"}' }, + { role: "toolResult", name: "read", text: "beta" }, + ], + undefined, + 80, + theme, + { now: 0 }, + ); + + assert.deepEqual(lines, [" Read a.ts", "", " Read b.ts"]); +}); diff --git a/extensions/workflows/transcript.ts b/extensions/workflows/transcript.ts new file mode 100644 index 00000000..31c63cdf --- /dev/null +++ b/extensions/workflows/transcript.ts @@ -0,0 +1,137 @@ +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { + AgentTranscriptRenderer, + type AgentTranscriptDocument, + type AgentTranscriptItem, + type AgentTranscriptPart, +} from "../shared/agent-transcript.ts"; +import type { TranscriptEntry } from "./model.ts"; + +/** Project the bounded Workflow artifact into the shared operator document. */ +function buildWorkflowTranscriptDocument( + transcript: ReadonlyArray, + cwd?: string, +): AgentTranscriptDocument { + const items: AgentTranscriptItem[] = []; + const pendingByName = new Map(); + + const appendAssistantPart = (part: AgentTranscriptPart) => { + const previous = items[items.length - 1]; + if (previous?.kind === "assistant") { + items[items.length - 1] = { + ...previous, + parts: [...previous.parts, part], + }; + } else { + items.push({ kind: "assistant", parts: [part] }); + } + }; + + for (let index = 0; index < transcript.length; index++) { + const entry = transcript[index]!; + if (entry.role === "user") { + items.push({ kind: "user", text: entry.text }); + continue; + } + if (entry.role === "assistant") { + appendAssistantPart({ type: "text", text: entry.text }); + continue; + } + if (entry.role === "thinking") { + appendAssistantPart({ type: "thinking", text: entry.text }); + continue; + } + + const name = entry.name ?? "unknown"; + if (entry.role === "tool") { + const toolId = entry.toolCallId ?? `workflow-tool-${index}`; + appendAssistantPart({ + type: "toolCall", + toolId, + name, + argsPreview: entry.text, + }); + const pending = pendingByName.get(name) ?? []; + pending.push(toolId); + pendingByName.set(name, pending); + continue; + } + + const pending = pendingByName.get(name); + let toolId: string; + if (entry.toolCallId) { + toolId = entry.toolCallId; + const pendingIndex = pending?.indexOf(toolId) ?? -1; + if (pendingIndex >= 0) pending?.splice(pendingIndex, 1); + } else { + toolId = pending?.shift() ?? `workflow-result-${index}`; + } + items.push({ + kind: "toolResult", + toolId, + name, + isError: entry.isError === true, + outputPreview: entry.text, + }); + } + + return { items, cwd }; +} + +/** Preserve the shared renderer's identity cache between Workflow repaint ticks. */ +class WorkflowTranscriptAdapter { + private previousEntries?: ReadonlyArray; + private previousCwd?: string; + private previousDocument?: AgentTranscriptDocument; + + document( + transcript: ReadonlyArray, + cwd?: string, + ): AgentTranscriptDocument { + if ( + this.previousDocument && + this.previousCwd === cwd && + this.previousEntries?.length === transcript.length && + this.previousEntries.every((entry, index) => entry === transcript[index]) + ) { + return this.previousDocument; + } + + const document = buildWorkflowTranscriptDocument(transcript, cwd); + this.previousEntries = [...transcript]; + this.previousCwd = cwd; + this.previousDocument = document; + return document; + } +} + +export function workflowTranscriptDocument( + transcript: ReadonlyArray, + cwd?: string, +) { + return buildWorkflowTranscriptDocument(transcript, cwd); +} + +export class WorkflowTranscriptRenderer { + private adapter = new WorkflowTranscriptAdapter(); + private renderer = new AgentTranscriptRenderer(); + + render( + transcript: ReadonlyArray, + cwd: string | undefined, + width: number, + theme: Theme, + options?: { readonly now?: number }, + ) { + return this.renderer.render( + this.adapter.document(transcript, cwd), + width, + theme, + options, + ); + } + + invalidate() { + this.renderer.invalidate(); + } +}