diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 5a5c1180d79..5f69e1be705 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -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; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 356f6f8e570..cdee556e241 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -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({ )} {mainView === 'scheduledTasks' && ( -
+
- )}
); } 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 && ( - + {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} 个工具` : ''