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.
This commit is contained in:
Bohdan Triapitsyn
2026-07-10 14:51:33 +03:00
parent 6c2e657511
commit b3fa19fe3e
23 changed files with 389 additions and 144 deletions
+3
View File
@@ -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).
@@ -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(
<MergedReasoningPart
key={`reasoning-merged-${messageId}`}
parts={flatReasoningParts}
messageId={messageId}
streamPhase={effectiveStreamPhase}
onContentChange={onContentChange}
/>
);
}
} 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,
@@ -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`).
@@ -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(
<JsonSummaryView
data={{
id: 'OPE-266',
title: 'Refresh git status',
url: 'https://linear.app/openchamber/issue/OPE-266',
relations: { blocks: [] },
}}
/>,
);
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(
<JsonSummaryView data={{ issues: [{ identifier: 'OPE-1', name: 'Example issue' }] }} />,
);
expect(html).toContain('Issues (1)');
expect(html).toContain('OPE-1 · Example issue');
});
});
@@ -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<string, unknown> => (
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, unknown>): 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 (
<details open={depth < 2} className="group/json-summary">
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1.5 typography-meta text-[var(--surface-foreground)] hover:text-[var(--surface-mutedForeground)]">
<Icon name="arrow-right-s" className="h-3.5 w-3.5 shrink-0 transition-transform group-open/json-summary:rotate-90" />
<span className="min-w-0 truncate font-medium">{summary}</span>
</summary>
<div className="relative ml-1 pl-3 pb-1">
<span aria-hidden="true" className="pointer-events-none absolute bottom-1 left-0 top-0 w-px bg-[var(--tools-border)]" />
<div className="space-y-1">
{value.map((item, index) => <JsonSummaryValue key={index} value={item} depth={depth + 1} />)}
</div>
</div>
</details>
);
}
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 = (
<div className="space-y-1">
{entries.map(([key, entry]) => <JsonSummaryValue key={key} label={key} value={entry} depth={depth + 1} />)}
</div>
);
if (!label && depth === 0) {
return <div className="space-y-2">{identity ? <div className="typography-meta font-medium text-[var(--surface-foreground)]">{identity}</div> : null}{content}</div>;
}
return (
<details open={depth < 2} className="group/json-summary">
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1.5 typography-meta text-[var(--surface-foreground)] hover:text-[var(--surface-mutedForeground)]">
<Icon name="arrow-right-s" className="h-3.5 w-3.5 shrink-0 transition-transform group-open/json-summary:rotate-90" />
<span className="min-w-0 truncate font-medium">{summary ?? (label ? formatKey(label) : '{}')}</span>
</summary>
<div className="relative ml-1 pl-3 pb-1">
<span aria-hidden="true" className="pointer-events-none absolute bottom-1 left-0 top-0 w-px bg-[var(--tools-border)]" />
{content}
</div>
</details>
);
}
const text = value === null ? 'null' : typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value);
const renderedValue = typeof value === 'string' && isUrl(value) ? (
<a href={value} target="_blank" rel="noopener noreferrer" className="truncate text-[var(--status-info)] underline underline-offset-2 hover:opacity-80" title={value}>{value}</a>
) : (
<span className={cn('min-w-0 break-words', value === null ? 'text-[var(--surface-mutedForeground)]' : 'text-[var(--surface-foreground)]')}>{text}</span>
);
return (
<div className="grid grid-cols-[minmax(6rem,auto)_minmax(0,1fr)] gap-x-2 py-1 typography-meta">
{label ? <span className="truncate text-[var(--surface-mutedForeground)]" title={label}>{formatKey(label)}</span> : <span />}
{renderedValue}
</div>
);
});
JsonSummaryValue.displayName = 'JsonSummaryValue';
export const JsonSummaryView = React.memo(({ data }: { data: unknown }) => (
<div className="space-y-1">
<JsonSummaryValue value={data} depth={0} />
</div>
));
JsonSummaryView.displayName = 'JsonSummaryView';
@@ -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(
<I18nProvider>
<ReasoningTimelineBlock
text={'Planning accessible icon labels with translations <!-- -->'}
variant="thinking"
blockId="reasoning-comment-test"
showDuration={false}
/>
</I18nProvider>,
);
expect(markup).toContain('Planning accessible icon labels with translations');
expect(markup).not.toContain('&lt;!-- --&gt;');
});
});
@@ -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(/<!--\s*-->/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 (
<ReasoningTimelineBlock
text={throttledMergedText}
variant="thinking"
onContentChange={onContentChange}
blockId={blockId}
time={mergedTime}
isStreaming={isStreaming}
/>
);
});
export default ReasoningPart;
@@ -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<HTMLButtonElement>) => {
const handleJsonViewChange = React.useCallback((view: 'summary' | 'formatted' | 'raw', event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setJsonViewMode((prev) => prev === 'formatted' ? 'raw' : 'formatted');
setJsonViewMode(view);
}, []);
const handleCopyOutput = React.useCallback(async (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -881,13 +883,35 @@ const ToolScrollableTextOutput: React.FC<{
<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}
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'summary' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
onClick={(event) => handleJsonViewChange('summary', event)}
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')}
aria-label={t('chat.toolPart.showNavigableJson')}
title={t('chat.toolPart.showNavigableJson')}
>
<Icon name={jsonViewMode === 'formatted' ? 'code-box' : 'list-check-2'} className="h-3.5 w-3.5" />
<Icon name="list-unordered" className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'formatted' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
onClick={(event) => handleJsonViewChange('formatted', event)}
onPointerDown={(event) => event.stopPropagation()}
aria-label={t('chat.toolPart.showFormattedJson')}
title={t('chat.toolPart.showFormattedJson')}
>
<Icon name="node-tree" className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6 rounded-md text-muted-foreground hover:text-foreground', jsonViewMode === 'raw' && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]')}
onClick={(event) => handleJsonViewChange('raw', event)}
onPointerDown={(event) => event.stopPropagation()}
aria-label={t('chat.toolPart.showRawJson')}
title={t('chat.toolPart.showRawJson')}
>
<Icon name="code-box" className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
@@ -901,7 +925,9 @@ const ToolScrollableTextOutput: React.FC<{
<Icon name={copiedJson ? 'check' : 'file-copy'} className="h-3.5 w-3.5" />
</Button>
</div>
{jsonViewMode === 'formatted' ? (
{jsonViewMode === 'summary' ? (
<JsonSummaryView data={jsonResult.data} />
) : jsonViewMode === 'formatted' ? (
<JsonTreeViewer
data={jsonResult.data}
initiallyExpandedDepth={1}
@@ -1654,6 +1680,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
onShowPopup,
}) => {
const { t } = useI18n();
const runtime = React.useContext(RuntimeAPIContext);
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const stateWithData = state as ToolStateWithMetadata;
@@ -1699,6 +1726,13 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = 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<ToolExpandedContentProps> = 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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<ToolExpandedContentProps> = React.memo(({
<div className="space-y-3">
{diffEntries.map((entry) => (
<div key={entry.id} className="w-full min-w-0">
{diffEntries.length > 1 ? (
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground rounded-lg mb-1">
<div className="mb-1 flex min-w-0 items-center gap-1 px-2 py-1">
<div className="min-w-0 flex-1 typography-meta font-medium text-muted-foreground">
{renderPathLikeGitChanges(entry.title)}
</div>
) : null}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
onClick={(event) => openEntryFile(entry, event)}
aria-label={t('chat.toolPart.openFileAtFirstChange')}
title={t('chat.toolPart.openFileAtFirstChange')}
>
<Icon name="file-edit" className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
onClick={(event) => openEntryDiff(entry, event)}
aria-label={t('chat.toolPart.openFileDiff')}
title={t('chat.toolPart.openFileDiff')}
>
<Icon name="git-pull-request" className="h-3.5 w-3.5" />
</Button>
</div>
{entry.renderMode === 'diff' ? (
<DiffPreview
diff={entry.patch}
@@ -1912,6 +1996,47 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
);
};
if (isTodoTool) {
if (state.status === 'error' && 'error' in state) {
return (
<div className="relative pr-2 pb-2 pt-2 space-y-2 pl-4">
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">{t('chat.toolPart.error')}</div>
<div className="typography-meta p-2 rounded-xl border" style={{
backgroundColor: 'var(--status-error-background)',
color: 'var(--status-error)',
borderColor: 'var(--status-error-border)',
}}>
{state.error}
</div>
</div>
);
}
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 (
<div className="relative pr-2 pb-2 pt-2 space-y-2 pl-4">
{renderScrollableBlock(
todoOutput ?? (
<ToolScrollableTextOutput
output={todoContent}
part={part}
metadata={metadata}
input={input}
/>
),
{ className: 'p-2', maxHeightClass: 'max-h-[46vh]' },
)}
</div>
);
}
return (
<div
className={cn(
@@ -230,10 +230,11 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ 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}
@@ -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);
});
});
@@ -1,9 +1,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', '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<string>(['read', 'skill']);
const STANDALONE_TOOL_NAMES = new Set<string>(['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));
};
+4 -1
View File
@@ -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',
+3
View File
@@ -1953,6 +1953,9 @@ export const dict: Record<I18nKey, string> = {
"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",
+3
View File
@@ -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',
+3
View File
@@ -1986,6 +1986,9 @@ export const dict: Record<I18nKey, string> = {
'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': '出力のコピーに失敗しました',
+3
View File
@@ -1987,6 +1987,9 @@ export const dict: Record<I18nKey, string> = {
'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': '출력을 복사하지 못했습니다',
+3
View File
@@ -1306,6 +1306,9 @@ export const dict: Record<I18nKey, string> = {
'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',
@@ -1953,6 +1953,9 @@ export const dict: Record<I18nKey, string> = {
"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",
+3
View File
@@ -1953,6 +1953,9 @@ export const dict: Record<I18nKey, string> = {
"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": "Не вдалося скопіювати вивід",
@@ -1953,6 +1953,9 @@ export const dict: Record<I18nKey, string> = {
'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': '复制输出失败',
@@ -1957,6 +1957,9 @@ export const dict: Record<I18nKey, string> = {
'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': '複製輸出失敗',
-2
View File
@@ -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<UIStore>()(
sessionRecapEnabled: true,
sessionSuggestionEnabled: true,
collapsibleThinkingBlocks: true,
groupReasoningBlocks: true,
chatRenderMode: 'live',
activityRenderMode: 'summary',
showDeletionDialog: true,
+3
View File
@@ -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.