feat(web-shell): reveal full tool detail and auto-collapse finished tools (#5088)

* feat(web-shell): reveal full tool detail and auto-collapse finished tools

Long tool descriptions were hard-capped at 120 characters and finished
tools (shell/edit/write) stayed expanded indefinitely, so commands were
unreadable and the transcript filled with stale output.

- Lift the 120-char description cap so the full command/path reaches the
  DOM; collapsed rows ellipsise via CSS (adapts to width) and a click
  reflows the full text into a wrapped block below the header.
- Add a leading disclosure chevron; any row with detail output or a long
  description is now expandable.
- Auto-collapse a tool to its one-line summary once it completes
  successfully. Running tools stay expanded (live output) and failures
  stay expanded (error visible); agents keep their own manual expand state.

* fix(web-shell): preserve manual expand on completion; correct auto-expand comment

Review feedback on #5088:

- shouldAutoExpand: rewrite the comment to match the code. Only the verbose
  kinds (shell/edit/write/ask) auto-expand and stay expanded on failure; other
  kinds are collapsed by default (their summary line shows the outcome and they
  stay click-to-expand). Force-expanding every failed tool was rejected because
  tools without an expanded-detail renderer would then hide the summary line
  and show an empty body — i.e. hide the error.
- Auto-collapse-on-completion no longer overrides an explicit user toggle: a
  userToggledRef latch (set on header click, reset on tool-identity change)
  guards the collapse effect, so a row the user expanded/collapsed keeps its
  state when the tool finishes.

* test(web-shell): assert tool-detail relocation via DOM, not textContent

Review feedback (#5088): the expand test asserted container.textContent
contains the command before and after the click, which passes regardless of
whether the description is relocated from the header span to the wrapped
block (textContent concatenates the whole subtree). Assert the DOM move
instead — the command is in a leaf <span> while collapsed and in none while
expanded — so a regression dropping the relocation now fails the test.
This commit is contained in:
Shaojin Wen 2026-06-14 10:11:13 +08:00 committed by GitHub
parent e8342715e5
commit 800507598c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 381 additions and 13 deletions

View file

@ -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(
<I18nProvider language="en">
<ToolGroup tools={[makeShellTool(output)]} />
<ToolGroup tools={[tool]} />
</I18nProvider>,
);
});
@ -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 <span> (CSS-ellipsised); expanded, it is no longer in any leaf <span>
// 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 <pre> 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(
<I18nProvider language="en">
<ToolGroup tools={[makeShellTool(output, 'in_progress')]} />
</I18nProvider>,
);
});
// 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(
<I18nProvider language="en">
<ToolGroup tools={[makeShellTool(output, 'completed')]} />
</I18nProvider>,
);
});
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(
<I18nProvider language="en">
<ToolGroup tools={[makeAgentTool(status)]} />
</I18nProvider>,
);
});
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(
<I18nProvider language="en">
<ToolGroup
tools={[
{
callId: 'call-usertoggle',
toolName: 'run_shell_command',
status,
args: { command },
rawOutput: { output: 'done' },
},
]}
/>
</I18nProvider>,
);
});
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('▾');
});
});

View file

@ -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 (
<span className={styles.lineToggle} aria-hidden="true">
{expandable ? (expanded ? '▾' : '▸') : ''}
</span>
);
}
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({
<div className={styles.line}>
<div
className={`${styles.lineMain} ${expandable ? styles.lineExpandable : ''}`}
onClick={expandable ? () => setExpanded(!expanded) : undefined}
onClick={
expandable
? () => {
userToggledRef.current = true;
setExpanded((value) => !value);
}
: undefined
}
>
<ToggleChevron expandable={expandable} expanded={expanded} />
<StatusIcon status={tool.status} />
<span className={styles.lineName}>{displayName}</span>
<ToolHeaderExtra
@ -738,17 +826,20 @@ export const ToolLine = memo(function ToolLine({
kind: getToolHeaderKind(tool),
tool,
displayName,
description,
description: relocateDescription ? '' : description,
elapsed,
workspaceCwd,
}}
/>
</div>
{isTodo && <TodoWriteContent tool={tool} />}
{!isTodo && !expanded && result && (
{relocateDescription && (
<div className={styles.lineFullArg}>{description}</div>
)}
{!isTodo && result && (!expanded || !detailView) && (
<div className={styles.lineOutput}>{result}</div>
)}
{!isTodo && expanded && (
{!isTodo && expanded && detailView && (
<div className={styles.lineDetail}>
{isShellToolName(name) && (
<ExpandedBashOutput tool={tool} maxLines={shellOutputMaxLines} />

View file

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

View file

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

View file

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