diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index 177b55060a..7bd0c1214d 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -27,23 +27,26 @@ afterEach(() => { } }); -function makeShellTool(output: string): ACPToolCall { +function makeShellTool( + output: string, + status: ACPToolCall['status'] = 'completed', +): ACPToolCall { return { callId: 'call-shell-1', toolName: 'Shell', - status: 'completed', + status, rawOutput: { output }, }; } -function renderShellTool(output: string): HTMLElement { +function renderTool(tool: ACPToolCall): HTMLElement { const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); act(() => { root.render( - + , ); }); @@ -51,6 +54,44 @@ function renderShellTool(output: string): HTMLElement { return container; } +function renderShellTool(output: string): HTMLElement { + const container = renderTool(makeShellTool(output)); + // Completed tools collapse to a one-line summary by default; open the row so + // the assertions below can inspect the bash-output view. + const chevron = [...container.querySelectorAll('span')].find( + (s) => s.textContent === '▸', + ); + if (chevron?.parentElement) click(chevron.parentElement); + return container; +} + +function makeShellCommandTool(command: string): ACPToolCall { + return { + callId: 'call-shell-cmd', + toolName: 'run_shell_command', + status: 'completed', + args: { command }, + rawOutput: { output: 'done' }, + }; +} + +function makeAgentTool(status: ACPToolCall['status']): ACPToolCall { + return { + callId: 'agent-1', + toolName: 'task', + status, + args: { description: 'agentDescMarker' }, + subTools: [ + { + callId: 'agent-1-sub-1', + toolName: 'Read', + status: 'completed', + args: { file_path: '/ws/SubToolMarker.ts' }, + }, + ], + }; +} + function getExpandButton(container: HTMLElement): HTMLButtonElement { const button = container.querySelector('button'); expect(button).not.toBeNull(); @@ -124,3 +165,185 @@ describe('shell tool output expand toggle', () => { expect(button.getAttribute('aria-expanded')).toBe('false'); }); }); + +describe('tool description expand toggle', () => { + it('relocates the full command from the header into a wrapped block on expand', () => { + const command = `npm run build && npm run test && npm run lint -- ${'x'.repeat( + 80, + )}`; + const container = renderTool(makeShellCommandTool(command)); + + // textContent alone can't prove relocation (it concatenates the whole + // subtree, so the command is present in either state). Assert the DOM move + // instead: collapsed, the full command lives in the header's single-line + // arg (CSS-ellipsised); expanded, it is no longer in any leaf + // 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 (
+    
+  );
+}
+
 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 { }