Skip to content

Commit 0b23501

Browse files
committed
feat: highlight JSON in generic tool call arguments and results
- Add HighlightedCode component with optional line numbers - Add JsonHighlight component for tool call display - Update GenericToolCall to use JsonHighlight - Add createGenericTool factory for stories - Add GenericToolCallWithJsonHighlighting story with play function - Update AGENTS.md with storybook best practices
1 parent 4a43d62 commit 0b23501

File tree

10 files changed

+230
-16
lines changed

10 files changed

+230
-16
lines changed

bun.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"undici": "^7.16.0",
6363
"write-file-atomic": "^6.0.0",
6464
"ws": "^8.18.3",
65+
"xxhash-wasm": "^1.1.0",
6566
"zod": "^4.1.11",
6667
"zod-to-json-schema": "^3.24.6",
6768
},
@@ -3616,6 +3617,8 @@
36163617

36173618
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
36183619

3620+
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
3621+
36193622
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
36203623

36213624
"yaku": ["yaku@0.16.7", "", {}, "sha512-Syu3IB3rZvKvYk7yTiyl1bo/jiEFaaStrgv1V2TIJTqYPStSMQVO8EQjg/z+DRzLq/4LIIharNT3iH1hylEIRw=="],

docs/AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ Avoid mock-heavy tests that verify implementation details rather than behavior.
7070

7171
### Storybook
7272

73-
- Prefer full-app stories (`App.stories.tsx`) to isolated components.
73+
- Prefer full-app stories (`App.stories.tsx`) to isolated component stories. This tests components in their real context with proper providers, state management, and styling.
74+
- Use play functions with `@storybook/test` utilities (`within`, `userEvent`, `waitFor`) to interact with the UI and set up the desired visual state. Do not add props to production components solely for storybook convenience.
7475

7576
### TDD Expectations
7677

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
"undici": "^7.16.0",
103103
"write-file-atomic": "^6.0.0",
104104
"ws": "^8.18.3",
105+
"xxhash-wasm": "^1.1.0",
105106
"zod": "^4.1.11",
106107
"zod-to-json-schema": "^3.24.6"
107108
},

src/browser/components/tools/GenericToolCall.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,8 @@ import {
1111
DetailContent,
1212
LoadingDots,
1313
} from "./shared/ToolPrimitives";
14-
import {
15-
useToolExpansion,
16-
getStatusDisplay,
17-
formatValue,
18-
type ToolStatus,
19-
} from "./shared/toolUtils";
14+
import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
15+
import { JsonHighlight } from "./shared/HighlightedCode";
2016

2117
interface GenericToolCallProps {
2218
toolName: string;
@@ -48,14 +44,18 @@ export const GenericToolCall: React.FC<GenericToolCallProps> = ({
4844
{args !== undefined && (
4945
<DetailSection>
5046
<DetailLabel>Arguments</DetailLabel>
51-
<DetailContent>{formatValue(args)}</DetailContent>
47+
<DetailContent>
48+
<JsonHighlight value={args} />
49+
</DetailContent>
5250
</DetailSection>
5351
)}
5452

5553
{result !== undefined && (
5654
<DetailSection>
5755
<DetailLabel>Result</DetailLabel>
58-
<DetailContent>{formatValue(result)}</DetailContent>
56+
<DetailContent>
57+
<JsonHighlight value={result} />
58+
</DetailContent>
5959
</DetailSection>
6060
)}
6161

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import React, { useState, useEffect } from "react";
2+
import { highlightCode } from "@/browser/utils/highlighting/highlightWorkerClient";
3+
import { extractShikiLines } from "@/browser/utils/highlighting/shiki-shared";
4+
import { useTheme } from "@/browser/contexts/ThemeContext";
5+
6+
interface HighlightedCodeProps {
7+
code: string;
8+
language: string;
9+
className?: string;
10+
showLineNumbers?: boolean;
11+
}
12+
13+
/**
14+
* Renders code with syntax highlighting using Shiki (via web worker)
15+
* Falls back to plain text on first render or if highlighting fails
16+
*/
17+
export const HighlightedCode: React.FC<HighlightedCodeProps> = ({
18+
code,
19+
language,
20+
className,
21+
showLineNumbers = false,
22+
}) => {
23+
const [highlightedLines, setHighlightedLines] = useState<string[] | null>(null);
24+
const { theme: themeMode } = useTheme();
25+
26+
const plainLines = code.split("\n").filter((line, i, arr) => i < arr.length - 1 || line !== "");
27+
28+
useEffect(() => {
29+
let cancelled = false;
30+
const theme = themeMode === "light" || themeMode === "solarized-light" ? "light" : "dark";
31+
32+
setHighlightedLines(null);
33+
34+
async function highlight() {
35+
try {
36+
const html = await highlightCode(code, language, theme);
37+
if (!cancelled) {
38+
const lines = extractShikiLines(html);
39+
const filtered = lines.filter((l, i, a) => i < a.length - 1 || l.trim() !== "");
40+
setHighlightedLines(filtered.length > 0 ? filtered : null);
41+
}
42+
} catch (error) {
43+
console.warn(`Failed to highlight ${language}:`, error);
44+
if (!cancelled) setHighlightedLines(null);
45+
}
46+
}
47+
48+
void highlight();
49+
return () => {
50+
cancelled = true;
51+
};
52+
}, [code, language, themeMode]);
53+
54+
const lines = highlightedLines ?? plainLines;
55+
56+
if (showLineNumbers) {
57+
return (
58+
<div className="code-block-container text-[11px]">
59+
{lines.map((content, idx) => (
60+
<React.Fragment key={idx}>
61+
<div className="line-number">{idx + 1}</div>
62+
{/* SECURITY AUDIT: dangerouslySetInnerHTML - Shiki escapes all content */}
63+
<div
64+
className="code-line"
65+
{...(highlightedLines
66+
? { dangerouslySetInnerHTML: { __html: content } }
67+
: { children: content })}
68+
/>
69+
</React.Fragment>
70+
))}
71+
</div>
72+
);
73+
}
74+
75+
if (highlightedLines) {
76+
return (
77+
<div
78+
className={className}
79+
dangerouslySetInnerHTML={{ __html: highlightedLines.join("\n") }}
80+
/>
81+
);
82+
}
83+
return <div className={className}>{code}</div>;
84+
};
85+
86+
interface JsonHighlightProps {
87+
value: unknown;
88+
className?: string;
89+
}
90+
91+
/** Renders a value as syntax-highlighted JSON with line numbers */
92+
export const JsonHighlight: React.FC<JsonHighlightProps> = ({ value, className }) => {
93+
const jsonString = React.useMemo(() => {
94+
if (value === null || value === undefined) return "null";
95+
if (typeof value === "string") {
96+
try {
97+
return JSON.stringify(JSON.parse(value), null, 2);
98+
} catch {
99+
return value;
100+
}
101+
}
102+
try {
103+
return JSON.stringify(value, null, 2);
104+
} catch {
105+
return "[Complex Object]";
106+
}
107+
}, [value]);
108+
109+
return (
110+
<HighlightedCode code={jsonString} language="json" className={className} showLineNumbers />
111+
);
112+
};

src/browser/components/tools/shared/ToolPrimitives.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export const DetailContent: React.FC<React.HTMLAttributes<HTMLPreElement>> = ({
122122
}) => (
123123
<pre
124124
className={cn(
125-
"m-0 px-2 py-1.5 bg-code-bg rounded-sm text-[11px] leading-relaxed whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto",
125+
"m-0 bg-code-bg rounded-sm text-[11px] leading-relaxed whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto",
126126
className
127127
)}
128128
{...props}

src/browser/stories/App.chat.stories.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import {
1111
createFileEditTool,
1212
createTerminalTool,
1313
createStatusTool,
14+
createGenericTool,
1415
} from "./mockFactory";
1516
import { setupSimpleChatStory, setupStreamingChatStory } from "./storyHelpers";
17+
import { within, userEvent, waitFor } from "@storybook/test";
1618

1719
export default {
1820
...appMeta,
@@ -264,3 +266,62 @@ export const Streaming: AppStory = {
264266
/>
265267
),
266268
};
269+
270+
/** Generic tool call with JSON-highlighted arguments and results */
271+
export const GenericTool: AppStory = {
272+
render: () => (
273+
<AppWithMocks
274+
setup={() =>
275+
setupSimpleChatStory({
276+
messages: [
277+
createUserMessage("msg-1", "Fetch a large dataset", {
278+
historySequence: 1,
279+
timestamp: STABLE_TIMESTAMP - 60000,
280+
}),
281+
createAssistantMessage("msg-2", "I'll fetch that data for you.", {
282+
historySequence: 2,
283+
timestamp: STABLE_TIMESTAMP - 55000,
284+
toolCalls: [
285+
createGenericTool(
286+
"call-1",
287+
"fetch_data",
288+
{
289+
endpoint: "/api/users",
290+
params: { limit: 100, offset: 0 },
291+
},
292+
{
293+
success: true,
294+
// Generate 100+ line result to test line number alignment
295+
data: Array.from({ length: 50 }, (_, i) => ({
296+
id: i + 1,
297+
name: `User ${i + 1}`,
298+
email: `user${i + 1}@example.com`,
299+
active: i % 3 !== 0,
300+
})),
301+
total: 500,
302+
page: 1,
303+
}
304+
),
305+
],
306+
}),
307+
],
308+
})
309+
}
310+
/>
311+
),
312+
parameters: {
313+
docs: {
314+
description: {
315+
story: "Generic tool call with JSON syntax highlighting and 100+ lines.",
316+
},
317+
},
318+
},
319+
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
320+
const canvas = within(canvasElement);
321+
322+
await waitFor(async () => {
323+
const toolHeader = canvas.getByText("fetch_data");
324+
await userEvent.click(toolHeader);
325+
});
326+
},
327+
};

src/browser/stories/mockFactory.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,23 @@ export function createPendingTool(toolCallId: string, toolName: string, args: ob
275275
};
276276
}
277277

278+
/** Create a generic tool call with custom name, args, and output - falls back to GenericToolCall */
279+
export function createGenericTool(
280+
toolCallId: string,
281+
toolName: string,
282+
input: object,
283+
output: object
284+
): MuxPart {
285+
return {
286+
type: "dynamic-tool",
287+
toolCallId,
288+
toolName,
289+
state: "output-available",
290+
input,
291+
output,
292+
};
293+
}
294+
278295
// ═══════════════════════════════════════════════════════════════════════════════
279296
// GIT STATUS MOCKS
280297
// ═══════════════════════════════════════════════════════════════════════════════

src/browser/utils/highlighting/highlightWorkerClient.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,8 @@ const highlightCache = new LRUCache<string, string>({
3030
});
3131

3232
async function getCacheKey(code: string, language: string, theme: string): Promise<string> {
33-
const data = new TextEncoder().encode(`${language}:${theme}:${code}`);
34-
const hash = await crypto.subtle.digest("SHA-256", data);
35-
// Take first 8 bytes (64 bits) as hex
36-
return Array.from(new Uint8Array(hash).slice(0, 8))
37-
.map((b) => b.toString(16).padStart(2, "0"))
38-
.join("");
33+
const { hashKey } = await import("@/common/lib/hashKey");
34+
return hashKey(`${language}:${theme}:${code}`);
3935
}
4036

4137
// ─────────────────────────────────────────────────────────────────────────────

src/common/lib/hashKey.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import xxhash from "xxhash-wasm";
2+
3+
let xxhasher: Awaited<ReturnType<typeof xxhash>> | null = null;
4+
5+
/**
6+
* Generate a 64-bit hash key for caching/deduplication.
7+
* Prefers SHA-256 (truncated to 64 bits) when crypto.subtle is available,
8+
* falls back to xxhash64 in insecure contexts (e.g., HTTP dev servers).
9+
*/
10+
export async function hashKey(input: string): Promise<string> {
11+
// crypto.subtle is only available in secure contexts (HTTPS/localhost)
12+
if (crypto.subtle) {
13+
const data = new TextEncoder().encode(input);
14+
const hash = await crypto.subtle.digest("SHA-256", data);
15+
// Take first 8 bytes (64 bits) as hex
16+
return Array.from(new Uint8Array(hash).slice(0, 8))
17+
.map((b) => b.toString(16).padStart(2, "0"))
18+
.join("");
19+
}
20+
// Fallback to xxhash64 for dev environments
21+
xxhasher ??= await xxhash();
22+
return xxhasher.h64(input).toString(16);
23+
}

0 commit comments

Comments
 (0)