feat(tui): remove tool group borders and collapse completed tool results (#5003)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* feat(tui): remove tool group borders and collapse completed tool results

Remove round borders from ToolGroupMessage, CompactToolGroupDisplay, and
InlineParallelAgentsDisplay. Completed tools now default to a single
collapsed header line with dimColor styling. Executing/error/confirming
tools continue to show their full result block.

Part of #4588 (Track 3: Simplify tool-call rendering).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): gate collapse on compact mode and fix innerWidth calculation

- Only collapse completed tool results in compact mode, preserving
  full visibility in non-compact mode
- Subtract 2 from innerWidth to account for ToolMessage paddingX={1}
- Update snapshots to reflect removed borders

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): address review feedback on collapse and visual alignment

- Gate isDim on compact mode so non-compact tools stay fully styled
- Add paddingX={1} to CompactToolGroupDisplay for left-edge alignment
- Delete Border Color Logic test block (borders removed)
- Add compact-mode test coverage for Error/Executing/Pending/forceShowResult
- Clean up stale border references in comments

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
ChiGao 2026-06-22 18:05:52 +08:00 committed by GitHub
parent dd17cedef5
commit e2db547a93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 83 additions and 192 deletions

View file

@ -9,7 +9,6 @@ import { Box, Text } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import type { AnsiOutputDisplay } from '@qwen-code/qwen-code-core';
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import { localizeToolDisplayName, t } from '../../../i18n/index.js';
import { ToolStatusIndicator } from '../shared/ToolStatusIndicator.js';
@ -129,19 +128,6 @@ export const CompactToolGroupDisplay: React.FC<
const overallStatus = getOverallStatus(toolCalls);
const activeTool = getActiveTool(toolCalls);
const isShellCommand = toolCalls.some(
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME,
);
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const borderColor = isShellCommand
? theme.ui.symbol
: hasPending
? theme.status.warning
: theme.border.default;
// Take only the first line of description to prevent multi-line shell scripts
// from expanding the compact view (wrap="truncate-end" only handles width overflow,
// not literal \n characters in the content)
@ -150,14 +136,7 @@ export const CompactToolGroupDisplay: React.FC<
: '';
return (
<Box
flexDirection="column"
borderStyle="round"
width={contentWidth}
borderDimColor={hasPending}
borderColor={borderColor}
gap={0}
>
<Box flexDirection="column" width={contentWidth} paddingX={1} gap={0}>
{/* Status line: icon + (summary | tool name + description) + count + elapsed */}
<Box flexDirection="row">
<ToolStatusIndicator status={overallStatus} name={activeTool.name} />

View file

@ -264,13 +264,7 @@ export const InlineParallelAgentsDisplay: React.FC<
const headerLabel = `Parallel agents · ${total} · ${doneCount}/${total} done`;
return (
<Box
flexDirection="column"
borderStyle="round"
width={contentWidth}
borderColor={hasLiveAgent ? theme.status.warning : theme.border.default}
paddingX={1}
>
<Box flexDirection="column" width={contentWidth} paddingX={1}>
<Box>
<Text bold color={theme.text.accent}>
{headerLabel}

View file

@ -196,7 +196,7 @@ describe('<ToolGroupMessage />', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('renders shell command with yellow border', () => {
it('renders shell command', () => {
const toolCalls = [
createToolCall({
callId: 'shell-1',
@ -503,45 +503,6 @@ describe('<ToolGroupMessage />', () => {
});
});
describe('Border Color Logic', () => {
it('uses yellow border when tools are pending', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
// The snapshot will capture the visual appearance including border color
expect(lastFrame()).toMatchSnapshot();
});
it('uses yellow border for shell commands even when successful', () => {
const toolCalls = [
createToolCall({
name: 'run_shell_command',
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('uses gray border when all tools are successful and no shell commands', () => {
const toolCalls = [
createToolCall({ status: ToolCallStatus.Success }),
createToolCall({
callId: 'tool-2',
name: 'another-tool',
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Height Calculation', () => {
it('calculates available height correctly with multiple tools with results', () => {
const toolCalls = [

View file

@ -13,8 +13,6 @@ import { ToolMessage } from './ToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { CompactToolGroupDisplay } from './CompactToolGroupDisplay.js';
import { InlineParallelAgentsDisplay } from './InlineParallelAgentsDisplay.js';
import { theme } from '../../semantic-colors.js';
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { useCompactMode } from '../../contexts/CompactModeContext.js';
import type { AgentResultDisplay } from '@qwen-code/qwen-code-core';
@ -156,7 +154,7 @@ interface ToolGroupMessageProps {
compactLabel?: string;
}
// Main component renders the border and maps the tools using ToolMessage
// Main component maps the tools using ToolMessage
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls,
availableTerminalHeight,
@ -292,15 +290,15 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
// Hide the entire group when the live-phase filter leaves nothing
// inline to render — i.e. a pure-running-subagent batch with no
// pending approval. LiveAgentPanel below the composer is the
// single source of truth for those rows; an empty bordered
// container floating above the panel would just be a duplicate
// chrome line. Terminal subagents (completed / failed / cancelled)
// single source of truth for those rows; an empty
// container floating above the panel would just be noise.
// Terminal subagents (completed / failed / cancelled)
// pass through `inlineToolCalls` because `unregisterForeground`'s
// post-delete emit already dropped them from the panel snapshot,
// and the inline path must render `SubagentScrollbackSummary`
// immediately so the user keeps a record of the run.
// (Gate on `isPending` so a degenerate empty `toolCalls=[]` in the
// committed phase still falls through to the legacy empty-border
// committed phase still falls through to the legacy empty-container
// snapshot — the suppression is specifically about live-phase
// panel ownership, not about hiding empty inputs in general.)
if (isPending && inlineToolCalls.length === 0) {
@ -345,22 +343,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
}
// Full expanded view
const hasPending = !inlineToolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const isShellCommand = inlineToolCalls.some(
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME,
);
const borderColor =
isShellCommand || isEmbeddedShellFocused
? theme.ui.symbol
: hasPending
? theme.status.warning
: theme.border.default;
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// account for border (2 chars) and padding (2 chars)
const innerWidth = contentWidth - 4;
const staticHeight = /* marginBottom */ 1;
const innerWidth = contentWidth - 2;
let countToolCallsWithResults = 0;
for (const tool of inlineToolCalls) {
@ -385,12 +369,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const readCount = memoryReadCount ?? 0;
const writeCount = memoryWriteCount ?? 0;
return (
<Box
flexDirection="column"
borderStyle="round"
width={contentWidth}
borderColor={theme.border.default}
>
<Box flexDirection="column" width={contentWidth}>
{readCount > 0 && (
<Box paddingLeft={1}>
<Text dimColor>
@ -412,22 +391,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
}
return (
<Box
flexDirection="column"
borderStyle="round"
/*
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
width={contentWidth}
borderDimColor={
hasPending && (!isShellCommand || !isEmbeddedShellFocused)
}
borderColor={borderColor}
gap={0}
>
<Box flexDirection="column" width={contentWidth} gap={0}>
{/* Memory badge for mixed groups (some memory ops + other ops) */}
{!isMemoryOnlyGroup &&
((memoryWriteCount ?? 0) > 0 || (memoryReadCount ?? 0) > 0) &&

View file

@ -190,6 +190,42 @@ describe('<ToolMessage />', () => {
expect(output).not.toContain('MockMarkdown:Test result'); // result hidden
});
it('shows result for Error status in compact mode', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Error} />,
StreamingState.Idle,
true,
);
expect(lastFrame()).toContain('MockMarkdown:Test result');
});
it('shows result for Executing status in compact mode', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
StreamingState.Idle,
true,
);
expect(lastFrame()).toContain('MockMarkdown:Test result');
});
it('shows result for Pending status in compact mode', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Pending} />,
StreamingState.Idle,
true,
);
expect(lastFrame()).toContain('MockMarkdown:Test result');
});
it('shows result when forceShowResult overrides compact collapse', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} forceShowResult />,
StreamingState.Idle,
true,
);
expect(lastFrame()).toContain('MockMarkdown:Test result');
});
describe('ToolStatusIndicator rendering', () => {
it('shows ✓ for Success status', () => {
const { lastFrame } = renderWithContext(

View file

@ -670,10 +670,12 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
// Use the custom hook to determine the display type
const displayRenderer = useResultDisplayRenderer(resultDisplay);
const { compactMode } = useCompactMode();
const effectiveDisplayRenderer =
!compactMode || forceShowResult
? displayRenderer
: { type: 'none' as const };
const isCompleted = status === ToolCallStatus.Success;
const shouldCollapse = compactMode && isCompleted && !forceShowResult;
const effectiveDisplayRenderer = shouldCollapse
? { type: 'none' as const }
: displayRenderer;
return (
<Box paddingX={1} paddingY={0} flexDirection="column">
@ -780,6 +782,7 @@ const ToolInfo: React.FC<ToolInfo> = ({
status,
emphasis,
}) => {
const { compactMode } = useCompactMode();
const nameColor = React.useMemo<string>(() => {
switch (emphasis) {
case 'high':
@ -794,13 +797,15 @@ const ToolInfo: React.FC<ToolInfo> = ({
}
}
}, [emphasis]);
const isDim = compactMode && status === ToolCallStatus.Success;
return (
<Box flexGrow={1}>
<Text
wrap="truncate-end"
strikethrough={status === ToolCallStatus.Canceled}
dimColor={isDim}
>
<Text color={nameColor} bold>
<Text color={nameColor} bold={!isDim}>
{localizeToolDisplayName(name)}
</Text>{' '}
<Text color={theme.text.secondary}>{description}</Text>

View file

@ -1,99 +1,51 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
│MockTool[tool-2]: ✓ another-tool - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ run_shell_command - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when tools are pending 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: o test-tool - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ? first-confirm - A tool for testing (high) │
│MockConfirmation: Confirm first tool │
│MockTool[tool-2]: ? second-confirm - A tool for testing (low) │
╰──────────────────────────────────────────────────────────────────────────────╯"
"MockTool[tool-1]: ? first-confirm - A tool for testing (high)
MockConfirmation: Confirm first tool
MockTool[tool-2]: ? second-confirm - A tool for testing (low)"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ read_file - Read a file (medium) │
│MockTool[tool-2]: ⊷ run_shell_command - Run command (medium) │
│MockTool[tool-3]: o write_file - Write to file (medium) │
╰──────────────────────────────────────────────────────────────────────────────╯"
"MockTool[tool-1]: ✓ read_file - Read a file (medium)
MockTool[tool-2]: ⊷ run_shell_command - Run command (medium)
MockTool[tool-3]: o write_file - Write to file (medium)"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium) │
│MockTool[tool-2]: o pending-tool - This tool is pending (medium) │
│MockTool[tool-3]: x error-tool - This tool failed (medium) │
╰──────────────────────────────────────────────────────────────────────────────╯"
"MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium)
MockTool[tool-2]: o pending-tool - This tool is pending (medium)
MockTool[tool-3]: x error-tool - This tool failed (medium)"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command with yellow border 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium) │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command 1`] = `"MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium)"`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `"MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)"`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting confirmation 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation │
│(high) │
│MockConfirmation: Are you sure you want to proceed? │
╰──────────────────────────────────────────────────────────────────────────────╯"
"MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation
(high)
MockConfirmation: Are you sure you want to proceed?"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = `"MockTool[tool-123]: ✓ test-tool - A tool for testing (medium)"`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium) │
│MockTool[tool-2]: ✓ another-tool - Another tool (medium) │
╰──────────────────────────────────────────────────────────────────────────────╯"
"MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium)
MockTool[tool-2]: ✓ another-tool - Another tool (medium)"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
"╭──────────────────────────────────────╮
│MockTool[tool-123]: ✓ │
│very-long-tool-name-that-might-wrap - │
│This is a very long description that │
│might cause wrapping issues (medium) │
╰──────────────────────────────────────╯"
"MockTool[tool-123]: ✓
very-long-tool-name-that-might-wrap -
This is a very long description that
might cause wrapping issues (medium)"
`;
exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│MockTool[tool-1]: ✓ test-tool - A tool for testing (medium) │
│MockTool[tool-2]: ✓ test-tool - A tool for testing (medium) │
│MockTool[tool-3]: ✓ test-tool - A tool for testing (medium) │
╰──────────────────────────────────────────────────────────────────────────────╯"
"MockTool[tool-1]: ✓ test-tool - A tool for testing (medium)
MockTool[tool-2]: ✓ test-tool - A tool for testing (medium)
MockTool[tool-3]: ✓ test-tool - A tool for testing (medium)"
`;