From a3908232ae53a38b1a12bbe92c482cf3f8383bf5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 3 Jun 2026 17:15:52 +0300 Subject: [PATCH] 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 --- .opencode/opencode.json | 33 +++++ packages/electron/tsconfig.json | 17 +++ .../chat/message/parts/ToolPart.tsx | 137 +++++++++++++++++- .../chat/message/parts/toolPresentation.tsx | 3 + .../chat/message/parts/toolRenderUtils.ts | 2 +- packages/ui/src/components/icon/sprite.ts | 2 + packages/ui/src/lib/i18n/messages/en.ts | 5 + packages/ui/src/lib/i18n/messages/es.ts | 5 + packages/ui/src/lib/i18n/messages/ko.ts | 5 + packages/ui/src/lib/i18n/messages/pl.ts | 5 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 + packages/ui/src/lib/i18n/messages/uk.ts | 5 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 5 + packages/ui/src/lib/toolHelpers.ts | 43 +++++- 15 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 .opencode/opencode.json create mode 100644 packages/electron/tsconfig.json diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 00000000..a9dd884c --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "tailwindcss": { + "command": ["tailwindcss-language-server", "--stdio"], + "extensions": [".ts", ".tsx", ".js", ".jsx", ".css", ".html"] + }, + "css": { + "command": ["vscode-css-language-server", "--stdio"], + "extensions": [".css", ".scss", ".less"] + }, + "html": { + "command": ["vscode-html-language-server", "--stdio"], + "extensions": [".html"] + }, + "json": { + "command": ["vscode-json-language-server", "--stdio"], + "extensions": [".json", ".jsonc"] + }, + "mdx": { + "command": ["mdx-language-server", "--stdio"], + "extensions": [".mdx"] + }, + "marksman": { + "command": ["marksman", "server"], + "extensions": [".md"] + }, + "dockerfile": { + "command": ["docker-langserver", "--stdio"], + "extensions": ["Dockerfile", ".dockerfile"] + } + } +} diff --git a/packages/electron/tsconfig.json b/packages/electron/tsconfig.json new file mode 100644 index 00000000..66377644 --- /dev/null +++ b/packages/electron/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "moduleDetection": "force", + "allowJs": true, + "checkJs": false, + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node", "electron"] + }, + "include": ["*.mjs", "*.cjs", "scripts/**/*.mjs", "scripts/**/*.cjs"] +} diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index c53d863b..4359ae7e 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -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 | 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 | 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) => { + event.stopPropagation(); + setJsonViewMode((prev) => prev === 'formatted' ? 'raw' : 'formatted'); + }, []); + + const handleCopyOutput = React.useCallback(async (event: React.MouseEvent) => { + 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 ( -
- +
+
+ + +
+ {jsonViewMode === 'formatted' ? ( + + ) : ( +
+ + {renderedOutput} + +
+ )}
); } @@ -2461,6 +2573,9 @@ const ToolPartContent: React.FC = ({ 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 = ({ } return null; }, [descriptionPath, normalizedPartTool, stateWithData, input]); - const runtime = React.useContext(RuntimeAPIContext); const handleMainClick = (e: { stopPropagation: () => void }) => { @@ -2505,6 +2619,10 @@ const ToolPartContent: React.FC = ({ } } 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 = ({ {justificationText} )} - {!justificationText && description && ( + {!justificationText && normalizedPartTool === 'lsp' && descriptionPath ? ( + renderAnimatedPathWithIcon(descriptionPath, animateTailText, false, showToolFileIcons) + ) : null} + {!justificationText && normalizedPartTool !== 'lsp' && description && ( descriptionPath && description === descriptionPath ? ( renderAnimatedPathWithIcon(descriptionPath, animateTailText, false, showToolFileIcons) ) : ( diff --git a/packages/ui/src/components/chat/message/parts/toolPresentation.tsx b/packages/ui/src/components/chat/message/parts/toolPresentation.tsx index da095f12..5dd8928f 100644 --- a/packages/ui/src/components/chat/message/parts/toolPresentation.tsx +++ b/packages/ui/src/components/chat/message/parts/toolPresentation.tsx @@ -56,6 +56,9 @@ export const getToolIcon = (toolName: string) => { if (tool === 'question') { return ; } + if (tool === 'lsp') { + return ; + } if (tool === 'plan_enter') { return ; } diff --git a/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts b/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts index 46b840b4..c012ab10 100644 --- a/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts +++ b/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts @@ -2,7 +2,7 @@ const EXPANDABLE_TOOL_NAMES = new Set([ '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(['task']); diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 1f00f32d..e45a5eae 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -156,6 +156,7 @@ export const iconSpriteData = { "macbook": ``, "menu-2": ``, "menu-fold-2": ``, + "menu": ``, "menu-search": ``, "mic": ``, "mic-off": ``, @@ -187,6 +188,7 @@ export const iconSpriteData = { "rocket": ``, "save-3": ``, "scales-3": ``, + "scan-2": ``, "scissors": ``, "search-eye": ``, "search": ``, diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 3d3101fc..bf016636 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1826,6 +1826,11 @@ export const dict = { 'chat.toolPart.awaitingResponse': 'Awaiting response...', 'chat.toolPart.noOutputProduced': 'No output produced', 'chat.toolPart.output': 'Output', + 'chat.toolPart.showRawJson': 'Show raw JSON', + 'chat.toolPart.showFormattedJson': 'Show formatted JSON', + 'chat.toolPart.copyOutput': 'Copy output', + 'chat.toolPart.copiedOutput': 'Copied output', + 'chat.toolPart.copyOutputFailed': 'Failed to copy output', 'chat.toolPart.openSubtask': 'Open {type} subtask', 'chat.todo.total': 'Total', 'chat.todo.inProgress': 'In Progress', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 2e6b4781..411355dd 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1792,6 +1792,11 @@ export const dict: Record = { "chat.toolPart.awaitingResponse": "Esperando respuesta...", "chat.toolPart.noOutputProduced": "No se produjo ninguna salida", "chat.toolPart.output": "Salida", + "chat.toolPart.showRawJson": "Mostrar JSON sin formato", + "chat.toolPart.showFormattedJson": "Mostrar JSON formateado", + "chat.toolPart.copyOutput": "Copiar salida", + "chat.toolPart.copiedOutput": "Salida copiada", + "chat.toolPart.copyOutputFailed": "No se pudo copiar la salida", "chat.toolPart.openSubtask": "Abrir subtarea {type}", "chat.todo.total": "Total", "chat.todo.inProgress": "En progreso", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 9c3016b6..44ea5cef 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1826,6 +1826,11 @@ export const dict: Record = { 'chat.toolPart.awaitingResponse': '응답 대기 중…', 'chat.toolPart.noOutputProduced': '출력 없음', 'chat.toolPart.output': '출력', + 'chat.toolPart.showRawJson': '원시 JSON 표시', + 'chat.toolPart.showFormattedJson': '형식화된 JSON 표시', + 'chat.toolPart.copyOutput': '출력 복사', + 'chat.toolPart.copiedOutput': '출력 복사됨', + 'chat.toolPart.copyOutputFailed': '출력을 복사하지 못했습니다', 'chat.toolPart.openSubtask': '{type} 하위 작업 열기', 'chat.todo.total': '전체', 'chat.todo.inProgress': '진행 중', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index f670de4c..58571d8c 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1199,6 +1199,11 @@ export const dict: Record = { 'chat.toolPart.noOutputProduced': 'Brak wygenerowanego wyniku', 'chat.toolPart.openSubtask': 'Otwórz podzadanie typu {type}', 'chat.toolPart.output': 'Wyjście', + 'chat.toolPart.showRawJson': 'Pokaż surowy JSON', + 'chat.toolPart.showFormattedJson': 'Pokaż sformatowany JSON', + 'chat.toolPart.copyOutput': 'Kopiuj wyjście', + 'chat.toolPart.copiedOutput': 'Skopiowano wyjście', + 'chat.toolPart.copyOutputFailed': 'Nie udało się skopiować wyjścia', 'commandPalette.description': 'Szukaj plików, sesji i poleceń.', 'commandPalette.empty.noResults': 'Nie znaleziono wyników.', 'commandPalette.empty.searchingFiles': 'Wyszukiwanie plików...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index c967faaa..21489feb 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1792,6 +1792,11 @@ export const dict: Record = { "chat.toolPart.awaitingResponse": "Aguardando resposta...", "chat.toolPart.noOutputProduced": "Nenhuma saída produzida", "chat.toolPart.output": "Saída", + "chat.toolPart.showRawJson": "Mostrar JSON bruto", + "chat.toolPart.showFormattedJson": "Mostrar JSON formatado", + "chat.toolPart.copyOutput": "Copiar saída", + "chat.toolPart.copiedOutput": "Saída copiada", + "chat.toolPart.copyOutputFailed": "Falha ao copiar saída", "chat.toolPart.openSubtask": "Abrir subtarefa {type}", "chat.todo.total": "Total", "chat.todo.inProgress": "Em andamento", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index c7354203..8058d4fd 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1792,6 +1792,11 @@ export const dict: Record = { "chat.toolPart.awaitingResponse": "Очікування відповіді...", "chat.toolPart.noOutputProduced": "Вивід відсутній", "chat.toolPart.output": "Вивід", + "chat.toolPart.showRawJson": "Показати сирий JSON", + "chat.toolPart.showFormattedJson": "Показати форматований JSON", + "chat.toolPart.copyOutput": "Скопіювати вивід", + "chat.toolPart.copiedOutput": "Вивід скопійовано", + "chat.toolPart.copyOutputFailed": "Не вдалося скопіювати вивід", "chat.toolPart.openSubtask": "Відкрити підзавдання {type}", "chat.todo.total": "Усього", "chat.todo.inProgress": "В роботі", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index c524d06d..ab468e17 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1792,6 +1792,11 @@ export const dict: Record = { 'chat.toolPart.awaitingResponse': '等待响应...', 'chat.toolPart.noOutputProduced': '未产生输出', 'chat.toolPart.output': '输出', + 'chat.toolPart.showRawJson': '显示原始 JSON', + 'chat.toolPart.showFormattedJson': '显示格式化 JSON', + 'chat.toolPart.copyOutput': '复制输出', + 'chat.toolPart.copiedOutput': '已复制输出', + 'chat.toolPart.copyOutputFailed': '复制输出失败', 'chat.toolPart.openSubtask': '打开{type}子任务', 'chat.todo.total': '总计', 'chat.todo.inProgress': '进行中', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index c7301384..283c24e8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1796,6 +1796,11 @@ export const dict: Record = { 'chat.toolPart.awaitingResponse': '等待回應...', 'chat.toolPart.noOutputProduced': '未產生輸出', 'chat.toolPart.output': '輸出', + 'chat.toolPart.showRawJson': '顯示原始 JSON', + 'chat.toolPart.showFormattedJson': '顯示格式化 JSON', + 'chat.toolPart.copyOutput': '複製輸出', + 'chat.toolPart.copiedOutput': '已複製輸出', + 'chat.toolPart.copyOutputFailed': '複製輸出失敗', 'chat.toolPart.openSubtask': '開啟{type}子任務', 'chat.todo.total': '總計', 'chat.todo.inProgress': '進行中', diff --git a/packages/ui/src/lib/toolHelpers.ts b/packages/ui/src/lib/toolHelpers.ts index a1edc6eb..f80514eb 100644 --- a/packages/ui/src/lib/toolHelpers.ts +++ b/packages/ui/src/lib/toolHelpers.ts @@ -165,12 +165,25 @@ export const TOOL_METADATA: Record = { { key: 'name', label: 'Skill Name', type: 'text' } ] }, - question: { - displayName: 'Question', - category: 'ai', - outputLanguage: 'text', + question: { + displayName: 'Question', + category: 'ai', + outputLanguage: 'text', + inputFields: [ + { key: 'questions', label: 'Questions', type: 'code', language: 'json' } + ] + }, + + lsp: { + displayName: 'LSP', + category: 'code', + outputLanguage: 'json', inputFields: [ - { key: 'questions', label: 'Questions', type: 'code', language: 'json' } + { key: 'operation', label: 'Operation', type: 'text' }, + { key: 'filePath', label: 'File Path', type: 'file' }, + { key: 'line', label: 'Line', type: 'text' }, + { key: 'character', label: 'Character', type: 'text' }, + { key: 'query', label: 'Query', type: 'text' } ] }, @@ -702,6 +715,26 @@ export function formatToolInput(input: Record, toolName: string if (cmd) return cmd; } + if (toolName === 'lsp') { + const operation = getString('operation') || 'lsp'; + const filePath = getString('filePath') || getString('file_path') || getString('path'); + const line = getString('line'); + const character = getString('character'); + const query = getString('query'); + const position = line && character ? ` (Line: ${line}; Character: ${character})` : ''; + + if (operation === 'workspaceSymbol') { + return query ? `Operation: ${operation} (Query: "${query}")` : `Operation: ${operation}`; + } + + const summary = `Operation: ${operation}${position}`; + if (filePath) { + return `${summary}\n${filePath}`; + } + + return summary; + } + if (toolName === 'task') { const prompt = getString('prompt'); if (prompt) return prompt;