Skip to content

Commit 7ac8239

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 7ac8239

File tree

10 files changed

+274
-16
lines changed

10 files changed

+274
-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: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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+
// Fixed width based on max line number digits + padding to prevent shift (e.g., 9 -> 10)
56+
const maxDigits = Math.max(2, String(lines.length).length);
57+
const lineNumberWidth = `calc(${maxDigits}ch + 1.625rem)`;
58+
59+
if (showLineNumbers) {
60+
return (
61+
<div
62+
className={className}
63+
style={{
64+
display: "grid",
65+
gridTemplateColumns: `${lineNumberWidth} 1fr`,
66+
fontFamily:
67+
'var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace)',
68+
}}
69+
>
70+
{lines.map((content, idx) => (
71+
<React.Fragment key={idx}>
72+
<span
73+
className="line-number"
74+
style={{
75+
whiteSpace: "nowrap",
76+
textAlign: "right",
77+
userSelect: "none",
78+
opacity: 0.5,
79+
paddingLeft: "0.5rem",
80+
paddingRight: "1.125rem",
81+
marginRight: "1.125rem",
82+
borderRight: "1px solid rgba(255,255,255,0.1)",
83+
}}
84+
>
85+
{idx + 1}
86+
</span>
87+
{/* SECURITY AUDIT: dangerouslySetInnerHTML - Shiki escapes all content */}
88+
<span
89+
className="code-line"
90+
{...(highlightedLines
91+
? { dangerouslySetInnerHTML: { __html: content } }
92+
: { children: content })}
93+
/>
94+
</React.Fragment>
95+
))}
96+
</div>
97+
);
98+
}
99+
100+
if (highlightedLines) {
101+
return (
102+
<div
103+
className={className}
104+
dangerouslySetInnerHTML={{ __html: highlightedLines.join("\n") }}
105+
/>
106+
);
107+
}
108+
return <div className={className}>{code}</div>;
109+
};
110+
111+
interface JsonHighlightProps {
112+
value: unknown;
113+
className?: string;
114+
}
115+
116+
/** Renders a value as syntax-highlighted JSON with line numbers */
117+
export const JsonHighlight: React.FC<JsonHighlightProps> = ({ value, className }) => {
118+
const jsonString = React.useMemo(() => {
119+
if (value === null || value === undefined) return "null";
120+
if (typeof value === "string") {
121+
try {
122+
return JSON.stringify(JSON.parse(value), null, 2);
123+
} catch {
124+
return value;
125+
}
126+
}
127+
try {
128+
return JSON.stringify(value, null, 2);
129+
} catch {
130+
return "[Complex Object]";
131+
}
132+
}, [value]);
133+
134+
return (
135+
<HighlightedCode code={jsonString} language="json" className={className} showLineNumbers />
136+
);
137+
};

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 py-1.5 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: 80 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,81 @@ export const Streaming: AppStory = {
264266
/>
265267
),
266268
};
269+
270+
/** Generic tool call with JSON-highlighted arguments and results */
271+
export const GenericToolCallWithJsonHighlighting: AppStory = {
272+
render: () => (
273+
<AppWithMocks
274+
setup={() =>
275+
setupSimpleChatStory({
276+
messages: [
277+
createUserMessage("msg-1", "Search the web for TypeScript best practices", {
278+
historySequence: 1,
279+
timestamp: STABLE_TIMESTAMP - 60000,
280+
}),
281+
createAssistantMessage(
282+
"msg-2",
283+
"I'll search for that information using a web search tool.",
284+
{
285+
historySequence: 2,
286+
timestamp: STABLE_TIMESTAMP - 55000,
287+
toolCalls: [
288+
createGenericTool(
289+
"call-1",
290+
"web_search",
291+
{
292+
query: "TypeScript best practices 2024",
293+
max_results: 5,
294+
include_domains: ["typescriptlang.org", "github.com"],
295+
},
296+
{
297+
success: true,
298+
results: [
299+
{
300+
title: "TypeScript Best Practices",
301+
url: "https://typescriptlang.org/docs/handbook/declaration-files/do-and-donts.html",
302+
snippet: "Guidelines for writing high-quality declaration files...",
303+
},
304+
{
305+
title: "TypeScript Style Guide",
306+
url: "https://github.com/typescript-eslint/typescript-eslint",
307+
snippet: "Monorepo for tooling enabling ESLint to support TypeScript...",
308+
},
309+
],
310+
total_found: 2,
311+
}
312+
),
313+
],
314+
}
315+
),
316+
createAssistantMessage(
317+
"msg-3",
318+
"Based on the search results, here are the key TypeScript best practices...",
319+
{
320+
historySequence: 3,
321+
timestamp: STABLE_TIMESTAMP - 50000,
322+
}
323+
),
324+
],
325+
})
326+
}
327+
/>
328+
),
329+
parameters: {
330+
docs: {
331+
description: {
332+
story:
333+
"Shows a generic tool call (web_search) with JSON syntax highlighting in both the arguments and results.",
334+
},
335+
},
336+
},
337+
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
338+
const canvas = within(canvasElement);
339+
340+
// Wait for the tool call to render and click it to expand
341+
await waitFor(async () => {
342+
const toolHeader = canvas.getByText("web_search");
343+
await userEvent.click(toolHeader);
344+
});
345+
},
346+
};

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)