({ id, role: 'assistant', content, timestamp: 0 }) as Message;
function item(message: Message) {
- return ;
+ return ;
}
describe('MessageItem error isolation', () => {
diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx
index a3bf0d5983e..8386388a60c 100644
--- a/packages/web-shell/client/components/MessageItem.tsx
+++ b/packages/web-shell/client/components/MessageItem.tsx
@@ -30,7 +30,6 @@ interface MessageItemProps {
onBranchSession?: () => void;
showAssistantActions?: boolean;
showAssistantBranch?: boolean;
- shellOutputMaxLines: number;
}
export const MessageItem = memo(function MessageItem({
@@ -44,7 +43,6 @@ export const MessageItem = memo(function MessageItem({
onBranchSession,
showAssistantActions = false,
showAssistantBranch = false,
- shellOutputMaxLines,
}: MessageItemProps) {
const body = ((): ReactElement | null => {
switch (message.role) {
@@ -77,7 +75,6 @@ export const MessageItem = memo(function MessageItem({
tools={message.tools}
pendingApproval={pendingApproval}
workspaceCwd={workspaceCwd}
- shellOutputMaxLines={shellOutputMaxLines}
/>
);
case 'plan':
@@ -239,7 +236,6 @@ function areMessageItemPropsEqual(
if (prev.onBranchSession !== next.onBranchSession) return false;
if (prev.showAssistantActions !== next.showAssistantActions) return false;
if (prev.showAssistantBranch !== next.showAssistantBranch) return false;
- if (prev.shellOutputMaxLines !== next.shellOutputMaxLines) return false;
return areMessagesEqual(prev.message, next.message);
}
diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx
index dcad00c5764..1c525f1612b 100644
--- a/packages/web-shell/client/components/MessageList.dom.test.tsx
+++ b/packages/web-shell/client/components/MessageList.dom.test.tsx
@@ -174,7 +174,6 @@ function mount(
loadingTranscript={opts.loadingTranscript}
catchingUp={opts.catchingUp}
isResponding={opts.isResponding}
- shellOutputMaxLines={50}
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
/>
,
@@ -205,7 +204,6 @@ function renderInto(
loadingTranscript={opts.loadingTranscript}
catchingUp={opts.catchingUp}
isResponding={opts.isResponding}
- shellOutputMaxLines={50}
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
/>
,
diff --git a/packages/web-shell/client/components/MessageList.module.css b/packages/web-shell/client/components/MessageList.module.css
index 7ec6ee2af89..8452d33cd2c 100644
--- a/packages/web-shell/client/components/MessageList.module.css
+++ b/packages/web-shell/client/components/MessageList.module.css
@@ -156,6 +156,9 @@
.turnAnswerRow {
max-width: 100%;
}
+.turnStatusRow {
+ margin-bottom: 4px;
+}
.turnAnswerRow {
margin-top: 10px;
diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx
index 545d9edaf31..9d9da626a89 100644
--- a/packages/web-shell/client/components/MessageList.tsx
+++ b/packages/web-shell/client/components/MessageList.tsx
@@ -48,7 +48,6 @@ interface MessageListProps {
tailContent?: ReactNode;
tailKey?: string;
virtualScrollThreshold?: number;
- shellOutputMaxLines: number;
activeTurnStartedAt?: number;
/**
* When true, scroll the tail content into view the moment it first appears
@@ -1712,7 +1711,6 @@ export const MessageList = memo(
tailContent,
tailKey = 'tail',
virtualScrollThreshold = VIRTUAL_SCROLL_THRESHOLD,
- shellOutputMaxLines,
autoScrollTailIntoView = false,
hideSessionTimeline = false,
showRetryHint = false,
@@ -2646,7 +2644,6 @@ export const MessageList = memo(
displayItem.message.role === 'assistant' &&
displayItem.message.id === lastCompletedAssistantId
}
- shellOutputMaxLines={shellOutputMaxLines}
/>
);
};
@@ -2681,7 +2678,6 @@ export const MessageList = memo(
showRetryHint,
onRetryClick,
onBranchSession,
- shellOutputMaxLines,
handleToggleCollapse,
],
);
diff --git a/packages/web-shell/client/components/messages/AssistantMessage.module.css b/packages/web-shell/client/components/messages/AssistantMessage.module.css
index 486cc65f6fd..8e7aa786d0a 100644
--- a/packages/web-shell/client/components/messages/AssistantMessage.module.css
+++ b/packages/web-shell/client/components/messages/AssistantMessage.module.css
@@ -188,15 +188,26 @@
.thinkingChevronRight,
.thinkingChevronDown {
- width: 7px;
- height: 7px;
- border-right: 1px solid currentColor;
- border-bottom: 1px solid currentColor;
+ width: 14px;
+ height: 14px;
+ position: relative;
flex-shrink: 0;
opacity: 0;
transition: opacity 120ms ease;
}
+.thinkingChevronRight::before,
+.thinkingChevronRight::after,
+.thinkingChevronDown::before,
+.thinkingChevronDown::after {
+ content: '';
+ position: absolute;
+ width: 6px;
+ height: 1px;
+ background: currentColor;
+ border-radius: 1px;
+}
+
.thinkingSummary:hover .thinkingChevronRight,
.thinkingSummary:hover .thinkingChevronDown,
.thinkingSummary:focus-visible .thinkingChevronRight,
@@ -205,15 +216,38 @@
}
.thinkingChevronRight {
+ transform: none;
+}
+
+.thinkingChevronRight::before {
+ top: calc(50% - 2px);
+ left: calc(50% - 3px);
+ transform: rotate(45deg);
+}
+
+.thinkingChevronRight::after {
+ top: calc(50% + 2px);
+ left: calc(50% - 3px);
transform: rotate(-45deg);
}
.thinkingChevronDown {
- transform: rotate(45deg);
- margin-top: -3px;
+ transform: none;
opacity: 1;
}
+.thinkingChevronDown::before {
+ top: 50%;
+ left: calc(50% - 5px);
+ transform: rotate(45deg);
+}
+
+.thinkingChevronDown::after {
+ top: 50%;
+ left: calc(50% - 1px);
+ transform: rotate(-45deg);
+}
+
.thinkingExpandedWrap {
margin-top: 4px;
}
diff --git a/packages/web-shell/client/components/messages/Markdown.module.css b/packages/web-shell/client/components/messages/Markdown.module.css
index 9cdb717fe2e..f8614413e8b 100644
--- a/packages/web-shell/client/components/messages/Markdown.module.css
+++ b/packages/web-shell/client/components/messages/Markdown.module.css
@@ -199,8 +199,7 @@
align-items: center;
justify-content: space-between;
padding: 0 12px;
- background: var(--subtle-bg);
- border-bottom: 0.5px solid var(--border);
+ background: var(--secondary);
}
.codeBlockLang {
@@ -238,6 +237,7 @@
font-size: 13px;
line-height: 1.5;
background: var(--secondary);
+ margin-top: 0;
}
.codeBlockContent pre {
diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.module.css b/packages/web-shell/client/components/messages/TasksStatusMessage.module.css
index 90649d1ac5c..ace2eca9a13 100644
--- a/packages/web-shell/client/components/messages/TasksStatusMessage.module.css
+++ b/packages/web-shell/client/components/messages/TasksStatusMessage.module.css
@@ -198,14 +198,20 @@
}
.embeddedPanel .inlineDetail {
- padding: 0;
+ background: var(--muted);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 8px 12px;
+ margin: 5px 0;
+ min-width: 0;
+ overflow: hidden;
}
.embeddedPanel .detail {
- margin: 8px;
- border-radius: 6px;
- background: var(--background);
- padding: 8px;
+ margin: 0;
+ border-radius: 0;
+ background: transparent;
+ padding: 0;
}
.embeddedPanel .nameCell {
diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx
index 1b966358067..03073352d68 100644
--- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx
+++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx
@@ -1,6 +1,9 @@
-import { describe, expect, it, vi } from 'vitest';
-import type { DaemonSettingDescriptor } from '@qwen-code/webui/daemon-react-sdk';
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
import type { ACPToolCall } from '../../adapters/types';
+import { I18nProvider } from '../../i18n';
vi.mock('../../App', async () => {
const { createContext } = await import('react');
@@ -14,17 +17,36 @@ vi.mock('../../App', async () => {
const {
buildUnifiedDiff,
extractDiff,
+ fencedCodeBlock,
+ formatSingleToolSummary,
+ formatRunningSingleToolSummary,
formatToolGroupSummary,
getActiveTool,
getRawFileDiff,
getToolHeaderKind,
hasActiveTool,
+ hasExpandableContent,
isActiveToolStatus,
isWebFetchToolName,
- resolveShellOutputMaxLines,
+ languageForPath,
shouldAutoExpand,
+ ToolGroup,
+ ToolLine,
} = await import('./ToolGroup');
+(
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
+).IS_REACT_ACT_ENVIRONMENT = true;
+
+const mounted: Array<{ root: Root; container: HTMLElement }> = [];
+
+afterEach(() => {
+ for (const { root, container } of mounted.splice(0)) {
+ act(() => root.unmount());
+ container.remove();
+ }
+});
+
function makeTool(overrides: Partial = {}): ACPToolCall {
return {
callId: 'call-1',
@@ -34,15 +56,66 @@ function makeTool(overrides: Partial = {}): ACPToolCall {
};
}
+function renderToolLine(tool: ACPToolCall): HTMLElement {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+ mounted.push({ root, container });
+ return container;
+}
+
+function renderToolGroup(tools: ACPToolCall[]): HTMLElement {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+ mounted.push({ root, container });
+ return container;
+}
+
const t = (key: string, values?: Record): string => {
if (key === 'toolGroup.running') {
- return `Running ${values?.name ?? 'tool'}${
+ return `Running ${values?.name ?? 'tool'}${values?.duration ? ` ${values.duration}` : ''}${
Number(values?.count ?? 0) > 1 ? ` · ${values?.count ?? 0} tools` : ''
}`;
}
if (key === 'toolGroup.summary') {
return `Ran ${values?.count ?? 0} tool${values?.count === 1 ? '' : 's'}`;
}
+ if (key === 'toolGroup.summary.editedFiles') {
+ return `Edited ${values?.count ?? 0} files`;
+ }
+ if (key === 'toolGroup.summary.ranCommands') {
+ return `Ran ${values?.count ?? 0} commands`;
+ }
+ if (key === 'toolGroup.summary.readFiles') {
+ return `Read ${values?.count ?? 0} files`;
+ }
+ if (key === 'toolGroup.summary.searched') {
+ return `Searched ${values?.count ?? 0} times`;
+ }
+ if (key === 'toolGroup.summary.updatedTodos') {
+ return `Updated todos ${values?.count ?? 0} times`;
+ }
+ if (key === 'toolGroup.summary.askedUser') {
+ return 'Asked user';
+ }
+ if (key === 'toolGroup.summary.otherTools') {
+ return `Called ${values?.count ?? 0} other tools`;
+ }
return key;
};
@@ -51,18 +124,6 @@ const zhT = (key: string, values?: Record): string => {
return t(key, values);
};
-function setting(value: unknown): DaemonSettingDescriptor {
- return {
- key: 'ui.shellOutputMaxLines',
- type: 'number',
- label: 'Shell output max lines',
- category: 'ui',
- requiresRestart: false,
- default: 5,
- values: { effective: value },
- };
-}
-
describe('tool group summary logic', () => {
it('detects active tool statuses', () => {
expect(isActiveToolStatus('pending')).toBe(true);
@@ -99,15 +160,98 @@ describe('tool group summary logic', () => {
expect(formatToolGroupSummary(tools, zhT)).toBe('Running 读取文件');
});
- it('falls back to a completed summary without tool names', () => {
+ it('summarizes completed tool groups by common action type', () => {
const tools = [
makeTool({ callId: 'shell', status: 'completed' }),
makeTool({ callId: 'read', toolName: 'ReadFile', status: 'completed' }),
+ makeTool({ callId: 'edit', toolName: 'edit', status: 'completed' }),
+ makeTool({ callId: 'grep', toolName: 'grep', status: 'completed' }),
+ makeTool({
+ callId: 'todo',
+ toolName: 'todo_write',
+ status: 'completed',
+ }),
+ makeTool({
+ callId: 'ask',
+ toolName: 'ask_user_question',
+ status: 'completed',
+ }),
];
expect(hasActiveTool(tools)).toBe(false);
- expect(getActiveTool(tools).callId).toBe('read');
- expect(formatToolGroupSummary(tools, t)).toBe('Ran 2 tools');
+ expect(getActiveTool(tools).callId).toBe('ask');
+ expect(formatToolGroupSummary(tools, t)).toBe(
+ 'Edited 1 files Ran 1 commands Read 1 files Searched 1 times Updated todos 1 times Asked user',
+ );
+ });
+
+ it('formats a single tool summary from the tool itself', () => {
+ expect(
+ formatSingleToolSummary(
+ makeTool({
+ toolName: 'Shell',
+ args: { command: 'npm run build' },
+ }),
+ t,
+ ),
+ ).toBe('Shell npm run build');
+ });
+
+ it('uses action summaries for single todo and ask-user tools', () => {
+ expect(
+ formatSingleToolSummary(makeTool({ toolName: 'todo_write' }), t),
+ ).toBe('Updated todos 1 times');
+ expect(
+ formatSingleToolSummary(makeTool({ toolName: 'ask_user_question' }), t),
+ ).toBe('Asked user');
+ });
+
+ it('keeps running state and command details for a single active tool', () => {
+ expect(
+ formatRunningSingleToolSummary(
+ makeTool({
+ toolName: 'Shell',
+ status: 'in_progress',
+ args: { command: 'npm run build' },
+ }),
+ t,
+ '0:03',
+ ),
+ ).toBe('Running Shell npm run build 0:03');
+ });
+
+ it('truncates long single tool descriptions in the chat summary', () => {
+ const summary = formatSingleToolSummary(
+ makeTool({
+ toolName: 'Shell',
+ args: { command: 'x'.repeat(200) },
+ }),
+ t,
+ );
+
+ expect(summary.length).toBeLessThan(140);
+ expect(summary).toContain('...');
+ });
+});
+
+describe('tool expandability', () => {
+ it('only marks tools with actual detail views as expandable by output', () => {
+ expect(
+ hasExpandableContent(
+ makeTool({
+ toolName: 'Shell',
+ content: [{ type: 'content', content: { text: 'first\nsecond' } }],
+ }),
+ ),
+ ).toBe(true);
+ expect(
+ hasExpandableContent(
+ makeTool({
+ toolName: 'list_directory',
+ rawOutput: 'a\nb',
+ }),
+ ),
+ ).toBe(false);
});
});
@@ -125,6 +269,9 @@ describe('tool kind logic', () => {
expect(getToolHeaderKind(makeTool({ toolName: 'todo_write' }))).toBe(
'todo',
);
+ expect(getToolHeaderKind(makeTool({ toolName: 'ask_user_question' }))).toBe(
+ 'ask',
+ );
});
it('recognizes web fetch aliases', () => {
@@ -150,13 +297,82 @@ describe('tool kind logic', () => {
});
});
+describe('tool row rendering', () => {
+ it('renders ANSI shell output as styled spans instead of escape text', () => {
+ const container = renderToolLine(
+ makeTool({
+ toolName: 'Shell',
+ status: 'failed',
+ content: [
+ {
+ type: 'content',
+ content: { text: '\u001b[31mfailed\u001b[0m\nplain' },
+ },
+ ],
+ }),
+ );
+
+ expect(container.textContent).toContain('failed');
+ expect(container.textContent).not.toContain('\u001b[31m');
+ expect(container.querySelector('pre span[style*="color"]')).not.toBeNull();
+ });
+
+ it('wraps a single expanded agent body in a headerless card', () => {
+ const container = renderToolGroup([
+ makeTool({
+ toolName: 'Task',
+ status: 'in_progress',
+ args: { description: 'Investigate build failure' },
+ subContent: 'working through the issue',
+ }),
+ ]);
+ const summary = container.querySelector('button') as HTMLButtonElement;
+
+ act(() => summary.click());
+
+ const card = container.querySelector('[class*="expandedAgentCard"]');
+ expect(card).not.toBeNull();
+ expect(card?.textContent).toContain('working through the issue');
+ expect(container.querySelector('[class*="expandedCardHeader"]')).toBeNull();
+ });
+
+ it('keeps glob details visible in the header after expanding', () => {
+ const pattern =
+ '**/very-long-component-pattern-that-crosses-the-expand-threshold-*.tsx';
+ const container = renderToolLine(
+ makeTool({
+ toolName: 'glob',
+ args: {
+ pattern,
+ path: 'packages/web-shell/client',
+ },
+ content: [
+ {
+ type: 'content',
+ content: {
+ text: 'packages/web-shell/client/App.tsx',
+ },
+ },
+ ],
+ }),
+ );
+ const header = container.querySelector('[role="button"]') as HTMLElement;
+
+ expect(header.textContent).toContain(pattern);
+ act(() => header.click());
+ expect(header.textContent).toContain(pattern);
+ expect(header.textContent).toContain('packages/web-shell/client');
+ });
+});
+
describe('tool output logic', () => {
- it('resolves shell output max lines from settings', () => {
- expect(resolveShellOutputMaxLines([])).toBe(5);
- expect(resolveShellOutputMaxLines([setting(12)])).toBe(12);
- expect(resolveShellOutputMaxLines([setting(2.8)])).toBe(2);
- expect(resolveShellOutputMaxLines([setting(-1)])).toBe(0);
- expect(resolveShellOutputMaxLines([setting('bad')])).toBe(5);
+ it('sanitizes read-file languages before building markdown fences', () => {
+ expect(languageForPath('src/App.tsx')).toBe('tsx');
+ expect(languageForPath('diagram.mermaid')).toBe('text');
+ expect(languageForPath('bad.weird\nlang')).toBe('text');
+ expect(fencedCodeBlock('tsx', 'const fence = "~~~";')).toBe(
+ '~~~~tsx\nconst fence = "~~~";\n~~~~',
+ );
});
it('suppresses truncated session diffs from raw output', () => {
diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx
index dc8bee91c41..a2145e0660f 100644
--- a/packages/web-shell/client/components/messages/ToolGroup.tsx
+++ b/packages/web-shell/client/components/messages/ToolGroup.tsx
@@ -1,5 +1,12 @@
-import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react';
-import type { DaemonSettingDescriptor } from '@qwen-code/webui/daemon-react-sdk';
+import {
+ memo,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
import type {
ACPToolCall,
PermissionRequest,
@@ -18,6 +25,7 @@ import {
} from '../../utils/todos';
import { useSharedNow } from '../../hooks/useSharedNow';
import { TodoEventSummary, TodoFullList } from './TodoView';
+import { Markdown } from './Markdown';
import {
formatDurationMs,
formatElapsed,
@@ -53,12 +61,9 @@ interface ToolGroupProps {
tools: ACPToolCall[];
pendingApproval?: PermissionRequest | null;
workspaceCwd?: string;
- shellOutputMaxLines?: number;
}
-const DEFAULT_SHELL_OUTPUT_MAX_LINES = 5;
-
-function hasExpandableContent(tool: ACPToolCall): boolean {
+export function hasExpandableContent(tool: ACPToolCall): boolean {
const name = tool.toolName.toLowerCase();
if (isAskUserQuestionToolName(tool.toolName)) return !!extractText(tool);
// write_file shows content from args even before completion
@@ -186,61 +191,33 @@ export function buildUnifiedDiff(oldText: string, newText: string): string {
return result.reverse().join('\n');
}
-const MAX_BASH_LINE_CHARS = 150;
-const MAX_READ_LINES = 25;
-
// A description longer than this is likely ellipsised on a normal-width row, so
// the row becomes expandable to re-flow the full text into a wrapped block.
const DESCRIPTION_EXPAND_THRESHOLD = 60;
-
-export function resolveShellOutputMaxLines(
- settings: readonly DaemonSettingDescriptor[],
-): number {
- const setting = settings.find((s) => s.key === 'ui.shellOutputMaxLines');
- const value = setting?.values.effective;
- const raw =
- typeof value === 'number' ? value : DEFAULT_SHELL_OUTPUT_MAX_LINES;
- return Math.max(0, Math.floor(raw || 0));
-}
-
-function truncateLine(line: string, max: number): string {
- if (line.length <= max) return line;
- return line.slice(0, max) + ' …';
-}
-
-function ExpandedBashOutput({
- tool,
- maxLines,
-}: {
- tool: ACPToolCall;
- maxLines: number;
-}) {
- const { t } = useI18n();
- const [showAll, setShowAll] = useState(false);
+const MAX_MARKDOWN_READ_CHARS = 200_000;
+const MAX_MARKDOWN_READ_LINES = 1000;
+const READ_LANGUAGE_ALIASES: Record = {
+ cjs: 'javascript',
+ cts: 'typescript',
+ h: 'c',
+ hpp: 'cpp',
+ js: 'javascript',
+ jsx: 'jsx',
+ mjs: 'javascript',
+ mts: 'typescript',
+ py: 'python',
+ rb: 'ruby',
+ sh: 'bash',
+ ts: 'typescript',
+ tsx: 'tsx',
+ yml: 'yaml',
+};
+
+function ExpandedBashOutput({ tool }: { tool: ACPToolCall }) {
const output = useMemo(() => extractText(tool) || '', [tool]);
- const lines = useMemo(() => output.split('\n'), [output]);
- const isLong = maxLines > 0 && lines.length > maxLines;
- const hiddenLinesCount = Math.max(0, lines.length - maxLines);
- const hasTruncatedLine = useMemo(
- () => lines.some((l) => l.length > MAX_BASH_LINE_CHARS),
- [lines],
- );
- const expandable = isLong || hasTruncatedLine;
- const displayText = useMemo(() => {
- if (showAll) return output;
- if (isLong) {
- return [
- `... first ${hiddenLinesCount} lines hidden ...`,
- ...lines
- .slice(-maxLines)
- .map((l) => truncateLine(l, MAX_BASH_LINE_CHARS)),
- ].join('\n');
- }
- return lines.map((l) => truncateLine(l, MAX_BASH_LINE_CHARS)).join('\n');
- }, [hiddenLinesCount, isLong, lines, maxLines, output, showAll]);
const ansiSegments = useMemo(
- () => (hasAnsi(displayText) ? parseAnsi(displayText) : null),
- [displayText],
+ () => (hasAnsi(output) ? parseAnsi(output) : null),
+ [output],
);
return (
@@ -259,55 +236,61 @@ function ExpandedBashOutput({
{seg.text}
))
- : displayText}
+ : output}
- {expandable && (
- setShowAll(!showAll)}
- aria-expanded={showAll}
- >
- {showAll
- ? t('tool.showLess')
- : isLong
- ? t('tool.showAll', { count: lines.length })
- : t('tool.showFullLines')}
-
- )}
);
}
function ExpandedReadContent({ tool }: { tool: ACPToolCall }) {
- const { t } = useI18n();
- const [showAll, setShowAll] = useState(false);
const content = useMemo(() => extractText(tool) || '', [tool]);
- const lines = useMemo(() => content.split('\n'), [content]);
- const isLong = lines.length > MAX_READ_LINES;
- const displayText = useMemo(
- () =>
- isLong && !showAll ? lines.slice(0, MAX_READ_LINES).join('\n') : content,
- [content, isLong, lines, showAll],
- );
+ const language = languageForPath(getReadFilePath(tool));
+ const plainText =
+ language === 'text' ||
+ content.length > MAX_MARKDOWN_READ_CHARS ||
+ exceedsLineLimit(content, MAX_MARKDOWN_READ_LINES);
return (
-
{displayText}
- {isLong && (
-
setShowAll(!showAll)}
- aria-expanded={showAll}
- >
- {showAll
- ? t('tool.showLess')
- : t('tool.linesTotal', { count: lines.length })}
-
+ {plainText ? (
+
{content}
+ ) : (
+
)}
);
}
+function getReadFilePath(tool: ACPToolCall): string {
+ const filePath = tool.args?.file_path ?? tool.args?.path;
+ return typeof filePath === 'string' ? filePath : '';
+}
+
+function exceedsLineLimit(text: string, maxLines: number): boolean {
+ let lines = 1;
+ for (let i = 0; i < text.length; i++) {
+ if (text.charCodeAt(i) === 10 && ++lines > maxLines) return true;
+ }
+ return false;
+}
+
+export function languageForPath(filePath: string): string {
+ const ext = filePath.split(/[?#]/, 1)[0]?.split('.').pop()?.toLowerCase();
+ if (!ext || ext === filePath.toLowerCase()) return 'text';
+ if (ext === 'mermaid' || ext === 'mmd') return 'text';
+ const language = READ_LANGUAGE_ALIASES[ext] ?? ext;
+ return /^[\w+.#-]+$/.test(language) ? language : 'text';
+}
+
+export function fencedCodeBlock(language: string, code: string): string {
+ const longestFence =
+ code
+ .match(/~{3,}/g)
+ ?.reduce((max, fence) => Math.max(max, fence.length), 0) ?? 0;
+ const fence = '~'.repeat(Math.max(3, longestFence + 1));
+ return `${fence}${language}\n${code}\n${fence}`;
+}
+
function ExpandedEditContent({ tool }: { tool: ACPToolCall }) {
const diff = useMemo(() => extractDiff(tool), [tool]);
const text = useMemo(() => extractText(tool) || '', [tool]);
@@ -323,6 +306,26 @@ function ExpandedEditContent({ tool }: { tool: ACPToolCall }) {
);
}
+function ToolExpandedCard({
+ title,
+ detail,
+ children,
+}: {
+ title: string;
+ detail?: string;
+ children?: ReactNode;
+}) {
+ return (
+
+
+ {title}
+ {detail && {detail} }
+
+ {children &&
{children}
}
+
+ );
+}
+
function getWriteContent(tool: ACPToolCall): string {
if (tool.args?.content) return tool.args.content as string;
if (tool.args?.new_string) return tool.args.new_string as string;
@@ -345,20 +348,24 @@ function TodoToolBody({
tool,
todos,
expanded,
+ title,
}: {
tool: ACPToolCall;
todos: TodoItem[];
expanded: boolean;
+ title: string;
}) {
const timeline = useContext(TodoTimelineContext);
const events = timeline.get(tool.callId)?.events ?? [];
- return (
-
- {expanded ? (
+ return expanded ? (
+
+
- ) : (
-
- )}
+
+
+ ) : (
+
+
);
}
@@ -367,8 +374,11 @@ interface ToolLineProps {
tool: ACPToolCall;
approval?: PermissionRequest | null;
workspaceCwd?: string;
- shellOutputMaxLines?: number;
summaryOnly?: boolean;
+ forceExpanded?: boolean;
+ forceExpandable?: boolean;
+ hideHeader?: boolean;
+ hideCollapsedOutput?: boolean;
}
function getAgentDisplayInfo(
@@ -468,6 +478,7 @@ function ExpandedAskUserQuestionOutput({ tool }: { tool: ACPToolCall }) {
export function getToolHeaderKind(tool: ACPToolCall): ToolHeaderKind {
const name = tool.toolName.toLowerCase();
if (isSubAgentToolCall(tool)) return 'agent';
+ if (isAskUserQuestionToolName(tool.toolName)) return 'ask';
if (isShellToolName(name)) return 'shell';
if (isWebFetchToolName(name)) return 'fetch';
if (isTodoWriteToolName(name)) return 'todo';
@@ -542,11 +553,101 @@ export function formatToolGroupSummary(
});
}
+ const summary = formatCompletedToolSummary(tools, t);
+ if (summary) return summary;
+
return t('toolGroup.summary', {
count: tools.length,
});
}
+export function formatSingleToolSummary(
+ tool: ACPToolCall,
+ t: ReturnType
['t'],
+ workspaceCwd?: string,
+): string {
+ if (isTodoWriteToolName(tool.toolName)) {
+ return t('toolGroup.summary.updatedTodos', { count: 1 });
+ }
+ if (isAskUserQuestionToolName(tool.toolName)) {
+ return t('toolGroup.summary.askedUser', { count: 1 });
+ }
+
+ const displayName = localizeToolDisplayName(tool.toolName, t);
+ const description = truncateText(getToolDescription(tool, workspaceCwd), 120);
+ return [displayName, description].filter(Boolean).join(' ');
+}
+
+export function formatRunningSingleToolSummary(
+ tool: ACPToolCall,
+ t: ReturnType['t'],
+ duration?: string,
+ workspaceCwd?: string,
+): string {
+ return t('toolGroup.running', {
+ name: formatSingleToolSummary(tool, t, workspaceCwd),
+ count: 1,
+ duration: duration ?? '',
+ });
+}
+
+function formatCompletedToolSummary(
+ tools: ACPToolCall[],
+ t: ReturnType['t'],
+): string {
+ let edited = 0;
+ let commands = 0;
+ let read = 0;
+ let searched = 0;
+ let todos = 0;
+ let asked = 0;
+ let other = 0;
+
+ for (const tool of tools) {
+ const name = tool.toolName.toLowerCase();
+ if (isShellToolName(name)) {
+ commands++;
+ } else if (
+ name === 'edit' ||
+ name === 'editfile' ||
+ name === 'write' ||
+ name === 'write_file' ||
+ name === 'writefile'
+ ) {
+ edited++;
+ } else if (name === 'read' || name === 'read_file' || name === 'readfile') {
+ read++;
+ } else if (
+ name === 'grep' ||
+ name === 'grep_search' ||
+ name === 'search' ||
+ name === 'glob' ||
+ name === 'web_search' ||
+ name === 'websearch'
+ ) {
+ searched++;
+ } else if (isTodoWriteToolName(name)) {
+ todos++;
+ } else if (isAskUserQuestionToolName(name)) {
+ asked++;
+ } else {
+ other++;
+ }
+ }
+
+ const parts = [
+ edited ? t('toolGroup.summary.editedFiles', { count: edited }) : '',
+ commands ? t('toolGroup.summary.ranCommands', { count: commands }) : '',
+ read ? t('toolGroup.summary.readFiles', { count: read }) : '',
+ searched ? t('toolGroup.summary.searched', { count: searched }) : '',
+ todos ? t('toolGroup.summary.updatedTodos', { count: todos }) : '',
+ asked ? t('toolGroup.summary.askedUser') : '',
+ other ? t('toolGroup.summary.otherTools', { count: other }) : '',
+ ].filter(Boolean);
+
+ return parts.join(' ');
+}
+
export function hasActiveTool(tools: ACPToolCall[]): boolean {
return tools.some((tool) => isActiveToolStatus(tool.status));
}
@@ -679,6 +780,27 @@ function TodoIcon() {
);
}
+function AskUserIcon() {
+ return (
+
+
+
+ );
+}
+
function AgentIcon() {
return (
;
+ if (kind === 'ask') return ;
if (kind === 'edit' || kind === 'write') return ;
if (kind === 'fetch') return ;
if (kind === 'read') return ;
@@ -767,8 +890,11 @@ function areToolLinePropsEqual(
): boolean {
if (prev.approval?.id !== next.approval?.id) return false;
if (prev.workspaceCwd !== next.workspaceCwd) return false;
- if (prev.shellOutputMaxLines !== next.shellOutputMaxLines) return false;
if (prev.summaryOnly !== next.summaryOnly) return false;
+ if (prev.forceExpanded !== next.forceExpanded) return false;
+ if (prev.forceExpandable !== next.forceExpandable) return false;
+ if (prev.hideHeader !== next.hideHeader) return false;
+ if (prev.hideCollapsedOutput !== next.hideCollapsedOutput) return false;
const a = prev.tool;
const b = next.tool;
return (
@@ -816,13 +942,16 @@ export const ToolLine = memo(function ToolLine({
tool,
approval,
workspaceCwd,
- shellOutputMaxLines = DEFAULT_SHELL_OUTPUT_MAX_LINES,
summaryOnly = false,
+ forceExpanded = false,
+ forceExpandable = false,
+ hideHeader = false,
+ hideCollapsedOutput = false,
}: ToolLineProps) {
const { t } = useI18n();
const compactMode = useContext(CompactModeContext);
const [expanded, setExpanded] = useState(
- () => !compactMode && shouldAutoExpand(tool),
+ () => forceExpanded || (!compactMode && shouldAutoExpand(tool)),
);
// Set once the user explicitly toggles this row, so auto-collapse-on-
// completion never silently overrides their choice.
@@ -830,12 +959,14 @@ export const ToolLine = memo(function ToolLine({
useEffect(
() => {
- setExpanded(compactMode ? false : shouldAutoExpand(tool));
+ setExpanded(
+ forceExpanded || (compactMode ? false : shouldAutoExpand(tool)),
+ );
// A new tool identity (or compact-mode toggle) resets the manual latch.
userToggledRef.current = false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
- [compactMode, tool.callId, tool.toolName],
+ [compactMode, forceExpanded, tool.callId, tool.toolName],
);
const isAgent = isSubAgentToolCall(tool);
const hasApproval = approval && approval.toolCallId === tool.callId;
@@ -853,10 +984,15 @@ export const ToolLine = memo(function ToolLine({
// user chose, driven from their own panel) and failures stay open so the
// error output remains visible.
useEffect(() => {
- if (!isAgent && tool.status === 'completed' && !userToggledRef.current) {
+ if (
+ !forceExpanded &&
+ !isAgent &&
+ tool.status === 'completed' &&
+ !userToggledRef.current
+ ) {
setExpanded(false);
}
- }, [isAgent, tool.status]);
+ }, [forceExpanded, isAgent, tool.status]);
if (isAgent) {
const info = getAgentDisplayInfo(tool, now);
@@ -876,31 +1012,41 @@ export const ToolLine = memo(function ToolLine({
]
.filter(Boolean)
.join(' · ');
- const showExpanded = expanded || !!hasApproval || !!hasSubToolApproval;
+ const showExpanded =
+ forceExpanded || expanded || !!hasApproval || !!hasSubToolApproval;
+ const panel = (
+
+ );
return (
-
setExpanded(!expanded)}
- >
-
- {displayName}
-
-
+ {!hideHeader && (
+
setExpanded(!expanded)}
+ >
+
+ {displayName}
+
+
+ )}
{showExpanded && (
-
+ {hideHeader ? (
+
{panel}
+ ) : (
+ panel
+ )}
)}
@@ -922,107 +1068,153 @@ export const ToolLine = memo(function ToolLine({
const todoCompleted = todoItems
? todoItems.filter((td) => td.status === 'completed').length
: 0;
+ const isShell = isShellToolName(name);
+ const isSearch =
+ name === 'grep' ||
+ name === 'grep_search' ||
+ name === 'search' ||
+ name === 'glob';
+ const isRead = name === 'read' || name === 'read_file' || name === 'readfile';
// A row expands when it has a todo list to reveal, detail output
// (bash/diff/read content), or a description long enough to be ellipsised.
// When a long description is expanded we move it out of the header into a
// wrapped block below, so the header drops its single-line copy.
const descExpandable = !isTodo && isDescriptionExpandable(description);
- const expandable = isTodo
- ? hasTodoList
- : hasExpandableContent(tool) || descExpandable;
- const relocateDescription = expanded && descExpandable;
+ const expandable =
+ !forceExpanded &&
+ (forceExpandable ||
+ (isTodo ? hasTodoList : hasExpandableContent(tool) || descExpandable));
// Whether the expanded row renders a kind-specific detail view. When it does
// not (e.g. grep/glob/web_fetch with a long description), keep the result
// summary visible instead of replacing it with an empty detail area.
const detailView = hasDetailView(tool);
+ const showDescriptionInDetail = expanded && descExpandable;
+ const useMarkdownDetail = isRead;
+ const hideDescriptionInHeader =
+ showDescriptionInDetail && !isShell && !isSearch && !isRead;
+ const expandedCardDetail = description;
+ const showExpandedSummaryPanel =
+ !isTodo && expanded && !detailView && (showDescriptionInDetail || result);
return (
-
{
- userToggledRef.current = true;
- setExpanded((value) => !value);
- }
- : undefined
- }
- onKeyDown={
- expandable
- ? (event) => {
- if (event.key !== 'Enter' && event.key !== ' ') return;
- event.preventDefault();
- userToggledRef.current = true;
- setExpanded((value) => !value);
- }
- : undefined
- }
- >
-
- {displayName}
- {isTodo && hasTodoList && (
-
- {todoCompleted}/{todoItems!.length}
-
- )}
-
-
+ {!hideHeader && (
+
{
+ userToggledRef.current = true;
+ setExpanded((value) => !value);
+ }
+ : undefined
+ }
+ onKeyDown={
+ expandable
+ ? (event) => {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ event.preventDefault();
+ userToggledRef.current = true;
+ setExpanded((value) => !value);
+ }
+ : undefined
+ }
+ >
+
+ {displayName}
+ {isTodo && hasTodoList && (
+
+ {todoCompleted}/{todoItems!.length}
+
+ )}
+
+
+ )}
{(!summaryOnly || expanded) && isTodo && hasTodoList && (
-
+
)}
{/* Todo tool whose payload couldn't be parsed (e.g. malformed args):
fall back to the raw result summary so the row isn't blank. */}
{(!summaryOnly || expanded) && isTodo && !hasTodoList && result && (
{result}
)}
- {relocateDescription && (
-
{description}
+ {showExpandedSummaryPanel && (
+
+ {result && (
+
+ {result}
+
+ )}
+
)}
{!isTodo &&
+ !hideCollapsedOutput &&
result &&
+ !showExpandedSummaryPanel &&
(!expanded || !detailView) &&
(!summaryOnly || expanded) && (
-
{result}
+
+ {result}
+
)}
{!isTodo && expanded && detailView && (
-
- {isShellToolName(name) && (
-
- )}
- {(name === 'write_file' || name === 'writefile') && (
-
- )}
- {(name === 'edit' || name === 'write' || name === 'editfile') && (
-
- )}
- {(name === 'read' || name === 'read_file' || name === 'readfile') && (
+
+ {isRead ? (
- )}
- {isAskUserQuestionToolName(tool.toolName) && (
-
+ ) : (
+
+ {isShellToolName(name) && }
+ {(name === 'write_file' || name === 'writefile') && (
+
+ )}
+ {(name === 'edit' || name === 'write' || name === 'editfile') && (
+
+ )}
+ {isAskUserQuestionToolName(tool.toolName) && (
+
+ )}
+
)}
)}
@@ -1034,13 +1226,13 @@ export const ToolGroup = memo(function ToolGroup({
tools,
pendingApproval,
workspaceCwd,
- shellOutputMaxLines,
}: ToolGroupProps) {
const { t } = useI18n();
const compactMode = useContext(CompactModeContext);
const [chatExpanded, setChatExpanded] = useState(false);
const hasRunningTool = hasActiveTool(tools);
const activeTool = tools.length > 0 ? getActiveTool(tools) : undefined;
+ const singleTool = tools.length === 1 ? tools[0] : undefined;
const summaryIconTool = tools[0] ?? activeTool;
const liveStartedAtRef = useRef(Date.now());
const summaryNow = useSharedNow(hasRunningTool);
@@ -1085,7 +1277,16 @@ export const ToolGroup = memo(function ToolGroup({
: styles.chatSummaryText
}
>
- {formatToolGroupSummary(tools, t, runningDuration)}
+ {singleTool
+ ? hasRunningTool
+ ? formatRunningSingleToolSummary(
+ singleTool,
+ t,
+ runningDuration,
+ workspaceCwd,
+ )
+ : formatSingleToolSummary(singleTool, t, workspaceCwd)
+ : formatToolGroupSummary(tools, t, runningDuration)}
))}
@@ -1128,7 +1330,6 @@ export const ToolGroup = memo(function ToolGroup({
tool={tool}
approval={pendingApproval}
workspaceCwd={workspaceCwd}
- shellOutputMaxLines={shellOutputMaxLines}
/>
))}
diff --git a/packages/web-shell/client/components/messages/toolFormatting.test.ts b/packages/web-shell/client/components/messages/toolFormatting.test.ts
index 025eca8a817..29ef6ab360f 100644
--- a/packages/web-shell/client/components/messages/toolFormatting.test.ts
+++ b/packages/web-shell/client/components/messages/toolFormatting.test.ts
@@ -305,11 +305,17 @@ describe('toolFormatting', () => {
it('keeps proper tool names / acronyms in English', () => {
const t = getTranslator('zh-CN');
expect(localizeToolDisplayName('agent', t)).toBe('Agent');
- expect(localizeToolDisplayName('grep_search', t)).toBe('Grep');
expect(localizeToolDisplayName('glob', t)).toBe('Glob');
expect(localizeToolDisplayName('lsp', t)).toBe('LSP');
});
+ it('localizes grep tool aliases in Chinese', () => {
+ const t = getTranslator('zh-CN');
+ expect(localizeToolDisplayName('grep', t)).toBe('搜索内容');
+ expect(localizeToolDisplayName('grep_search', t)).toBe('搜索内容');
+ expect(localizeToolDisplayName('search', t)).toBe('搜索内容');
+ });
+
it('falls back to the English display name when the locale has no entry', () => {
const t = getTranslator('en');
expect(localizeToolDisplayName('todo_write', t)).toBe('TodoList');
@@ -325,7 +331,7 @@ describe('toolFormatting', () => {
it('has a zh translation for every tool in the display-name map', () => {
const tZh = getTranslator('zh-CN');
// Tools intentionally shown in English (proper names / acronyms).
- const keepEnglish = new Set(['agent', 'grep_search', 'glob', 'search']);
+ const keepEnglish = new Set(['agent', 'glob']);
const untranslated = Object.keys(TOOL_DISPLAY_NAMES).filter(
(wire) =>
!keepEnglish.has(wire) &&
diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts
index 9e3412bceef..72b53ca0721 100644
--- a/packages/web-shell/client/components/messages/toolFormatting.ts
+++ b/packages/web-shell/client/components/messages/toolFormatting.ts
@@ -4,6 +4,7 @@ export const TOOL_DISPLAY_NAMES: Record = {
edit: 'Edit',
write_file: 'WriteFile',
read_file: 'ReadFile',
+ grep: 'Grep',
grep_search: 'Grep',
glob: 'Glob',
run_shell_command: 'Shell',
diff --git a/packages/web-shell/client/components/messages/tools/DiffView.module.css b/packages/web-shell/client/components/messages/tools/DiffView.module.css
index 81ff148e12c..288f071817e 100644
--- a/packages/web-shell/client/components/messages/tools/DiffView.module.css
+++ b/packages/web-shell/client/components/messages/tools/DiffView.module.css
@@ -1,8 +1,6 @@
.view {
- margin-top: 6px;
- border-radius: 4px;
+ margin: 0;
overflow: hidden;
- border: 1px solid var(--border);
font-size: 12px;
}
diff --git a/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.module.css b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.module.css
index a09b4705f48..3598053d3c9 100644
--- a/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.module.css
+++ b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.module.css
@@ -52,8 +52,8 @@
}
.summaryToolIcon {
- width: 13px;
- height: 13px;
+ width: 14px;
+ height: 14px;
display: block;
}
@@ -99,15 +99,26 @@
.chevronRight,
.chevronDown {
- width: 7px;
- height: 7px;
- border-right: 1px solid currentColor;
- border-bottom: 1px solid currentColor;
+ width: 14px;
+ height: 14px;
+ position: relative;
flex-shrink: 0;
opacity: 0;
transition: opacity 120ms ease;
}
+.chevronRight::before,
+.chevronRight::after,
+.chevronDown::before,
+.chevronDown::after {
+ content: '';
+ position: absolute;
+ width: 6px;
+ height: 1px;
+ background: currentColor;
+ border-radius: 1px;
+}
+
.summary:hover .chevronRight,
.summary:hover .chevronDown,
.summary:focus-visible .chevronRight,
@@ -116,17 +127,40 @@
}
.chevronRight {
+ transform: none;
+}
+
+.chevronRight::before {
+ top: calc(50% - 2px);
+ left: calc(50% - 3px);
+ transform: rotate(45deg);
+}
+
+.chevronRight::after {
+ top: calc(50% + 2px);
+ left: calc(50% - 3px);
transform: rotate(-45deg);
}
.chevronDown {
+ transform: none;
+}
+
+.chevronDown::before {
+ top: 50%;
+ left: calc(50% - 5px);
transform: rotate(45deg);
- margin-top: -3px;
+}
+
+.chevronDown::after {
+ top: 50%;
+ left: calc(50% - 1px);
+ transform: rotate(-45deg);
}
.group {
- background: var(--muted);
- border: 1px solid var(--border);
+ background: var(--secondary);
+ border: 0.5px solid var(--border);
border-radius: var(--radius);
padding: 8px 12px;
margin: 5px 0;
@@ -157,8 +191,6 @@
display: flex;
flex-direction: column;
gap: 0;
- /* border-left: 2px solid var(--border); */
- padding-left: 10px;
}
.row {
@@ -199,6 +231,4 @@
.detail {
margin-top: 4px;
- padding: 6px 0 2px;
- border-top: 1px solid var(--border);
}
diff --git a/packages/web-shell/client/components/messages/tools/SubAgentPanel.module.css b/packages/web-shell/client/components/messages/tools/SubAgentPanel.module.css
index 16856a1e0a3..4ac32fbfd7b 100644
--- a/packages/web-shell/client/components/messages/tools/SubAgentPanel.module.css
+++ b/packages/web-shell/client/components/messages/tools/SubAgentPanel.module.css
@@ -1,8 +1,7 @@
.panel {
- background: var(--muted);
- border: 1px solid var(--border);
+ background: var(--secondary);
+ border: 0.5px solid var(--border);
border-radius: var(--radius);
- padding: 8px 12px;
width: 100%;
min-width: 0;
max-width: 100%;
diff --git a/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx b/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx
index be297a39b57..2fae6bd50dc 100644
--- a/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx
+++ b/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx
@@ -99,4 +99,50 @@ describe('SubAgentPanel sub-tool timestamps', () => {
);
expect(container.textContent).not.toContain(formatTimestamp(reference));
});
+
+ it('keeps sub-tools expandable while hiding their collapsed output summary', () => {
+ const container = renderPanel(
+ makeAgentWithSubTool({
+ callId: 'sub-1',
+ toolName: 'Shell',
+ status: 'completed',
+ args: { command: 'npm test' },
+ content: [
+ {
+ type: 'content',
+ content: { text: 'first line\nsecond line' },
+ },
+ ],
+ }),
+ );
+ const row = Array.from(container.querySelectorAll('[role="button"]')).find(
+ (el) => el.textContent?.includes('Shell'),
+ ) as HTMLElement | undefined;
+
+ expect(row).toBeDefined();
+ expect(row!.textContent).toContain('npm test');
+ expect(container.textContent).not.toContain('first line');
+ act(() => row!.click());
+ expect(container.textContent).toContain('first line');
+ expect(container.textContent).toContain('second line');
+ });
+
+ it('hides non-standard sub-tool summaries until the row is expanded', () => {
+ const container = renderPanel(
+ makeAgentWithSubTool({
+ callId: 'sub-1',
+ toolName: 'list_directory',
+ status: 'completed',
+ rawOutput: 'src\npackage.json',
+ }),
+ );
+ const row = Array.from(container.querySelectorAll('[role="button"]')).find(
+ (el) => el.textContent?.includes('ListFiles'),
+ ) as HTMLElement | undefined;
+
+ expect(row).toBeDefined();
+ expect(container.textContent).not.toContain('2 item(s)');
+ act(() => row!.click());
+ expect(container.textContent).toContain('2 item(s)');
+ });
});
diff --git a/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx b/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx
index 2d558e30a7b..cd9d6f44bdd 100644
--- a/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx
+++ b/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx
@@ -104,14 +104,13 @@ const SubToolLine = memo(function SubToolLine({ tool }: { tool: ACPToolCall }) {
tool.subTools || tool.subContent ? (
) : (
-
+
);
return {body} ;
});
function TaskToolCallLine({ tc }: { tc: TaskToolCall }) {
const { t } = useI18n();
- const desc = tc.description || '';
return (
@@ -119,9 +118,6 @@ function TaskToolCallLine({ tc }: { tc: TaskToolCall }) {
{localizeToolDisplayName(tc.name, t)}
- {desc && (
- {truncateText(desc, 70)}
- )}
);
diff --git a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css
index 3e2363f9e0a..be33a4b9f90 100644
--- a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css
+++ b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css
@@ -67,8 +67,8 @@
}
.chatSummaryToolIcon {
- width: 13px;
- height: 13px;
+ width: 14px;
+ height: 14px;
display: block;
}
@@ -111,15 +111,26 @@
.chatChevronRight,
.chatChevronDown {
- width: 7px;
- height: 7px;
- border-right: 1px solid currentColor;
- border-bottom: 1px solid currentColor;
+ width: 14px;
+ height: 14px;
+ position: relative;
flex-shrink: 0;
opacity: 0;
transition: opacity 120ms ease;
}
+.chatChevronRight::before,
+.chatChevronRight::after,
+.chatChevronDown::before,
+.chatChevronDown::after {
+ content: '';
+ position: absolute;
+ width: 6px;
+ height: 1px;
+ background: currentColor;
+ border-radius: 1px;
+}
+
.chatSummary:hover .chatChevronRight,
.chatSummary:hover .chatChevronDown,
.chatSummary:focus-visible .chatChevronRight,
@@ -128,15 +139,38 @@
}
.chatChevronRight {
+ transform: none;
+}
+
+.chatChevronRight::before {
+ top: calc(50% - 2px);
+ left: calc(50% - 3px);
+ transform: rotate(45deg);
+}
+
+.chatChevronRight::after {
+ top: calc(50% + 2px);
+ left: calc(50% - 3px);
transform: rotate(-45deg);
}
.chatChevronDown {
- transform: rotate(45deg);
- margin-top: -3px;
+ transform: none;
opacity: 1;
}
+.chatChevronDown::before {
+ top: 50%;
+ left: calc(50% - 5px);
+ transform: rotate(45deg);
+}
+
+.chatChevronDown::after {
+ top: 50%;
+ left: calc(50% - 1px);
+ transform: rotate(-45deg);
+}
+
.chatSummaryContentClip {
display: grid;
grid-template-rows: 1fr;
@@ -208,7 +242,9 @@
cursor: pointer;
}
-.lineExpandable:hover .lineName {
+.lineExpandable:hover .lineName,
+.lineExpandable:hover .lineArg,
+.lineExpandable:hover .lineElapsed {
color: var(--foreground);
}
@@ -221,22 +257,73 @@
line-height: 1.4;
}
-/* Full, wrapped tool argument shown below the header when the row is expanded
- (the header drops its single-line ellipsised copy). Aligns with the expanded
- output block below it. */
-.lineFullArg {
- margin-left: 16px;
- margin-top: 2px;
+.expandedCard {
+ margin: 5px 0;
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ border: 0.5px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--secondary);
+}
+
+.expandedCardHeader {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ align-items: stretch;
+ gap: 3px;
+ padding: 8px 12px 8px;
+}
+
+.expandedCardTitle {
+ flex-shrink: 0;
+ color: var(--foreground);
font-size: 12px;
- line-height: 1.4;
+ font-weight: 500;
+}
+
+.expandedCardDetail {
+ min-width: 0;
color: var(--muted-foreground);
font-family: var(--font-mono);
- white-space: pre-wrap;
- word-break: break-word;
+ font-size: 12px;
+ line-height: 1.4;
overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.expandedCardBody {
+ padding: 6px 12px 8px;
+ min-width: 0;
+}
+
+.expandedAgentCard {
+ background: var(--secondary);
+ border: 0.5px solid var(--border);
+ border-radius: var(--radius);
+ padding: 8px 12px;
+ margin: 5px 0;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.expandedLineOutput {
+ margin-left: 0;
}
.lineDetail {
+ margin: 5px 0;
+ min-width: 0;
+ overflow: visible;
+}
+
+.markdownLineDetail {
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+ padding: 0;
+ overflow: visible;
}
.expandedBash,
@@ -245,20 +332,21 @@
}
.expandedOutput {
- padding: 0px 0px 0 16px;
+ margin: 0;
+ padding: 0;
font-size: 12px;
line-height: 1.5;
color: var(--muted-foreground);
+ font-family: var(--font-mono);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
- margin-bottom: 0;
}
.expandedEdit {
- margin-top: 2px;
+ margin: 0;
}
.expandedWrite {
@@ -271,25 +359,6 @@
color: var(--success-color);
}
-.expandBtn {
- display: block;
- width: 100%;
- padding: 3px 10px;
- margin-top: 10px;
- font-size: 11px;
- font-family: var(--font-mono);
- background: var(--subtle-bg);
- border: none;
- color: var(--muted-foreground);
- cursor: pointer;
- text-align: left;
-}
-
-.expandBtn:hover {
- color: var(--agent-blue-500);
- background: var(--agent-blue-100);
-}
-
.todoProgress {
color: var(--muted-foreground);
font-size: 12px;
@@ -301,6 +370,10 @@
padding: 4px 0 2px 14px;
}
+.expandedCardBody .todoBody {
+ padding: 0;
+}
+
.compactGroup {
margin-bottom: 12px;
padding: 8px 14px;
diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx
index e6a5c7c2c3d..b7c3dad44e5 100644
--- a/packages/web-shell/client/customization.tsx
+++ b/packages/web-shell/client/customization.tsx
@@ -65,6 +65,7 @@ export type MarkdownTableMode = 'basic' | 'advanced';
export type ToolHeaderKind =
| 'agent'
+ | 'ask'
| 'edit'
| 'fetch'
| 'read'
diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx
index ad03d440515..55020aca355 100644
--- a/packages/web-shell/client/i18n.tsx
+++ b/packages/web-shell/client/i18n.tsx
@@ -1460,13 +1460,22 @@ const EN: Messages = {
'tool.expand': 'Expand',
'tool.collapseHint': 'Collapse',
'tool.status.failed': 'Failed',
- 'tool.showAll': (v) => `▼ Show all (${v?.count ?? 0} lines)`,
- 'tool.showLess': '▲ Show less',
- 'tool.showFullLines': '▼ Show full lines',
- 'tool.linesTotal': (v) => `▼ ${v?.count ?? 0} lines total`,
'toolGroup.moreKinds': (v) => ` +${v?.count ?? 0}`,
'toolGroup.summary': (v) =>
`Ran ${v?.count ?? 0} tool${v?.count === 1 ? '' : 's'}`,
+ 'toolGroup.summary.editedFiles': (v) =>
+ `Edited ${v?.count ?? 0} file${v?.count === 1 ? '' : 's'}`,
+ 'toolGroup.summary.ranCommands': (v) =>
+ `Ran ${v?.count ?? 0} command${v?.count === 1 ? '' : 's'}`,
+ 'toolGroup.summary.readFiles': (v) =>
+ `Read ${v?.count ?? 0} file${v?.count === 1 ? '' : 's'}`,
+ 'toolGroup.summary.searched': (v) =>
+ `Searched ${v?.count ?? 0} time${v?.count === 1 ? '' : 's'}`,
+ 'toolGroup.summary.updatedTodos': (v) =>
+ `Updated task list${v?.count === 1 ? '' : ` ${v?.count ?? 0} times`}`,
+ 'toolGroup.summary.askedUser': 'Asked user',
+ 'toolGroup.summary.otherTools': (v) =>
+ `Called ${v?.count ?? 0} other tool${v?.count === 1 ? '' : 's'}`,
'toolGroup.running': (v) =>
`Running ${v?.name ?? 'tool'}${v?.duration ? ` ${v.duration}` : ''}${
Number(v?.count ?? 0) > 1 ? ` · ${v?.count ?? 0} tools` : ''
@@ -1521,7 +1530,8 @@ const ZH: Messages = {
'toolName.edit': '编辑',
'toolName.write_file': '写入文件',
'toolName.read_file': '读取文件',
- 'toolName.grep_search': 'Grep',
+ 'toolName.grep': '搜索内容',
+ 'toolName.grep_search': '搜索内容',
'toolName.glob': 'Glob',
'toolName.run_shell_command': '运行命令',
'toolName.todo_write': '任务清单',
@@ -1561,7 +1571,7 @@ const ZH: Messages = {
'toolName.readfile': '读取文件',
'toolName.write': '写入文件',
'toolName.writefile': '写入文件',
- 'toolName.search': 'Grep',
+ 'toolName.search': '搜索内容',
'toolName.todowrite': '任务清单',
'toolName.savememory': '保存记忆',
'toolName.askuserquestion': '询问用户',
@@ -2915,12 +2925,18 @@ const ZH: Messages = {
'tool.expand': '展开',
'tool.collapseHint': '收起',
'tool.status.failed': '执行失败',
- 'tool.showAll': (v) => `▼ 显示全部(${v?.count ?? 0} 行)`,
- 'tool.showLess': '▲ 显示更少',
- 'tool.showFullLines': '▼ 显示完整行',
- 'tool.linesTotal': (v) => `▼ 共 ${v?.count ?? 0} 行`,
'toolGroup.moreKinds': (v) => ` +${v?.count ?? 0}`,
'toolGroup.summary': (v) => `调用了 ${v?.count ?? 0} 个工具`,
+ 'toolGroup.summary.editedFiles': (v) => `已编辑 ${v?.count ?? 0} 个文件`,
+ 'toolGroup.summary.ranCommands': (v) => `已运行 ${v?.count ?? 0} 条命令`,
+ 'toolGroup.summary.readFiles': (v) => `已读取 ${v?.count ?? 0} 个文件`,
+ 'toolGroup.summary.searched': (v) => `已搜索 ${v?.count ?? 0} 次`,
+ 'toolGroup.summary.updatedTodos': (v) =>
+ Number(v?.count ?? 0) > 1
+ ? `已更新任务清单 ${v?.count ?? 0} 次`
+ : '已更新任务清单',
+ 'toolGroup.summary.askedUser': '已询问用户',
+ 'toolGroup.summary.otherTools': (v) => `调用了 ${v?.count ?? 0} 个工具`,
'toolGroup.running': (v) =>
`正在执行 ${v?.name ?? '工具'}${v?.duration ? ` ${v.duration}` : ''}${
Number(v?.count ?? 0) > 1 ? ` · 共 ${v?.count ?? 0} 个工具` : ''