From ba56e42c60e7c5c09a66c3270d160aad72d3bf95 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 24 Aug 2026 21:04:43 +0800 Subject: [PATCH] fix(tui): render child transcripts with Pi message components --- extensions/file-mutation-display/render.ts | 44 ++--- extensions/shared/agent-session-page.test.ts | 2 +- extensions/shared/agent-transcript.ts | 190 +++++++++---------- extensions/shared/tool-activity.ts | 18 ++ extensions/subagents/transcript.test.ts | 69 +++++-- extensions/workflows/dashboard.test.ts | 9 +- extensions/workflows/transcript.test.ts | 9 +- 7 files changed, 191 insertions(+), 150 deletions(-) diff --git a/extensions/file-mutation-display/render.ts b/extensions/file-mutation-display/render.ts index 3fff9956..d208bf17 100644 --- a/extensions/file-mutation-display/render.ts +++ b/extensions/file-mutation-display/render.ts @@ -3,9 +3,9 @@ import type { Theme, ToolDefinition, } from "@earendil-works/pi-coding-agent"; -import { truncateToWidth, type Component } from "@earendil-works/pi-tui"; +import type { Component } from "@earendil-works/pi-tui"; import type { TSchema } from "typebox"; -import { toolActivityText } from "../shared/tool-activity.ts"; +import { renderPaddedToolActivityLine } from "../shared/tool-activity.ts"; type ActivityStatus = "pending" | "success" | "error"; @@ -21,8 +21,6 @@ type ActivityRenderState = { }; }; -const HORIZONTAL_PADDING = " "; - const emptyComponent: Component = { render: () => [], invalidate() {}, @@ -46,29 +44,21 @@ function activityComponent( ): Component { return { render(width) { - const contentWidth = width - HORIZONTAL_PADDING.length * 2; - if (contentWidth <= 0) return []; - const ellipsis = - state.status === "success" ? theme.fg("muted", "…") : "…"; - return [ - `${HORIZONTAL_PADDING}${truncateToWidth( - 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}`, - ]; + const line = renderPaddedToolActivityLine( + { + name, + args, + output: textOutput(state.result), + details: state.result?.details, + status: state.status, + cwd, + startedAt: state.startedAt, + endedAt: state.endedAt, + }, + theme, + width, + ); + return line ? [line] : []; }, invalidate() {}, }; diff --git a/extensions/shared/agent-session-page.test.ts b/extensions/shared/agent-session-page.test.ts index 1041d408..36c4b4b4 100644 --- a/extensions/shared/agent-session-page.test.ts +++ b/extensions/shared/agent-session-page.test.ts @@ -63,7 +63,7 @@ test("writable and read-only children use one full-terminal page", () => { 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"), /Inspect the page/); assert.match(lines.join("\n"), /Result/); assert.doesNotMatch(lines.join("\n"), /╭|╮|Transcript/); } diff --git a/extensions/shared/agent-transcript.ts b/extensions/shared/agent-transcript.ts index 79c2e8b9..873d33af 100644 --- a/extensions/shared/agent-transcript.ts +++ b/extensions/shared/agent-transcript.ts @@ -1,18 +1,17 @@ -/** Shared operator-facing agent transcript rendering. */ +/** Shared Pi-native operator-facing agent transcript rendering. */ -import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; import { - Markdown, - truncateToWidth, - visibleWidth, - wrapTextWithAnsi, - type DefaultTextStyle, - type MarkdownOptions, -} from "@earendil-works/pi-tui"; + AssistantMessageComponent, + getMarkdownTheme, + UserMessageComponent, + type Theme, +} from "@earendil-works/pi-coding-agent"; +import { TruncatedText } from "@earendil-works/pi-tui"; import { sanitizeTerminalText } from "./terminal-text.ts"; import { parseToolArgsPreview, - renderToolActivityLine, + renderPaddedToolActivityLine, type ToolActivityStatus, } from "./tool-activity.ts"; @@ -73,71 +72,72 @@ export function sanitizeText(text: string): string { return sanitizeTerminalText(text); } -function transcriptMarkdownTheme() { - const theme = getMarkdownTheme(); +const emptyUsage: AssistantMessage["usage"] = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +function assistantMessage( + parts: ReadonlyArray, +): AssistantMessage { 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(/^(?:[-+*]) /, "• ")), + role: "assistant", + content: parts.map((part) => { + if (part.type === "text") + return { type: "text", text: sanitizeText(part.text) }; + if (part.type === "thinking") { + return { + type: "thinking", + thinking: part.redacted + ? "[redacted reasoning]" + : sanitizeText(part.text), + ...(part.redacted ? { redacted: true } : {}), + }; + } + const { args } = parseToolArgsPreview(part.argsPreview); + return { + type: "toolCall", + id: part.toolId, + name: part.name, + arguments: + args !== null && typeof args === "object" && !Array.isArray(args) + ? args + : {}, + }; + }), + api: "openai-responses", + provider: "openai", + model: "child-transcript", + usage: emptyUsage, + stopReason: parts.some((part) => part.type === "toolCall") + ? "toolUse" + : "stop", + timestamp: 0, }; } -function renderMarkdown( - text: string, - width: number, - defaultTextStyle?: DefaultTextStyle, - options?: MarkdownOptions, -) { +function renderUserText(text: string, width: number) { 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, - ), - ); + return new UserMessageComponent(clean, getMarkdownTheme()).render(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, +function renderAssistantParts( + parts: ReadonlyArray, + width: number, + streaming = false, +) { + const component = new AssistantMessageComponent( + assistantMessage(parts), + false, + getMarkdownTheme(), ); - for (let i = 0; i < lines.length; i++) { - out.push(truncateToWidth((i === 0 ? prefix : " ") + lines[i], width)); - } - return out; + if (streaming) component.updateContent(assistantMessage(parts), true); + return component.render(width); } export type ToolPhase = "live" | "ok" | "error" | "pending"; @@ -159,7 +159,7 @@ function renderToolLine( cwd?: string, ) { const { args, fallback } = parseToolArgsPreview(argsPreview); - return renderToolActivityLine( + return renderPaddedToolActivityLine( { name, args, @@ -182,24 +182,15 @@ function renderAssistantItem( now: number, cwd?: string, ) { - const out: string[] = []; + const out = renderAssistantParts(item.parts, width); 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") { + 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(""); out.push( renderToolLine( theme, @@ -227,6 +218,7 @@ function renderToolResultItem( ) { if (paired) return []; return [ + "", renderToolLine( theme, item.isError ? "error" : "ok", @@ -267,7 +259,7 @@ function renderTranscriptItem( now: number, cwd?: string, ) { - if (item.kind === "user") return renderUserText(theme, item.text, width); + if (item.kind === "user") return renderUserText(item.text, width); if (item.kind === "assistant") { return renderAssistantItem(theme, item, width, context.tools, now, cwd); } @@ -378,7 +370,6 @@ export class AgentTranscriptRenderer { this.itemCache.set(item, widths); } if (lines.length > 0) { - if (out.length > 0 && !context.paired) out.push(""); out.push(...lines); } } @@ -387,18 +378,17 @@ export class AgentTranscriptRenderer { // 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(); + const parts: AgentTranscriptPart[] = []; + if (thinking.trim()) parts.push({ type: "thinking", text: thinking }); + if (text.trim()) parts.push({ type: "text", text }); + out.push(...renderAssistantParts(parts, width, true)); } // 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(""); + out.push(""); const phase: ToolPhase = tool.done ? tool.isError ? "error" @@ -418,24 +408,18 @@ export class AgentTranscriptRenderer { ); } - // Queued steering/follow-up messages: show them immediately so Enter - // visibly acknowledges the user's input instead of appearing to do nothing. + // Match Pi's pending-message projection. The dequeue hint is intentionally + // omitted because the child page does not expose Pi's queue editor. + if ((document.queued?.length ?? 0) > 0) out.push(""); 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)), + const label = message.kind === "steer" ? "Steering" : "Follow-up"; + out.push( + ...new TruncatedText( + theme.fg("dim", `${label}: ${sanitizeText(message.text)}`), + 1, + 0, + ).render(width), ); - 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; diff --git a/extensions/shared/tool-activity.ts b/extensions/shared/tool-activity.ts index d9c27350..06087a10 100644 --- a/extensions/shared/tool-activity.ts +++ b/extensions/shared/tool-activity.ts @@ -314,6 +314,24 @@ export function renderToolActivityLine( ); } +/** + * Match the horizontal shell used by Pi's collapsed tool component. Keeping + * this here lets persisted child transcripts and live parent tools share the + * exact same operator-facing row without pretending bounded previews contain + * the full native tool result. + */ +export function renderPaddedToolActivityLine( + activity: ToolActivity, + theme: Theme, + width: number, + now = Date.now(), +) { + const padding = " "; + const contentWidth = width - padding.length * 2; + if (contentWidth <= 0) return ""; + return `${padding}${renderToolActivityLine(activity, theme, contentWidth, now)}${padding}`; +} + /** Historical Direct helper retained without owning a second formatter. */ export function summarizeToolArgs( name: string, diff --git a/extensions/subagents/transcript.test.ts b/extensions/subagents/transcript.test.ts index be2b7480..4e031bbe 100644 --- a/extensions/subagents/transcript.test.ts +++ b/extensions/subagents/transcript.test.ts @@ -82,10 +82,11 @@ test("takeover transcript renders finalized and live assistant Markdown within i ); const rendered = plain(lines); - assert.doesNotMatch(rendered, /\*\*|`|- first item|- active item/); + assert.doesNotMatch(rendered, /\*\*|`/); + assert.doesNotMatch(rendered, /(?:^|\n)> Request:/); assert.match( rendered, - /Request:|takeover\.ts|Counts:|npm test|• first item|Live:|Markdown|Plan:/, + /Request:|takeover\.ts|Counts:|npm test|- first item|Live:|Markdown|Plan:/, ); assert.ok(lines.every((line) => visibleWidth(line) <= 24)); }); @@ -227,7 +228,7 @@ test("tool call and output lines drop the child cwd prefix", () => { theme, ); - assert.deepEqual(lines, [" Searched *.mjs in scripts 1 result"]); + assert.deepEqual(lines, ["", "  Searched *.mjs in scripts 1 result "]); }); test("pending tool calls use the parent activity verbs", () => { @@ -259,8 +260,11 @@ test("pending tool calls use the parent activity verbs", () => { ), ); - assert.match(rendered, /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Running {2}git status --porcelain/m); - assert.match(rendered, /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Reading {2}src\/index\.ts/m); + assert.match( + rendered, + /^ {2}[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Running {2}git status --porcelain {2}$/m, + ); + assert.match(rendered, /^ {2}[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Reading {2}src\/index\.ts {2}$/m); }); test("settled tools use one semantic activity row", () => { @@ -291,7 +295,7 @@ test("settled tools use one semantic activity row", () => { theme, ); - assert.deepEqual(lines, [" Ran printf ok"]); + assert.deepEqual(lines, ["", "  Ran printf ok "]); }); test("parallel tool results reuse their earlier command lines", () => { @@ -335,7 +339,12 @@ test("parallel tool results reuse their earlier command lines", () => { theme, ); - assert.deepEqual(lines, [" Read a.ts", " Read b.ts"]); + assert.deepEqual(lines, [ + "", + "  Read a.ts ", + "", + "  Read b.ts ", + ]); }); test("tool errors and empty results use status glyphs", () => { @@ -420,11 +429,11 @@ test("a running tool becomes settled without reflowing", () => { ); // The command appears exactly once and keeps the same target column. - assert.deepEqual(running, ["⠋ Running git status"]); - assert.deepEqual(settled, [" Ran git status"]); + assert.deepEqual(running, ["", " ⠋ Running git status "]); + assert.deepEqual(settled, ["", "  Ran git status "]); assert.equal( - running[0]?.indexOf("git status"), - settled[0]?.indexOf("git status"), + running[1]?.indexOf("git status"), + settled[1]?.indexOf("git status"), ); }); @@ -451,8 +460,38 @@ test("the spinner advances between frames instead of freezing in the cache", () const first = renderer.render(snap, 80, theme, { now: 0 }); const later = renderer.render(snap, 80, theme, { now: SPINNER_INTERVAL_MS }); - assert.notEqual(first[0], later[0]); - assert.equal(first[0]?.slice(1), later[0]?.slice(1)); + assert.notEqual(first[1], later[1]); + assert.equal(first[1]?.slice(3), later[1]?.slice(3)); +}); + +test("empty live assistant buffers do not hide live tools or Pi-style queued messages", () => { + const rendered = plain( + buildTranscriptLines( + snapshot({ + liveAssistant: { text: "", thinking: "" }, + liveTools: [ + { + toolId: "read-1", + name: "read", + argsPreview: '{"path":"src/index.ts"}', + }, + ], + queued: [ + { kind: "steer", text: "check tests" }, + { kind: "follow-up", text: "summarize" }, + ], + }), + 80, + theme, + undefined, + { now: 0 }, + ), + ); + + assert.match(rendered, /Reading {2}src\/index\.ts/); + assert.match(rendered, /Steering: check tests/); + assert.match(rendered, /Follow-up: summarize/); + assert.doesNotMatch(rendered, /\[queued/); }); test("cached items are keyed by width and by tool phase", () => { @@ -494,8 +533,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[1]!, /^ {2}[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); + assert.match(settled[1]!, /^ {2}/); }); test("spinnerFrame is deterministic and advances every 120ms", () => { diff --git a/extensions/workflows/dashboard.test.ts b/extensions/workflows/dashboard.test.ts index fa6110f3..ea1de41b 100644 --- a/extensions/workflows/dashboard.test.ts +++ b/extensions/workflows/dashboard.test.ts @@ -10,7 +10,11 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import type { KeybindingsManager } from "@earendil-works/pi-coding-agent"; +import { stripVTControlCharacters } from "node:util"; +import { + initTheme, + type KeybindingsManager, +} from "@earendil-works/pi-coding-agent"; import type { TUI } from "@earendil-works/pi-tui"; import type { Theme, WorkflowDetails } from "./model.ts"; import { SPINNER_INTERVAL_MS } from "../shared/spinner.ts"; @@ -18,6 +22,7 @@ import { SPINNER_INTERVAL_MS } from "../shared/spinner.ts"; // runsDir() resolves against getAgentDir(), which reads this env var. const agentDir = mkdtempSync(join(tmpdir(), "my-pi-setup-workflows-")); process.env.PI_CODING_AGENT_DIR = agentDir; +initTheme("dark", false); const { buildWorkflowReport, @@ -439,7 +444,7 @@ test("direct workflow navigation drills right and returns left through every lev assert.match(transcript, /writer/); assert.match(transcript, /git status/); assert.doesNotMatch(transcript, /╭|╮|Transcript/); - assert.doesNotMatch(transcript, /clipboard|\u001b/); + assert.doesNotMatch(stripVTControlCharacters(transcript), /clipboard/); dashboard.handleInput("left"); assert.match(dashboard.render(120).at(-1) ?? "", /select agent/); diff --git a/extensions/workflows/transcript.test.ts b/extensions/workflows/transcript.test.ts index 30d80eb6..567feba2 100644 --- a/extensions/workflows/transcript.test.ts +++ b/extensions/workflows/transcript.test.ts @@ -111,7 +111,7 @@ test("old Workflow transcript entries without call ids remain renderable", () => { now: 0 }, ); - assert.deepEqual(lines, [" Read a.ts"]); + assert.deepEqual(lines, ["", "  Read a.ts "]); }); test("explicit results consume pending calls before legacy id fallback", () => { @@ -138,5 +138,10 @@ test("explicit results consume pending calls before legacy id fallback", () => { { now: 0 }, ); - assert.deepEqual(lines, [" Read a.ts", "", " Read b.ts"]); + assert.deepEqual(lines, [ + "", + "  Read a.ts ", + "", + "  Read b.ts ", + ]); });