Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/research/CHILD_RENDER_PARITY_2026-10-05.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/research/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
34 changes: 33 additions & 1 deletion extensions/file-mutation-display/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TParams extends TSchema, TDetails, TState>(
definition: ToolDefinition<TParams, TDetails, TState>,
Expand All @@ -25,6 +29,32 @@ function compact<TParams extends TSchema, TDetails, TState>(
* 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
Expand All @@ -41,6 +71,8 @@ export default function fileMutationDisplay(pi: ExtensionAPI) {
display.fileMutationDisplay === "full",
);

if (modern) return;

pi.registerTool(
compact(
createBashToolDefinition(ctx.cwd),
Expand Down
33 changes: 29 additions & 4 deletions extensions/file-mutation-display/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -71,10 +80,26 @@ function activityComponent(
export function withActivityRenderer<TParams extends TSchema, TDetails, TState>(
definition: ToolDefinition<TParams, TDetails, TState>,
): ToolDefinition<TParams, TDetails, TState & ActivityRenderState<TDetails>> {
return {
...definition,
...activityRenderers(definition.name, definition),
};
}

/** Presentation-only projection, usable by headless child inspection too. */
export function activityRenderers<TParams extends TSchema, TDetails, TState>(
name: string,
definition: Pick<
ToolDefinition<TParams, TDetails, TState>,
"renderCall" | "renderResult"
>,
): Pick<
ToolDefinition<TParams, TDetails, TState & ActivityRenderState<TDetails>>,
"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<TDetails>;
Expand All @@ -85,7 +110,7 @@ export function withActivityRenderer<TParams extends TSchema, TDetails, TState>(
}
if (
context.executionStarted &&
definition.name === "bash" &&
name === "bash" &&
activity.status === "pending" &&
!context.expanded &&
activity.interval === undefined
Expand All @@ -108,7 +133,7 @@ export function withActivityRenderer<TParams extends TSchema, TDetails, TState>(
return component;
}
return activityComponent(
definition.name,
name,
args,
activity as NonNullable<ActivityRenderState<unknown>["openpiActivity"]>,
theme,
Expand All @@ -127,7 +152,7 @@ export function withActivityRenderer<TParams extends TSchema, TDetails, TState>(
: "success";
if (
options.isPartial &&
definition.name === "bash" &&
name === "bash" &&
!options.expanded &&
activity.interval === undefined
) {
Expand Down
73 changes: 72 additions & 1 deletion extensions/shared/agent-tool-renderer.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<ToolExecutionComponent["updateResult"]>[0];

interface ToolExecutionRecord {
Expand Down Expand Up @@ -90,6 +138,27 @@ function normalizeResult(value: unknown, isError: boolean): ToolResult {
export class AgentToolRenderLedger implements AgentToolRenderer {
private executions = new Map<string, ToolExecutionRecord>();

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,
Expand Down Expand Up @@ -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,
);
Expand Down
53 changes: 44 additions & 9 deletions extensions/shared/agent-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<AgentTranscriptPart>,
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);
Expand Down Expand Up @@ -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" };
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand All @@ -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 ??
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading