feat: render LSP tool output in chat

Adds expandable LSP tool rows with scan icon and stable file path display
Adds formatted/raw JSON output toggle and copy action
Improves compact LSP input formatting
This commit is contained in:
Bohdan Triapitsyn
2026-06-03 17:15:52 +03:00
parent 8e2c7549ca
commit a3908232ae
15 changed files with 263 additions and 14 deletions
@@ -20,8 +20,11 @@ import { opencodeClient } from '@/lib/opencode/client';
import { isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import { Text } from '@/components/ui/text';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { copyTextToClipboard } from '@/lib/clipboard';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
@@ -665,9 +668,49 @@ const getToolDescriptionPath = (part: ToolPartType, state: ToolStateUnion, curre
}
}
if (part.tool === 'lsp' && input) {
const filePath = input?.filePath || input?.file_path || input?.path;
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory);
}
}
return null;
};
const getLspToolDescription = (input: Record<string, unknown> | undefined, currentDirectory: string): string => {
if (!input) {
return '';
}
const operation = typeof input.operation === 'string' ? input.operation : 'lsp';
if (operation === 'workspaceSymbol') {
const query = typeof input.query === 'string' && input.query.trim().length > 0
? ` "${input.query.trim()}"`
: '';
return `${operation}${query}`;
}
const filePath = typeof input.filePath === 'string'
? input.filePath
: typeof input.file_path === 'string'
? input.file_path
: typeof input.path === 'string'
? input.path
: '';
const displayPath = filePath ? getRelativePath(filePath, currentDirectory) : '';
if (operation === 'documentSymbol') {
return displayPath ? `${operation} ${displayPath}` : operation;
}
const line = typeof input.line === 'number' && Number.isFinite(input.line) ? Math.trunc(input.line) : undefined;
const character = typeof input.character === 'number' && Number.isFinite(input.character) ? Math.trunc(input.character) : undefined;
const position = line !== undefined && character !== undefined ? `:${line}:${character}` : '';
return displayPath ? `${operation} ${displayPath}${position}` : operation;
};
const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string => {
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
@@ -701,6 +744,10 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDi
return input.description.substring(0, 80);
}
if (part.tool === 'lsp') {
return getLspToolDescription(input, currentDirectory);
}
const desc = input?.description || metadata?.description || ('title' in state && state.title) || '';
return typeof desc === 'string' ? desc : '';
};
@@ -768,18 +815,83 @@ const ToolScrollableTextOutput: React.FC<{
input: Record<string, unknown> | undefined;
syntaxTheme: { [key: string]: React.CSSProperties };
}> = ({ output, part, metadata, input, syntaxTheme }) => {
const { t } = useI18n();
const renderedOutput = getToolOutputText(output, part, metadata);
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]);
const [jsonViewMode, setJsonViewMode] = React.useState<'formatted' | 'raw'>('formatted');
const [copiedJson, setCopiedJson] = React.useState(false);
React.useEffect(() => {
setJsonViewMode('formatted');
setCopiedJson(false);
}, [renderedOutput]);
const handleToggleJsonView = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setJsonViewMode((prev) => prev === 'formatted' ? 'raw' : 'formatted');
}, []);
const handleCopyOutput = React.useCallback(async (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
const result = await copyTextToClipboard(renderedOutput);
if (!result.ok) {
toast.error(t('chat.toolPart.copyOutputFailed'));
return;
}
setCopiedJson(true);
if (typeof window !== 'undefined') {
window.setTimeout(() => setCopiedJson(false), 1200);
}
}, [renderedOutput, t]);
if (jsonResult.isJson) {
return (
<div className="tool-output-surface p-2 rounded-xl w-full min-w-0">
<JsonTreeViewer
data={jsonResult.data}
initiallyExpandedDepth={1}
maxHeight="400px"
/>
<div className="tool-output-surface relative p-2 rounded-xl w-full min-w-0">
<div className="absolute right-2 top-2 z-10 flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md bg-[var(--surface-elevated)]/80 text-muted-foreground hover:text-foreground"
onClick={handleToggleJsonView}
onPointerDown={(event) => event.stopPropagation()}
aria-label={jsonViewMode === 'formatted' ? t('chat.toolPart.showRawJson') : t('chat.toolPart.showFormattedJson')}
title={jsonViewMode === 'formatted' ? t('chat.toolPart.showRawJson') : t('chat.toolPart.showFormattedJson')}
>
<Icon name={jsonViewMode === 'formatted' ? 'code-box' : 'list-check-2'} className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md bg-[var(--surface-elevated)]/80 text-muted-foreground hover:text-foreground"
onClick={handleCopyOutput}
onPointerDown={(event) => event.stopPropagation()}
aria-label={copiedJson ? t('chat.toolPart.copiedOutput') : t('chat.toolPart.copyOutput')}
title={copiedJson ? t('chat.toolPart.copiedOutput') : t('chat.toolPart.copyOutput')}
>
<Icon name={copiedJson ? 'check' : 'file-copy'} className="h-3.5 w-3.5" />
</Button>
</div>
{jsonViewMode === 'formatted' ? (
<JsonTreeViewer
data={jsonResult.data}
initiallyExpandedDepth={1}
maxHeight="400px"
/>
) : (
<div className="typography-code pr-12 text-muted-foreground/90">
<SyntaxHighlighter
style={syntaxTheme}
language="json"
PreTag="div"
customStyle={TOOL_COLLAPSED_CUSTOM_STYLE}
codeTagProps={CODE_TAG_PROPS}
wrapLongLines
>
{renderedOutput}
</SyntaxHighlighter>
</div>
)}
</div>
);
}
@@ -2461,6 +2573,9 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
if (normalizedPartTool === 'apply_patch') {
return null;
}
if (normalizedPartTool === 'lsp') {
return null;
}
if (
descriptionPath
&& (normalizedPartTool === 'apply_patch' || normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'write')
@@ -2477,7 +2592,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}
return null;
}, [descriptionPath, normalizedPartTool, stateWithData, input]);
const runtime = React.useContext(RuntimeAPIContext);
const handleMainClick = (e: { stopPropagation: () => void }) => {
@@ -2505,6 +2619,10 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}
} else if (['write', 'create', 'file_write'].includes(part.tool)) {
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
} else if (part.tool === 'lsp') {
filePath = input?.filePath || input?.file_path || input?.path;
const line = input?.line;
targetLine = typeof line === 'number' && Number.isFinite(line) ? Math.trunc(line) : undefined;
}
if (typeof filePath === 'string') {
@@ -2632,7 +2750,10 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
{justificationText}
</span>
)}
{!justificationText && description && (
{!justificationText && normalizedPartTool === 'lsp' && descriptionPath ? (
renderAnimatedPathWithIcon(descriptionPath, animateTailText, false, showToolFileIcons)
) : null}
{!justificationText && normalizedPartTool !== 'lsp' && description && (
descriptionPath && description === descriptionPath ? (
renderAnimatedPathWithIcon(descriptionPath, animateTailText, false, showToolFileIcons)
) : (
@@ -56,6 +56,9 @@ export const getToolIcon = (toolName: string) => {
if (tool === 'question') {
return <Icon name="survey" className={iconClass} />;
}
if (tool === 'lsp') {
return <Icon name="scan-2" className={iconClass} />;
}
if (tool === 'plan_enter') {
return <Icon name="file-list-2" className={iconClass} />;
}
@@ -2,7 +2,7 @@ const EXPANDABLE_TOOL_NAMES = new Set<string>([
'edit', 'multiedit', 'apply_patch', 'str_replace', 'str_replace_based_edit_tool',
'bash', 'shell', 'cmd', 'terminal',
'write', 'create', 'file_write',
'question', 'task',
'question', 'task', 'lsp',
]);
const STANDALONE_TOOL_NAMES = new Set<string>(['task']);