diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte index 2067e42688..22ffc256ba 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte @@ -1,5 +1,5 @@ @@ -45,11 +49,11 @@ {meta.errorMessage} - {:else if meta && meta.edits.length > 0} + {:else if meta && editFileBody && editFileBody.edits.length > 0} {#each editDiffs as diffLines, ei (ei)}
- Edit {ei + 1} of {meta.edits.length} + Edit {ei + 1} of {editFileBody.edits.length}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte index 178c479d98..cafa5280bc 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte @@ -1,5 +1,5 @@ @@ -45,7 +49,7 @@
{:else if meta} | null { } } +// Compiled per key on first use; the key set is tiny and fixed. +const toolArgStringRegexes = new Map(); + +/** + * Extract a string field from a JSON tool-args blob without parsing the + * whole document. write_file and edit_file args embed full file contents, + * yet the block title needs only the path; a targeted key match plus a + * JSON.parse of the captured string literal alone keeps title rendering + * O(path) instead of O(blob). Returns undefined when the key is missing + * or its value is not a string; callers fall back to the full parse. + */ +export function extractToolArgString(toolArgs: string, keys: string[]): string | undefined { + for (const key of keys) { + let pattern = toolArgStringRegexes.get(key); + + if (!pattern) { + pattern = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`); + toolArgStringRegexes.set(key, pattern); + } + + const match = pattern.exec(toolArgs); + + if (!match) continue; + + try { + const value: unknown = JSON.parse(`"${match[1]}"`); + + if (typeof value === 'string') return value; + } catch { + // fall through to the next key; the full parse is the fallback + } + } + + return undefined; +} + /** * Parse a section's toolArgs against an expected tool name. Returns * `null` when: diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts index 9ed6f92bc0..d88186b581 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts @@ -3,7 +3,7 @@ // rendering), plus the result blob for `result` / `edits_applied` / // `error` fields. -import { parseToolArgs } from './_shared'; +import { extractToolArgString, parseToolArgs } from './_shared'; import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants'; import { BuiltInTool } from '$lib/enums'; import type { AgenticSection } from '$lib/types'; @@ -23,6 +23,19 @@ export type EditFileMeta = { errorMessage?: string; }; +/** Everything the block title and status pill show; the full meta (with + * the embedded edit strings) stays body-only so collapsed blocks never + * parse the args blob. */ +export type EditFileTitleMeta = { + fileName: string; + filePath: string; + resultMessage?: string; + editsApplied?: number; + errorMessage?: string; +}; + +const PATH_KEYS = ['path', 'file_path', 'filePath']; + export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null { const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true }); @@ -79,3 +92,45 @@ export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null resultMessage }; } + +/** + * Title-tier meta for edit_file blocks: everything the header and status + * pill render, obtained without parsing the embedded edit strings. The path + * comes from a targeted key extraction; the full parse runs only as a + * fallback for arg shapes the extraction can't see. + */ +export function parseEditFileTitleMeta(section: AgenticSection): EditFileTitleMeta | null { + if (section.toolName !== BuiltInTool.SERVER_EDIT_FILE || !section.toolArgs) return null; + + let rawPath: string | undefined = extractToolArgString(section.toolArgs, PATH_KEYS); + + if (!rawPath) { + const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true }); + const fallbackPath = args?.path ?? args?.file_path ?? args?.filePath; + + if (typeof fallbackPath === 'string' && fallbackPath) rawPath = fallbackPath; + } + + if (!rawPath) return null; + + const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath; + const resultObj = tryParseToolResultObject(section.toolResult); + + let resultMessage: string | undefined; + let editsApplied: number | undefined; + let errorMessage: string | undefined; + + if (typeof resultObj?.error === 'string') { + errorMessage = resultObj.error; + } else if (resultObj) { + if (typeof resultObj.result === 'string') { + resultMessage = resultObj.result; + } + + if (Number.isFinite(Number(resultObj.edits_applied))) { + editsApplied = Number(resultObj.edits_applied); + } + } + + return { editsApplied, errorMessage, fileName, filePath: rawPath, resultMessage }; +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts index 5b9bf9f88c..de40bdc80b 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts @@ -3,7 +3,7 @@ // finishes) and surfaces `bytes`, `result`, and `error` from the // result blob. -import { parseToolArgs } from './_shared'; +import { extractToolArgString, parseToolArgs } from './_shared'; import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX } from '$lib/constants'; import { BuiltInTool } from '$lib/enums'; import type { AgenticSection } from '$lib/types'; @@ -19,6 +19,20 @@ export type WriteFileMeta = { errorMessage?: string; }; +/** Everything the block title and status pill show; the full meta (with + * the embedded file content) stays body-only so collapsed blocks never + * parse the content blob. */ +export type WriteFileTitleMeta = { + fileName: string; + filePath: string; + language: string; + bytesWritten?: number; + resultMessage?: string; + errorMessage?: string; +}; + +const PATH_KEYS = ['path', 'file_path', 'filePath']; + export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null { const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true }); @@ -51,3 +65,43 @@ export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | nul resultMessage }; } + +/** + * Title-tier meta for write_file blocks: everything the header and status + * pill render, obtained without parsing the embedded file content. The path + * comes from a targeted key extraction; the full parse runs only as a + * fallback for arg shapes the extraction can't see. + */ +export function parseWriteFileTitleMeta(section: AgenticSection): WriteFileTitleMeta | null { + if (section.toolName !== BuiltInTool.SERVER_WRITE_FILE || !section.toolArgs) return null; + + let rawPath: string | undefined = extractToolArgString(section.toolArgs, PATH_KEYS); + + if (!rawPath) { + const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true }); + const fallbackPath = args?.path ?? args?.file_path ?? args?.filePath; + + if (typeof fallbackPath === 'string' && fallbackPath) rawPath = fallbackPath; + } + + if (!rawPath) return null; + + const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath; + const language = + getFileTypeByExtension(rawPath)?.replace(CODE_BLOCK.TEXT_LANGUAGE_PREFIX_REGEX, '') ?? + CODE_BLOCK.DEFAULT_LANGUAGE; + const resultObj = tryParseToolResultObject(section.toolResult); + const bytesWritten = + resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined; + const resultMessage = typeof resultObj?.result === 'string' ? resultObj.result : undefined; + const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined; + + return { + bytesWritten, + errorMessage, + fileName, + filePath: rawPath, + language, + resultMessage + }; +} diff --git a/tools/ui/tests/unit/tool-calls.test.ts b/tools/ui/tests/unit/tool-calls.test.ts index f84a2405ec..d1c5272932 100644 --- a/tools/ui/tests/unit/tool-calls.test.ts +++ b/tools/ui/tests/unit/tool-calls.test.ts @@ -1,5 +1,8 @@ import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared'; -import { parseEditFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file'; +import { + parseEditFileMeta, + parseEditFileTitleMeta +} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file'; import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command'; import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search'; import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search'; @@ -7,6 +10,7 @@ import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMes import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript'; import { parseWriteFileMeta, + parseWriteFileTitleMeta, type WriteFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file'; import { AgenticSectionType, BuiltInTool } from '$lib/enums'; @@ -223,6 +227,113 @@ describe('parseWriteFileMeta', () => { }); }); +describe('parseWriteFileTitleMeta', () => { + it('matches the full meta for path, language and result fields', () => { + const args = JSON.stringify({ content: 'x'.repeat(50_000), path: '/foo.ts' }); + const toolResult = '{"result":"wrote","bytes":42}'; + const section = makeSection( + { toolArgs: args, toolName: BuiltInTool.SERVER_WRITE_FILE, toolResult }, + BuiltInTool.SERVER_WRITE_FILE + ); + const full = parseWriteFileMeta(section); + const title = parseWriteFileTitleMeta(section); + + expect(title?.filePath).toBe(full?.filePath); + expect(title?.fileName).toBe(full?.fileName); + expect(title?.language).toBe(full?.language); + expect(title?.bytesWritten).toBe(full?.bytesWritten); + expect(title?.resultMessage).toBe(full?.resultMessage); + expect(title?.errorMessage).toBe(full?.errorMessage); + }); + + it('extracts a path with escaped characters without parsing the content blob', () => { + const section = makeSection( + { + toolArgs: '{"path":"/a\\nb\\"c/d.ts","content":"x"}', + toolName: BuiltInTool.SERVER_WRITE_FILE + }, + BuiltInTool.SERVER_WRITE_FILE + ); + + expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/a\nb"c/d.ts'); + }); + + it('falls back to the full parse for args the extractor can not see', () => { + const section = makeSection( + { + // key written with an escaped unicode escape sequence in the name + toolArgs: '{"\\u0070ath":"/foo.ts","content":"x"}', + toolName: BuiltInTool.SERVER_WRITE_FILE + }, + BuiltInTool.SERVER_WRITE_FILE + ); + + expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.ts'); + }); + + it('accepts partial args like the full parser', () => { + const section = makeSection( + { toolArgs: '{"path":"/foo.t', toolName: BuiltInTool.SERVER_WRITE_FILE }, + BuiltInTool.SERVER_WRITE_FILE + ); + + expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.t'); + }); + + it('returns null for sections with a different tool name', () => { + expect( + parseWriteFileTitleMeta( + makeSection({ + toolArgs: '{"path":"/x","content":"y"}', + toolName: BuiltInTool.SERVER_READ_FILE + }) + ) + ).toBeNull(); + }); +}); + +describe('parseEditFileTitleMeta', () => { + it('matches the full meta for path and result fields', () => { + const section = makeSection( + { + toolArgs: '{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"}]}' + ' '.repeat(0), + toolName: BuiltInTool.SERVER_EDIT_FILE, + toolResult: '{"result":"ok","edits_applied":1}' + }, + BuiltInTool.SERVER_EDIT_FILE + ); + const full = parseEditFileMeta(section); + const title = parseEditFileTitleMeta(section); + + expect(title?.filePath).toBe(full?.filePath); + expect(title?.fileName).toBe(full?.fileName); + expect(title?.editsApplied).toBe(full?.editsApplied); + expect(title?.resultMessage).toBe(full?.resultMessage); + expect(title?.errorMessage).toBe(full?.errorMessage); + }); + + it('surfaces errorMessage from the result blob without parsing args', () => { + const section = makeSection( + { + toolArgs: '{"path":"/foo.ts","edits":[]}', + toolName: BuiltInTool.SERVER_EDIT_FILE, + toolResult: '{"error":"permission denied"}' + }, + BuiltInTool.SERVER_EDIT_FILE + ); + + expect(parseEditFileTitleMeta(section)?.errorMessage).toBe('permission denied'); + }); + + it('returns null when args have no path-like field', () => { + expect( + parseEditFileTitleMeta( + makeSection({ toolArgs: '{"edits":[]}', toolName: BuiltInTool.SERVER_EDIT_FILE }) + ) + ).toBeNull(); + }); +}); + describe('parseEditFileMeta', () => { it('parses edits array and applies editsApplied from the result', () => { const section = makeSection(