fix(web-shell): refine tool detail cards (#6399)

Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
ytahdn 2026-07-06 22:58:22 +08:00 committed by GitHub
parent 2fc6b08b52
commit 0f98842ff2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 954 additions and 333 deletions

View file

@ -27,7 +27,7 @@
padding-right: env(safe-area-inset-right);
background: var(--background);
color: var(--foreground);
font-family: var(--font-mono);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
overflow: hidden;
@ -521,6 +521,11 @@
pointer-events: auto;
}
.approvalOverlay:focus,
.approvalOverlay:focus-visible {
outline: none;
}
.scrollToBottomLayer {
position: absolute;
top: -44px;

View file

@ -68,7 +68,6 @@ import { DaemonStatusDialog } from './components/dialogs/DaemonStatusDialog';
import { ScheduledTasksDialog } from './components/dialogs/ScheduledTasksDialog';
import { ExtensionsDialog } from './components/dialogs/ExtensionsDialog';
import { SettingsMessage } from './components/messages/SettingsMessage';
import { resolveShellOutputMaxLines } from './components/messages/ToolGroup';
import { isAskUserQuestionToolName } from './components/messages/toolFormatting';
import { ToolApproval } from './components/messages/ToolApproval';
import { AskUserQuestion } from './components/messages/AskUserQuestion';
@ -1976,7 +1975,6 @@ export function App({
)?.values.effective;
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
})();
const shellOutputMaxLines = resolveShellOutputMaxLines(workspaceSettings);
const [compactMode, setCompactMode] = useState(false);
const compactModeRef = useRef(compactMode);
compactModeRef.current = compactMode;
@ -4364,7 +4362,10 @@ export function App({
</section>
)}
{mainView === 'scheduledTasks' && (
<div className={styles.fullPage} data-testid="scheduled-tasks-page">
<div
className={styles.fullPage}
data-testid="scheduled-tasks-page"
>
<div className={styles.fullPageHeader}>
<button
type="button"
@ -4461,7 +4462,6 @@ export function App({
isResponding={streamingState !== 'idle'}
activeTurnStartedAt={activeTurnStartedAt}
workspaceCwd={connection.workspaceCwd || ''}
shellOutputMaxLines={shellOutputMaxLines}
hideSessionTimeline={
effectiveChatWidthMode === 'wide'
}

View file

@ -84,7 +84,7 @@ const assistantMsg = (id: string, content: string): Message =>
({ id, role: 'assistant', content, timestamp: 0 }) as Message;
function item(message: Message) {
return <MessageItem message={message} shellOutputMaxLines={50} />;
return <MessageItem message={message} />;
}
describe('MessageItem error isolation', () => {

View file

@ -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);
}

View file

@ -174,7 +174,6 @@ function mount(
loadingTranscript={opts.loadingTranscript}
catchingUp={opts.catchingUp}
isResponding={opts.isResponding}
shellOutputMaxLines={50}
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
/>
</I18nProvider>,
@ -205,7 +204,6 @@ function renderInto(
loadingTranscript={opts.loadingTranscript}
catchingUp={opts.catchingUp}
isResponding={opts.isResponding}
shellOutputMaxLines={50}
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
/>
</I18nProvider>,

View file

@ -156,6 +156,9 @@
.turnAnswerRow {
max-width: 100%;
}
.turnStatusRow {
margin-bottom: 4px;
}
.turnAnswerRow {
margin-top: 10px;

View file

@ -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,
],
);

View file

@ -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;
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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> = {}): ACPToolCall {
return {
callId: 'call-1',
@ -34,15 +56,66 @@ function makeTool(overrides: Partial<ACPToolCall> = {}): ACPToolCall {
};
}
function renderToolLine(tool: ACPToolCall): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<ToolLine tool={tool} />
</I18nProvider>,
);
});
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(
<I18nProvider language="en">
<ToolGroup tools={tools} />
</I18nProvider>,
);
});
mounted.push({ root, container });
return container;
}
const t = (key: string, values?: Record<string, string | number>): 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, string | number>): 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', () => {

View file

@ -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;
const MAX_MARKDOWN_READ_CHARS = 200_000;
const MAX_MARKDOWN_READ_LINES = 1000;
const READ_LANGUAGE_ALIASES: Record<string, string> = {
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',
};
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);
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}
</span>
))
: displayText}
: output}
</pre>
{expandable && (
<button
className={styles.expandBtn}
onClick={() => setShowAll(!showAll)}
aria-expanded={showAll}
>
{showAll
? t('tool.showLess')
: isLong
? t('tool.showAll', { count: lines.length })
: t('tool.showFullLines')}
</button>
)}
</div>
);
}
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 (
<div className={styles.expandedRead}>
<pre className={styles.expandedOutput}>{displayText}</pre>
{isLong && (
<button
className={styles.expandBtn}
onClick={() => setShowAll(!showAll)}
aria-expanded={showAll}
>
{showAll
? t('tool.showLess')
: t('tool.linesTotal', { count: lines.length })}
</button>
{plainText ? (
<pre className={styles.expandedOutput}>{content}</pre>
) : (
<Markdown content={fencedCodeBlock(language, content)} />
)}
</div>
);
}
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 (
<div className={styles.expandedCard}>
<div className={styles.expandedCardHeader}>
<span className={styles.expandedCardTitle}>{title}</span>
{detail && <span className={styles.expandedCardDetail}>{detail}</span>}
</div>
{children && <div className={styles.expandedCardBody}>{children}</div>}
</div>
);
}
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 (
<div className={styles.todoBody}>
{expanded ? (
return expanded ? (
<ToolExpandedCard title={title}>
<div className={styles.todoBody}>
<TodoFullList todos={todos} />
) : (
<TodoEventSummary todos={todos} events={events} />
)}
</div>
</ToolExpandedCard>
) : (
<div className={styles.todoBody}>
<TodoEventSummary todos={todos} events={events} />
</div>
);
}
@ -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<typeof useI18n>['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<typeof useI18n>['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<typeof useI18n>['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 (
<svg
className={styles.chatSummaryToolIcon}
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
aria-hidden="true"
>
<path
d="M3.4 3.2h7.2c.7 0 1.25.55 1.25 1.25v3.4c0 .7-.55 1.25-1.25 1.25H7.5L4.5 11V9.1H3.4c-.7 0-1.25-.55-1.25-1.25v-3.4c0-.7.55-1.25 1.25-1.25Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function AgentIcon() {
return (
<svg
@ -702,6 +824,7 @@ function AgentIcon() {
function ToolSummaryIcon({ tool }: { tool: ACPToolCall }) {
const kind = getToolHeaderKind(tool);
if (kind === 'agent') return <AgentIcon />;
if (kind === 'ask') return <AskUserIcon />;
if (kind === 'edit' || kind === 'write') return <PencilIcon />;
if (kind === 'fetch') return <WebFetchIcon />;
if (kind === 'read') return <FileIcon />;
@ -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 = (
<SubAgentPanel tool={tool} hideHeader defaultExpanded inline />
);
return (
<div className={styles.line}>
<div
className={`${styles.lineMain} ${styles.lineExpandable}`}
onClick={() => setExpanded(!expanded)}
>
<StatusIcon status={isComplete ? info.status : tool.status} />
<span className={styles.lineName}>{displayName}</span>
<ToolHeaderExtra
info={{
kind: 'agent',
tool,
displayName,
description: info.description
? truncateText(info.description, 60)
: '',
elapsed: isComplete ? completeMeta : runningMeta,
workspaceCwd,
}}
/>
</div>
{!hideHeader && (
<div
className={`${styles.lineMain} ${styles.lineExpandable}`}
onClick={() => setExpanded(!expanded)}
>
<StatusIcon status={isComplete ? info.status : tool.status} />
<span className={styles.lineName}>{displayName}</span>
<ToolHeaderExtra
info={{
kind: 'agent',
tool,
displayName,
description: info.description
? truncateText(info.description, 60)
: '',
elapsed: isComplete ? completeMeta : runningMeta,
workspaceCwd,
}}
/>
</div>
)}
{showExpanded && (
<div className={styles.lineDetail}>
<SubAgentPanel tool={tool} hideHeader defaultExpanded inline />
{hideHeader ? (
<div className={styles.expandedAgentCard}>{panel}</div>
) : (
panel
)}
</div>
)}
</div>
@ -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 (
<div className={styles.line}>
<div
className={`${styles.lineMain} ${expandable ? styles.lineExpandable : ''}`}
title={
expandable
? expanded
? t('tool.collapseHint')
: t('tool.expand')
: undefined
}
aria-expanded={expandable ? expanded : undefined}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
onClick={
expandable
? () => {
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
}
>
<StatusIcon status={tool.status} />
<span className={styles.lineName}>{displayName}</span>
{isTodo && hasTodoList && (
<span className={styles.todoProgress}>
{todoCompleted}/{todoItems!.length}
</span>
)}
<ToolHeaderExtra
info={{
kind: getToolHeaderKind(tool),
tool,
displayName,
// A todo row carries its checklist in the body below; a redundant
// "Update Todos" description and the instant write duration would
// only clutter the header next to the progress count.
description: isTodo || relocateDescription ? '' : description,
elapsed: isTodo ? '' : elapsed,
workspaceCwd,
}}
/>
</div>
{!hideHeader && (
<div
className={`${styles.lineMain} ${expandable ? styles.lineExpandable : ''}`}
title={
expandable
? expanded
? t('tool.collapseHint')
: t('tool.expand')
: undefined
}
aria-expanded={expandable ? expanded : undefined}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
onClick={
expandable
? () => {
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
}
>
<StatusIcon status={tool.status} />
<span className={styles.lineName}>{displayName}</span>
{isTodo && hasTodoList && (
<span className={styles.todoProgress}>
{todoCompleted}/{todoItems!.length}
</span>
)}
<ToolHeaderExtra
info={{
kind: getToolHeaderKind(tool),
tool,
displayName,
// A todo row carries its checklist in the body below; a redundant
// "Update Todos" description and the instant write duration would
// only clutter the header next to the progress count.
description: isTodo || hideDescriptionInHeader ? '' : description,
elapsed: isTodo ? '' : elapsed,
workspaceCwd,
}}
/>
</div>
)}
{(!summaryOnly || expanded) && isTodo && hasTodoList && (
<TodoToolBody tool={tool} todos={todoItems!} expanded={expanded} />
<TodoToolBody
tool={tool}
todos={todoItems!}
expanded={expanded}
title={displayName}
/>
)}
{/* 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 && (
<div className={styles.lineOutput}>{result}</div>
)}
{relocateDescription && (
<div className={styles.lineFullArg}>{description}</div>
{showExpandedSummaryPanel && (
<ToolExpandedCard title={displayName} detail={expandedCardDetail}>
{result && (
<div
className={`${styles.lineOutput} ${styles.expandedLineOutput}`}
>
{result}
</div>
)}
</ToolExpandedCard>
)}
{!isTodo &&
!hideCollapsedOutput &&
result &&
!showExpandedSummaryPanel &&
(!expanded || !detailView) &&
(!summaryOnly || expanded) && (
<div className={styles.lineOutput}>{result}</div>
<div
className={
expanded
? `${styles.lineOutput} ${styles.expandedLineOutput}`
: styles.lineOutput
}
>
{result}
</div>
)}
{!isTodo && expanded && detailView && (
<div className={styles.lineDetail}>
{isShellToolName(name) && (
<ExpandedBashOutput tool={tool} maxLines={shellOutputMaxLines} />
)}
{(name === 'write_file' || name === 'writefile') && (
<ExpandedEditContent tool={tool} />
)}
{(name === 'edit' || name === 'write' || name === 'editfile') && (
<ExpandedEditContent tool={tool} />
)}
{(name === 'read' || name === 'read_file' || name === 'readfile') && (
<div
className={
useMarkdownDetail
? `${styles.lineDetail} ${styles.markdownLineDetail}`
: styles.lineDetail
}
>
{isRead ? (
<ExpandedReadContent tool={tool} />
)}
{isAskUserQuestionToolName(tool.toolName) && (
<ExpandedAskUserQuestionOutput tool={tool} />
) : (
<ToolExpandedCard title={displayName} detail={expandedCardDetail}>
{isShellToolName(name) && <ExpandedBashOutput tool={tool} />}
{(name === 'write_file' || name === 'writefile') && (
<ExpandedEditContent tool={tool} />
)}
{(name === 'edit' || name === 'write' || name === 'editfile') && (
<ExpandedEditContent tool={tool} />
)}
{isAskUserQuestionToolName(tool.toolName) && (
<ExpandedAskUserQuestionOutput tool={tool} />
)}
</ToolExpandedCard>
)}
</div>
)}
@ -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)}
</span>
<span
className={
@ -1109,8 +1310,9 @@ export const ToolGroup = memo(function ToolGroup({
tool={tool}
approval={pendingApproval}
workspaceCwd={workspaceCwd}
shellOutputMaxLines={shellOutputMaxLines}
summaryOnly
summaryOnly={!singleTool}
forceExpanded={!!singleTool}
hideHeader={!!singleTool}
/>
))}
</div>
@ -1128,7 +1330,6 @@ export const ToolGroup = memo(function ToolGroup({
tool={tool}
approval={pendingApproval}
workspaceCwd={workspaceCwd}
shellOutputMaxLines={shellOutputMaxLines}
/>
))}
</div>

View file

@ -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) &&

View file

@ -4,6 +4,7 @@ export const TOOL_DISPLAY_NAMES: Record<string, string> = {
edit: 'Edit',
write_file: 'WriteFile',
read_file: 'ReadFile',
grep: 'Grep',
grep_search: 'Grep',
glob: 'Glob',
run_shell_command: 'Shell',

View file

@ -1,8 +1,6 @@
.view {
margin-top: 6px;
border-radius: 4px;
margin: 0;
overflow: hidden;
border: 1px solid var(--border);
font-size: 12px;
}

View file

@ -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);
}

View file

@ -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%;

View file

@ -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)');
});
});

View file

@ -104,14 +104,13 @@ const SubToolLine = memo(function SubToolLine({ tool }: { tool: ACPToolCall }) {
tool.subTools || tool.subContent ? (
<SubAgentPanel tool={tool} />
) : (
<ToolLine tool={tool} />
<ToolLine tool={tool} forceExpandable hideCollapsedOutput />
);
return <SubToolTime timestamp={tool.startTime}>{body}</SubToolTime>;
});
function TaskToolCallLine({ tc }: { tc: TaskToolCall }) {
const { t } = useI18n();
const desc = tc.description || '';
return (
<div className={chromeStyles.line}>
<div className={chromeStyles.lineMain}>
@ -119,9 +118,6 @@ function TaskToolCallLine({ tc }: { tc: TaskToolCall }) {
<span className={chromeStyles.lineName}>
{localizeToolDisplayName(tc.name, t)}
</span>
{desc && (
<span className={chromeStyles.lineArg}>{truncateText(desc, 70)}</span>
)}
</div>
</div>
);

View file

@ -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;

View file

@ -65,6 +65,7 @@ export type MarkdownTableMode = 'basic' | 'advanced';
export type ToolHeaderKind =
| 'agent'
| 'ask'
| 'edit'
| 'fetch'
| 'read'

View file

@ -1507,13 +1507,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` : ''
@ -1568,7 +1577,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': '任务清单',
@ -1608,7 +1618,7 @@ const ZH: Messages = {
'toolName.readfile': '读取文件',
'toolName.write': '写入文件',
'toolName.writefile': '写入文件',
'toolName.search': 'Grep',
'toolName.search': '搜索内容',
'toolName.todowrite': '任务清单',
'toolName.savememory': '保存记忆',
'toolName.askuserquestion': '询问用户',
@ -3009,12 +3019,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} 个工具` : ''