fix(chat): render LSP diagnostics in tool output (#837)
This commit is contained in:
@@ -348,6 +348,146 @@ const getRelativePath = (absolutePath: string, currentDirectory: string): string
|
|||||||
return normalizedAbsolutePath;
|
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 usePierreThemeConfig = () => {
|
||||||
const themeSystem = useOptionalThemeSystem();
|
const themeSystem = useOptionalThemeSystem();
|
||||||
const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []);
|
const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []);
|
||||||
@@ -1344,6 +1484,10 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
const writeDisplayPath = shouldShowWriteInputPreview
|
const writeDisplayPath = shouldShowWriteInputPreview
|
||||||
? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory) : 'New file')
|
? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory) : 'New file')
|
||||||
: null;
|
: null;
|
||||||
|
const diagnosticSection = React.useMemo(
|
||||||
|
() => getToolDiagnosticSection(part.tool, input, metadata, currentDirectory),
|
||||||
|
[currentDirectory, input, metadata, part.tool],
|
||||||
|
);
|
||||||
|
|
||||||
const inputTextContent = React.useMemo(() => {
|
const inputTextContent = React.useMemo(() => {
|
||||||
if (!input || typeof input !== 'object' || Object.keys(input).length === 0) {
|
if (!input || typeof input !== 'object' || Object.keys(input).length === 0) {
|
||||||
@@ -1381,6 +1525,50 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderResultContent = () => {
|
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
|
// Question tool: show parsed Q&A summary
|
||||||
if (part.tool === 'question') {
|
if (part.tool === 'question') {
|
||||||
if (state.status === 'completed' && hasStringOutput) {
|
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(
|
return renderScrollableBlock(
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{diffEntries.map((entry) => (
|
{diffEntries.map((entry) => (
|
||||||
@@ -1444,11 +1632,21 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{renderDiagnosticsSection()}
|
||||||
</div>,
|
</div>,
|
||||||
{ className: 'p-1' }
|
{ className: 'p-1' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (part.tool === 'write' && diagnosticSection) {
|
||||||
|
return renderScrollableBlock(
|
||||||
|
<div className="space-y-3">
|
||||||
|
{renderDiagnosticsSection()}
|
||||||
|
</div>,
|
||||||
|
{ className: 'p-1' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasStringOutput && outputString.trim()) {
|
if (hasStringOutput && outputString.trim()) {
|
||||||
return renderScrollableBlock(
|
return renderScrollableBlock(
|
||||||
<ToolScrollableTextOutput
|
<ToolScrollableTextOutput
|
||||||
|
|||||||
@@ -13,12 +13,20 @@ const cleanOutput = (output: string) => {
|
|||||||
|
|
||||||
export const hasLspDiagnostics = (output: string): boolean => {
|
export const hasLspDiagnostics = (output: string): boolean => {
|
||||||
if (!output) return false;
|
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 => {
|
const stripLspDiagnostics = (output: string): string => {
|
||||||
if (!output) return '';
|
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) => {
|
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 => {
|
export const formatEditOutput = (output: string, toolName: string, metadata?: Record<string, unknown>): string => {
|
||||||
let cleaned = cleanOutput(output);
|
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);
|
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;
|
const diff = metadata.diff;
|
||||||
return typeof diff === 'string' ? diff : String(diff);
|
return typeof diff === 'string' ? diff : String(diff);
|
||||||
|
|||||||
Reference in New Issue
Block a user