Skip to content

feat(tui): show elapsed time on tool parts - #50892

Closed
BoYanZh wants to merge 17 commits into
anomalyco:devfrom
BoYanZh:feat/tui-tool-elapsed-time
Closed

BoYanZh wants to merge 17 commits into
anomalyco:devfrom
BoYanZh:feat/tui-tool-elapsed-time

Conversation

@BoYanZh

@BoYanZh BoYanZh commented Sep 23, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #50891

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Tool calls show no timing anywhere: the TUI renders them as static one-liners with a spinner while running, and the Web UI only shows per-message turn duration. Since parts already carry time.start/time.end, this wires that data into both frontends:

  • TUI (packages/tui): new useToolElapsed hook + toolElapsedText formatter. InlineTool rows and BlockTool titles gain a muted · Xs badge that ticks once per second while the part runs and freezes on its total once finished. Nothing renders while timestamps are unknown (pending parts), so idle output is unchanged.
  • Web UI (packages/session-ui): BasicTool trigger rows gain the same badge via new optional startedAt/endedAt props, threaded once through the Dynamic call site (per-tool renderers pick it up via prop spread, GenericTool forwards explicitly). Ticks while pending, frozen when done, formatted with the existing ui.message.duration.* strings — no new i18n keys. Shell rows render it inline as Shell 11s <cmd> via a shared ToolElapsed component (function triggers bypass the default title branch, so shell places its own badge and suppresses the generic one); the turn Thinking indicator carries the same live badge timed from the user message. Follow-ups: shell rows read Shell 11s <cmd> (badge moved inside the shell trigger; a hideElapsedBadge escape hatch suppresses the generic slot), and the Thinking row now stays mounted after the turn with a frozen total instead of vanishing with the first visible output (shimmer stops when idle; errors/retries still suppress it). Correction from live testing: the main web session view builds its own timeline (packages/app/.../timeline/rows.ts), not session-ui SessionTurn, so Thinking persistence and Exploring-style group header/item timing are implemented there (TimelineRow.Thinking gains startedAt/endedAt/running; group time range = earliest start to latest end, ticking until all parts finish).

How did you verify your code works?

  • New unit tests: tool-elapsed.test.ts (formatter edge cases) in both packages, plus tool-elapsed-hook.test.ts covering the TUI hook's pending/running/completed/error states via createRoot — all pass.
  • Headless render tests (inline-tool-elapsed.test.tsx, testRender + captureCharFrame): badge shows on completed/spinner/pending rows, absent without elapsed, and — driving the real hook — two frames 2.2s apart show different tick values, proving live updates through the production row component.
  • Full session-ui suite: 89/89. Full tui suite: only failure is a pre-existing Windows home-path test that fails identically on the clean tree.
  • Live backend data contract: real sleep 5 / sleep 25 turns return tool parts with time: {start, end} spanning 5.4s / 25.4s — the exact fields the badge reads, matching wall clock.
  • tsgo --noEmit clean in both packages; oxlint shows no new warnings on touched files.

Screenshots / recordings

Web UI verified with headless-Chrome storybook screenshots: running tool shows a ticking badge (Working... 19s, advancing between captures), completed tool shows the frozen total (Done 3s). TUI screenshots pending an unlocked desktop; the headless render tests above cover the same rows.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions

Copy link
Copy Markdown
Contributor

Hey! Your PR title feat(tui,session-ui): show elapsed time on tool parts doesn't follow conventional commit format.

Please update it to start with one of:

  • feat: or feat(scope): new feature
  • fix: or fix(scope): bug fix
  • docs: or docs(scope): documentation changes
  • chore: or chore(scope): maintenance tasks
  • refactor: or refactor(scope): code refactoring
  • test: or test(scope): adding or updating tests

Where scope is the package name (e.g., app, desktop, opencode).

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@BoYanZh BoYanZh changed the title feat(tui,session-ui): show elapsed time on tool parts feat(tui): show elapsed time on tool parts Sep 23, 2026
InlineTool rows and BlockTool titles now carry a muted elapsed badge

that ticks once per second while the part runs and freezes on its

total once finished, reusing Locale.duration. Pure formatter covered

by unit tests.
BasicTool trigger rows gain a muted elapsed badge driven by new

optional startedAt/endedAt props, threaded once through the Dynamic

call site; per-tool renderers pick it up via prop spread, GenericTool

forwards explicitly. Ticks while pending, frozen text when done,

formatted with the existing ui.message.duration i18n strings.
The badge was only wired into the completed row: running tools take

the spinner branch and spinner-less pending tools take the fallback

branch, so neither ever displayed it. Covered by render tests.
@BoYanZh
BoYanZh force-pushed the feat/tui-tool-elapsed-time branch from d7c9393 to 7abf2db Compare September 23, 2026 11:44
The shell renderer (and any function trigger) bypasses the title

branch where the badge lived, so shell rows never showed timing.

Add the badge slot to the function-trigger and fallback branches;

branches are exclusive so it can never double-render. Covered by

a function-trigger story, verified with a headless screenshot.
The badge relied on a text-space separator that collapses against

custom trigger layouts, leaving it glued to the command. Use a

fixed 8px margin (matching existing trigger gaps) plus muted,

tabular styling.
…r timed

Shell rows now read \Shell 11s <cmd>\: the badge moved inside the

shell trigger between title and command via a shared ToolElapsed

component (BasicTool refactored onto it; tight mode avoids double

spacing inside flex-gap containers). The turn Thinking indicator

gains the same live badge timed from the user message.
The turn Thinking indicator no longer disappears with the first

visible output; it ticks live while working and freezes on the

turn total when done (shimmer animation also stops). Errors and

retries still suppress it.
…eline

The main web session view builds its own timeline (app timeline

rows), not session-ui SessionTurn, so the earlier thinking change

never reached it. Now: Thinking rows persist after completion

with frozen totals (live tick while busy), and Exploring-style

group headers plus their rows carry group/per-part timing.

Existing row tests updated for the new persistent rows; new test

locks busy-vs-done timing fields.
Group header now reads \�xplored 1s 1 read\: the badge moved

before the count summary inside the flex-gap title row.
Their custom JSX triggers bypassed the generic badge slot, leaving

the timer on the next line. Place ToolElapsed inside the flex-gap

title row (same pattern as shell) and suppress the generic slot.
Thinking is the cause and the reply is the effect: push the Thinking

row before assistant parts so completed turns read Thinking

total first, answer second. Row tests reordered accordingly.
@thdxr

thdxr commented Oct 10, 2026

Copy link
Copy Markdown
Member

This branch has been upgraded to V2. We're no longer taking PRs for V1. If you think this is still relevant, please port it over and open it for V2.

— from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Show elapsed time for running shell/tool calls in TUI and Web UI

2 participants