Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,7 @@ export async function loadCliConfig(
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
cronEnabled: settings.experimental?.cron ?? false,
emitToolUseSummaries: settings.experimental?.emitToolUseSummaries ?? true,
listExtensions: argv.listExtensions || false,
overrideExtensions: overrideExtensions || argv.extensions,
noBrowser: !!process.env['NO_BROWSER'],
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1900,6 +1900,16 @@ const SETTINGS_SCHEMA = {
'Enable in-session cron/loop tools (experimental). When enabled, the model can create recurring prompts using cron_create, cron_list, and cron_delete tools. Can also be enabled via QWEN_CODE_ENABLE_CRON=1 environment variable.',
showInDialog: true,
},
emitToolUseSummaries: {
type: 'boolean',
label: 'Tool Use Summaries',
category: 'Experimental',
requiresRestart: false,
default: true,
Comment thread
wenshao marked this conversation as resolved.
Resolved
description:
'Generate a short LLM-based label after each tool batch completes. The label folds N parallel tool calls into a single row in compact mode and is also emitted to SDK clients as a `tool_use_summary` message. Requires a fast model to be configured; runs in parallel with the next API call so latency is hidden. Can be overridden with QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0 or =1.',
Comment thread
wenshao marked this conversation as resolved.
Resolved
showInDialog: true,
},
},
},
} as const satisfies SettingsSchema;
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,4 +284,23 @@ describe('<HistoryItemDisplay />', () => {

expect(lastFrame()).toMatchSnapshot();
});

it('renders tool_use_summary as a dim badge line in full mode', () => {
const item: HistoryItem = {
id: 1,
type: 'tool_use_summary',
summary: 'Read txt files',
precedingToolUseIds: ['c1', 'c2', 'c3', 'c4'],
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
{...baseItem}
item={item}
isPending={false}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain('Read txt files');
expect(lastFrame()).toContain('●');
});
});
24 changes: 24 additions & 0 deletions packages/cli/src/ui/components/HistoryItemDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ interface HistoryItemDisplayProps {
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
availableTerminalHeightGemini?: number;
/**
* When the item is a `tool_group`, an optional short LLM-generated label
* summarizing the batch. Replaces the generic "Tool × N" line in compact
* mode. Computed by the parent from `tool_use_summary` history items.
*/
compactLabel?: string;
}

const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
Expand All @@ -77,6 +83,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
activeShellPtyId,
embeddedShellFocused,
availableTerminalHeightGemini,
compactLabel,
}) => {
const marginTop =
item.type === 'gemini_content' || item.type === 'gemini_thought_content'
Expand Down Expand Up @@ -198,8 +205,25 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
memoryWriteCount={itemForDisplay.memoryWriteCount}
memoryReadCount={itemForDisplay.memoryReadCount}
isUserInitiated={itemForDisplay.isUserInitiated}
compactLabel={compactLabel}
/>
)}
{/*
`tool_use_summary` as a standalone inline item. This renders in full
mode so the summary is always visible (the label arrives via the
fast-model call *after* the tool_group has been committed to the
append-only <Static> above, so we cannot update the tool_group's
header retroactively). Compact mode already folds the label into
the merged tool_group header, so the standalone line would be
redundant — `mergeCompactToolGroups` drops these items between
merge candidates, and the explicit compactMode guard below hides
any remaining trailing summary item.
*/}
{!compactMode && itemForDisplay.type === 'tool_use_summary' && (
Comment thread
wenshao marked this conversation as resolved.
Resolved
<Box paddingLeft={1}>
<Text dimColor>● {itemForDisplay.summary}</Text>
</Box>
)}
{itemForDisplay.type === 'compression' && (
<CompressionMessage compression={itemForDisplay.compression} />
)}
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/src/ui/components/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { Box, Static } from 'ink';
import { useEffect, useMemo, useRef } from 'react';
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { Notifications } from './Notifications.js';
Expand Down Expand Up @@ -55,6 +56,42 @@ export const MainContent = () => {
],
);

// Build a callId → summary lookup from `tool_use_summary` history items so
// compact-mode tool groups can render a semantic label instead of a generic
// "Tool × N" line. A summary is indexed under every callId it covers; when
// multiple groups are merged, the first group's summary wins (see below).
const summaryByCallId = useMemo(() => {
const map = new Map<string, string>();
for (const item of uiState.history) {
if (item.type === 'tool_use_summary') {
for (const callId of item.precedingToolUseIds) {
// First summary wins — earlier summaries represent the opening
// intent of a batch streak, later ones would override it otherwise.
if (!map.has(callId)) {
map.set(callId, item.summary);
}
}
}
}
return map;
}, [uiState.history]);

const getCompactLabel = useMemo(
() =>
(item: HistoryItem | HistoryItemWithoutId): string | undefined => {
if (item.type !== 'tool_group' || item.tools.length === 0)
return undefined;
// Match on the first tool's callId — for a merged group this is the
// earliest call, so we pick up the summary for the leading intent.
for (const tool of item.tools) {
const label = summaryByCallId.get(tool.callId);
if (label) return label;
}
Comment thread
wenshao marked this conversation as resolved.
Resolved
return undefined;
},
[summaryByCallId],
);

// Ink's <Static> is append-only: once an item is rendered to the terminal
// buffer, it cannot be replaced. In compact mode, when a new tool_group is
// merged into a previous one, the merged result has FEWER items than the
Expand Down Expand Up @@ -102,6 +139,7 @@ export const MainContent = () => {
item={h}
isPending={false}
commands={uiState.slashCommands}
compactLabel={getCompactLabel(h)}
/>
)),
]}
Expand All @@ -123,6 +161,7 @@ export const MainContent = () => {
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
compactLabel={getCompactLabel(item)}
/>
))}
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,22 @@ function shellTool(
};
}

describe('<CompactToolGroupDisplay />', () => {
function toolCall(
overrides: Partial<IndividualToolCallDisplay> = {},
): IndividualToolCallDisplay {
return {
callId: 'call-1',
name: 'read_file',
description: 'Read a.ts',
resultDisplay: 'file contents',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
renderOutputAsMarkdown: false,
...overrides,
};
}

describe('<CompactToolGroupDisplay /> — shell timeout plumbing', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
Expand Down Expand Up @@ -91,3 +106,76 @@ describe('<CompactToolGroupDisplay />', () => {
expect(lastFrame()).not.toContain('timeout');
});
});

describe('<CompactToolGroupDisplay /> — summary label', () => {
it('renders default header (active tool name + count) when no compactLabel is provided', () => {
const tools = [
toolCall({ callId: 'c1', name: 'read_file' }),
toolCall({ callId: 'c2', name: 'read_file' }),
toolCall({ callId: 'c3', name: 'grep' }),
];
const { lastFrame } = render(
<CompactToolGroupDisplay toolCalls={tools} contentWidth={80} />,
);
const frame = lastFrame()!;
// Active tool = last in array when none are executing/confirming.
expect(frame).toContain('grep');
expect(frame).toContain('× 3');
});

it('replaces header with compactLabel when provided', () => {
const tools = [
toolCall({ callId: 'c1', name: 'read_file' }),
toolCall({ callId: 'c2', name: 'grep' }),
];
const { lastFrame } = render(
<CompactToolGroupDisplay
toolCalls={tools}
contentWidth={80}
compactLabel="Searched in auth/"
/>,
);
const frame = lastFrame()!;
expect(frame).toContain('Searched in auth/');
expect(frame).toContain('2 tools');
// The raw tool name should not appear as the primary header when a
// summary is shown.
expect(frame).not.toContain('read_file × 2');
});

it('shows tool count suffix only when multiple tools are present', () => {
const tools = [toolCall({ callId: 'c1', name: 'read_file' })];
const { lastFrame } = render(
<CompactToolGroupDisplay
toolCalls={tools}
contentWidth={80}
compactLabel="Read config.json"
/>,
);
const frame = lastFrame()!;
expect(frame).toContain('Read config.json');
expect(frame).not.toContain('tools');
});

it('renders nothing for empty tool calls', () => {
const { lastFrame } = render(
<CompactToolGroupDisplay toolCalls={[]} contentWidth={80} />,
);
expect(lastFrame()).toBe('');
});

it('preserves default rendering for shell commands without label', () => {
const tools = [
toolCall({
callId: 'c1',
name: 'Bash',
description: 'ls -la',
}),
];
const { lastFrame } = render(
<CompactToolGroupDisplay toolCalls={tools} contentWidth={80} />,
);
expect(lastFrame()).toContain('Bash');
expect(lastFrame()).toContain('ls -la');
});
});
56 changes: 41 additions & 15 deletions packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ import { ToolElapsedTime } from '../shared/ToolElapsedTime.js';
interface CompactToolGroupDisplayProps {
toolCalls: IndividualToolCallDisplay[];
contentWidth: number;
/**
* Optional LLM-generated label (~30 chars, git-commit-subject style) that
* replaces the "active tool name + count + description" header when
* present. Falls back to the default rendering while the label is still
* being generated or if generation was skipped/failed.
*/
compactLabel?: string;
}

// Priority: Confirming > Executing > Error > Canceled > Pending > Success
Expand Down Expand Up @@ -68,7 +75,7 @@ function getShellTimeoutMs(

export const CompactToolGroupDisplay: React.FC<
CompactToolGroupDisplayProps
> = ({ toolCalls, contentWidth }) => {
> = ({ toolCalls, contentWidth, compactLabel }) => {
if (toolCalls.length === 0) return null;

const overallStatus = getOverallStatus(toolCalls);
Expand All @@ -94,6 +101,11 @@ export const CompactToolGroupDisplay: React.FC<
? activeTool.description.split('\n')[0]
: '';

// When a generated summary is available, show it in place of the active
// tool name + description. The tool count is kept so users still see how
// many calls were folded.
const hasSummary = !!compactLabel;

return (
<Box
flexDirection="column"
Expand All @@ -103,24 +115,38 @@ export const CompactToolGroupDisplay: React.FC<
borderColor={borderColor}
gap={0}
>
{/* Status line: icon + tool name + count + description + elapsed */}
{/* Status line: icon + (summary | tool name + description) + count + elapsed */}
<Box flexDirection="row">
<ToolStatusIndicator status={overallStatus} name={activeTool.name} />
<Box flexGrow={1}>
<Text wrap="truncate-end">
<Text bold>{activeTool.name}</Text>
{toolCalls.length > 1 ? (
<Text color={theme.text.secondary}>
{' × '}
{toolCalls.length}
</Text>
) : null}
{activeToolDescription ? (
<Text color={theme.text.secondary}>
{' '}
{activeToolDescription}
</Text>
) : null}
{hasSummary ? (
Comment thread
wenshao marked this conversation as resolved.
Resolved
<>
<Text bold>{compactLabel}</Text>
{toolCalls.length > 1 ? (
<Text color={theme.text.secondary}>
{' · '}
{toolCalls.length} tools
</Text>
) : null}
</>
) : (
<>
<Text bold>{activeTool.name}</Text>
{toolCalls.length > 1 ? (
<Text color={theme.text.secondary}>
{' × '}
{toolCalls.length}
</Text>
) : null}
{activeToolDescription ? (
<Text color={theme.text.secondary}>
{' '}
{activeToolDescription}
</Text>
) : null}
</>
)}
</Text>
</Box>
<ToolElapsedTime
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ interface ToolGroupMessageProps {
/** Pre-computed count of read ops from managed-auto-memory files. */
memoryReadCount?: number;
isUserInitiated?: boolean;
/**
* Short LLM-generated label for this batch. Used in compact mode in place
* of the "active tool name × count" line. Undefined when summary
* generation is disabled, still in-flight, or failed.
*/
compactLabel?: string;
}

// Main component renders the border and maps the tools using ToolMessage
Expand All @@ -57,6 +63,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
memoryWriteCount,
memoryReadCount,
isUserInitiated,
compactLabel,
}) => {
const config = useConfig();
const { compactMode } = useCompactMode();
Expand Down Expand Up @@ -139,6 +146,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
<CompactToolGroupDisplay
toolCalls={toolCalls}
contentWidth={contentWidth}
compactLabel={compactLabel}
/>
);
}
Expand Down
Loading
Loading