Render transcript markdown and fold command stdout
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
60
ui/src/components/transcript/RunTranscriptView.test.tsx
Normal file
60
ui/src/components/transcript/RunTranscriptView.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { TranscriptEntry } from "../../adapters";
|
||||
import { ThemeProvider } from "../../context/ThemeContext";
|
||||
import { RunTranscriptView, normalizeTranscript } from "./RunTranscriptView";
|
||||
|
||||
describe("RunTranscriptView", () => {
|
||||
it("keeps running command stdout inside the command fold instead of a standalone stdout block", () => {
|
||||
const entries: TranscriptEntry[] = [
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: "2026-03-12T00:00:00.000Z",
|
||||
name: "command_execution",
|
||||
toolUseId: "cmd_1",
|
||||
input: { command: "ls -la" },
|
||||
},
|
||||
{
|
||||
kind: "stdout",
|
||||
ts: "2026-03-12T00:00:01.000Z",
|
||||
text: "file-a\nfile-b",
|
||||
},
|
||||
];
|
||||
|
||||
const blocks = normalizeTranscript(entries, false);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
type: "command_group",
|
||||
items: [{ result: "file-a\nfile-b", status: "running" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders assistant and thinking content as markdown in compact mode", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<RunTranscriptView
|
||||
density="compact"
|
||||
entries={[
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-03-12T00:00:00.000Z",
|
||||
text: "Hello **world**",
|
||||
},
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: "2026-03-12T00:00:01.000Z",
|
||||
text: "- first\n- second",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("<strong>world</strong>");
|
||||
expect(html).toContain("<li>first</li>");
|
||||
expect(html).toContain("<li>second</li>");
|
||||
});
|
||||
});
|
||||
@@ -98,16 +98,6 @@ function truncate(value: string, max: number): string {
|
||||
return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}…` : value;
|
||||
}
|
||||
|
||||
function stripMarkdown(value: string): string {
|
||||
return compactWhitespace(
|
||||
value
|
||||
.replace(/```[\s\S]*?```/g, " code ")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/[*_#>-]/g, " "),
|
||||
);
|
||||
}
|
||||
|
||||
function humanizeLabel(value: string): string {
|
||||
return value
|
||||
.replace(/[_-]+/g, " ")
|
||||
@@ -329,7 +319,7 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
|
||||
export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
|
||||
const blocks: TranscriptBlock[] = [];
|
||||
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
|
||||
const pendingActivityBlocks = new Map<string, Extract<TranscriptBlock, { type: "activity" }>>();
|
||||
@@ -486,6 +476,17 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeCommandBlock = [...blocks].reverse().find(
|
||||
(block): block is Extract<TranscriptBlock, { type: "tool" }> =>
|
||||
block.type === "tool" && block.status === "running" && isCommandTool(block.name, block.input),
|
||||
);
|
||||
if (activeCommandBlock) {
|
||||
activeCommandBlock.result = activeCommandBlock.result
|
||||
? `${activeCommandBlock.result}${activeCommandBlock.result.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`}`
|
||||
: entry.text;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous?.type === "stdout") {
|
||||
previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`;
|
||||
previous.ts = entry.ts;
|
||||
@@ -519,15 +520,14 @@ function TranscriptMessageBlock({
|
||||
<span>User</span>
|
||||
</div>
|
||||
)}
|
||||
{compact ? (
|
||||
<div className="text-xs leading-5 text-foreground/85 whitespace-pre-wrap break-words">
|
||||
{truncate(stripMarkdown(block.text), 360)}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownBody className="text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
{block.text}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
<MarkdownBody
|
||||
className={cn(
|
||||
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
compact ? "text-xs leading-5 text-foreground/85" : "text-sm",
|
||||
)}
|
||||
>
|
||||
{block.text}
|
||||
</MarkdownBody>
|
||||
{block.streaming && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 text-[10px] font-medium italic text-muted-foreground">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
@@ -549,14 +549,14 @@ function TranscriptThinkingBlock({
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
<MarkdownBody
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words italic text-foreground/70",
|
||||
"italic text-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
density === "compact" ? "text-[11px] leading-5" : "text-sm leading-6",
|
||||
)}
|
||||
>
|
||||
{block.text}
|
||||
</div>
|
||||
</MarkdownBody>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user