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:
@@ -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('<!-- -->');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '出力のコピーに失敗しました',
|
||||
|
||||
@@ -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': '출력을 복사하지 못했습니다',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '複製輸出失敗',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user