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
44 changes: 17 additions & 27 deletions extensions/file-mutation-display/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -21,8 +21,6 @@ type ActivityRenderState<TDetails> = {
};
};

const HORIZONTAL_PADDING = " ";

const emptyComponent: Component = {
render: () => [],
invalidate() {},
Expand All @@ -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() {},
};
Expand Down
2 changes: 1 addition & 1 deletion extensions/shared/agent-session-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
}
Expand Down
190 changes: 87 additions & 103 deletions extensions/shared/agent-transcript.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<AgentTranscriptPart>,
): 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<AgentTranscriptPart>,
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";
Expand All @@ -159,7 +159,7 @@ function renderToolLine(
cwd?: string,
) {
const { args, fallback } = parseToolArgsPreview(argsPreview);
return renderToolActivityLine(
return renderPaddedToolActivityLine(
{
name,
args,
Expand All @@ -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,
Expand Down Expand Up @@ -227,6 +218,7 @@ function renderToolResultItem(
) {
if (paired) return [];
return [
"",
renderToolLine(
theme,
item.isError ? "error" : "ok",
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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"
Expand All @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions extensions/shared/tool-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading