refactor: handle complex streaming/scrolling with better rendering (#8214)
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user