From fc44465c00bce93fad33a95d2d25742509dfe2be Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Sat, 4 Apr 2026 11:15:32 +0300 Subject: [PATCH] fix(chat): render LSP diagnostics in tool output (#837) --- .../chat/message/parts/ToolPart.tsx | 200 +++++++++++++++++- .../components/chat/message/toolRenderers.tsx | 16 +- 2 files changed, 211 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index bcde0dd2..2fc120a1 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -348,6 +348,146 @@ const getRelativePath = (absolutePath: string, currentDirectory: string): string return normalizedAbsolutePath; }; +type ToolDiagnostic = { + message: string; + line: number; + character: number; +}; + +type ToolDiagnosticSection = { + displayPath: string; + diagnostics: ToolDiagnostic[]; + remaining: number; +}; + +const TOOL_DIAGNOSTICS_MAX_PER_FILE = 5; + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null; +}; + +const normalizeToolDiagnostic = (value: unknown): ToolDiagnostic | null => { + if (!isRecord(value)) { + return null; + } + + const message = typeof value.message === 'string' ? value.message.trim() : ''; + if (!message) { + return null; + } + + const severity = typeof value.severity === 'number' && Number.isFinite(value.severity) ? Math.trunc(value.severity) : undefined; + if (severity !== undefined && severity !== 1) { + return null; + } + + const range = isRecord(value.range) ? value.range : undefined; + const start = range && isRecord(range.start) ? range.start : undefined; + const rawLine = typeof start?.line === 'number' && Number.isFinite(start.line) ? Math.max(0, Math.trunc(start.line)) : 0; + const rawCharacter = typeof start?.character === 'number' && Number.isFinite(start.character) + ? Math.max(0, Math.trunc(start.character)) + : 0; + + return { + message, + line: rawLine + 1, + character: rawCharacter + 1, + }; +}; + +const getPrimaryToolPath = ( + toolName: string, + input: Record | undefined, + metadata: Record | undefined, +): string | null => { + if (toolName === 'apply_patch') { + const files = Array.isArray(metadata?.files) ? metadata.files : []; + const first = files.find((entry) => { + if (!isRecord(entry)) { + return false; + } + return entry.type !== 'delete'; + }); + if (!isRecord(first)) { + return null; + } + return typeof first.movePath === 'string' + ? first.movePath + : typeof first.filePath === 'string' + ? first.filePath + : typeof first.relativePath === 'string' + ? first.relativePath + : null; + } + + if (toolName === 'edit' || toolName === 'multiedit') { + const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined; + if (isRecord(fileDiff) && typeof fileDiff.file === 'string') { + return fileDiff.file; + } + return typeof input?.filePath === 'string' + ? input.filePath + : typeof input?.file_path === 'string' + ? input.file_path + : typeof input?.path === 'string' + ? input.path + : null; + } + + if (toolName === 'write') { + return typeof input?.filePath === 'string' + ? input.filePath + : typeof input?.file_path === 'string' + ? input.file_path + : typeof input?.path === 'string' + ? input.path + : null; + } + + return null; +}; + +const getToolDiagnosticSection = ( + toolName: string, + input: Record | undefined, + metadata: Record | undefined, + currentDirectory: string, +): ToolDiagnosticSection | null => { + if (!['edit', 'multiedit', 'write', 'apply_patch'].includes(toolName)) { + return null; + } + + const primaryPath = getPrimaryToolPath(toolName, input, metadata); + if (!primaryPath || !metadata || !isRecord(metadata.diagnostics)) { + return null; + } + + const normalizedPath = normalizeDisplayPath(primaryPath); + const absolutePath = normalizedPath.startsWith('/') + ? normalizedPath + : `${normalizeDisplayPath(currentDirectory)}/${normalizedPath}`.replace(/\/+/g, '/'); + + const rawDiagnostics = (metadata.diagnostics as Record)[normalizedPath] + ?? (metadata.diagnostics as Record)[absolutePath]; + if (!Array.isArray(rawDiagnostics)) { + return null; + } + + const diagnostics = rawDiagnostics + .map((entry) => normalizeToolDiagnostic(entry)) + .filter((entry): entry is ToolDiagnostic => !!entry); + if (diagnostics.length === 0) { + return null; + } + + const visible = diagnostics.slice(0, TOOL_DIAGNOSTICS_MAX_PER_FILE); + return { + displayPath: normalizedPath.startsWith('/') ? getRelativePath(normalizedPath, currentDirectory) : normalizedPath, + diagnostics: visible, + remaining: Math.max(0, diagnostics.length - visible.length), + }; +}; + const usePierreThemeConfig = () => { const themeSystem = useOptionalThemeSystem(); const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []); @@ -1344,6 +1484,10 @@ const ToolExpandedContent: React.FC = React.memo(({ const writeDisplayPath = shouldShowWriteInputPreview ? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory) : 'New file') : null; + const diagnosticSection = React.useMemo( + () => getToolDiagnosticSection(part.tool, input, metadata, currentDirectory), + [currentDirectory, input, metadata, part.tool], + ); const inputTextContent = React.useMemo(() => { if (!input || typeof input !== 'object' || Object.keys(input).length === 0) { @@ -1381,6 +1525,50 @@ const ToolExpandedContent: React.FC = React.memo(({ ); const renderResultContent = () => { + const renderDiagnosticsSection = () => { + if (!diagnosticSection) { + return null; + } + + return ( +
+
+ LSP errors +
+
+
+ {renderPathLikeGitChanges(diagnosticSection.displayPath, false)} +
+
+ {diagnosticSection.diagnostics.map((diagnostic, index) => ( +
+
+ + [{diagnostic.line}:{diagnostic.character}] + + + {diagnostic.message} + +
+
+ ))} +
+ {diagnosticSection.remaining > 0 ? ( +
+ +{diagnosticSection.remaining} more errors +
+ ) : null} +
+
+ ); + }; + // Question tool: show parsed Q&A summary if (part.tool === 'question') { if (state.status === 'completed' && hasStringOutput) { @@ -1426,7 +1614,7 @@ const ToolExpandedContent: React.FC = React.memo(({ ); } - if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffEntries.length > 0) { + if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && (diffEntries.length > 0 || !!diagnosticSection)) { return renderScrollableBlock(
{diffEntries.map((entry) => ( @@ -1444,11 +1632,21 @@ const ToolExpandedContent: React.FC = React.memo(({ />
))} + {renderDiagnosticsSection()} , { className: 'p-1' } ); } + if (part.tool === 'write' && diagnosticSection) { + return renderScrollableBlock( +
+ {renderDiagnosticsSection()} +
, + { className: 'p-1' }, + ); + } + if (hasStringOutput && outputString.trim()) { return renderScrollableBlock( { export const hasLspDiagnostics = (output: string): boolean => { if (!output) return false; - return output.includes('') || output.includes('This file has errors') || output.includes('please fix'); + return output.includes('') + || output.includes('LSP errors detected') + || output.includes('This file has errors'); }; const stripLspDiagnostics = (output: string): string => { if (!output) return ''; - return output.replace(/This file has errors.*?<\/file_diagnostics>/s, '').trim(); + return output + .replace(/\n{0,2}LSP errors detected[\s\S]*?]*>[\s\S]*?<\/diagnostics>/g, '') + .replace(/\n{0,2}This file has errors[\s\S]*?<\/file_diagnostics>/g, '') + .replace(/]*>[\s\S]*?<\/diagnostics>/g, '') + .replace(/[\s\S]*?<\/file_diagnostics>/g, '') + .trim(); }; const formatInputForDisplay = (input: Record, toolName?: string) => { @@ -61,11 +69,11 @@ export const tryParseJsonOutput = (output: string): { data: unknown; isJson: boo export const formatEditOutput = (output: string, toolName: string, metadata?: Record): string => { let cleaned = cleanOutput(output); - if ((toolName === 'edit' || toolName === 'multiedit') && hasLspDiagnostics(cleaned)) { + if ((toolName === 'edit' || toolName === 'multiedit' || toolName === 'write' || toolName === 'apply_patch') && hasLspDiagnostics(cleaned)) { cleaned = stripLspDiagnostics(cleaned); } - if ((toolName === 'edit' || toolName === 'multiedit') && cleaned.trim().length === 0 && metadata?.diff) { + if ((toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') && cleaned.trim().length === 0 && metadata?.diff) { const diff = metadata.diff; return typeof diff === 'string' ? diff : String(diff);