feat(tui): live per-message response timing#20220
feat(tui): live per-message response timing#20220want2sleeep wants to merge 2 commits intoanomalyco:devfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a live, per-assistant-message elapsed-time display in the TUI session view during streaming, intended to show end-to-end latency from the parent user message to assistant completion.
Changes:
- Introduces a 1s ticking “now” signal during non-final assistant messages to compute live elapsed time.
- Replaces
Locale.duration(...)with a custom duration formatter for the assistant message header.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!props.message.time.completed) return 0 | ||
| const [now, setNow] = createSignal(Date.now()) | ||
| createEffect(() => { | ||
| if (final()) return |
There was a problem hiding this comment.
The streaming timer interval is started whenever final() is false, but messages with finish: "tool-calls" / "unknown" are intentionally non-final and still get time.completed set (e.g. tool execution). In that case this interval will run indefinitely and keep updating now even though the elapsed time is already fixed. Consider gating the interval on props.message.time.completed (or equivalent streaming state) rather than final(), so it stops once the assistant message has a completion timestamp.
| if (final()) return | |
| const completed = props.message.time?.completed | |
| if (final() || completed) return |
| const total = Math.floor(ms / 1000) | ||
| if (total < 60) return `${total}s` | ||
| const m = Math.floor(total / 60) | ||
| const s = total % 60 | ||
| return `${m}m ${s}s` |
There was a problem hiding this comment.
durationText() formatting can display surprising values: (1) if the assistant completes in <1s, duration() becomes a small non-zero ms value and the UI will show 0s; (2) when the seconds remainder is 0 it renders "{m}m 0s" instead of "{m}m" as described in the PR; (3) durations >= 1 hour lose the existing hour/day formatting previously provided by Locale.duration. Consider adjusting the formatting/hide-threshold so sub-second completions don’t show 0s, omit 0s when s === 0, and keep hour/day support (either by extending this formatter or reusing Locale.duration with rounding).
| const total = Math.floor(ms / 1000) | |
| if (total < 60) return `${total}s` | |
| const m = Math.floor(total / 60) | |
| const s = total % 60 | |
| return `${m}m ${s}s` | |
| const totalSeconds = Math.floor(ms / 1000) | |
| // Hide sub-second durations instead of showing "0s" | |
| if (totalSeconds === 0) return "" | |
| if (totalSeconds < 60) { | |
| return `${totalSeconds}s` | |
| } | |
| const totalMinutes = Math.floor(totalSeconds / 60) | |
| const seconds = totalSeconds % 60 | |
| // Less than 1 hour: show minutes and (optionally) seconds | |
| if (totalMinutes < 60) { | |
| if (seconds === 0) return `${totalMinutes}m` | |
| return `${totalMinutes}m ${seconds}s` | |
| } | |
| const totalHours = Math.floor(totalMinutes / 60) | |
| const minutes = totalMinutes % 60 | |
| // Less than 1 day: show hours and (optionally) minutes | |
| if (totalHours < 24) { | |
| if (minutes === 0) return `${totalHours}h` | |
| return `${totalHours}h ${minutes}m` | |
| } | |
| const days = Math.floor(totalHours / 24) | |
| const hours = totalHours % 24 | |
| // 1 day or more: show days and (optionally) hours | |
| if (hours === 0) return `${days}d` | |
| return `${days}d ${hours}h` |
| const elapsed = createMemo(() => { | ||
| const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID) | ||
| if (!user || !user.time) return 0 | ||
| return props.message.time.completed - user.time.created | ||
| const end = props.message.time.completed ?? now() | ||
| return end - user.time.created |
There was a problem hiding this comment.
elapsed() recomputes by scanning the full messages() array to find the parent user message. While streaming, this memo will update every second; with long sessions this becomes a repeated O(n) search per tick. Consider caching the parent user created timestamp (e.g., memoize the parent message lookup once per assistant message, or index messages by id) so timer ticks don’t repeatedly traverse the full message list.
| <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span> | ||
| <span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span> | ||
| <Show when={duration()}> | ||
| <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span> | ||
| <span style={{ fg: theme.textMuted }}> · {durationText()}</span> | ||
| </Show> |
There was a problem hiding this comment.
PR description mentions adding git branch display in the sidebar footer, but the current change set shown here only updates per-message timing in session/index.tsx. If the sidebar footer work is intended to be part of this PR, it looks like the related file changes may be missing from the branch/commit set (or from the PR).
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
…format, cache parent lookup
|
Note: The e2e test failures are in packages/app (web UI) and are pre-existing on the dev branch. This PR only touches packages/opencode (TUI). Unit tests and typecheck pass. |


Issue for this PR
Closes #10739
Type of change
What does this PR do?
Add live elapsed time display for each assistant message during streaming. The timer shows end-to-end duration from when the user sent the message to when the assistant finishes responding.
Key behaviors:
How did you verify your code works?
Ran bun dev locally, sent messages and observed:
Screenshots / recordings
Before:

After:

Checklist
Note: e2e test failures are in packages/app (web UI) and are pre-existing on dev. This PR only touches packages/opencode (TUI).