+ // but reflowed into the wrapped block below.
+ const commandInLeafSpan = () =>
+ [...container.querySelectorAll('span')].some(
+ (s) => s.textContent === command,
+ );
+
+ expect(container.textContent).toContain('▸');
+ expect(commandInLeafSpan()).toBe(true);
+
+ const chevron = [...container.querySelectorAll('span')].find(
+ (s) => s.textContent === '▸',
+ );
+ click(chevron!.parentElement!);
+
+ expect(container.textContent).toContain('▾');
+ expect(commandInLeafSpan()).toBe(false);
+ expect(container.textContent).toContain(command); // still present, in the block
+ });
+
+ it('keeps the result summary when expanding a long-description tool with no detail view', () => {
+ // glob with a long pattern: descExpandable but no kind-specific renderer.
+ const pattern = `**/${'x'.repeat(80)}/*.ts`;
+ const container = renderTool({
+ callId: 'call-glob',
+ toolName: 'glob',
+ status: 'completed',
+ args: { pattern },
+ rawOutput: 'a.ts\nb.ts\nc.ts',
+ });
+
+ const chevron = [...container.querySelectorAll('span')].find(
+ (s) => s.textContent === '▸',
+ );
+ expect(chevron).toBeTruthy(); // long pattern → expandable
+ expect(container.textContent).toContain('matching file'); // summary, collapsed
+
+ click(chevron!.parentElement!);
+
+ // Expanded: the summary must NOT be lost (no detail view replaces it), and
+ // the full pattern is reflowed into the block.
+ expect(container.textContent).toContain('matching file');
+ expect(container.textContent).toContain(pattern);
+ });
+});
+
+describe('auto-collapse on finish', () => {
+ it('collapses a completed tool to its summary by default', () => {
+ const container = renderTool(makeShellTool('a\nb\nc\nd'));
+ // The expanded bash is not rendered until the user opens the row.
+ expect(container.querySelector('pre')).toBeNull();
+ expect(container.textContent).toContain('▸');
+ });
+
+ it('keeps a running tool expanded so streaming output stays visible', () => {
+ const container = renderTool(
+ makeShellTool('streaming output', 'in_progress'),
+ );
+ expect(container.querySelector('pre')?.textContent).toContain('streaming');
+ });
+
+ it('keeps a failed tool expanded so the error stays visible', () => {
+ const container = renderTool(
+ makeShellTool('error: boom\n at step 1', 'failed'),
+ );
+ expect(container.querySelector('pre')?.textContent).toContain('boom');
+ });
+
+ it('auto-collapses a running tool when it transitions to completed', () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ mounted.push({ root, container });
+
+ const output = 'line1\nline2\nline3\nline4';
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+ // Running → expanded: the bash output is visible.
+ expect(container.querySelector('pre')).not.toBeNull();
+
+ // Same callId, now finished → the row collapses on its own.
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+ expect(container.querySelector('pre')).toBeNull();
+ });
+
+ it('does not collapse an agent the user expanded when it completes', () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ mounted.push({ root, container });
+
+ const render = (status: ACPToolCall['status']) =>
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ render('in_progress');
+ // Agents start collapsed: the sub-tool panel is hidden.
+ expect(container.textContent).not.toContain('SubToolMarker');
+
+ // Expand by clicking the agent's summary row.
+ const summaryLabel = [...container.querySelectorAll('span')].find(
+ (s) => s.textContent === 'task:',
+ );
+ expect(summaryLabel).toBeTruthy();
+ click(summaryLabel!.parentElement!);
+ expect(container.textContent).toContain('SubToolMarker');
+
+ // Completion must NOT yank the panel shut: the collapse-on-finish effect
+ // is scoped to non-agent tools.
+ render('completed');
+ expect(container.textContent).toContain('SubToolMarker');
+ });
+
+ it('keeps a tool the user manually expanded open when it completes', () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ mounted.push({ root, container });
+
+ // Long command → the row is expandable/clickable even while running.
+ const command = `echo ${'z'.repeat(80)}`;
+ const renderStatus = (status: ACPToolCall['status']) =>
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ renderStatus('in_progress');
+ const chevron = () =>
+ [...container.querySelectorAll('span')].find(
+ (s) => s.textContent === '▾' || s.textContent === '▸',
+ );
+ // Toggle twice → marks the row user-controlled, ending in the expanded state.
+ click(chevron()!.parentElement!);
+ click(chevron()!.parentElement!);
+ expect(container.textContent).toContain('▾');
+
+ // Completion must NOT override the user's explicit expand.
+ renderStatus('completed');
+ expect(container.textContent).toContain('▾');
+ });
+});
diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx
index 7964eedd7a..2b00a16367 100644
--- a/packages/web-shell/client/components/messages/ToolGroup.tsx
+++ b/packages/web-shell/client/components/messages/ToolGroup.tsx
@@ -1,4 +1,4 @@
-import { memo, useContext, useEffect, useMemo, useState } from 'react';
+import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { DaemonSettingDescriptor } from '@qwen-code/webui/daemon-react-sdk';
import type {
ACPToolCall,
@@ -81,6 +81,27 @@ function hasExpandableContent(tool: ACPToolCall): boolean {
return false;
}
+// Tools whose expanded row renders a kind-specific detail view (shell output /
+// diff / file content / Q&A). Must stay in sync with the renderers in
+// ToolLine's lineDetail block below. Tools NOT in this set have nothing extra
+// to show when expanded, so they keep their one-line result summary instead of
+// hiding it behind an empty detail area.
+function hasDetailView(tool: ACPToolCall): boolean {
+ const name = tool.toolName.toLowerCase();
+ return (
+ isShellToolName(name) ||
+ name === 'write_file' ||
+ name === 'writefile' ||
+ name === 'edit' ||
+ name === 'write' ||
+ name === 'editfile' ||
+ name === 'read' ||
+ name === 'read_file' ||
+ name === 'readfile' ||
+ isAskUserQuestionToolName(tool.toolName)
+ );
+}
+
function hasDiffContent(tool: ACPToolCall): boolean {
if (tool.content?.some((b) => b.type === 'diff')) return true;
if (tool.rawOutput && typeof tool.rawOutput === 'object') {
@@ -154,6 +175,10 @@ function buildUnifiedDiff(oldText: string, newText: string): string {
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 {
@@ -411,6 +436,14 @@ function getAgentDisplayInfo(
}
function shouldAutoExpand(tool: ACPToolCall): boolean {
+ // Only the verbose tool kinds below (shell/edit/write/ask) auto-expand, and
+ // only while pending/in-progress or after failing: a successful completion
+ // collapses them to a one-line summary so the transcript stays scannable
+ // (click to reopen), while a failure of those kinds stays expanded so its
+ // error output is visible without a click. Every other tool kind is collapsed
+ // by default regardless of status — its summary line already shows the
+ // outcome and it stays click-to-expand.
+ if (tool.status === 'completed') return false;
const name = tool.toolName.toLowerCase();
if (isAskUserQuestionToolName(tool.toolName)) return true;
if (name === 'write_file' || name === 'writefile') return true;
@@ -465,6 +498,27 @@ function ToolHeaderExtra({ info }: { info: ToolHeaderExtraRenderInfo }) {
);
}
+function isDescriptionExpandable(description: string): boolean {
+ return (
+ description.length > DESCRIPTION_EXPAND_THRESHOLD ||
+ description.includes('\n')
+ );
+}
+
+function ToggleChevron({
+ expandable,
+ expanded,
+}: {
+ expandable: boolean;
+ expanded: boolean;
+}) {
+ return (
+
+ {expandable ? (expanded ? '▾' : '▸') : ''}
+
+ );
+}
+
function getActiveTool(tools: ACPToolCall[]): ACPToolCall {
return (
tools.find((t) => t.status === 'in_progress') ?? tools[tools.length - 1]
@@ -586,11 +640,16 @@ export const ToolLine = memo(function ToolLine({
const [expanded, setExpanded] = useState(
() => !compactMode && shouldAutoExpand(tool),
);
+ // Set once the user explicitly toggles this row, so auto-collapse-on-
+ // completion never silently overrides their choice.
+ const userToggledRef = useRef(false);
const [now, setNow] = useState(() => Date.now());
useEffect(
() => {
setExpanded(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],
@@ -611,6 +670,17 @@ export const ToolLine = memo(function ToolLine({
return () => clearInterval(id);
}, [isRunningAgent]);
+ // Collapse a regular tool to its one-line summary once it completes
+ // successfully — unless the user explicitly toggled this row, in which case
+ // their choice wins. Agents are excluded (they keep whatever expand state the
+ // 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) {
+ setExpanded(false);
+ }
+ }, [isAgent, tool.status]);
+
if (isAgent) {
if (hasApproval && onConfirm) {
return (
@@ -712,10 +782,20 @@ export const ToolLine = memo(function ToolLine({
isShellToolName(tool.toolName) || isWebFetchToolName(tool.toolName)
? ''
: formatElapsed(tool.startTime, tool.endTime);
- const expandable = hasExpandableContent(tool);
const name = tool.toolName.toLowerCase();
const isTodo = name === 'todowrite';
+ // A row expands when it has detail output (bash/diff/read content) or when
+ // its description is 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 && (hasExpandableContent(tool) || descExpandable);
+ const relocateDescription = expanded && 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);
if (hasApproval && onConfirm) {
return (
@@ -729,8 +809,16 @@ export const ToolLine = memo(function ToolLine({
setExpanded(!expanded) : undefined}
+ onClick={
+ expandable
+ ? () => {
+ userToggledRef.current = true;
+ setExpanded((value) => !value);
+ }
+ : undefined
+ }
>
+
{displayName}
{isTodo &&
}
- {!isTodo && !expanded && result && (
+ {relocateDescription && (
+
{description}
+ )}
+ {!isTodo && result && (!expanded || !detailView) && (
{result}
)}
- {!isTodo && expanded && (
+ {!isTodo && expanded && detailView && (
{isShellToolName(name) && (
diff --git a/packages/web-shell/client/components/messages/toolFormatting.test.ts b/packages/web-shell/client/components/messages/toolFormatting.test.ts
index 4fddd045e4..a2a1b3ee6f 100644
--- a/packages/web-shell/client/components/messages/toolFormatting.test.ts
+++ b/packages/web-shell/client/components/messages/toolFormatting.test.ts
@@ -170,4 +170,24 @@ describe('toolFormatting', () => {
),
).toBe('3 line(s)');
});
+
+ it('keeps long shell commands in full instead of capping at one line', () => {
+ const command = `echo ${'a'.repeat(200)}`;
+ expect(
+ getToolDescription(
+ tool({ toolName: 'run_shell_command', args: { command } }),
+ ),
+ ).toBe(command);
+ });
+
+ it('still bounds a pathologically long description', () => {
+ const result = getToolDescription(
+ tool({
+ toolName: 'run_shell_command',
+ args: { command: 'x'.repeat(5000) },
+ }),
+ );
+ expect(result.length).toBeLessThan(5000);
+ expect(result.endsWith('...')).toBe(true);
+ });
});
diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts
index 652af52a50..e18d956db8 100644
--- a/packages/web-shell/client/components/messages/toolFormatting.ts
+++ b/packages/web-shell/client/components/messages/toolFormatting.ts
@@ -49,14 +49,21 @@ export function truncateText(text: string, max: number): string {
return text.slice(0, max) + '...';
}
+// The tool-header description is shown single-line (CSS-ellipsised) when the
+// row is collapsed and fully wrapped when it is expanded, so we keep the whole
+// string rather than hard-capping it at a line's worth of characters. A
+// generous ceiling still guards against a pathological multi-megabyte command
+// bloating the DOM.
+const MAX_DESCRIPTION_LENGTH = 2000;
+
export function getToolDescription(
tool: ACPToolCall,
workspaceCwd?: string,
): string {
const fromTitle = getDescriptionFromTitle(tool, workspaceCwd);
- if (fromTitle) return truncateText(fromTitle, 120);
+ if (fromTitle) return truncateText(fromTitle, MAX_DESCRIPTION_LENGTH);
const fromArgs = getDescriptionFromArgs(tool, workspaceCwd);
- if (fromArgs) return truncateText(fromArgs, 120);
+ if (fromArgs) return truncateText(fromArgs, MAX_DESCRIPTION_LENGTH);
return '';
}
@@ -177,7 +184,7 @@ function getDescriptionFromArgs(
if (args.description) {
description += ` (${String(args.description).replace(/\n/g, ' ')})`;
}
- return truncateText(description, 120);
+ return truncateText(description, MAX_DESCRIPTION_LENGTH);
}
if (name === 'grep_search' || name === 'grep' || name === 'search') {
const pattern = args.pattern ?? args.query;
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 ac4dd48195..02f123e536 100644
--- a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css
+++ b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css
@@ -24,6 +24,18 @@
min-width: 0;
}
+/* Leading disclosure column. Rendered (blank) for every tool row so names stay
+ aligned; shows ▸/▾ only when the row can expand. */
+.lineToggle {
+ width: 12px;
+ flex-shrink: 0;
+ font-size: 10px;
+ line-height: 1;
+ color: var(--text-dimmed);
+ text-align: center;
+ user-select: none;
+}
+
.icon {
font-size: 13px;
flex-shrink: 0;
@@ -110,6 +122,21 @@
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;
+ font-size: 12px;
+ line-height: 1.4;
+ color: var(--text-secondary);
+ font-family: var(--font-mono);
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+
.lineDetail {
}