From b3fa19fe3e304cb0011028450c2b4ab945b89923 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 10 Jul 2026 14:51:33 +0300 Subject: [PATCH] feat: add navigable JSON summaries for tool output Tool JSON output now starts with a compact navigable summary view. Expandable tool output includes quick open-file and diff actions for changed files. Reasoning headers strip stray HTML comments, and navigation tools stay compact. --- CHANGELOG.md | 3 + .../components/chat/message/MessageBody.tsx | 27 +--- .../chat/message/parts/DOCUMENTATION.md | 16 +- .../message/parts/JsonSummaryView.test.tsx | 34 ++++ .../chat/message/parts/JsonSummaryView.tsx | 111 +++++++++++++ .../chat/message/parts/ReasoningPart.test.tsx | 16 ++ .../chat/message/parts/ReasoningPart.tsx | 82 +--------- .../chat/message/parts/ToolPart.tsx | 153 ++++++++++++++++-- .../chat/message/parts/UserTextPart.tsx | 9 +- .../message/parts/toolRenderUtils.test.ts | 30 ++++ .../chat/message/parts/toolRenderUtils.ts | 15 +- packages/ui/src/lib/i18n/messages/en.ts | 5 +- packages/ui/src/lib/i18n/messages/es.ts | 3 + packages/ui/src/lib/i18n/messages/fr.ts | 3 + packages/ui/src/lib/i18n/messages/ja.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 3 + packages/ui/src/stores/useUIStore.ts | 2 - packages/vscode/CHANGELOG.md | 3 + 23 files changed, 389 insertions(+), 144 deletions(-) create mode 100644 packages/ui/src/components/chat/message/parts/JsonSummaryView.test.tsx create mode 100644 packages/ui/src/components/chat/message/parts/JsonSummaryView.tsx create mode 100644 packages/ui/src/components/chat/message/parts/toolRenderUtils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a1c85d4..07926e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ All notable changes to this project will be documented in this file. - Desktop: the header dropdown (instance / usage / MCP) was restyled with cards — usage grouped per provider, hosts showing a colored status line with ping and the active host highlighted, and MCP servers in one card. Host statuses persist between openings instead of flashing "Unknown", and switching to an already-checked host is immediate. - Desktop: the servers list in Settings shows live per-server reachability, and importing a pairing link is the primary way to add a server. - Desktop: Windows builds can launch at login and minimize to the system tray (thanks to @achcyano). +- Chat/Tools: every tool call now expands to show its input, result, and errors, including MCP, plugin, and custom tools; Read and Skill stay compact links to their files. JSON results open in a new navigable summary view with linked URLs and expandable nested data, alongside tree and raw JSON views. +- Chat/Tools: expanded file-edit and patch results now include per-file buttons to open the diff or jump to the first changed line in the file editor. +- Chat/Thinking: reasoning parts stay separate and in chronological order instead of merging into one block, and collapsed previews no longer show empty trailing HTML comments. - Projects: each project can now set its own default model (thanks to @makeittech). - Diff/Chat: added a Last turn mode to the Diff view, and latest-turn changed-file chips in chat now open that snapshot while older turn chips stay read-only. - Chat: Mermaid diagrams now have zoom controls (thanks to @c-w-xiaohei). diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 00d9969c..6ff02043 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -4,7 +4,7 @@ import type { Part } from '@opencode-ai/sdk/v2'; import UserTextPart from './parts/UserTextPart'; import ToolPart from './parts/ToolPart'; import AssistantTextPart from './parts/AssistantTextPart'; -import ReasoningPart, { MergedReasoningPart } from './parts/ReasoningPart'; +import ReasoningPart from './parts/ReasoningPart'; import { MessageFilesDisplay } from '../FileAttachment'; import { TurnChangedFilesDropdown } from '../TurnChangedFilesDropdown'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; @@ -1210,7 +1210,6 @@ const AssistantMessageBody = React.memo(({ const [isForkSubmitting, setIsForkSubmitting] = React.useState(false); const chatRenderMode = useUIStore((state) => state.chatRenderMode); const collapsibleThinkingBlocks = useUIStore((state) => state.collapsibleThinkingBlocks); - const groupReasoningBlocks = useUIStore((state) => state.groupReasoningBlocks); const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const vscodeApi = useRuntimeAPIs().vscode; @@ -1712,15 +1711,6 @@ const AssistantMessageBody = React.memo(({ // Group consecutive static tools (read, grep, glob, etc.) into compact rows. // Expandable tools (bash, edit, task) get individual rows. // Text renders inline at its natural position. - // Reasoning: all reasoning parts for this message are merged into ONE block - // at the position of the first reasoning part (VSCode Copilot pattern). - const flatReasoningParts = visibleParts.filter((p) => { - if (p.type !== 'reasoning') return false; - const a = activityByPart.get(p); - return a?.kind !== 'reasoning'; - }); - let reasoningMergeRendered = false; - let i = 0; while (i < visibleParts.length) { const part = visibleParts[i]; @@ -1782,20 +1772,6 @@ const AssistantMessageBody = React.memo(({ onShowPopup={onShowPopup} /> ); - } else if (groupReasoningBlocks) { - // Merged mode (VSCode pattern): one block for all reasoning parts. - if (!reasoningMergeRendered) { - reasoningMergeRendered = true; - rendered.push( - - ); - } } else { // Per-part mode: each reasoning block at its natural position. rendered.push( @@ -1893,7 +1869,6 @@ const AssistantMessageBody = React.memo(({ animateActivityRows, chatRenderMode, collapsibleThinkingBlocks, - groupReasoningBlocks, collapsedPreviewCount, expandedTools, isMobile, diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 91f26d5d..42147870 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -45,26 +45,26 @@ Use this doc when you ask an agent to change tool/header/description behavior. ## Current important behavior -- `read` and most search/fetch tools are treated as **static tools** and usually render via `StaticToolRow`. -- `bash/edit/write/question/task` are **expandable tools** and render via `ToolPart`. -- `perplexity` is currently treated as static and grouped into search/web-search style rows (through static grouping + short description extraction). +- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`. +- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`. +- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render. - Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`). ## "I want to change description for Perplexity" (example recipe) -If task is: "change text shown near Perplexity tool header/description": +If task is: "change text shown near Read or Skill in compact mode": 1. Edit `ProgressiveGroup.tsx` -> `getToolShortDescription(activity)`. -2. Update the branch that handles web-search tools (`websearch`, `web-search`, `search_web`, `codesearch`, `perplexity`, etc.). -3. If needed, update group rendering in `StaticToolRow` (search/fetch specific rendering branches). +2. Update the branch that handles `read` or `skill` in `StaticToolRow`. +3. Keep all other tool header/output behavior in `ToolPart.tsx`. 4. Keep icon changes (if any) in `toolPresentation.tsx`. -Why: in current pipeline Perplexity is static/grouped, so `StaticToolRow` is the primary path. +Why: only navigation tools use the compact static path; all other tools need observable input and output. ## "I want tool to become expandable" (example) 1. Update `toolRenderUtils.ts`: - - add/remove tool name in `EXPANDABLE_TOOL_NAMES` + - add/remove a tool name from `STATIC_TOOL_NAMES` only when it has a reliable direct in-app navigation action 2. Ensure `ToolPart.tsx` supports desired header + expanded output format for that tool. 3. Validate both modes (`sorted` and `live`). diff --git a/packages/ui/src/components/chat/message/parts/JsonSummaryView.test.tsx b/packages/ui/src/components/chat/message/parts/JsonSummaryView.test.tsx new file mode 100644 index 00000000..ccee164d --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/JsonSummaryView.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { describe, expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { JsonSummaryView } from './JsonSummaryView'; + +describe('JsonSummaryView', () => { + test('prioritizes a record identity and makes URLs navigable', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('OPE-266 · Refresh git status'); + expect(html).toContain('href="https://linear.app/openchamber/issue/OPE-266"'); + expect(html).toContain('Relations'); + expect(html).not.toContain('surface-elevated'); + }); + + test('summarizes record arrays as expandable sections', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('Issues (1)'); + expect(html).toContain('OPE-1 · Example issue'); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/JsonSummaryView.tsx b/packages/ui/src/components/chat/message/parts/JsonSummaryView.tsx new file mode 100644 index 00000000..28f6dbc2 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/JsonSummaryView.tsx @@ -0,0 +1,111 @@ +import React from 'react'; + +import { Icon } from '@/components/icon/Icon'; +import { cn } from '@/lib/utils'; + +const IDENTITY_KEYS = new Set(['id', 'identifier', 'title', 'name']); + +const isRecord = (value: unknown): value is Record => ( + typeof value === 'object' && value !== null && !Array.isArray(value) +); + +const formatKey = (key: string) => key + .replace(/([A-Z])/g, ' $1') + .replace(/[_-]/g, ' ') + .replace(/^./, (character) => character.toUpperCase()); + +const getIdentity = (record: Record): string | null => { + const id = typeof record.id === 'string' ? record.id : typeof record.identifier === 'string' ? record.identifier : ''; + const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : ''; + if (id && title) return `${id} · ${title}`; + return title || id || null; +}; + +const isUrl = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +const JsonSummaryValue = React.memo(({ + value, + label, + depth, +}: { + value: unknown; + label?: string; + depth: number; +}) => { + if (Array.isArray(value)) { + const summary = label ? `${formatKey(label)} (${value.length})` : `(${value.length})`; + return ( +
+ + + {summary} + +
+
+
+ ); + } + + if (isRecord(value)) { + const identity = getIdentity(value); + const entries = Object.entries(value).filter(([key]) => !IDENTITY_KEYS.has(key)); + const summary = label ? `${formatKey(label)}${identity ? ` · ${identity}` : ''}` : identity; + const content = ( +
+ {entries.map(([key, entry]) => )} +
+ ); + + if (!label && depth === 0) { + return
{identity ?
{identity}
: null}{content}
; + } + + return ( +
+ + + {summary ?? (label ? formatKey(label) : '{}')} + +
+
+
+ ); + } + + const text = value === null ? 'null' : typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value); + const renderedValue = typeof value === 'string' && isUrl(value) ? ( + {value} + ) : ( + {text} + ); + + return ( +
+ {label ? {formatKey(label)} : } + {renderedValue} +
+ ); +}); + +JsonSummaryValue.displayName = 'JsonSummaryValue'; + +export const JsonSummaryView = React.memo(({ data }: { data: unknown }) => ( +
+ +
+)); + +JsonSummaryView.displayName = 'JsonSummaryView'; diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx index 12647cb0..90691c57 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.test.tsx @@ -96,4 +96,20 @@ describe('ReasoningTimelineBlock', () => { // The ellipsis character marks that the text was truncated expect(markup).toContain('…'); }); + + test('omits trailing empty HTML comments from the header summary', () => { + const markup = renderToStaticMarkup( + + '} + variant="thinking" + blockId="reasoning-comment-test" + showDuration={false} + /> + , + ); + + expect(markup).toContain('Planning accessible icon labels with translations'); + expect(markup).not.toContain('<!-- -->'); + }); }); diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 59e1fc5b..c2abb190 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -40,6 +40,8 @@ const EXPANDED_CONTENT_TRANSITION = { duration: 0.2, ease: 'easeOut' as const }; /** Strip common markdown syntax so the header preview reads as plain text. */ const stripMarkdown = (text: string): string => text + // Empty HTML comments are frequently appended by model tool wrappers. + .replace(//g, '') // Fenced code blocks → keep inner text on one line .replace(/```[\w]*\n?([\s\S]*?)```/g, (_, inner: string) => inner.trim()) // Inline code @@ -476,84 +478,4 @@ const ReasoningPart = React.memo(({ ); }); -type MergedReasoningPartProps = { - parts: Part[]; - onContentChange?: (reason?: ContentChangeReason) => void; - messageId: string; - streamPhase?: StreamPhase; -}; - -/** - * Renders ALL reasoning parts for a message as a single collapsible block, - * merging their text and spanning their combined time range. - * This matches the VSCode Copilot pattern of showing one "Thought" block per turn. - */ -export const MergedReasoningPart = React.memo(({ - parts, - onContentChange, - messageId, - streamPhase, -}: MergedReasoningPartProps) => { - const chatRenderMode = useUIStore((state) => state.chatRenderMode); - - const mergedText = React.useMemo(() => { - return parts - .map((part) => { - const p = part as PartWithText; - return cleanReasoningText(p.text || p.content || ''); - }) - .filter((t) => t.length > 0) - .join('\n\n'); - }, [parts]); - - const mergedTime = React.useMemo(() => { - let earliestStart: number | undefined; - let latestEnd: number | undefined; - - for (const part of parts) { - const time = (part as PartWithText).time; - if (typeof time?.start === 'number' && Number.isFinite(time.start)) { - if (earliestStart === undefined || time.start < earliestStart) { - earliestStart = time.start; - } - } - if (typeof time?.end === 'number' && Number.isFinite(time.end)) { - if (latestEnd === undefined || time.end > latestEnd) { - latestEnd = time.end; - } - } - } - - return earliestStart !== undefined ? { start: earliestStart, end: latestEnd } : undefined; - }, [parts]); - - const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed'; - const isStreaming = chatRenderMode === 'live' && canBeStreaming && parts.some( - (part) => typeof (part as PartWithText).time?.end !== 'number', - ); - - const throttledMergedText = useStreamingTextThrottle({ - text: mergedText, - isStreaming, - identityKey: `${messageId}:reasoning-merged`, - }); - - const blockId = parts[0]?.id ?? `${messageId}-reasoning-merged`; - - if (!throttledMergedText.trim()) { - return null; - } - - return ( - - ); -}); - export default ReasoningPart; diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index b67a1b0f..5af7e702 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -30,9 +30,11 @@ import { formatEditOutput, detectLanguageFromOutput, formatInputForDisplay, + renderTodoOutput, tryParseJsonOutput, } from '../toolRenderers'; import { JsonTreeViewer } from '@/components/ui/JsonTreeViewer'; +import { JsonSummaryView } from './JsonSummaryView'; import { Icon } from "@/components/icon/Icon"; import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle'; import { MinDurationShineText } from './MinDurationShineText'; @@ -43,7 +45,7 @@ import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId'; import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser'; import { areRenderRelevantPartsEqual } from '../renderCompare'; import { useI18n } from '@/lib/i18n'; -import { getDiffPatchEntries, getPatchText } from './toolDiffUtils'; +import { getDiffPatchEntries, getPatchText, type DiffPatchEntry } from './toolDiffUtils'; const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal'; const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS); @@ -848,17 +850,17 @@ const ToolScrollableTextOutput: React.FC<{ 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 [jsonViewMode, setJsonViewMode] = React.useState<'summary' | 'formatted' | 'raw'>('summary'); const [copiedJson, setCopiedJson] = React.useState(false); React.useEffect(() => { - setJsonViewMode('formatted'); + setJsonViewMode('summary'); setCopiedJson(false); }, [renderedOutput]); - const handleToggleJsonView = React.useCallback((event: React.MouseEvent) => { + const handleJsonViewChange = React.useCallback((view: 'summary' | 'formatted' | 'raw', event: React.MouseEvent) => { event.stopPropagation(); - setJsonViewMode((prev) => prev === 'formatted' ? 'raw' : 'formatted'); + setJsonViewMode(view); }, []); const handleCopyOutput = React.useCallback(async (event: React.MouseEvent) => { @@ -881,13 +883,35 @@ const ToolScrollableTextOutput: React.FC<{ + + - {jsonViewMode === 'formatted' ? ( + {jsonViewMode === 'summary' ? ( + + ) : jsonViewMode === 'formatted' ? ( = React.memo(({ onShowPopup, }) => { const { t } = useI18n(); + const runtime = React.useContext(RuntimeAPIContext); const { pierreTheme, pierreThemeType } = usePierreThemeConfig(); const [diffViewMode, setDiffViewMode] = React.useState('unified'); const stateWithData = state as ToolStateWithMetadata; @@ -1699,6 +1726,13 @@ const ToolExpandedContent: React.FC = React.memo(({ }, [input, part.tool]); const hasInputText = !hideToolInputPreview && inputTextContent.trim().length > 0; const isWriteLikeTool = part.tool === 'write' || part.tool === 'create' || part.tool === 'file_write'; + const isTodoTool = part.tool === 'todowrite' || part.tool === 'todoread'; + const todoContent = React.useMemo(() => { + if (Array.isArray(input?.todos)) { + return JSON.stringify(input.todos); + } + return outputString; + }, [input?.todos, outputString]); const writeLikeInputPatch = React.useMemo(() => { if (!isWriteLikeTool || !hasInputText) { return undefined; @@ -1732,6 +1766,36 @@ const ToolExpandedContent: React.FC = React.memo(({ ); const renderResultContent = () => { + const getEntryAbsolutePath = (entry: DiffPatchEntry) => ( + entry.title.startsWith('/') ? entry.title : `${currentDirectory}/${entry.title}`.replace(/\/+/g, '/') + ); + const openEntryFile = (entry: DiffPatchEntry, event: React.MouseEvent) => { + event.stopPropagation(); + const line = extractFirstChangedLineFromDiff(entry.patch); + const absolutePath = getEntryAbsolutePath(entry); + if (runtime?.editor && runtime.runtime.isVSCode) { + void runtime.editor.openFile(absolutePath, line); + return; + } + useUIStore.getState().openContextFileAtLine(currentDirectory, absolutePath, line ?? 1, 1); + }; + const openEntryDiff = (entry: DiffPatchEntry, event: React.MouseEvent) => { + event.stopPropagation(); + const line = extractFirstChangedLineFromDiff(entry.patch); + const absolutePath = getEntryAbsolutePath(entry); + if (runtime?.editor && runtime.runtime.isVSCode) { + void runtime.editor.openDiff('', absolutePath, `${getRelativePath(absolutePath, currentDirectory)} (changes)`, { line, patch: entry.patch }); + return; + } + const store = useUIStore.getState(); + const relativePath = getRelativePath(absolutePath, currentDirectory); + if (store.isMobile) { + store.navigateToDiff(relativePath); + store.setRightSidebarOpen(false); + return; + } + store.openContextDiff(currentDirectory, relativePath); + }; const renderDiagnosticsSection = () => { if (!diagnosticSection) { return null; @@ -1855,11 +1919,31 @@ const ToolExpandedContent: React.FC = React.memo(({
{diffEntries.map((entry) => (
- {diffEntries.length > 1 ? ( -
+
+
{renderPathLikeGitChanges(entry.title)}
- ) : null} + + +
{entry.renderMode === 'diff' ? ( = React.memo(({ ); }; + if (isTodoTool) { + if (state.status === 'error' && 'error' in state) { + return ( +
+
{t('chat.toolPart.error')}
+
+ {state.error} +
+
+ ); + } + + const todoOutput = renderTodoOutput(todoContent, { + total: t('chat.todo.total'), + inProgress: t('chat.todo.inProgress'), + pending: t('chat.todo.pending'), + completed: t('chat.todo.completed'), + cancelled: t('chat.todo.cancelled'), + }, { unstyled: true }); + + return ( +
+ {renderScrollableBlock( + todoOutput ?? ( + + ), + { className: 'p-2', maxHeightClass: 'max-h-[46vh]' }, + )} +
+ ); + } + return (
= ({ part, messageId, agentMenti "[&_[data-component='markdown-code']]:bg-transparent", "[&_[data-component='markdown-code']>*:first-child]:hidden", "[&_[data-component='markdown-code']>div]:inline", - "[&_[data-component='markdown-code']>div]:p-0", - "[&_[data-component='markdown-code']_pre]:inline", - "[&_[data-component='markdown-code']_code]:inline", - ] + "[&_[data-component='markdown-code']>div]:p-0", + "[&_[data-component='markdown-code']_pre]:inline", + "[&_[data-component='markdown-code']_code]:inline", + "[&_[data-md-code-line-numbers]]:hidden", + ] )} disableLinkSafety enableFileReferences={false} diff --git a/packages/ui/src/components/chat/message/parts/toolRenderUtils.test.ts b/packages/ui/src/components/chat/message/parts/toolRenderUtils.test.ts new file mode 100644 index 00000000..a9485c2c --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/toolRenderUtils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test'; + +import { isExpandableTool, isStaticTool } from './toolRenderUtils'; + +describe('tool rendering classification', () => { + test('keeps navigation tools compact', () => { + expect(isStaticTool('read')).toBe(true); + expect(isStaticTool('skill')).toBe(true); + expect(isExpandableTool('read')).toBe(false); + expect(isExpandableTool('skill')).toBe(false); + }); + + test('expands built-in tools without direct navigation', () => { + expect(isExpandableTool('grep')).toBe(true); + expect(isExpandableTool('webfetch')).toBe(true); + expect(isExpandableTool('todowrite')).toBe(true); + expect(isExpandableTool('plan_exit')).toBe(true); + }); + + test('expands custom and MCP tools', () => { + expect(isExpandableTool('linear_list_issues')).toBe(true); + expect(isExpandableTool('my-plugin_publish')).toBe(true); + expect(isStaticTool('linear_list_issues')).toBe(false); + }); + + test('normalizes dotted and indexed tool names', () => { + expect(isStaticTool('runtime.read:2')).toBe(true); + expect(isExpandableTool('runtime.custom_tool:2')).toBe(true); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts b/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts index f577ca9d..62fc0a20 100644 --- a/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts +++ b/packages/ui/src/components/chat/message/parts/toolRenderUtils.ts @@ -1,9 +1,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', 'lsp', -]); +// Keep only tools with a direct in-app navigation destination compact. Every +// other tool uses ToolPart so custom, plugin, and MCP calls expose their input +// and output through the common expandable renderer. +const STATIC_TOOL_NAMES = new Set(['read', 'skill']); const STANDALONE_TOOL_NAMES = new Set(['task']); @@ -21,7 +19,7 @@ const normalizeToolName = (toolName: unknown): string => { }; export const isExpandableTool = (toolName: unknown): boolean => { - return EXPANDABLE_TOOL_NAMES.has(normalizeToolName(toolName)); + return !isStaticTool(toolName); }; export const isStandaloneTool = (toolName: unknown): boolean => { @@ -29,6 +27,5 @@ export const isStandaloneTool = (toolName: unknown): boolean => { }; export const isStaticTool = (toolName: unknown): boolean => { - if (typeof toolName !== 'string') return false; - return !isExpandableTool(toolName) && !isStandaloneTool(toolName); + return STATIC_TOOL_NAMES.has(normalizeToolName(toolName)); }; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index a0896c0b..7fca78d9 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1986,7 +1986,10 @@ export const dict = { 'chat.toolPart.noOutputProduced': 'No output produced', 'chat.toolPart.output': 'Output', 'chat.toolPart.showRawJson': 'Show raw JSON', - 'chat.toolPart.showFormattedJson': 'Show formatted JSON', + 'chat.toolPart.showFormattedJson': 'Show formatted JSON', + 'chat.toolPart.showNavigableJson': 'Show navigable JSON', + 'chat.toolPart.openFileAtFirstChange': 'Open file at first change', + 'chat.toolPart.openFileDiff': 'Open file diff', 'chat.toolPart.copyOutput': 'Copy output', 'chat.toolPart.copiedOutput': 'Copied output', 'chat.toolPart.copyOutputFailed': 'Failed to copy output', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 55e5be06..f640e906 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1953,6 +1953,9 @@ export const dict: Record = { "chat.toolPart.output": "Salida", "chat.toolPart.showRawJson": "Mostrar JSON sin formato", "chat.toolPart.showFormattedJson": "Mostrar JSON formateado", + "chat.toolPart.showNavigableJson": "Mostrar JSON navegable", + "chat.toolPart.openFileAtFirstChange": "Abrir archivo en el primer cambio", + "chat.toolPart.openFileDiff": "Abrir diferencias del archivo", "chat.toolPart.copyOutput": "Copiar salida", "chat.toolPart.copiedOutput": "Salida copiada", "chat.toolPart.copyOutputFailed": "No se pudo copiar la salida", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 8af49a6f..47957084 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2711,6 +2711,9 @@ export const dict = { 'chat.chatInput.reviewCommentsRemove': 'Retirer les commentaires de revue', 'chat.toolPart.showRawJson': 'Afficher le JSON brut', 'chat.toolPart.showFormattedJson': 'Afficher le JSON formaté', + 'chat.toolPart.showNavigableJson': 'Afficher le JSON navigable', + 'chat.toolPart.openFileAtFirstChange': 'Ouvrir le fichier à la première modification', + 'chat.toolPart.openFileDiff': 'Ouvrir les différences du fichier', 'chat.toolPart.copyOutput': 'Copier la sortie', 'chat.toolPart.copiedOutput': 'Sortie copiée', 'chat.toolPart.copyOutputFailed': 'Impossible de copier la sortie', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 82b6c394..54c0890f 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1986,6 +1986,9 @@ export const dict: Record = { 'chat.toolPart.output': '出力', 'chat.toolPart.showRawJson': '生JSONを表示', 'chat.toolPart.showFormattedJson': '整形JSONを表示', + 'chat.toolPart.showNavigableJson': 'ナビゲーション可能なJSONを表示', + 'chat.toolPart.openFileAtFirstChange': '最初の変更箇所でファイルを開く', + 'chat.toolPart.openFileDiff': 'ファイル差分を開く', 'chat.toolPart.copyOutput': '出力をコピー', 'chat.toolPart.copiedOutput': '出力をコピーしました', 'chat.toolPart.copyOutputFailed': '出力のコピーに失敗しました', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 1f3ee29a..97095cae 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1987,6 +1987,9 @@ export const dict: Record = { 'chat.toolPart.output': '출력', 'chat.toolPart.showRawJson': '원시 JSON 표시', 'chat.toolPart.showFormattedJson': '형식화된 JSON 표시', + 'chat.toolPart.showNavigableJson': '탐색 가능한 JSON 표시', + 'chat.toolPart.openFileAtFirstChange': '첫 번째 변경 위치에서 파일 열기', + 'chat.toolPart.openFileDiff': '파일 diff 열기', 'chat.toolPart.copyOutput': '출력 복사', 'chat.toolPart.copiedOutput': '출력 복사됨', 'chat.toolPart.copyOutputFailed': '출력을 복사하지 못했습니다', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 5693f210..16ea1048 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1306,6 +1306,9 @@ export const dict: Record = { 'chat.toolPart.output': 'Wyjście', 'chat.toolPart.showRawJson': 'Pokaż surowy JSON', 'chat.toolPart.showFormattedJson': 'Pokaż sformatowany JSON', + 'chat.toolPart.showNavigableJson': 'Pokaż nawigowalny JSON', + 'chat.toolPart.openFileAtFirstChange': 'Otwórz plik przy pierwszej zmianie', + 'chat.toolPart.openFileDiff': 'Otwórz różnice pliku', 'chat.toolPart.copyOutput': 'Kopiuj wyjście', 'chat.toolPart.copiedOutput': 'Skopiowano wyjście', 'chat.toolPart.copyOutputFailed': 'Nie udało się skopiować wyjścia', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index f30fac68..a0284f9f 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1953,6 +1953,9 @@ export const dict: Record = { "chat.toolPart.output": "Saída", "chat.toolPart.showRawJson": "Mostrar JSON bruto", "chat.toolPart.showFormattedJson": "Mostrar JSON formatado", + "chat.toolPart.showNavigableJson": "Mostrar JSON navegável", + "chat.toolPart.openFileAtFirstChange": "Abrir arquivo na primeira alteração", + "chat.toolPart.openFileDiff": "Abrir diferenças do arquivo", "chat.toolPart.copyOutput": "Copiar saída", "chat.toolPart.copiedOutput": "Saída copiada", "chat.toolPart.copyOutputFailed": "Falha ao copiar saída", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 522eb0b2..26f6afb5 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1953,6 +1953,9 @@ export const dict: Record = { "chat.toolPart.output": "Вивід", "chat.toolPart.showRawJson": "Показати сирий JSON", "chat.toolPart.showFormattedJson": "Показати форматований JSON", + "chat.toolPart.showNavigableJson": "Показати навігаційний JSON", + "chat.toolPart.openFileAtFirstChange": "Відкрити файл на першій зміні", + "chat.toolPart.openFileDiff": "Відкрити diff файлу", "chat.toolPart.copyOutput": "Скопіювати вивід", "chat.toolPart.copiedOutput": "Вивід скопійовано", "chat.toolPart.copyOutputFailed": "Не вдалося скопіювати вивід", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 0381efa0..f5fbcb09 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1953,6 +1953,9 @@ export const dict: Record = { 'chat.toolPart.output': '输出', 'chat.toolPart.showRawJson': '显示原始 JSON', 'chat.toolPart.showFormattedJson': '显示格式化 JSON', + 'chat.toolPart.showNavigableJson': '显示可导航 JSON', + 'chat.toolPart.openFileAtFirstChange': '在首次更改处打开文件', + 'chat.toolPart.openFileDiff': '打开文件差异', 'chat.toolPart.copyOutput': '复制输出', 'chat.toolPart.copiedOutput': '已复制输出', 'chat.toolPart.copyOutputFailed': '复制输出失败', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 909a673a..a7aaee4b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1957,6 +1957,9 @@ export const dict: Record = { 'chat.toolPart.output': '輸出', 'chat.toolPart.showRawJson': '顯示原始 JSON', 'chat.toolPart.showFormattedJson': '顯示格式化 JSON', + 'chat.toolPart.showNavigableJson': '顯示可導覽 JSON', + 'chat.toolPart.openFileAtFirstChange': '在首次變更處開啟檔案', + 'chat.toolPart.openFileDiff': '開啟檔案差異', 'chat.toolPart.copyOutput': '複製輸出', 'chat.toolPart.copiedOutput': '已複製輸出', 'chat.toolPart.copyOutputFailed': '複製輸出失敗', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 1983b7be..8bba2be3 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -575,7 +575,6 @@ interface UIStore { sessionRecapEnabled: boolean; sessionSuggestionEnabled: boolean; collapsibleThinkingBlocks: boolean; - groupReasoningBlocks: boolean; chatRenderMode: ChatRenderMode; activityRenderMode: ActivityRenderMode; showDeletionDialog: boolean; @@ -873,7 +872,6 @@ export const useUIStore = create()( sessionRecapEnabled: true, sessionSuggestionEnabled: true, collapsibleThinkingBlocks: true, - groupReasoningBlocks: true, chatRenderMode: 'live', activityRenderMode: 'summary', showDeletionDialog: true, diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index e1647e69..cfe50a85 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,5 +1,8 @@ ## [Unreleased] +- Chat/Tools: every tool call now expands to show its input, result, and errors, including MCP, plugin, and custom tools; Read and Skill stay compact links to their files. JSON results now offer navigable summary, tree, and raw views. +- Chat/Tools: expanded file-edit and patch results include per-file buttons to open the diff or jump to the first changed line in the editor. +- Chat/Thinking: reasoning parts stay separate and in chronological order instead of merging into one block, and collapsed previews no longer show empty trailing HTML comments. - Chat: Mermaid diagrams now have zoom controls (thanks to @c-w-xiaohei). - Chat: code blocks can show line numbers that stay aligned while streaming, and a new Wrap Code Block Lines setting controls long-line wrapping. - Chat: with Sticky User Header enabled, user messages no longer float over earlier messages in long conversations.