diff --git a/web-app/src/components/ai-elements/tools/tool.tsx b/web-app/src/components/ai-elements/tools/tool.tsx index 01544562f..8e2306ea6 100644 --- a/web-app/src/components/ai-elements/tools/tool.tsx +++ b/web-app/src/components/ai-elements/tools/tool.tsx @@ -15,9 +15,15 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from 'react' -import { CodeBlock } from '../code-block' +import { StickToBottom } from 'use-stick-to-bottom' +import { CodeBlock, highlightCode } from '../code-block' +import { + guessBlockLanguage, + splitToolInput, +} from '@/lib/toolParamPreview' type ToolContextValue = { isOpen: boolean @@ -159,16 +165,210 @@ export type ToolInputProps = ComponentProps<'div'> & { input: unknown } +/// Roughly how many lines fit in the fixed preview height (`h-80` less its +/// padding, at `text-sm`). Past this the wrapper switches to a definite +/// height so the scroller inside it is bounded. +const SCROLL_AFTER_LINES = 12 + +/// How many trailing lines stay live-highlighted while a tool input streams. +/// Comfortably more than the box shows, so scrolling back a little during a +/// write still lands on highlighted content, while keeping each pass a fixed +/// cost no matter how large the file grows. +const STREAM_TAIL_LINES = 200 + +/// Matches the shared `CodeBlock` surface so highlighted tool input looks like +/// every other code block in the app. +/// +/// Long lines are wrapped rather than scrolled sideways. Shiki emits +/// `white-space: pre`, so a single long line (a bundled URL, a minified rule) +/// overflows horizontally and pops a horizontal scrollbar mid-stream. That +/// shrinks the viewport height and shifts the scroll maths, which the +/// stick-to-bottom logic reads as the reader scrolling away — following then +/// stops dead on that line. Wrapping keeps the container's width stable. +const HIGHLIGHT_SURFACE = + '[&>pre]:m-0 [&>pre]:bg-transparent! [&>pre]:p-4 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap [&>pre]:wrap-break-word [&_code]:font-mono [&_code]:text-sm' + +/** + * A multiline string parameter (e.g. the `content` of a file write) rendered + * as real, syntax-highlighted text instead of one `\n`-escaped JSON line. + * + * Highlighting is *self-pacing*: a new pass starts only once the previous one + * finishes, always against the newest value. Streaming updates arrive far + * faster than a highlight completes, and the two obvious approaches both fail + * — rendering every queued result replays a growing backlog of stale frames + * (the preview visibly lags seconds behind), while dropping any result that is + * no longer newest starves every frame and the preview freezes. Skipping the + * intermediate values keeps the view current at whatever rate the machine can + * actually sustain. + * + * The raw text renders until the first highlight lands, so the preview is + * never blank and stays readable if highlighting fails. + * + * Scrolling uses the same `use-stick-to-bottom` behaviour as the conversation + * itself, so it eases to the tail instead of snapping, handles the height jump + * when a new line lands, and releases when the reader scrolls away. + */ +const ToolInputBlock = memo( + ({ + name, + value, + language, + streaming, + }: { + name: string + value: string + language: string + streaming: boolean + }) => { + // Highlighting re-tokenises whatever it is given, so feeding it the whole + // document makes every pass more expensive as the file grows and the + // preview visibly slows down the longer a write runs. While streaming the + // view is pinned to the tail, so only the tail can actually be seen: + // highlight a bounded window of it and the cost stays flat regardless of + // file size. The complete document is rendered once the write finishes. + const displayText = useMemo(() => { + if (!streaming) return value + let seen = 0 + for (let i = value.length - 1; i >= 0; i--) { + if (value[i] === '\n' && ++seen > STREAM_TAIL_LINES) { + return value.slice(i + 1) + } + } + return value + }, [value, streaming]) + + const latestRef = useRef(displayText) + latestRef.current = displayText + const runningRef = useRef(false) + const aliveRef = useRef(true) + const [html, setHtml] = useState('') + const [darkHtml, setDarkHtml] = useState('') + + // Once the content is taller than the box, the wrapper needs a definite + // height (see the note on StickToBottom below). Counted with an early + // exit so a large file is not re-split on every streamed frame. + const isScrollable = useMemo(() => { + let lines = 1 + for (let i = 0; i < displayText.length; i++) { + if (displayText[i] === '\n' && ++lines > SCROLL_AFTER_LINES) return true + } + return false + }, [displayText]) + + useEffect(() => { + aliveRef.current = true + return () => { + aliveRef.current = false + } + }, []) + + useEffect(() => { + if (runningRef.current) return + runningRef.current = true + void (async () => { + let rendered: string | null = null + while (aliveRef.current && latestRef.current !== rendered) { + const target = latestRef.current + try { + const [light, dark] = await highlightCode(target, language as never) + if (!aliveRef.current) break + setHtml(light) + setDarkHtml(dark) + } catch { + // Keep the last good render rather than blanking the preview. + } + rendered = target + } + runningRef.current = false + })() + }, [displayText, language]) + + return ( +
+

+ {name} +

+ {/* StickToBottom scrolls an inner element it renders with + `height: 100%`, so this wrapper needs a *definite* height for the + scroller to be bounded — a `max-height` leaves it indefinite, so + nothing overflows, nothing scrolls, and the preview just clips + while content streams in below the fold. Height is only fixed once + there is more content than fits, so short parameters stay compact. + Overflow is deliberately not set here: the library assigns + `overflow: auto` to its own scroller, and setting it on this + wrapper would scroll the wrong element. */} + + + {html ? ( + <> +
+
+ + ) : ( +
+                {displayText}
+              
+ )} + + +
+ ) + } +) + export const ToolInput = memo( ({ className, input, ...props }: ToolInputProps) => { + const { state } = useTool() + const streaming = state === 'input-streaming' + const { compact, blocks } = splitToolInput(input) + // Sniff from the block itself when no path-like sibling param exists + // (a `content`-only write is common), so it does not fall back to + // markdown and render uncoloured. + const blockLanguage = guessBlockLanguage(compact, blocks[0]?.value) + return (

Parameters

-
- -
+ {blocks.length === 0 ? ( +
+ +
+ ) : ( + <> + {compact && ( +
+ +
+ )} + {blocks.map((block) => ( + + ))} + + )}
) } diff --git a/web-app/src/lib/__tests__/toolParamPreview.test.ts b/web-app/src/lib/__tests__/toolParamPreview.test.ts new file mode 100644 index 000000000..18ac0875c --- /dev/null +++ b/web-app/src/lib/__tests__/toolParamPreview.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest' +import { + splitToolInput, + guessBlockLanguage, + sniffLanguageFromContent, +} from '../toolParamPreview' + +describe('splitToolInput', () => { + it('extracts multiline string params and keeps the rest compact', () => { + const { compact, blocks } = splitToolInput({ + path: '/tmp/index.html', + content: '\n\n', + overwrite: true, + }) + + expect(compact).toEqual({ path: '/tmp/index.html', overwrite: true }) + expect(blocks).toEqual([ + { key: 'content', value: '\n\n' }, + ]) + }) + + it('extracts very long single-line strings too', () => { + const long = 'x'.repeat(500) + const { compact, blocks } = splitToolInput({ query: long, limit: 5 }) + + expect(compact).toEqual({ limit: 5 }) + expect(blocks).toEqual([{ key: 'query', value: long }]) + }) + + it('returns no blocks for inputs without multiline strings', () => { + expect(splitToolInput({ path: '/a', n: 3 })).toEqual({ + compact: null, + blocks: [], + }) + }) + + it('returns no blocks for non-object inputs', () => { + expect(splitToolInput('partial json string')).toEqual({ + compact: null, + blocks: [], + }) + expect(splitToolInput(null)).toEqual({ compact: null, blocks: [] }) + expect(splitToolInput([1, 2])).toEqual({ compact: null, blocks: [] }) + }) + + it('yields null compact when every param becomes a block', () => { + const { compact, blocks } = splitToolInput({ content: 'a\nb' }) + expect(compact).toBeNull() + expect(blocks).toHaveLength(1) + }) +}) + +describe('guessBlockLanguage', () => { + it('guesses from a path-like sibling param', () => { + expect(guessBlockLanguage({ path: '/site/index.html' })).toBe('html') + expect(guessBlockLanguage({ file_path: 'src/app.tsx' })).toBe('tsx') + expect(guessBlockLanguage({ filename: 'notes.md' })).toBe('markdown') + }) + + it('ignores non-path params and unknown extensions', () => { + expect(guessBlockLanguage({ query: 'not a path.html' })).toBe('markdown') + expect(guessBlockLanguage({ path: '/bin/data.xyzq' })).toBe('markdown') + expect(guessBlockLanguage(null)).toBe('markdown') + }) + + it('sniffs the content when there is no path-like sibling param', () => { + // A `content`-only write_file is common; without sniffing this would fall + // back to markdown and render uncoloured. + expect(guessBlockLanguage(null, '\n')).toBe( + 'html' + ) + expect(guessBlockLanguage({}, '{"a": 1}')).toBe('json') + }) + + it('prefers an explicit path extension over content sniffing', () => { + expect( + guessBlockLanguage({ path: 'notes.md' }, '') + ).toBe('markdown') + }) +}) + +describe('sniffLanguageFromContent', () => { + it('detects html', () => { + expect(sniffLanguageFromContent('\n')).toBe('html') + expect(sniffLanguageFromContent(' ')).toBe('html') + }) + + it('detects json, including partial json mid-stream', () => { + expect(sniffLanguageFromContent('{"a": 1, "b": [2]}')).toBe('json') + expect(sniffLanguageFromContent('{\n "name": "half-writ')).toBe('json') + }) + + it('detects shebang scripts', () => { + expect(sniffLanguageFromContent('#!/bin/bash\necho hi')).toBe('bash') + expect(sniffLanguageFromContent('#!/usr/bin/env python\nx = 1')).toBe( + 'python' + ) + }) + + it('detects css and js/ts', () => { + expect(sniffLanguageFromContent('.hero { color: red; }')).toBe('css') + expect( + sniffLanguageFromContent("import { x } from './y'\n") + ).toBe('typescript') + expect(sniffLanguageFromContent('const a = 1\n')).toBe('javascript') + }) + + it('detects xml/svg and php', () => { + expect(sniffLanguageFromContent('')).toBe('xml') + expect(sniffLanguageFromContent('')).toBe('xml') + expect(sniffLanguageFromContent(' { + expect(sniffLanguageFromContent('')).toBeNull() + expect(sniffLanguageFromContent(' ')).toBeNull() + expect(sniffLanguageFromContent('just some prose here')).toBeNull() + }) +}) diff --git a/web-app/src/lib/toolParamPreview.ts b/web-app/src/lib/toolParamPreview.ts new file mode 100644 index 000000000..761fec1e8 --- /dev/null +++ b/web-app/src/lib/toolParamPreview.ts @@ -0,0 +1,157 @@ +/** + * Helpers for rendering tool-call parameter previews. + * + * Tool inputs are shown as pretty-printed JSON. That works for small scalar + * parameters, but any multiline string (e.g. the `content` of a file write) + * gets its newlines escaped to literal `\n` and renders as one enormous + * line. These helpers split such parameters out so the UI can render them as + * real text blocks, and guess a syntax-highlight language from a sibling + * path-like parameter. + */ + +export type MultilineParam = { + key: string + value: string +} + +export type SplitToolInput = { + /** Input minus the extracted multiline params; null when nothing remains. */ + compact: Record | null + /** Params to render as dedicated text blocks, in original key order. */ + blocks: MultilineParam[] +} + +/** Strings at least this long are pulled out even without a newline. */ +const LONG_STRING_THRESHOLD = 400 + +/** + * Split a tool input object into compact JSON-able params and multiline + * string params that deserve their own block. Non-object inputs (including + * arrays and partially streamed strings) yield no blocks so callers can fall + * back to the plain JSON rendering. + */ +export function splitToolInput(input: unknown): SplitToolInput { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return { compact: null, blocks: [] } + } + + const compact: Record = {} + const blocks: MultilineParam[] = [] + + for (const [key, value] of Object.entries(input as Record)) { + if ( + typeof value === 'string' && + (value.includes('\n') || value.length >= LONG_STRING_THRESHOLD) + ) { + blocks.push({ key, value }) + } else { + compact[key] = value + } + } + + if (blocks.length === 0) { + return { compact: null, blocks: [] } + } + + return { + compact: Object.keys(compact).length > 0 ? compact : null, + blocks, + } +} + +// Extensions mapped to Shiki bundled language ids. +const EXTENSION_LANGUAGES: Record = { + html: 'html', + htm: 'html', + css: 'css', + scss: 'scss', + js: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + jsx: 'jsx', + ts: 'typescript', + tsx: 'tsx', + json: 'json', + md: 'markdown', + py: 'python', + rs: 'rust', + sh: 'bash', + bash: 'bash', + zsh: 'bash', + yml: 'yaml', + yaml: 'yaml', + toml: 'toml', + sql: 'sql', + c: 'c', + h: 'c', + cpp: 'cpp', + hpp: 'cpp', + go: 'go', + java: 'java', + swift: 'swift', + kt: 'kotlin', + rb: 'ruby', + php: 'php', + xml: 'xml', + svg: 'xml', +} + +/** + * Best-effort language detection from the content itself. Used when the call + * carries no path-like parameter to infer from — a `write_file` whose only + * argument is `content` is common, and without this the text would fall back + * to `markdown` and render essentially uncoloured. + */ +export function sniffLanguageFromContent(text: string): string | null { + const head = text.slice(0, 500).trimStart() + if (!head) return null + + if (/^]/i.test(head)) return 'html' + if (/^<\?php/i.test(head)) return 'php' + if (/^<\?xml[\s?]/i.test(head) || /^]/i.test(head)) return 'xml' + if (/^#!.*\b(bash|sh|zsh)\b/.test(head)) return 'bash' + if (/^#!.*\bpython/.test(head)) return 'python' + + if (/^[{[]/.test(head)) { + try { + JSON.parse(text) + return 'json' + } catch { + // Partial JSON is expected while a tool call is still streaming. + if (/"[^"]*"\s*:/.test(head)) return 'json' + } + } + + if (/^(import|export)\s[^\n]*\sfrom\s+['"]/m.test(head)) return 'typescript' + if (/^\s*(async\s+)?function\s+\w+|^\s*(const|let|var)\s+\w+\s*=/m.test(head)) + return 'javascript' + if (/^\s*(def|class)\s+\w+[^\n]*:\s*$/m.test(head)) return 'python' + if (/^\s*[.#@]?[\w-]+[^\n{]*\{[^}]*[\w-]+\s*:[^}]+;/m.test(head)) return 'css' + if (/^#{1,6}\s+\S|^\s*[-*]\s+\S/m.test(head)) return 'markdown' + + return null +} + +/** + * Guess a highlight language for an extracted block. Prefers a path-like + * sibling parameter (`path`, `file_path`, `filename`, …) because an explicit + * extension is the strongest signal; otherwise sniffs the content. Falls back + * to `markdown`, which renders arbitrary text acceptably. + */ +export function guessBlockLanguage( + compact: Record | null, + sample?: string +): string { + if (compact) { + for (const [key, value] of Object.entries(compact)) { + if (typeof value !== 'string') continue + if (!/(^|_)(path|file|filename|dest|destination|target)$/i.test(key)) { + continue + } + const match = value.match(/\.([A-Za-z0-9]+)$/) + const language = match && EXTENSION_LANGUAGES[match[1].toLowerCase()] + if (language) return language + } + } + return (sample && sniffLanguageFromContent(sample)) || 'markdown' +}