refactor: handle complex streaming/scrolling with better rendering (#8214)

This commit is contained in:
Alex Hancock
2026-03-31 10:56:13 -04:00
committed by GitHub
parent 4deebf7550
commit 2cfe21566d
3 changed files with 403 additions and 532 deletions
+16 -6
View File
@@ -1,10 +1,20 @@
import { marked } from "marked"; import { Marked } from "marked";
import { markedTerminal } from "marked-terminal"; import { markedTerminal } from "marked-terminal";
marked.use(markedTerminal({ width: 76, reflowText: true, tab: 2 }) as any); let renderer: Marked | null = null;
let rendererWidth = 0;
export function renderMarkdown(src: string): string { function getRenderer(width: number): Marked {
if (!src) return ""; if (renderer && rendererWidth === width) return renderer;
const rendered = marked.parse(src) as string; renderer = new Marked();
return rendered.replace(/\n+$/, ""); renderer.use(markedTerminal({ width, reflowText: true, tab: 2 }) as any);
rendererWidth = width;
return renderer;
}
export function renderMarkdown(src: string, width = 76): string[] {
if (!src) return [];
const m = getRenderer(width);
const rendered = (m.parse(src) as string).replace(/\n+$/, "");
return rendered.split("\n");
} }
+99 -129
View File
@@ -40,51 +40,48 @@ const STATUS_INDICATORS: Record<string, { icon: string; color: string }> = {
failed: { icon: "✗", color: CRANBERRY }, failed: { icon: "✗", color: CRANBERRY },
}; };
function formatJsonCompact(value: unknown, maxWidth: number): string[] { function truncateLine(line: string, maxWidth: number): string {
if (value === undefined || value === null) return []; if (line.length <= maxWidth) return line;
let raw: string; return maxWidth > 1 ? line.slice(0, maxWidth - 1) + "…" : line.slice(0, maxWidth);
try {
raw = JSON.stringify(value, null, 2);
} catch {
raw = String(value);
}
const lines = raw.split("\n");
const result: string[] = [];
for (const line of lines) {
if (line.length <= maxWidth) {
result.push(line);
} else {
let remaining = line;
while (remaining.length > maxWidth) {
result.push(remaining.slice(0, maxWidth));
remaining = remaining.slice(maxWidth);
}
if (remaining) result.push(remaining);
}
}
return result;
} }
function extractTextFromContent(content: ToolCallContent[]): string[] { function formatJsonLines(value: unknown, maxWidth: number): string[] {
if (value === undefined || value === null) return [];
let raw: string;
if (typeof value === "string") {
raw = value;
} else {
try {
raw = JSON.stringify(value, null, 2);
} catch {
raw = String(value);
}
}
return raw.split("\n").map((line) => truncateLine(line, maxWidth));
}
function extractTextLines(content: ToolCallContent[], maxWidth: number): string[] {
const lines: string[] = []; const lines: string[] = [];
for (const item of content) { for (const item of content) {
if (item.type === "content" && item.content) { if (item.type === "content" && item.content) {
const block = item.content as any; const block = item.content as any;
if (block.type === "text" && block.text) { if (block.type === "text" && block.text) {
lines.push(...block.text.split("\n")); for (const line of block.text.split("\n")) {
lines.push(truncateLine(line, maxWidth));
}
} }
} else if (item.type === "diff") { } else if (item.type === "diff") {
const diff = item as any; const diff = item as any;
lines.push(`diff: ${diff.path || "unknown"}`); lines.push(truncateLine(`diff: ${diff.path || "unknown"}`, maxWidth));
} else if (item.type === "terminal") { } else if (item.type === "terminal") {
const term = item as any; const term = item as any;
lines.push(`terminal: ${term.terminalId || "unknown"}`); lines.push(truncateLine(`terminal: ${term.terminalId || "unknown"}`, maxWidth));
} }
} }
return lines; return lines;
} }
function summarizeContent(info: ToolCallInfo): string { function summarizeContent(info: ToolCallInfo, maxWidth: number): string {
const parts: string[] = []; const parts: string[] = [];
if (info.locations && info.locations.length > 0) { if (info.locations && info.locations.length > 0) {
@@ -94,7 +91,7 @@ function summarizeContent(info: ToolCallInfo): string {
} }
if (info.content && info.content.length > 0) { if (info.content && info.content.length > 0) {
const textLines = extractTextFromContent(info.content); const textLines = extractTextLines(info.content, maxWidth);
if (textLines.length > 0) { if (textLines.length > 0) {
const first = textLines[0]!.trim(); const first = textLines[0]!.trim();
if (first.length > 60) { if (first.length > 60) {
@@ -117,129 +114,102 @@ function summarizeContent(info: ToolCallInfo): string {
} }
} }
return parts.join(" · "); return truncateLine(parts.join(" · "), maxWidth);
} }
export function findFeaturedToolCallId( export function renderToolCallLines(
toolCallOrder: string[], info: ToolCallInfo,
toolCalls: Map<string, ToolCallInfo>, width: number,
): string | undefined { expanded: boolean,
for (let i = toolCallOrder.length - 1; i >= 0; i--) { showTabHint: boolean,
const tc = toolCalls.get(toolCallOrder[i]!); ): React.ReactElement[] {
if (tc && (tc.status === "pending" || tc.status === "in_progress")) {
return toolCallOrder[i]!;
}
}
return toolCallOrder[toolCallOrder.length - 1];
}
interface ToolCallProps {
info: ToolCallInfo;
width: number;
expanded: boolean;
showTabHint: boolean;
keyPrefix: string;
}
export function ToolCallCard({
info,
width,
expanded,
showTabHint,
keyPrefix,
}: ToolCallProps): React.ReactNode[] {
const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙"; const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙";
const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!; const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!;
const borderColor = info.status === "failed" ? CRANBERRY : CEDAR; const borderColor = info.status === "failed" ? CRANBERRY : CEDAR;
const dimBorder = info.status !== "failed"; const dimBorder = info.status !== "failed";
const hasInput = info.rawInput !== undefined && info.rawInput !== null; const innerWidth = Math.max(width - 4, 10);
const hasOutput = info.rawOutput !== undefined && info.rawOutput !== null; const indentedWidth = Math.max(innerWidth - 2, 8);
const hasContent = info.content && info.content.length > 0;
const hasLocations = info.locations && info.locations.length > 0;
const contentWidth = width - 4; const lines: React.ReactElement[] = [];
const k = info.toolCallId;
const lines: React.ReactNode[] = []; const hRule = "─".repeat(Math.max(width - 2, 0));
const content: React.ReactNode[] = []; lines.push(
<Box key={`${k}-t`} width={width} height={1}>
// Header <Text color={borderColor} dimColor={dimBorder}>{hRule}</Text>
const runningText = info.status === "in_progress" ? " running…" : ""; </Box>,
content.push(
<Box key="header" flexDirection="row">
<Text color={statusInfo.color}>{statusInfo.icon}</Text>
<Text> </Text>
<Text>{kindIcon}</Text>
<Text> </Text>
<Text color={TEXT_SECONDARY} bold>{info.title}</Text>
{runningText && <Text color={TEXT_DIM} italic>{runningText}</Text>}
<Box flexGrow={1} />
{showTabHint && !expanded && <Text color={TEXT_DIM} italic>tab </Text>}
</Box>
); );
// Compact view - show summary const row = (key: string, content: React.ReactNode) => {
if (!expanded) { lines.push(
const summary = summarizeContent(info); <Box key={key} width={width} height={1}>
if (summary) { <Text color={borderColor} dimColor={dimBorder}> </Text>
content.push( <Box width={innerWidth} height={1}>
<Box key="summary"> {content}
<Text color={TEXT_DIM}>{summary}</Text>
</Box> </Box>
); <Text color={borderColor} dimColor={dimBorder}> </Text>
</Box>,
);
};
const statusIcon = statusInfo.icon;
const runningText = info.status === "in_progress" ? " running…" : "";
const tabHintText = showTabHint && !expanded ? "tab ↔" : "";
const fixedLen = 4 + runningText.length + tabHintText.length; // icon+space+kind+space + suffix + hint
const titleMax = Math.max(innerWidth - fixedLen, 4);
const title = truncateLine(info.title, titleMax);
row(`${k}-h`, (
<>
<Text color={statusInfo.color}>{statusIcon}</Text>
<Text> {kindIcon} </Text>
<Text wrap="truncate-end" color={TEXT_SECONDARY} bold>{title}</Text>
{runningText ? <Text color={TEXT_DIM} italic>{runningText}</Text> : null}
<Box flexGrow={1} />
{tabHintText ? <Text color={TEXT_DIM} italic>{tabHintText}</Text> : null}
</>
));
if (!expanded) {
const summary = summarizeContent(info, innerWidth);
if (summary) {
row(`${k}-s`, <Text wrap="truncate-end" color={TEXT_DIM}>{summary}</Text>);
} }
} else { } else {
// Expanded view - show all details if (info.locations) {
const inputLines = hasInput ? formatJsonCompact(info.rawInput, contentWidth - 6) : []; for (let i = 0; i < info.locations.length; i++) {
const outputLines = hasOutput ? formatJsonCompact(info.rawOutput, contentWidth - 6) : []; const loc = info.locations[i]!;
const contentLines = hasContent ? extractTextFromContent(info.content!) : []; const t = truncateLine(`📁 ${loc.path}${loc.line ? `:${loc.line}` : ""}`, innerWidth);
row(`${k}-l${i}`, <Text wrap="truncate-end" color={TEXT_DIM}>{t}</Text>);
if (hasLocations) {
for (let i = 0; i < info.locations!.length; i++) {
const loc = info.locations![i]!;
content.push(
<Box key={`loc-${i}`}>
<Text color={TEXT_DIM}>📁 {loc.path}{loc.line ? `:${loc.line}` : ""}</Text>
</Box>
);
} }
} }
const addSection = (label: string, sectionLines: string[]) => { const section = (label: string, sLines: string[]) => {
if (sectionLines.length === 0) return; if (sLines.length === 0) return;
row(`${k}-${label}H`, <Text color={TEXT_DIM}> {label}:</Text>);
content.push( for (let i = 0; i < sLines.length; i++) {
<Box key={`${label}-header`}> row(`${k}-${label}${i}`, (
<Text color={TEXT_DIM}> {label}:</Text> <Text wrap="truncate-end" color={TEXT_DIM}>{" "}{sLines[i]}</Text>
</Box> ));
);
for (let i = 0; i < sectionLines.length; i++) {
content.push(
<Box key={`${label}-${i}`} paddingLeft={2}>
<Text color={TEXT_DIM}>{sectionLines[i]}</Text>
</Box>
);
} }
}; };
addSection("input", inputLines); if (info.rawInput !== undefined && info.rawInput !== null) {
addSection("output", outputLines); section("in", formatJsonLines(info.rawInput, indentedWidth));
addSection("content", contentLines); }
if (info.rawOutput !== undefined && info.rawOutput !== null) {
section("out", formatJsonLines(info.rawOutput, indentedWidth));
}
if (info.content && info.content.length > 0) {
section("ct", extractTextLines(info.content, indentedWidth));
}
} }
lines.push( lines.push(
<Box <Box key={`${k}-b`} width={width} height={1}>
key={keyPrefix} <Text color={borderColor} dimColor={dimBorder}>{hRule}</Text>
width={width} </Box>,
flexDirection="column"
borderStyle="round"
borderColor={borderColor}
borderDimColor={dimBorder}
paddingX={1}
>
{content}
</Box>
); );
return lines; return lines;
+288 -397
View File
File diff suppressed because it is too large Load Diff