Skip to content
Open
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
33 changes: 23 additions & 10 deletions src/ui/conversation-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import type { AgentSession } from "@earendil-works/pi-coding-agent";
import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import { type Component, Input, Markdown, type MarkdownTheme, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import { extractText } from "../context.js";
import type { AgentRecord } from "../types.js";
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
Expand All @@ -31,6 +31,8 @@ export class ConversationViewer implements Component {
private keys: ViewerKeys;
/** Steering composer — present while the user is typing a message to the agent. */
private composer: Input | undefined;
/** Viewer-local Markdown styling avoids depending on Pi's global theme initialization in tests and embedded sessions. */
private readonly markdownTheme: MarkdownTheme;

constructor(
private tui: TUI,
Expand All @@ -46,6 +48,22 @@ export class ConversationViewer implements Component {
/** Send a steering message to the agent. Omitted → no compose affordance. */
private onSteer?: (message: string) => void,
) {
this.markdownTheme = {
heading: text => theme.bold(theme.fg("accent", text)),
link: text => theme.fg("accent", text),
linkUrl: text => theme.fg("muted", text),
code: text => theme.fg("muted", text),
codeBlock: text => theme.fg("muted", text),
codeBlockBorder: text => theme.fg("dim", text),
quote: text => theme.fg("muted", text),
quoteBorder: text => theme.fg("dim", text),
hr: text => theme.fg("dim", text),
listBullet: text => theme.fg("accent", text),
bold: text => theme.bold(text),
italic: text => text,
strikethrough: text => theme.fg("dim", text),
underline: text => text,
};
this.keys = createViewerKeys(keybindings);
this.unsubscribe = session.subscribe(() => {
if (this.closed) return;
Expand Down Expand Up @@ -316,22 +334,17 @@ export class ConversationViewer implements Component {
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(th.bold("[Assistant]"));
if (textParts.length > 0) {
for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
lines.push(line);
}
lines.push(...new Markdown(textParts.join("\n").trim(), 0, 0, this.markdownTheme).render(width));
}
for (const name of toolCalls) {
lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
}
} else if (msg.role === "toolResult") {
const text = extractText(msg.content);
const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
if (!truncated.trim()) continue;
const text = extractText(msg.content).trim();
if (!text) continue;
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(th.fg("dim", "[Result]"));
for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
lines.push(th.fg("dim", line));
}
lines.push(...new Markdown(text, 0, 0, this.markdownTheme).render(width));
} else if ((msg as any).role === "bashExecution") {
const bash = msg as any;
if (needsSeparator) lines.push(th.fg("dim", "───"));
Expand Down
32 changes: 32 additions & 0 deletions test/conversation-viewer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,38 @@ describe("ConversationViewer", () => {
}
});
});
describe("Markdown rendering", () => {

it("renders assistant Markdown instead of raw source markers", () => {
const messages = [
{ role: "assistant", content: [{ type: "text", text: "# Heading\n\n- first\n- second\n\n**bold**" }] },
];
const viewer = new ConversationViewer(
mockTui(200, 80), mockSession(messages), mockRecord({ status: "completed" }), undefined, ansiTheme(), vi.fn(),
);
const output = viewer.render(80).join("\n");

expect(output).toContain("Heading");
expect(output).not.toContain("# Heading");
expect(output).not.toContain("**bold**");
});

it("renders complete tool-result Markdown without the old 500-character truncation", () => {
const sentinel = "END_OF_CTX_RESULT";
const text = `## ctx_execute\n\n\`\`\`javascript\n${"const value = 1;\n".repeat(40)}\`\`\`\n\n${sentinel}`;
const messages = [
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text }] },
];
const viewer = new ConversationViewer(
mockTui(2000, 80), mockSession(messages), mockRecord({ status: "completed" }), undefined, ansiTheme(), vi.fn(),
);
const output = viewer.render(80).join("\n");

expect(output).toContain(sentinel);
expect(output).not.toContain("... (truncated)");
});
});


describe("safety net against upstream wrapTextWithAnsi bugs", () => {
// These tests call buildContentLines() directly (via the private method)
Expand Down