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:
Shaojin Wen 2026-04-20 16:04:58 +08:00 committed by GitHub
parent 33d0b4af00
commit 5fedf10419
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 420 additions and 29 deletions

View file

@ -12,6 +12,7 @@ import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import { t } from '../../../i18n/index.js';
import { ToolStatusIndicator } from '../shared/ToolStatusIndicator.js';
import { ToolElapsedTime } from '../shared/ToolElapsedTime.js';
interface CompactToolGroupDisplayProps {
toolCalls: IndividualToolCallDisplay[];
@ -83,7 +84,7 @@ export const CompactToolGroupDisplay: React.FC<
borderColor={borderColor}
gap={0}
>
{/* Status line: icon + tool name + count + description */}
{/* Status line: icon + tool name + count + description + elapsed */}
<Box flexDirection="row">
<ToolStatusIndicator status={overallStatus} name={activeTool.name} />
<Box flexGrow={1}>
@ -103,6 +104,10 @@ export const CompactToolGroupDisplay: React.FC<
) : null}
</Text>
</Box>
<ToolElapsedTime
status={overallStatus}
executionStartTime={activeTool.executionStartTime}
/>
</Box>
{/* Hint line */}