mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 12:11:09 +00:00
feat(cli): add tool execution progress messages (#3155)
* feat(cli): add tool execution progress messages with per-tool elapsed time, shell stats, and terminal progress bar
- Show per-tool elapsed time (Ns) next to spinner after 3 seconds of execution,
covering all tools (not just shell), by piping existing core startTime through
to the UI layer via IndividualToolCallDisplay.executionStartTime
- Add shell output statistics bar below ANSI output showing +N lines overflow
count, byte size, and explicit timeout when set by user
- Add terminal tab progress bar via OSC 9;4 sequences for iTerm2, Ghostty, and
ConEmu, with tmux/screen DCS passthrough support
- Extend AnsiOutputDisplay with optional totalLines/totalBytes/timeoutMs fields
- Add ShellStatsBar component for rendering shell output statistics
* fix(cli): address review feedback — use formatDuration for timeout, pass displayHeight to ShellStatsBar
- Use existing formatDuration() from formatters.ts instead of inline
timeout formatting for correct precision (e.g., "2m 3s" not "2m")
- Add displayHeight prop to ShellStatsBar so +N lines overflow
calculation respects actual terminal height, not hardcoded DEFAULT_HEIGHT
* fix(cli): guard terminal progress bar against non-TTY stdout
Check process.stdout.isTTY in isProgressBarSupported() so escape sequences
are not emitted when stdout is piped, redirected to log files, or running
in CI environments where TERM_PROGRAM may be set but stdout is not a TTY.
Also add defensive isProgressBarSupported() guard in the effect cleanup.
* fix(cli): format tool elapsed time with minutes/hours for long-running tools
Previously showed raw seconds (e.g. "3600s") for long-running tools.
Now formats as "3s" for under a minute, "1m 30s" for minutes, and
"2h 15m" for hours, while keeping compact integer seconds for short
durations.
* fix(cli): audit fixes for terminal progress and shell output stats
Three issues found by post-merge audit:
- useTerminalProgress: WT_SESSION was wrongly used to exclude Windows
Terminal. WT 1.6+ actually supports OSC 9;4 progress sequences (per
Microsoft docs), so treat it as a positive indicator like iTerm2 and
Ghostty.
- useTerminalProgress: add process.on('exit'|'SIGINT'|'SIGTERM') handler
that writes PROGRESS_CLEAR. Without it, killing the CLI mid-tool (Ctrl+C,
SIGTERM) left the terminal tab stuck showing an indeterminate progress
indicator because React cleanup never ran. Mirrors the useBracketedPaste
cleanup pattern.
- shell.ts: ANSI totalBytes used token.text.length (character count),
inconsistent with the string path's Buffer.byteLength(..., 'utf-8').
Multi-byte chars (CJK, emoji) now count as their true UTF-8 byte length
in both paths.
* refactor(cli): right-align tool elapsed time, extract to its own component
Move the executing-tool elapsed-seconds indicator out of
ToolStatusIndicator (where it sat immediately after the spinner on the
left edge) and into a new right-aligned ToolElapsedTime component.
The left placement caused layout jitter: every second the elapsed text
width would change (e.g. "9s" → "10s" → "1m" → "1m 15s"), shifting the
tool name and description horizontally. Right-aligning the elapsed keeps
the tool name anchored and only the far-right timer moves.
- New packages/cli/src/ui/components/shared/ToolElapsedTime.tsx owns the
setInterval + formatElapsed logic.
- ToolStatusIndicator is now pure status again; the executionStartTime
prop is gone from it.
- ToolMessage and CompactToolGroupDisplay mount ToolElapsedTime as the
last flex child of the status row, with marginLeft=1.
- ToolInfo gains flexGrow=1 so the description fills the middle and the
timer sits flush at the right edge of the row.
* fix(core): measure tool elapsed from executing-transition, not validating-entry
trackedCall.startTime is stamped when a tool is first registered with the
scheduler (validating state), then preserved through awaiting_approval,
scheduled, and executing transitions. Using it for the executing-row
elapsed display meant any approval-wait time was counted as execution
time — a tool that waited 30s for user approval would flash "30s"
immediately when it actually began running.
Add a separate executionStartTime on ExecutingToolCall, stamped at the
moment of the transition into 'executing', and pipe that through
useReactToolScheduler into IndividualToolCallDisplay.executionStartTime.
startTime is kept as-is for durationMs bookkeeping.
Also stops piping executionStartTime for validating/scheduled states,
since those don't have a meaningful execution duration yet.
* fix(cli): only hook 'exit' for terminal progress cleanup, not SIGINT/SIGTERM
Registering SIGINT/SIGTERM handlers that neither re-raise nor exit
inhibits Node's default termination behavior. If this hook were ever the
only signal handler in play, Ctrl+C would leave the process hanging.
Drop the signal handlers and rely on 'exit' alone. Other parts of the
CLI already own the signal-to-shutdown path (gemini.tsx, telemetry
shutdown, sharedTokenManager, etc.) and ultimately call process.exit(),
which fires 'exit' and runs this cleanup. SIGKILL cannot be cleaned up
either way.
* fix(cli): thread executionStartTime through agent-view tool groups
The main TUI renders per-tool elapsed time via IndividualToolCallDisplay.
executionStartTime, but the agent-view adapter
(agentHistoryAdapter.ts) constructed its display items without this
field, so sub-agent tool groups never showed the elapsed indicator.
Thread it through the sub-agent event pipeline:
- AgentToolOutputUpdateEvent gains an optional executionStartTime,
emitted once per callId by agent-core.onToolCallsUpdate the first time
a call is seen in the scheduler's 'executing' state (carrying
ExecutingToolCall.executionStartTime). This also fires for tools that
produce no live output, so their elapsed indicator appears too.
- AgentInteractive tracks executionStartTimes in a callId→timestamp map,
analogous to liveOutputs/shellPids. First TOOL_OUTPUT_UPDATE with a
value wins; later events that re-carry it are ignored. Cleared on
TOOL_RESULT.
- AgentChatView passes the map as the new fifth argument to
agentMessagesToHistoryItems.
- The adapter reads the map for Executing tools and sets
IndividualToolCallDisplay.executionStartTime, matching the main-view
plumbing. Agent-view tool_groups now render the same elapsed-time
indicator the main view does.
Adds three test cases covering set-when-executing, skip-when-completed,
and skip-when-map-absent.
* fix(core): skip stats accounting for string shell chunks
totalLines/totalBytes are only emitted alongside AnsiOutputDisplay in
the ANSI-array branch of updateOutput. Computing split('\n') and
Buffer.byteLength for string chunks was wasted work — the values never
left the function.
Only compute stats when event.chunk is an AnsiLine[] now.
This commit is contained in:
parent
33d0b4af00
commit
5fedf10419
19 changed files with 420 additions and 29 deletions
|
|
@ -10,7 +10,8 @@ import type { IndividualToolCallDisplay } from '../../types.js';
|
|||
import { ToolCallStatus } from '../../types.js';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { AnsiOutputText } from '../AnsiOutput.js';
|
||||
import { AnsiOutputText, ShellStatsBar } from '../AnsiOutput.js';
|
||||
import type { ShellStatsBarProps } from '../AnsiOutput.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { TodoDisplay } from '../TodoDisplay.js';
|
||||
import type {
|
||||
|
|
@ -18,6 +19,7 @@ import type {
|
|||
AgentResultDisplay,
|
||||
PlanResultDisplay,
|
||||
AnsiOutput,
|
||||
AnsiOutputDisplay,
|
||||
Config,
|
||||
McpToolProgressData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -34,6 +36,7 @@ import {
|
|||
ToolStatusIndicator,
|
||||
STATUS_INDICATOR_WIDTH,
|
||||
} from '../shared/ToolStatusIndicator.js';
|
||||
import { ToolElapsedTime } from '../shared/ToolElapsedTime.js';
|
||||
|
||||
const STATIC_HEIGHT = 1;
|
||||
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
|
||||
|
|
@ -51,7 +54,7 @@ type DisplayRendererResult =
|
|||
| { type: 'string'; data: string }
|
||||
| { type: 'diff'; data: { fileDiff: string; fileName: string } }
|
||||
| { type: 'task'; data: AgentResultDisplay }
|
||||
| { type: 'ansi'; data: AnsiOutput };
|
||||
| { type: 'ansi'; data: AnsiOutput; stats?: ShellStatsBarProps };
|
||||
|
||||
/**
|
||||
* Custom hook to determine the type of result display and return appropriate rendering info
|
||||
|
|
@ -136,7 +139,16 @@ const useResultDisplayRenderer = (
|
|||
resultDisplay !== null &&
|
||||
'ansiOutput' in resultDisplay
|
||||
) {
|
||||
return { type: 'ansi', data: resultDisplay.ansiOutput as AnsiOutput };
|
||||
const display = resultDisplay as AnsiOutputDisplay;
|
||||
return {
|
||||
type: 'ansi',
|
||||
data: display.ansiOutput,
|
||||
stats: {
|
||||
totalLines: display.totalLines,
|
||||
totalBytes: display.totalBytes,
|
||||
timeoutMs: display.timeoutMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Default to string
|
||||
|
|
@ -282,6 +294,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
forceShowResult,
|
||||
isFocused,
|
||||
isWaitingForOtherApproval,
|
||||
executionStartTime,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isThisShellFocused =
|
||||
|
|
@ -366,6 +379,10 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<ToolElapsedTime
|
||||
status={status}
|
||||
executionStartTime={executionStartTime}
|
||||
/>
|
||||
{emphasis === 'high' && <TrailingIndicator />}
|
||||
</Box>
|
||||
{effectiveDisplayRenderer.type !== 'none' && (
|
||||
|
|
@ -400,11 +417,19 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
/>
|
||||
)}
|
||||
{effectiveDisplayRenderer.type === 'ansi' && (
|
||||
<AnsiOutputText
|
||||
data={effectiveDisplayRenderer.data}
|
||||
availableTerminalHeight={availableHeight}
|
||||
maxWidth={innerWidth}
|
||||
/>
|
||||
<>
|
||||
<AnsiOutputText
|
||||
data={effectiveDisplayRenderer.data}
|
||||
availableTerminalHeight={availableHeight}
|
||||
maxWidth={innerWidth}
|
||||
/>
|
||||
{effectiveDisplayRenderer.stats && (
|
||||
<ShellStatsBar
|
||||
{...effectiveDisplayRenderer.stats}
|
||||
displayHeight={availableHeight}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{effectiveDisplayRenderer.type === 'string' && (
|
||||
<StringResultRenderer
|
||||
|
|
@ -456,7 +481,7 @@ const ToolInfo: React.FC<ToolInfo> = ({
|
|||
}
|
||||
}, [emphasis]);
|
||||
return (
|
||||
<Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text
|
||||
wrap="truncate-end"
|
||||
strikethrough={status === ToolCallStatus.Canceled}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue