From 5e6e4612b09434fdee57ae4f38e04ea78ddd7edc Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 5 Oct 2026 01:10:50 +0800 Subject: [PATCH] fix(tui): reuse Session presentation in child conversations --- .../CHILD_RENDER_PARITY_2026-10-05.md | 29 +++ docs/research/README.md | 2 + extensions/file-mutation-display/index.ts | 34 ++- extensions/file-mutation-display/render.ts | 33 ++- extensions/shared/agent-tool-renderer.ts | 73 +++++- extensions/shared/agent-transcript.ts | 53 ++++- extensions/subagents/src/backends/pi.ts | 4 +- extensions/workflows/runner.ts | 4 +- .../file-mutation-display/index.test.ts | 21 +- .../shared/agent-tool-renderer-native.test.ts | 209 ++++++++++++++++++ 10 files changed, 441 insertions(+), 21 deletions(-) create mode 100644 docs/research/CHILD_RENDER_PARITY_2026-10-05.md create mode 100644 tests/extensions/shared/agent-tool-renderer-native.test.ts diff --git a/docs/research/CHILD_RENDER_PARITY_2026-10-05.md b/docs/research/CHILD_RENDER_PARITY_2026-10-05.md new file mode 100644 index 00000000..4e591a33 --- /dev/null +++ b/docs/research/CHILD_RENDER_PARITY_2026-10-05.md @@ -0,0 +1,29 @@ +# Child conversation presentation parity + +- Status: validated at the isolated SDK/component boundary; live terminal acceptance remains unverified. +- Created and verified: 2026-10-05. +- Source boundary: OpenPI main `331327e590c63769fb0f7ce99d03b2ddfd4145c0` plus the change linked from Issue #681; Pi SDK 0.99.1 and installed SDK 1.0.2. +- Related Issue: [#681](https://github.com/openpi-dev/openpi/issues/681), extending [#658](https://github.com/openpi-dev/openpi/issues/658) and [#673](https://github.com/openpi-dev/openpi/issues/673). +- Superseding relationship: none; this supplements the earlier child-page and Code Mode investigations. + +## Verified facts + +Main TUI presentation applies the Session's public `resolveToolRenderers` chain. The previous child ledger constructed `ToolExecutionComponent` directly from captured execution definitions, skipping that chain. Consequently OpenPI's presentation-only Code Mode extension did not affect the inspected child tool cards. + +Both Direct and Workflow child ledgers now resolve presentation through their own bound Session. Ordinary tool activity cards register a presentation resolver on newer hosts in both interactive and headless modes. The resolver preserves native execution definitions, parameters, model-facing metadata and result payloads. Pi 0.99.1 has no resolver API, so the child ledger applies the shared activity projection locally; it does not register replacement child tools or discard configured shell executors. + +User and assistant message components now receive the child Session's thinking visibility, output padding, code-block indentation and registered Markdown transformers. Cache identity includes the scalar settings. Messages with registered transformers are not cached, because transformer behavior can change without changing the message identity. None of these values is written into the model transcript or persisted execution state. + +## Validation + +`tests/extensions/shared/agent-tool-renderer-native.test.ts` creates an isolated headless Session without model calls. With the same arguments, event payloads and clock, it compares the child ledger to the main public presentation chain for read, bash, write, edit, grep, find, ls and Code Mode. Coverage includes partial updates, success/error completion, widths 40/80 and collapsed/expanded/collapsed transitions. Tool executor identity and the model tool surface remain unchanged. Changing thinking visibility after a cached render takes effect. + +The test passes against both locked SDK 0.99.1 and installed SDK 1.0.2. Independent source review and isolated native-host smoke also checked the two child backends, native expanded output, and the absence of child executor replacement. These results prove component output, not the currently running user's terminal pixels. + +## Boundaries and unknowns + +- Main Pi additionally installs a private Mermaid Markdown transformer. It is not available through the public extension runner and is not copied by this fix. Mermaid parity requires a public Pi presentation seam; ordinary Markdown uses the same native message components. +- Child pages continue to suppress native image painting. Fullscreen image ownership is a separate upstream Pi concern tracked in #657; enabling it here would reintroduce unbounded terminal ownership. +- Historical transcripts without a live renderer ledger retain their explicitly bounded previews. Full event payloads are not reconstructed from truncated persisted previews. +- Installed source provenance was checked before diagnosis. The installed checkout contains unrelated user changes and was preserved. Isolated tests explicitly loaded the repair source; the running installed Session has not been shown to reload it. +- Real terminal scrolling, image pixels and an actual running child's visual comparison still require manual acceptance. Component equivalence alone does not close those observations. diff --git a/docs/research/README.md b/docs/research/README.md index b66765bf..7a90eaf7 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -16,6 +16,8 @@ Research records preserve sourced investigation and distinguish observations, in ## Validated investigations +- [`CHILD_RENDER_PARITY_2026-10-05.md`](CHILD_RENDER_PARITY_2026-10-05.md) — Session-owned child message/tool presentation, native paired component validation and host-private Mermaid/image boundaries ([#681](https://github.com/openpi-dev/openpi/issues/681)). + - [`CODEMODE_TUI_PRESENTATION_2026-10-04.md`](CODEMODE_TUI_PRESENTATION_2026-10-04.md) — OpenPI presentation through Pi public tool renderer resolvers, compact summaries and raw-evidence boundaries ([#673](https://github.com/openpi-dev/openpi/issues/673)). - [`INSTALLED_RUNTIME_ACCEPTANCE_2026-10-04.md`](INSTALLED_RUNTIME_ACCEPTANCE_2026-10-04.md) — installed-source snapshot, Direct/Workflow receipts and native setup rendering in an isolated SDK Session; live TUI, multi-client and model-error-rate boundaries remain open (#651/#652/#654). diff --git a/extensions/file-mutation-display/index.ts b/extensions/file-mutation-display/index.ts index fd885834..98256a97 100644 --- a/extensions/file-mutation-display/index.ts +++ b/extensions/file-mutation-display/index.ts @@ -11,7 +11,11 @@ import { } from "@earendil-works/pi-coding-agent"; import type { TSchema } from "typebox"; import { loadSetupConfig } from "../shared/setup-config.ts"; -import { withActivityRenderer } from "./render.ts"; +import { + activityEnabled, + activityRenderers, + withActivityRenderer, +} from "./render.ts"; function compact( definition: ToolDefinition, @@ -25,6 +29,32 @@ function compact( * native schema, prompt metadata, execute function, result, and details. */ export default function fileMutationDisplay(pi: ExtensionAPI) { + // Modern hosts separate presentation from tool execution. Register the same + // resolver in TUI and headless Sessions so child inspection inherits it + // without replacing the child's shell/filesystem executor. + type Presentation = Pick< + ToolDefinition, + "renderCall" | "renderResult" | "renderShell" + >; + const host = pi as ExtensionAPI & { + registerToolRenderer?: ( + resolver: ( + name: string, + next: () => Presentation | undefined, + ) => Presentation | undefined, + ) => void; + }; + const modern = typeof host.registerToolRenderer === "function"; + if (host.registerToolRenderer) { + host.registerToolRenderer((name, next) => { + const base = next(); + if (!base) return base; + const display = loadSetupConfig().ui; + return activityEnabled(name, display) + ? { ...base, ...activityRenderers(name, base) } + : base; + }); + } pi.on("session_start", (_event, ctx) => { const display = loadSetupConfig().ui; // This extension changes only the interactive TUI projection. Headless @@ -41,6 +71,8 @@ export default function fileMutationDisplay(pi: ExtensionAPI) { display.fileMutationDisplay === "full", ); + if (modern) return; + pi.registerTool( compact( createBashToolDefinition(ctx.cwd), diff --git a/extensions/file-mutation-display/render.ts b/extensions/file-mutation-display/render.ts index d208bf17..210df956 100644 --- a/extensions/file-mutation-display/render.ts +++ b/extensions/file-mutation-display/render.ts @@ -6,6 +6,15 @@ import type { import type { Component } from "@earendil-works/pi-tui"; import type { TSchema } from "typebox"; import { renderPaddedToolActivityLine } from "../shared/tool-activity.ts"; +import type { MyPiSetupConfig } from "../shared/setup-config.ts"; + +export function activityEnabled(name: string, display: MyPiSetupConfig["ui"]) { + return name === "bash" + ? display.bashToolDisplay !== "full" + : name === "write" || name === "edit" + ? display.fileMutationDisplay !== "full" + : ["read", "grep", "find", "ls"].includes(name); +} type ActivityStatus = "pending" | "success" | "error"; @@ -71,10 +80,26 @@ function activityComponent( export function withActivityRenderer( definition: ToolDefinition, ): ToolDefinition> { + return { + ...definition, + ...activityRenderers(definition.name, definition), + }; +} + +/** Presentation-only projection, usable by headless child inspection too. */ +export function activityRenderers( + name: string, + definition: Pick< + ToolDefinition, + "renderCall" | "renderResult" + >, +): Pick< + ToolDefinition>, + "renderCall" | "renderResult" | "renderShell" +> { const nativeRenderCall = definition.renderCall; const nativeRenderResult = definition.renderResult; return { - ...definition, renderShell: "self", renderCall(args, theme, context) { const state = context.state as TState & ActivityRenderState; @@ -85,7 +110,7 @@ export function withActivityRenderer( } if ( context.executionStarted && - definition.name === "bash" && + name === "bash" && activity.status === "pending" && !context.expanded && activity.interval === undefined @@ -108,7 +133,7 @@ export function withActivityRenderer( return component; } return activityComponent( - definition.name, + name, args, activity as NonNullable["openpiActivity"]>, theme, @@ -127,7 +152,7 @@ export function withActivityRenderer( : "success"; if ( options.isPartial && - definition.name === "bash" && + name === "bash" && !options.expanded && activity.interval === undefined ) { diff --git a/extensions/shared/agent-tool-renderer.ts b/extensions/shared/agent-tool-renderer.ts index 9b43e22c..868cd43e 100644 --- a/extensions/shared/agent-tool-renderer.ts +++ b/extensions/shared/agent-tool-renderer.ts @@ -1,10 +1,17 @@ /** Ephemeral bridge from child tool events to Pi's canonical tool renderer. */ import { + type AgentSession, + type MarkdownTransformer, ToolExecutionComponent, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import type { TUI } from "@earendil-works/pi-tui"; +import { + activityEnabled, + activityRenderers, +} from "../file-mutation-display/render.ts"; +import { loadSetupConfig } from "./setup-config.ts"; export interface AgentToolRenderRequest { readonly toolId: string; @@ -20,9 +27,50 @@ export interface AgentToolRenderer { request: AgentToolRenderRequest, width: number, ): string[] | undefined; + readonly messagePresentation?: AgentMessagePresentation; invalidate?(): void; } +export interface AgentMessagePresentation { + readonly hideThinking: boolean; + readonly outputPad: number; + readonly codeBlockIndent: string; + readonly markdownTransformers: readonly MarkdownTransformer[]; +} + +type Presentation = Pick< + ToolDefinition, + "renderCall" | "renderResult" | "renderShell" +>; + +/** Use the child Session's own public presentation chain, never its executor. */ +export function createAgentToolRenderLedger(session: AgentSession) { + const runner = session.extensionRunner as typeof session.extensionRunner & { + resolveToolRenderers?: ( + name: string, + base: () => Presentation | undefined, + ) => Presentation | undefined; + }; + return new AgentToolRenderLedger( + (name, definition) => { + if (typeof runner.resolveToolRenderers === "function") { + return runner.resolveToolRenderers(name, () => definition); + } + // Older Pi has no presentation resolver. Reuse OpenPI's projection + // locally, without registering/replacing the headless child's tools. + return definition && activityEnabled(name, loadSetupConfig().ui) + ? { ...definition, ...activityRenderers(name, definition) } + : definition; + }, + () => ({ + hideThinking: session.settingsManager.getHideThinkingBlock(), + outputPad: session.settingsManager.getOutputPad(), + codeBlockIndent: session.settingsManager.getCodeBlockIndent(), + markdownTransformers: runner.getMarkdownTransformers(), + }), + ); +} + type ToolResult = Parameters[0]; interface ToolExecutionRecord { @@ -90,6 +138,27 @@ function normalizeResult(value: unknown, isError: boolean): ToolResult { export class AgentToolRenderLedger implements AgentToolRenderer { private executions = new Map(); + private resolveRenderers?: ( + name: string, + definition?: ToolDefinition, + ) => Presentation | undefined; + private getMessagePresentation?: () => AgentMessagePresentation; + + constructor( + resolveRenderers?: ( + name: string, + definition?: ToolDefinition, + ) => Presentation | undefined, + getMessagePresentation?: () => AgentMessagePresentation, + ) { + this.resolveRenderers = resolveRenderers; + this.getMessagePresentation = getMessagePresentation; + } + + get messagePresentation() { + return this.getMessagePresentation?.(); + } + start( toolId: string, name: string, @@ -189,7 +258,9 @@ export class AgentToolRenderLedger implements AgentToolRenderer { request.toolId, execution.args, { showImages: false }, - execution.definition, + this.resolveRenderers + ? this.resolveRenderers(execution.name, execution.definition) + : execution.definition, inertTui, cwd, ); diff --git a/extensions/shared/agent-transcript.ts b/extensions/shared/agent-transcript.ts index a05fc5c1..aeb7478c 100644 --- a/extensions/shared/agent-transcript.ts +++ b/extensions/shared/agent-transcript.ts @@ -8,7 +8,10 @@ import { type Theme, } from "@earendil-works/pi-coding-agent"; import { Text, TruncatedText } from "@earendil-works/pi-tui"; -import type { AgentToolRenderer } from "./agent-tool-renderer.ts"; +import type { + AgentMessagePresentation, + AgentToolRenderer, +} from "./agent-tool-renderer.ts"; import { sanitizeTerminalText } from "./terminal-text.ts"; import { parseToolArgsPreview, @@ -141,21 +144,41 @@ function assistantMessage( }; } -function renderUserText(text: string, width: number) { +function messageTheme(presentation?: AgentMessagePresentation) { + return { + ...getMarkdownTheme(), + ...(presentation ? { codeBlockIndent: presentation.codeBlockIndent } : {}), + }; +} + +function renderUserText( + text: string, + width: number, + presentation?: AgentMessagePresentation, +) { const clean = sanitizeText(text).trim(); if (!clean) return []; - return new UserMessageComponent(clean, getMarkdownTheme()).render(width); + return new UserMessageComponent( + clean, + messageTheme(presentation), + presentation?.outputPad, + presentation?.markdownTransformers, + ).render(width); } function renderAssistantParts( parts: ReadonlyArray, width: number, streaming = false, + presentation?: AgentMessagePresentation, ) { const component = new AssistantMessageComponent( assistantMessage(parts), - false, - getMarkdownTheme(), + presentation?.hideThinking ?? false, + messageTheme(presentation), + undefined, + presentation?.outputPad, + presentation?.markdownTransformers, ); if (streaming) component.updateContent(assistantMessage(parts), true); return component.render(width); @@ -231,7 +254,12 @@ function renderAssistantItem( toolRenderer?: AgentToolRenderer, expanded = false, ) { - const out = renderAssistantParts(item.parts, width); + const out = renderAssistantParts( + item.parts, + width, + false, + toolRenderer?.messagePresentation, + ); for (const part of item.parts) { if (part.type === "toolCall") { const state = tools.get(part.toolId) ?? { phase: "pending" }; @@ -335,7 +363,8 @@ function renderTranscriptItem( toolRenderer?: AgentToolRenderer, expanded = false, ) { - if (item.kind === "user") return renderUserText(item.text, width); + if (item.kind === "user") + return renderUserText(item.text, width, toolRenderer?.messagePresentation); if (item.kind === "assistant") { return renderAssistantItem( theme, @@ -471,6 +500,7 @@ export class AgentTranscriptRenderer { const out: string[] = []; const now = options?.now ?? Date.now(); const expanded = options?.expanded === true; + const presentation = document.toolRenderer?.messagePresentation; const liveTools = document.liveTools ?? []; if (document.toolRenderer) this.toolRenderers.add(document.toolRenderer); const liveIds = new Set(liveTools.map((tool) => tool.toolId)); @@ -485,8 +515,13 @@ export class AgentTranscriptRenderer { context.token, document.cwd, itemHasTool(item) && expanded, + presentation?.hideThinking, + presentation?.outputPad, + presentation?.codeBlockIndent, ]); - const cacheable = !document.toolRenderer || !itemHasTool(item); + const cacheable = + !document.toolRenderer || + (!itemHasTool(item) && !presentation?.markdownTransformers.length); const cached = cacheable ? this.itemCache.get(item)?.get(key) : undefined; const lines = cached ?? @@ -521,7 +556,7 @@ export class AgentTranscriptRenderer { 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)); + out.push(...renderAssistantParts(parts, width, true, presentation)); } // Live tool executions. The manager drops a live entry when its ToolEnd diff --git a/extensions/subagents/src/backends/pi.ts b/extensions/subagents/src/backends/pi.ts index b60878af..a1a4ecc4 100644 --- a/extensions/subagents/src/backends/pi.ts +++ b/extensions/subagents/src/backends/pi.ts @@ -25,7 +25,7 @@ import { } from "@earendil-works/pi-coding-agent"; import type { Cause, Scope } from "effect"; import { Effect, Queue, Stream } from "effect"; -import { AgentToolRenderLedger } from "../../../shared/agent-tool-renderer.ts"; +import { createAgentToolRenderLedger } from "../../../shared/agent-tool-renderer.ts"; import { bindChildSessionExtensions, CHILD_SHUTDOWN_TIMEOUT_MS, @@ -326,7 +326,7 @@ const makePiSession = ( const toolTimeout = createToolCallTimeoutGuard(); toolTimeout.apply(session); - const toolRenderer = new AgentToolRenderLedger(); + const toolRenderer = createAgentToolRenderLedger(session); const activeModel = (): Model | undefined => { const sessionModel = session.model; diff --git a/extensions/workflows/runner.ts b/extensions/workflows/runner.ts index c6f23713..1528d14a 100644 --- a/extensions/workflows/runner.ts +++ b/extensions/workflows/runner.ts @@ -22,7 +22,7 @@ import { SettingsManager, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; -import { AgentToolRenderLedger } from "../shared/agent-tool-renderer.ts"; +import { createAgentToolRenderLedger } from "../shared/agent-tool-renderer.ts"; import { bindChildSessionExtensions, childToolPolicy, @@ -413,7 +413,7 @@ export async function runAgent( } const childSession = session; - const toolRenderer = new AgentToolRenderLedger(); + const toolRenderer = createAgentToolRenderLedger(childSession); let usage = emptyUsage(); let modelId = childSession.model?.id ?? options.model?.id; let contextWindow = childSession.model?.contextWindow; diff --git a/tests/extensions/file-mutation-display/index.test.ts b/tests/extensions/file-mutation-display/index.test.ts index 1c36a1f0..c96a982b 100644 --- a/tests/extensions/file-mutation-display/index.test.ts +++ b/tests/extensions/file-mutation-display/index.test.ts @@ -21,6 +21,7 @@ import { ToolExecutionComponent, type ExtensionContext, type Theme, + type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import type { TUI } from "@earendil-works/pi-tui"; import fileMutationDisplay from "../../../extensions/file-mutation-display/index.ts"; @@ -65,6 +66,22 @@ async function withSession( test("overrides all seven activity renderers without changing model-facing definitions", async () => { await withSession(async (session, cwd) => { + type Presentation = Pick< + ToolDefinition, + "renderCall" | "renderResult" | "renderShell" + >; + const runner = session.extensionRunner as typeof session.extensionRunner & { + resolveToolRenderers?: ( + name: string, + base: () => Presentation | undefined, + ) => Presentation | undefined; + }; + const presentation = (name: string) => { + const definition = session.getToolDefinition(name); + return runner.resolveToolRenderers + ? runner.resolveToolRenderers(name, () => definition) + : definition; + }; const native = Object.fromEntries( [ createBashToolDefinition(cwd), @@ -82,7 +99,7 @@ test("overrides all seven activity renderers without changing model-facing defin const expected = native[name]; assert.ok(actual, name); assert.ok(expected, name); - assert.equal(actual.renderShell, "self", name); + assert.equal(presentation(name)?.renderShell, "self", name); assert.equal(actual.name, expected.name, name); assert.equal(actual.label, expected.label, name); assert.equal(actual.description, expected.description, name); @@ -99,7 +116,7 @@ test("overrides all seven activity renderers without changing model-facing defin const write = session.getToolDefinition("write"); const edit = session.getToolDefinition("edit"); - const renderWrite = write?.renderCall; + const renderWrite = presentation("write")?.renderCall; assert.ok(renderWrite); const identityTheme = new Proxy( {}, diff --git a/tests/extensions/shared/agent-tool-renderer-native.test.ts b/tests/extensions/shared/agent-tool-renderer-native.test.ts new file mode 100644 index 00000000..5dc772ab --- /dev/null +++ b/tests/extensions/shared/agent-tool-renderer-native.test.ts @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + createAgentSession, + createCodemodeExtension, + DefaultResourceLoader, + initTheme, + SessionManager, + SettingsManager, + ToolExecutionComponent, + type ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { + KeybindingsManager, + setKeybindings, + type TUI, +} from "@earendil-works/pi-tui"; +import codemodeDisplay from "../../../extensions/codemode-display/index.ts"; +import fileMutationDisplay from "../../../extensions/file-mutation-display/index.ts"; +import { + activityEnabled, + activityRenderers, +} from "../../../extensions/file-mutation-display/render.ts"; +import { createAgentToolRenderLedger } from "../../../extensions/shared/agent-tool-renderer.ts"; +import { AgentTranscriptRenderer } from "../../../extensions/shared/agent-transcript.ts"; +import { stripVTControlCharacters } from "node:util"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { loadSetupConfig } from "../../../extensions/shared/setup-config.ts"; + +initTheme("dark", false); +setKeybindings( + new KeybindingsManager({ + "app.tools.expand": { + defaultKeys: "ctrl+o", + description: "Toggle tool output", + }, + }), +); + +test("child tool cards equal the main public renderer chain, without changing execution", async (t) => { + t.mock.method(Date, "now", () => 1000); + const cwd = await mkdtemp(path.join(tmpdir(), "openpi-child-parity-")); + const agentDir = path.join(cwd, "agent"); + await mkdir(agentDir); + const settingsManager = SettingsManager.inMemory(undefined, { + projectTrusted: false, + }); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager, + extensionFactories: [ + createCodemodeExtension(), + codemodeDisplay, + fileMutationDisplay, + ], + }); + await loader.reload(); + const { session } = await createAgentSession({ + cwd, + agentDir, + settingsManager, + resourceLoader: loader, + sessionManager: SessionManager.inMemory(cwd), + }); + try { + await session.bindExtensions({ mode: "print" }); + const ledger = createAgentToolRenderLedger(session); + type Presentation = Pick< + ToolDefinition, + "renderCall" | "renderResult" | "renderShell" + >; + const runner = session.extensionRunner as typeof session.extensionRunner & { + resolveToolRenderers?: ( + name: string, + base: () => Presentation | undefined, + ) => Presentation | undefined; + }; + const surfaceBefore = JSON.stringify(session.getAllTools()); + const fixtures = [ + ["read", { path: "fixture.ts" }], + ["bash", { command: "printf evidence" }], + ["write", { path: "fixture.ts", content: "evidence" }], + [ + "edit", + { + path: "fixture.ts", + edits: [{ oldText: "before", newText: "after" }], + }, + ], + ["grep", { pattern: "evidence", path: "." }], + ["find", { pattern: "*.ts", path: "." }], + ["ls", { path: "." }], + ["codemode", { code: "text(await tools.read({path: 'fixture.ts'}));" }], + ] as const; + for (const [name, args] of fixtures) { + const definition = session.getToolDefinition(name); + assert.ok(definition, name); + const executor = definition.execute; + const selected = runner.resolveToolRenderers + ? runner.resolveToolRenderers(name, () => definition) + : activityEnabled(name, loadSetupConfig().ui) + ? { ...definition, ...activityRenderers(name, definition) } + : definition; + for (const isError of [false, true]) { + const id = `${name}-${isError}`; + const parent = new ToolExecutionComponent( + name, + id, + args, + { showImages: false }, + selected, + { requestRender() {} } as TUI, + cwd, + ); + parent.markExecutionStarted(); + parent.setArgsComplete(); + ledger.start(id, name, args, definition); + const result = { + content: [ + { + type: "text" as const, + text: "Script completed\nWall time 0.1 seconds\nOutput:\nevidence-sentinel", + }, + ], + details: + name === "codemode" + ? { + calls: [ + { + id: "1", + name: "read", + args: '{"path":"fixture.ts"}', + status: isError ? "error" : "ok", + durationMs: 2, + }, + ], + } + : undefined, + isError, + }; + parent.updateResult({ ...result, isError: false }, true); + ledger.update(id, name, args, result); + for (const width of [40, 80]) { + assert.deepEqual( + ledger.renderTool({ toolId: id, name, cwd }, width), + parent.render(width), + `${name} streaming ${width}`, + ); + } + parent.updateResult(result, false); + ledger.end(id, name, result, isError); + for (const expanded of [false, true, false]) { + parent.setExpanded(expanded); + for (const width of [40, 80]) { + assert.deepEqual( + ledger.renderTool({ toolId: id, name, cwd, expanded }, width), + parent.render(width), + `${name} error=${isError} expanded=${expanded} width=${width}`, + ); + } + } + assert.equal(session.getToolDefinition(name)?.execute, executor); + } + } + assert.equal(JSON.stringify(session.getAllTools()), surfaceBefore); + const renderer = new AgentTranscriptRenderer(); + const document = { + items: [ + { + kind: "assistant" as const, + parts: [ + { type: "thinking" as const, text: "thinking-sentinel" }, + { type: "text" as const, text: "**answer-sentinel**" }, + ], + }, + ], + toolRenderer: ledger, + }; + const theme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + } as Theme; + const rendered = () => + stripVTControlCharacters(renderer.render(document, 80, theme).join("\n")); + assert.match(rendered(), /thinking-sentinel/); + settingsManager.setHideThinkingBlock(true); + assert.doesNotMatch(rendered(), /thinking-sentinel/); + assert.match(rendered(), /answer-sentinel/); + settingsManager.setOutputPad(0); + assert.equal(ledger.messagePresentation?.hideThinking, true); + assert.equal(ledger.messagePresentation?.outputPad, 0); + settingsManager.setHideThinkingBlock(false); + assert.equal(ledger.messagePresentation?.hideThinking, false); + assert.match(rendered(), /thinking-sentinel/); + t.diagnostic( + runner.resolveToolRenderers + ? "public renderer resolver exercised" + : "older SDK presentation-only compatibility exercised", + ); + } finally { + session.dispose(); + await rm(cwd, { recursive: true, force: true }); + } +});