Skip to content

Commit 3e4e1a2

Browse files
committed
feat(chat): add dedicated ANSI-to-Spans utility and enhance tool exploration logic
- Introduce `ansiToSpans` for rendering ANSI color codes in UI output - Refactor `ExplorationCard` with improved input parsing and heading generation - Support richer detail formatting for tool operations using new helpers - Deduplicate tool lifecycle entries to prevent redundant logs in UI
1 parent 9245876 commit 3e4e1a2

7 files changed

Lines changed: 523 additions & 77 deletions

File tree

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ function sameId(left: string | null | undefined, right: string | null | undefine
7171
return left === right;
7272
}
7373

74-
function truncateDetail(value: string, limit = 180): string {
74+
const SUMMARY_DETAIL_LIMIT = 180;
75+
const TOOL_OUTPUT_LIMIT = 20_000;
76+
77+
function truncateDetail(value: string, limit = SUMMARY_DETAIL_LIMIT): string {
7578
return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
7679
}
7780

@@ -443,7 +446,9 @@ function runtimeEventToActivities(
443446
payload: {
444447
itemType: event.payload.itemType,
445448
...(event.payload.status ? { status: event.payload.status } : {}),
446-
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
449+
...(event.payload.detail
450+
? { detail: truncateDetail(event.payload.detail, TOOL_OUTPUT_LIMIT) }
451+
: {}),
447452
...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
448453
},
449454
turnId: toTurnId(event.turnId) ?? null,
@@ -465,7 +470,9 @@ function runtimeEventToActivities(
465470
summary: event.payload.title ?? "Tool",
466471
payload: {
467472
itemType: event.payload.itemType,
468-
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
473+
...(event.payload.detail
474+
? { detail: truncateDetail(event.payload.detail, TOOL_OUTPUT_LIMIT) }
475+
: {}),
469476
...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
470477
},
471478
turnId: toTurnId(event.turnId) ?? null,
@@ -487,7 +494,9 @@ function runtimeEventToActivities(
487494
summary: `${event.payload.title ?? "Tool"} started`,
488495
payload: {
489496
itemType: event.payload.itemType,
490-
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
497+
...(event.payload.detail
498+
? { detail: truncateDetail(event.payload.detail, TOOL_OUTPUT_LIMIT) }
499+
: {}),
491500
},
492501
turnId: toTurnId(event.turnId) ?? null,
493502
...maybeSequence,

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1886,6 +1886,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
18861886
});
18871887
}
18881888

1889+
const completedDetail = toolResult.text.length > 0 ? toolResult.text : tool.detail;
18891890
const completedStamp = yield* makeEventStamp();
18901891
yield* offerRuntimeEvent({
18911892
type: "item.completed",
@@ -1899,7 +1900,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
18991900
itemType: tool.itemType,
19001901
status: itemStatus,
19011902
title: tool.title,
1902-
...(tool.detail ? { detail: tool.detail } : {}),
1903+
...(completedDetail ? { detail: completedDetail } : {}),
19031904
data: toolData,
19041905
},
19051906
providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }),

apps/web/src/components/chat/CommandExecutionCard.tsx

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, CircleXIcon, TerminalIcon } from "lucide-react";
22
import { memo, useLayoutEffect, useMemo, useRef, useState } from "react";
33
import { cn } from "~/lib/utils";
4+
import { ansiToSpans } from "~/lib/ansiToSpans";
45
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
56
import type { WorkLogEntry } from "../../session-logic";
67

@@ -11,23 +12,28 @@ interface CommandExecutionCardProps {
1112

1213
type CommandStatus = "running" | "error" | "success";
1314

14-
const PREVIEW_MAX_HEIGHT = "120px";
15+
const PREVIEW_MAX_HEIGHT_PX = 120;
16+
const MIN_OVERFLOW_PX = 24;
1517

1618
function deriveCommandStatus(entry: WorkLogEntry, isLive: boolean): CommandStatus {
17-
if (isLive && !entry.detail && entry.exitCode === undefined) return "running";
19+
if (!entry.toolCompleted && entry.exitCode === undefined) return "running";
20+
if (isLive && entry.exitCode === undefined) return "running";
1821
if (entry.tone === "error" || (entry.exitCode !== undefined && entry.exitCode !== 0))
1922
return "error";
2023
return "success";
2124
}
2225

23-
const DETAIL_COMMAND_PREFIX_RE = /^(?:Bash|Shell|Sh):\s*/i;
26+
const DETAIL_COMMAND_PREFIX_RE = /^(?:Bash|Shell|Sh|Read|Edit|Write|Grep|Glob):\s*/i;
2427

2528
function deriveCommandAndOutput(entry: WorkLogEntry): {
2629
displayCommand: string | null;
2730
output: string | null;
2831
} {
2932
if (entry.command) {
30-
return { displayCommand: entry.command, output: entry.detail ?? null };
33+
const output = detailIsDistinctOutput(entry.detail, entry.command)
34+
? (entry.detail ?? null)
35+
: null;
36+
return { displayCommand: entry.command, output };
3137
}
3238
if (entry.detail) {
3339
const firstNewline = entry.detail.indexOf("\n");
@@ -41,6 +47,14 @@ function deriveCommandAndOutput(entry: WorkLogEntry): {
4147
return { displayCommand: null, output: entry.detail ?? null };
4248
}
4349

50+
function detailIsDistinctOutput(detail: string | undefined, command: string): boolean {
51+
if (!detail || detail.length === 0) return false;
52+
const stripped = detail.replace(DETAIL_COMMAND_PREFIX_RE, "").trim();
53+
if (stripped === command.trim()) return false;
54+
if (stripped.startsWith("{") && stripped.includes(command.slice(0, 20))) return false;
55+
return true;
56+
}
57+
4458
const STATUS_ACCENT: Record<CommandStatus, string> = {
4559
running: "border-l-amber-400/40",
4660
error: "border-l-rose-400/40",
@@ -86,11 +100,12 @@ export const CommandExecutionCard = memo(function CommandExecutionCard(
86100

87101
const status = deriveCommandStatus(entry, isLive);
88102
const { displayCommand, output } = useMemo(() => deriveCommandAndOutput(entry), [entry]);
103+
const renderedOutput = useMemo(() => (output ? ansiToSpans(output) : null), [output]);
89104

90105
useLayoutEffect(() => {
91106
const el = previewRef.current;
92107
if (!el || expanded) return;
93-
setPreviewOverflows(el.scrollHeight > el.clientHeight + 1);
108+
setPreviewOverflows(el.scrollHeight > el.clientHeight + MIN_OVERFLOW_PX);
94109
}, [expanded, output]);
95110

96111
const hasMoreContent = expanded || previewOverflows;
@@ -125,25 +140,25 @@ export const CommandExecutionCard = memo(function CommandExecutionCard(
125140
)}
126141
</Tooltip>
127142

128-
{output && !expanded && (
143+
{renderedOutput && !expanded && (
129144
<div
130145
ref={previewRef}
131146
className="relative overflow-hidden border-t border-border/20"
132-
style={{ maxHeight: PREVIEW_MAX_HEIGHT }}
147+
style={{ maxHeight: `${PREVIEW_MAX_HEIGHT_PX}px` }}
133148
>
134149
<pre className="whitespace-pre-wrap break-words px-3 py-1.5 font-mono text-[10px] leading-4 text-muted-foreground/55">
135-
{output}
150+
{renderedOutput}
136151
</pre>
137152
{previewOverflows && (
138153
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-card/90 to-transparent" />
139154
)}
140155
</div>
141156
)}
142157

143-
{output && expanded && (
158+
{renderedOutput && expanded && (
144159
<div className="border-t border-border/20">
145-
<pre className="max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words px-3 py-1.5 font-mono text-[10px] leading-4 text-muted-foreground/55">
146-
{output}
160+
<pre className="overflow-y-auto whitespace-pre-wrap break-words px-3 py-1.5 font-mono text-[10px] leading-4 text-muted-foreground/55">
161+
{renderedOutput}
147162
</pre>
148163
</div>
149164
)}

apps/web/src/components/chat/ExplorationCard.tsx

Lines changed: 114 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,84 @@ function isSearchToolName(toolName: string | undefined): boolean {
2424
return SEARCH_TOOL_NAMES.has(toolName.toLowerCase());
2525
}
2626

27+
function fileNameFromPath(filePath: string): string {
28+
const parts = filePath.split("/");
29+
return parts[parts.length - 1] ?? filePath;
30+
}
31+
32+
function inputStr(input: Record<string, unknown> | undefined, key: string): string | null {
33+
if (!input) return null;
34+
const value = input[key];
35+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
36+
}
37+
38+
function inputNum(input: Record<string, unknown> | undefined, key: string): number | null {
39+
if (!input) return null;
40+
const value = input[key];
41+
return typeof value === "number" && Number.isFinite(value) ? value : null;
42+
}
43+
44+
function inputFilePath(input: Record<string, unknown> | undefined): string | null {
45+
return inputStr(input, "file_path") ?? inputStr(input, "filePath") ?? inputStr(input, "path");
46+
}
47+
48+
function formatLineRange(input: Record<string, unknown> | undefined): string | null {
49+
if (!input) return null;
50+
const offset = inputNum(input, "offset");
51+
const limit = inputNum(input, "limit");
52+
if (offset !== null && limit !== null) {
53+
return `L${offset + 1}${offset + limit}`;
54+
}
55+
if (offset !== null) {
56+
return `from L${offset + 1}`;
57+
}
58+
if (limit !== null && limit < 2000) {
59+
return `first ${limit} lines`;
60+
}
61+
return null;
62+
}
63+
2764
function explorationEntryHeading(entry: WorkLogEntry): string {
28-
if (entry.toolName) {
29-
const lower = entry.toolName.toLowerCase();
30-
if (lower === "read") return `Read ${extractFileName(entry.detail)}`;
31-
if (lower === "grep") return `Searched for ${extractSearchSummary(entry.detail)}`;
32-
if (lower === "glob") return `Glob ${extractSearchSummary(entry.detail)}`;
33-
if (lower === "list" || lower === "ls") return `Listed ${extractPathSummary(entry.detail)}`;
34-
if (lower === "find") return `Found ${extractPathSummary(entry.detail)}`;
65+
const input = entry.toolInput;
66+
const lower = entry.toolName?.toLowerCase();
67+
68+
if (lower === "read") {
69+
const filePath = inputFilePath(input);
70+
const fileName = filePath
71+
? fileNameFromPath(filePath)
72+
: extractFileNameFromDetail(entry.detail);
73+
const lineRange = formatLineRange(input);
74+
if (fileName && lineRange) return `Read ${fileName} (${lineRange})`;
75+
if (fileName) return `Read ${fileName}`;
76+
return "Read file";
77+
}
78+
79+
if (lower === "grep") {
80+
const pattern = inputStr(input, "pattern");
81+
const path = inputFilePath(input);
82+
if (pattern && path) return `Searched for ${pattern} in ${fileNameFromPath(path)}`;
83+
if (pattern) return `Searched for ${pattern}`;
84+
return `Searched ${extractSearchSummaryFromDetail(entry.detail)}`;
85+
}
86+
87+
if (lower === "glob") {
88+
const pattern = inputStr(input, "pattern");
89+
const path = inputFilePath(input);
90+
if (pattern && path) return `Glob ${pattern} in ${fileNameFromPath(path)}`;
91+
if (pattern) return `Glob ${pattern}`;
92+
return `Glob ${extractSearchSummaryFromDetail(entry.detail)}`;
93+
}
94+
95+
if (lower === "list" || lower === "ls") {
96+
const path = inputFilePath(input);
97+
if (path) return `Listed ${fileNameFromPath(path)}`;
98+
return `Listed ${extractPathSummaryFromDetail(entry.detail)}`;
99+
}
100+
101+
if (lower === "find") {
102+
const path = inputFilePath(input);
103+
if (path) return `Found ${fileNameFromPath(path)}`;
104+
return `Found ${extractPathSummaryFromDetail(entry.detail)}`;
35105
}
36106

37107
const raw = (entry.toolTitle ?? entry.label).trim();
@@ -54,34 +124,59 @@ function isGenericLabel(label: string): boolean {
54124
);
55125
}
56126

57-
function extractFileName(detail: string | undefined): string {
127+
function stripToolPrefix(value: string): string {
128+
return value.replace(/^[A-Za-z_]+:\s*/, "").trim();
129+
}
130+
131+
function tryParseJson(value: string): Record<string, unknown> | null {
132+
if (!value.startsWith("{")) return null;
133+
try {
134+
const parsed = JSON.parse(value);
135+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
136+
} catch {
137+
return null;
138+
}
139+
}
140+
141+
function extractFilePathFromValue(value: string): string | null {
142+
const parsed = tryParseJson(value);
143+
if (parsed) {
144+
const path =
145+
typeof parsed.file_path === "string"
146+
? parsed.file_path
147+
: typeof parsed.filePath === "string"
148+
? parsed.filePath
149+
: typeof parsed.path === "string"
150+
? parsed.path
151+
: null;
152+
return path;
153+
}
154+
if (value.includes("/")) return value.trim();
155+
return null;
156+
}
157+
158+
function extractFileNameFromDetail(detail: string | undefined): string {
58159
if (!detail) return "";
59160
const cleaned = stripToolPrefix(detail);
60161
const filePath = extractFilePathFromValue(cleaned);
61-
if (filePath) {
62-
const parts = filePath.split("/");
63-
return parts[parts.length - 1] ?? filePath;
64-
}
162+
if (filePath) return fileNameFromPath(filePath);
65163
return "";
66164
}
67165

68-
function extractSearchSummary(detail: string | undefined): string {
166+
function extractSearchSummaryFromDetail(detail: string | undefined): string {
69167
if (!detail) return "";
70168
const cleaned = stripToolPrefix(detail);
71169
const parsed = tryParseJson(cleaned);
72170
if (parsed) {
73171
const pattern = typeof parsed.pattern === "string" ? parsed.pattern : null;
74172
const path = typeof parsed.path === "string" ? parsed.path : null;
75-
if (pattern && path) {
76-
const shortPath = path.split("/").pop() ?? path;
77-
return `${pattern} in ${shortPath}`;
78-
}
173+
if (pattern && path) return `${pattern} in ${fileNameFromPath(path)}`;
79174
if (pattern) return pattern;
80175
}
81176
return cleaned.slice(0, 120);
82177
}
83178

84-
function extractPathSummary(detail: string | undefined): string {
179+
function extractPathSummaryFromDetail(detail: string | undefined): string {
85180
if (!detail) return "";
86181
const cleaned = stripToolPrefix(detail);
87182
const filePath = extractFilePathFromValue(cleaned);
@@ -92,44 +187,10 @@ function extractPathSummary(detail: string | undefined): string {
92187
return cleaned.slice(0, 120);
93188
}
94189

95-
function stripToolPrefix(value: string): string {
96-
return value.replace(/^[A-Za-z_]+:\s*/, "").trim();
97-
}
98-
99-
function extractFilePathFromValue(value: string): string | null {
100-
const parsed = tryParseJson(value);
101-
if (parsed) {
102-
const path =
103-
typeof parsed.file_path === "string"
104-
? parsed.file_path
105-
: typeof parsed.filePath === "string"
106-
? parsed.filePath
107-
: typeof parsed.path === "string"
108-
? parsed.path
109-
: null;
110-
return path;
111-
}
112-
if (value.includes("/")) return value.trim();
113-
return null;
114-
}
115-
116-
function tryParseJson(value: string): Record<string, unknown> | null {
117-
if (!value.startsWith("{")) return null;
118-
try {
119-
const parsed = JSON.parse(value);
120-
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
121-
} catch {
122-
return null;
123-
}
124-
}
125-
126190
function cleanDetailAsHeading(detail: string): string {
127191
const cleaned = stripToolPrefix(detail);
128192
const filePath = extractFilePathFromValue(cleaned);
129-
if (filePath) {
130-
const fileName = filePath.split("/").pop() ?? filePath;
131-
return `Read ${fileName}`;
132-
}
193+
if (filePath) return `Read ${fileNameFromPath(filePath)}`;
133194
return cleaned.slice(0, 80);
134195
}
135196

apps/web/src/components/chat/FileChangeCard.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "./InlineDiffPreview";
1010

1111
const PREVIEW_MAX_HEIGHT = "120px";
12+
const MIN_OVERFLOW_PX = 24;
1213

1314
interface FileChangeCardProps {
1415
diffPreviews: ReadonlyArray<InlineDiffHunk>;
@@ -45,7 +46,7 @@ export const FileChangeCard = memo(function FileChangeCard(props: FileChangeCard
4546
useLayoutEffect(() => {
4647
const el = previewRef.current;
4748
if (!el || expanded) return;
48-
setPreviewOverflows(el.scrollHeight > el.clientHeight + 1);
49+
setPreviewOverflows(el.scrollHeight > el.clientHeight + MIN_OVERFLOW_PX);
4950
}, [expanded, diffPreviews]);
5051

5152
if (diffPreviews.length === 0) return null;

0 commit comments

Comments
 (0)