fix(chat): render LSP diagnostics in tool output (#837)

This commit is contained in:
Iuliia Ivashko
2026-04-04 11:15:32 +03:00
committed by GitHub
parent 8587db89b1
commit fc44465c00
2 changed files with 211 additions and 5 deletions
@@ -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<string, unknown> => {
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<string, unknown> | undefined,
metadata: Record<string, unknown> | 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<string, unknown> | undefined,
metadata: Record<string, unknown> | 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<string, unknown>)[normalizedPath]
?? (metadata.diagnostics as Record<string, unknown>)[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<ToolExpandedContentProps> = 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<ToolExpandedContentProps> = React.memo(({
);
const renderResultContent = () => {
const renderDiagnosticsSection = () => {
if (!diagnosticSection) {
return null;
}
return (
<div
className="tool-output-surface rounded-xl border p-2 space-y-2"
style={{
borderColor: 'var(--status-error-border)',
backgroundColor: 'var(--status-error-background)',
}}
>
<div className="typography-meta font-medium" style={{ color: 'var(--status-error)' }}>
LSP errors
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 min-w-0">
{renderPathLikeGitChanges(diagnosticSection.displayPath, false)}
</div>
<div className="space-y-1">
{diagnosticSection.diagnostics.map((diagnostic, index) => (
<div key={`${diagnosticSection.displayPath}:${diagnostic.line}:${diagnostic.character}:${index}`} className="rounded-md border px-2 py-1" style={{ borderColor: 'var(--status-error-border)', backgroundColor: 'var(--surface-elevated)' }}>
<div className="flex items-start gap-2 min-w-0">
<span className="typography-micro shrink-0" style={{ color: 'var(--status-error)' }}>
[{diagnostic.line}:{diagnostic.character}]
</span>
<span className="typography-meta text-foreground whitespace-pre-wrap break-words">
{diagnostic.message}
</span>
</div>
</div>
))}
</div>
{diagnosticSection.remaining > 0 ? (
<div className="typography-micro text-muted-foreground">
+{diagnosticSection.remaining} more errors
</div>
) : null}
</div>
</div>
);
};
// Question tool: show parsed Q&A summary
if (part.tool === 'question') {
if (state.status === 'completed' && hasStringOutput) {
@@ -1426,7 +1614,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = 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(
<div className="space-y-3">
{diffEntries.map((entry) => (
@@ -1444,11 +1632,21 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
/>
</div>
))}
{renderDiagnosticsSection()}
</div>,
{ className: 'p-1' }
);
}
if (part.tool === 'write' && diagnosticSection) {
return renderScrollableBlock(
<div className="space-y-3">
{renderDiagnosticsSection()}
</div>,
{ className: 'p-1' },
);
}
if (hasStringOutput && outputString.trim()) {
return renderScrollableBlock(
<ToolScrollableTextOutput
@@ -13,12 +13,20 @@ const cleanOutput = (output: string) => {
export const hasLspDiagnostics = (output: string): boolean => {
if (!output) return false;
return output.includes('<file_diagnostics>') || output.includes('This file has errors') || output.includes('please fix');
return output.includes('<diagnostics')
|| output.includes('<file_diagnostics>')
|| 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]*?<diagnostics[^>]*>[\s\S]*?<\/diagnostics>/g, '')
.replace(/\n{0,2}This file has errors[\s\S]*?<\/file_diagnostics>/g, '')
.replace(/<diagnostics[^>]*>[\s\S]*?<\/diagnostics>/g, '')
.replace(/<file_diagnostics>[\s\S]*?<\/file_diagnostics>/g, '')
.trim();
};
const formatInputForDisplay = (input: Record<string, unknown>, 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, unknown>): 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);