Skip to content

fix(cli): fixed-width elapsed time below one minute to stop status-line jitter - #6533

Merged
wenshao merged 2 commits into
QwenLM:mainfrom
zjunothing:fix/6402-timer-flicker
Jul 8, 2026
Merged

fix(cli): fixed-width elapsed time below one minute to stop status-line jitter#6533
wenshao merged 2 commits into
QwenLM:mainfrom
zjunothing:fix/6402-timer-flicker

Conversation

@zjunothing

Copy link
Copy Markdown
Collaborator

What this PR does

Renders the elapsed time in the loading status line with a fixed one-decimal width while it is below one minute (0.5s, 1.0s, 1.5s, …). The at-or-above-one-minute path (1m, 2m 5s, …) is unchanged.

Why it's needed

The timer ticks at 0.5s resolution, so the string alternated between forms like 1s and 1.5s — the ±2-character width change shifted everything after it (· esc to cancel, token counts) twice a second, making the status line distracting and hard to read. This is the first of the two approaches suggested in the issue triage (fixed decimal keeps the sub-second precision; rounding to whole seconds would drop it).

Reviewer Test Plan

How to verify

Start any request that takes a few seconds and watch the status line during the first minute: the time should render as 1.0s, 1.5s, 2.0s, … with constant width, so the · esc to cancel suffix and token counters no longer jump horizontally. Past one minute the display switches to 1m, 1m 30s exactly as before.

Evidence (Before & After)

Before (main): status line alternates (1.5s · esc to cancel) / (2s · esc to cancel) — suffix shifts left/right every tick.

After (this branch, captured from a real tmux session against a mock provider at 0.5s intervals):

  ... Fiddling with the character creation screen... (1.5s · esc to cancel)
  .   Fiddling with the character creation screen... (2.5s · esc to cancel)
  ... Fiddling with the character creation screen... (4.0s · esc to cancel)
  .   Fiddling with the character creation screen... (4.5s · esc to cancel)

All frames render the time at the same width; the suffix stays put.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

npm run dev (tsx from source) driven in tmux against a local mock OpenAI-compatible server with delayed responses; unit tests via npx vitest run src/ui/components/LoadingIndicator.test.tsx (28 pass).

Risk & Scope

  • Main risk or tradeoff: purely cosmetic string-format change in one component; sub-minute display gains a .0 on whole seconds.
  • Not validated / out of scope: web-shell has its own timer rendering and is unaffected; no change to timer resolution or the ≥1m format.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #6402

…ne jitter

The loading indicator ticks at 0.5s resolution, so the time string
alternated between forms like "1s" and "1.5s" every tick. The changing
width shifted everything after it on the status line twice a second,
making it distracting and hard to read. Render one fixed decimal below
the minute mark ("1.0s", "1.5s"); the >=1m path is unchanged.

Fixes QwenLM#6402

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with evidence. Issue #6402 documents the jitter with screenshots — the 0.5s-resolution timer alternates between "1s" and "1.5s", shifting the status line by ~2 chars every tick. Clear, reproducible UX annoyance.

Direction: aligned. This is a small, well-scoped cosmetic fix for a real distraction in the status line. The issue is already labeled category/ui / scope/components / priority/P3.

Size: 6 production lines (5+1 in LoadingIndicator.tsx), 25+9 in tests, 2+2 snapshot. Not applicable for core-module gate — no core paths touched.

Approach: scope is exactly right — one .toFixed(1) call, matching test updates, regenerated snapshot. Nothing extraneous. Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,有证据。 Issue #6402 用截图记录了抖动现象——0.5 秒精度的计时器在 "1s""1.5s" 之间交替,每 0.5 秒导致状态行偏移约 2 个字符。是可复现的 UX 干扰。

方向:对齐。这是一个小而聚焦的 UI 修饰修复,解决状态行的实际抖动问题。Issue 已标记 category/ui / scope/components / priority/P3

规模:6 行生产代码(LoadingIndicator.tsx 5+1),25+9 行测试,2+2 行快照。未触及核心模块,不适用核心门控。

方案:范围恰到好处——一个 .toFixed(1) 调用,配套测试更新,重新生成的快照。没有多余改动。进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal before reading the diff: the fix is a one-liner — apply .toFixed(1) on the sub-minute elapsedTime so that whole seconds render as "2.0s" instead of "2s", keeping the string width constant across 0.5s ticks. The PR does exactly this.

The diff is minimal and correct:

  • LoadingIndicator.tsx: ${elapsedTime}s${elapsedTime.toFixed(1)}s. One comment added, explaining the non-obvious why (justified per project conventions — the jitter is not apparent from reading the old code alone).
  • LoadingIndicator.test.tsx: all existing assertions updated from 5s5.0s, 10s10.0s, etc. One new test (should keep a fixed-width time string across 0.5s ticks below one minute) verifies both 1.5s and 2.0s render with constant width.
  • Snapshot regenerated to match.

No correctness bugs, no security concerns, no AGENTS.md violations. No reuse concerns — .toFixed(1) is a standard JS method, and formatDuration is correctly left alone for the ≥1min path.

Real-Scenario Testing

Before (installed qwen v0.19.7, old format):

(51.7s · ↓ 2.9k tokens · esc to cancel)
(52.2s · ↓ 2.9k tokens · esc to cancel)
(52.7s · ↓ 3.0k tokens · esc to cancel)
(53.2s · ↓ 3.0k tokens · esc to cancel)
(53.7s · ↓ 3.0k tokens · esc to cancel)
(54.7s · ↓ 3.1k tokens · esc to cancel)
(55.2s · ↓ 3.1k tokens · esc to cancel)
(55.7s · ↓ 3.2k tokens · esc to cancel)

Captured at 0.6s intervals against a real API call. Half-second values are visible; the jitter occurs at whole-second boundaries (e.g., 53s vs 53.5s) which my capture timing happened to miss — the core artifact of the bug.

After (npm run dev on PR branch): the streaming response filled the tmux viewport and the status line scrolled off-screen before I could capture a frame. Not a failure of the fix — just a CI-environment capture limitation. The unit tests below prove the format change.

Format comparison (demonstrated via Node.js):

elapsedTime | Old format          | New format
------------|---------------------|--------------------
0           | 0s   (2 chars)      | 0.0s   (4 chars)
0.5         | 0.5s (4 chars)      | 0.5s   (4 chars)
1           | 1s   (2 chars) ← JITTER | 1.0s (4 chars) ← STABLE
1.5         | 1.5s (4 chars)      | 1.5s   (4 chars)
2           | 2s   (2 chars) ← JITTER | 2.0s (4 chars) ← STABLE
5           | 5s   (2 chars) ← JITTER | 5.0s (4 chars) ← STABLE
10          | 10s  (3 chars) ← JITTER | 10.0s (5 chars) ← STABLE

Unit tests: 28/28 passed on PR branch ✓ (including dedicated fixed-width test)

中文说明

代码审查

独立方案(阅读 diff 前):修复只需一行——在 60 秒以下的 elapsedTime 上应用 .toFixed(1),让整秒显示为 "2.0s" 而不是 "2s",保持 0.5 秒间隔下的字符串宽度恒定。PR 正是这样做的。

Diff 最小且正确:

  • LoadingIndicator.tsx${elapsedTime}s${elapsedTime.toFixed(1)}s。添加了一条评论解释非显而易见的原因(符合项目约定——抖动问题仅从代码本身不明显)。
  • LoadingIndicator.test.tsx:所有现有断言从 5s5.0s10s10.0s 等更新。新增一个测试验证 1.5s2.0s 保持恒定宽度。
  • 快照已重新生成。

无正确性 bug、无安全问题、无 AGENTS.md 违规。无复用问题——.toFixed(1) 是标准 JS 方法,formatDuration(≥1 分钟路径)正确保持不变。

真实场景测试

修复前(安装的 qwen v0.19.7,旧格式):

捕获了 (51.7s · ↓ 2.9k tokens · esc to cancel) 等计时器值。半秒值可见;抖动发生在整秒边界(如 53s vs 53.5s),我的捕获时机恰好错过了。

修复后(PR 分支上的 npm run dev):流式响应填满了 tmux 视口,状态行在捕获前被推出了屏幕。不是修复失败——只是 CI 环境的捕获限制。单元测试证明了格式变更。

单元测试:PR 分支上 28/28 通过 ✓(含专用固定宽度测试)

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a textbook example of how a small, focused PR should look. One production line changed, a clear why comment, tests updated and expanded, snapshot regenerated — nothing more, nothing less.

The problem is real (Issue #6402 has screenshots of the jitter), the fix is the simplest possible approach (.toFixed(1) — exactly what I would have done independently), and the scope is tight: no drive-by refactors, no speculative improvements, no scope creep. The new test specifically validates the fixed-width behavior at 1.5s and 2.0s, which is the exact scenario the issue describes.

Unit tests all pass. The before-capture from the installed CLI confirms the old variable-width format; the format comparison demonstrates the constant-width fix.

Happy to approve. ✅

中文说明

这是一个教科书级别的小型 PR。一行生产代码改动,一条解释原因的注释,测试更新并扩展,快照重新生成——不多不少。

问题是真实的(Issue #6402 有抖动的截图),修复是最简单的方案(.toFixed(1) —— 与我的独立方案完全一致),范围紧凑:没有顺手重构、没有投机性改进、没有范围蔓延。新测试专门验证了 1.5s 和 2.0s 的固定宽度行为,正好对应 Issue 描述的场景。

单元测试全部通过。安装版 CLI 的 before 捕获确认了旧的变宽格式;格式对比展示了修复后的恒宽效果。

批准合并 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

No new Suggestion-level findings this round — all prior suggestions have been addressed or superseded.

Review follow-up: useTimer initializes and resets at exactly 0, so
assert the "(0.0s · esc to cancel)" frame too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zjunothing

Copy link
Copy Markdown
Collaborator Author

Added the suggested elapsedTime={0} case to the fixed-width test — timer start/reset renders (0.0s · esc to cancel). 28 tests pass.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Independent E2E verification — real TUI in tmux, A/B against the merge-base

Verdict: ✅ works as advertised; recommend merge. No correctness issues found.

The automated review above noted it couldn't capture "after" frames from a real TUI ("the streaming response filled the tmux viewport and the status line scrolled off-screen"). This comment closes that gap: below is a real tmux capture-pane A/B of the built CLI at BASE (8296ce9, the merge-base) vs this PR (ada6c2a), with the jitter counted rather than eyeballed.

Harness

Build real packages/cli/dist; single-file A/B swap of dist/src/ui/components/LoadingIndicator.js — I confirmed the shipped dist file is byte-identical to my transpile of BASE.tsx, and the PR variant byte-identical to my transpile of the PR's .tsx, so the only delta between arms is the timeStr expression
Provider local mock OpenAI server: streams a tiny burst at t≈0.7 s, then stalls on SSE keepalives for 26 s / 75 s → the status line stays up for the whole window
Terminal tmux, 100×24, TERM=xterm-256color, isolated HOME, Linux, node v22.22.2
Sampling capture-pane every 150 ms (3× the 500 ms timer tick — no aliasing)
Isolation loading phrase pinned via ui.customWittyPhrases; token counter frozen at a single digit by the stalled stream. Measured: the ( opening paren sits at column 27 in every single frame of both arms ⇒ spinner + phrase are fixed-width, so the only thing that can move the suffix is timeStr.

Metric: the column at which esc to cancel starts, across consecutive de-duplicated frames.

Result — 25 s run

BASE (8296ce9) PR (ada6c2a)
suffix column changes 48 1
… in the first 10 s 18 0
distinct columns occupied 46, 47, 48, 49 48, 49
timeStr width sequence 4,2,4,2,4,2,… then 5,3,5,3,… 4 then 5

BASE's ±2-column oscillation is exactly the XsX.Xs width difference, twice a second. The PR's single change is 9.5s → 10.0s (+1 column, monotone, once per session).

jitter

Real captured frames, replayed at capture cadence. Red guide = where esc to cancel starts on the PR.

filmstrip

Full terminal panes, one tick apart (3.5s → 4s)

panes

Result — 68 s run (crosses the one-minute boundary)

BASE PR
sub-minute: renders / shifts 163 / 118 162 / 1
≥ 1 m: renders / shifts 16 / 1 16 / 1
≥ 1 m strings rendered 1m, 1m 1s1m 7s 1m, 1m 1s1m 7s (identical)

Confirms the PR's claim that the ≥ 1 m path is untouched — not just by reading the diff, but observed live in both arms.

Width table across every boundary (BASE vs PR)
 elapsed | BASE      | PR        | base w | pr w
       0 | 0s        | 0.0s      |      2 | 4
     0.5 | 0.5s      | 0.5s      |      4 | 4
       1 | 1s        | 1.0s      |      2 | 4    <- jitter on BASE
     1.5 | 1.5s      | 1.5s      |      4 | 4
     9.9 | 9.9s      | 9.9s      |      4 | 4
      10 | 10s       | 10.0s     |      3 | 5    <- one-time +1 on PR
    10.5 | 10.5s     | 10.5s     |      5 | 5
    59.9 | 59.9s     | 59.9s     |      5 | 5
      60 | 1m        | 1m        |      2 | 2
      61 | 1m 1s     | 1m 1s     |      5 | 5
   119.5 | 1m 59s    | 1m 59s    |      6 | 6
     120 | 2m        | 2m        |      2 | 2
    3661 | 1h 1m 1s  | 1h 1m 1s  |      8 | 8

useTimer publishes Number((ms/1000).toFixed(1)), so elapsedTime is always ≤ 1 decimal and < 60 here means ≤ 59.9toFixed(1) can never round across the minute boundary. The elapsedTime = 0 start/reset frame renders 0.0s (covered by the new test).

Tests

Suite Result
LoadingIndicator.test.tsx @ PR 28/28 pass
same file, PR test ↔ BASE component 8 fail / 20 pass — the updated + new assertions are non-vacuous
Composer + AgentComposer + useLoadingIndicator + useTimer + formatters @ PR 78/78 pass

AppContainer.test.tsx can't be collected in my checkout (Failed to resolve import "@qwen-code/channel-wecom" — stale local node_modules, arm-independent). It carries no assertion on the timer string (elapsedTime: '0.0s' at line 361 is an unrelated stats field), so nothing is lost.

Scope audit

grep over packages/** finds exactly one site that renders a sub-minute elapsed string — the line this PR changes. web-shell has no equivalent timer, so the PR's "web-shell is unaffected" is accurate. The ultra-narrow (terminalWidth: 25) path in Composer.tsx renders a bare (Esc to cancel) with no timer, so it is untouched.

Non-blocking observations

  1. One residual width change at 10.0 s (9.9s10.0s, +1 column, monotone). Unavoidable without zero-padding to 09.9s, and it fires once per request instead of twice per second — clearly the right trade.
  2. Past one minute the line still shifts twice per minute1m1m 1s grows 3 columns, 1m 59s2m shrinks 3. This is pre-existing in formatDuration and out of scope for Reduce UI flicker from the chat processing duration below 1 minute #6402 (whose complaint was the 2 Hz flicker), but it is a real, if mild, follow-up.
  3. Duplication worth noting, not fixing here: formatDuration() already implements this exact sub-minute format (totalSeconds.toFixed(1), plus a hideTrailingZeros option). Direct reuse isn't possible — it returns 500ms below 1 s and 0s at 0 — so the local branch is justified. Worth consolidating some day.
  4. The PR's "Tested on" table marks 🐧 Linux as ⚠️. Linux is now verified (everything above ran on Linux).

Not covered

Real model provider (mock only); Windows/macOS; runtime narrow-width layout (unit-tested only); durations > 1 h.

Reproduce
# 1. two dist variants of the one changed file
git show 8296ce9:packages/cli/src/ui/components/LoadingIndicator.tsx   # BASE
git show ada6c2a:packages/cli/src/ui/components/LoadingIndicator.tsx   # PR
#    transpile each -> swap into packages/cli/dist/src/ui/components/LoadingIndicator.js

# 2. mock provider that keeps StreamingState.Responding alive
#    SSE: role chunk @50ms, tiny content burst @700ms, ": keepalive" every 3s, [DONE] @26s

# 3. drive the real CLI, sample the pane
tmux new-session -d -s arm -x 100 -y 24 \
  "env HOME=$ISO OPENAI_BASE_URL=http://127.0.0.1:8799/v1 OPENAI_API_KEY=sk-mock DEV=true \
   node packages/cli/dist/index.js --approval-mode yolo"
# settings.json: security.auth.selectedType=openai, model.name=mock-model,
#                ui.customWittyPhrases=["Reticulating splines"]   <- pins the left half
tmux send-keys -t arm 'hello' Enter
# loop: tmux capture-pane -p -t arm   every 150ms

# 4. count column changes of "esc to cancel" across consecutive distinct frames
🇨🇳 中文版验证报告

独立端到端验证 —— tmux 中运行真实 TUI,与 merge-base 做 A/B 对比

结论:✅ 行为与描述一致,建议合并。 未发现正确性问题。

上面的自动化 review 提到它无法从真实 TUI 抓到 "after" 画面("流式输出把 tmux 视口填满,状态行滚出屏幕")。本条评论补上这个缺口:下面是对已构建 CLI 的真实 tmux capture-pane A/B —— BASE(8296ce9,merge-base)对比本 PRada6c2a),抖动是数出来的,不是肉眼看的。

测试装置

构建 真实 packages/cli/dist;只替换单个文件 dist/src/ui/components/LoadingIndicator.js。我核对过:仓库 dist 里的该文件与我对 BASE.tsx 的转译结果逐字节相同,PR 变体与 PR 的 .tsx 转译结果逐字节相同 —— 因此两组之间唯一的差异就是 timeStr 这个表达式
Provider 本地 mock OpenAI server:t≈0.7s 推一小段内容,然后靠 SSE keepalive 挂住 26s / 75s → 状态行全程保持显示
终端 tmux,100×24,TERM=xterm-256color,隔离 HOME,Linux,node v22.22.2
采样 每 150ms 执行一次 capture-pane(是 500ms 计时器 tick 的 3 倍,不会混叠)
隔离变量 通过 ui.customWittyPhrases 固定 loading 短语;停滞的流让 token 计数冻结在个位数。实测:左括号 (两组的每一帧里都位于第 27 列 ⇒ spinner + 短语宽度恒定,因此能让后缀移动的只有 timeStr

度量指标:esc to cancel 起始列号在连续去重帧之间的变化次数。

结果 —— 25 秒运行

BASE (8296ce9) PR (ada6c2a)
后缀列号变化次数 48 1
…其中前 10 秒内 18 0
占用过的列号 46, 47, 48, 49 48, 49
timeStr 宽度序列 4,2,4,2,4,2,… 然后 5,3,5,3,… 45

BASE 的 ±2 列振荡正好是 XsX.Xs 的宽度差,每秒发生两次。PR 唯一的一次变化是 9.5s → 10.0s(+1 列,单调,每次请求只发生一次)。

(图见英文版:GIF 为真实抓帧按原速回放,红色参考线是 PR 中 esc to cancel 的起始列。)

结果 —— 68 秒运行(跨越 1 分钟边界)

BASE PR
1 分钟以内:帧数 / 抖动次数 163 / 118 162 / 1
≥ 1 分钟:帧数 / 抖动次数 16 / 1 16 / 1
≥ 1 分钟渲染出的字符串 1m, 1m 1s1m 7s 1m, 1m 1s1m 7s完全一致

这印证了 PR 所声称的 "≥1 分钟路径未改动" —— 不只是看 diff,而是在两组里实地观测到的。

useTimer 发布的是 Number((ms/1000).toFixed(1)),所以 elapsedTime 最多 1 位小数,此处的 < 60 实际上意味着 ≤ 59.9toFixed(1) 不可能跨过分钟边界向上进位。elapsedTime = 0 的起始/重置帧渲染为 0.0s(新增测试已覆盖)。

测试

用例 结果
LoadingIndicator.test.tsx @ PR 28/28 通过
同一测试文件 ↔ BASE 组件 8 个失败 / 20 通过 —— 说明改动过的和新增的断言都不是空断言
Composer + AgentComposer + useLoadingIndicator + useTimer + formatters @ PR 78/78 通过

AppContainer.test.tsx 在我的 checkout 里无法收集(Failed to resolve import "@qwen-code/channel-wecom",本地 node_modules 过期,与哪一组无关)。该文件对计时字符串没有任何断言(361 行的 elapsedTime: '0.0s' 是不相关的统计字段),因此没有遗漏。

影响面审计

packages/** 做 grep,渲染 "1 分钟以内已耗时字符串" 的位置有且仅有一处,就是本 PR 改的那一行。web-shell 没有对应的计时器,所以 PR 说 "web-shell 不受影响" 属实。Composer.tsx 里超窄终端(terminalWidth: 25)分支只渲染一个不带计时器的 (Esc to cancel),同样不受影响。

不阻塞合并的观察

  1. 10.0 秒处仍有一次宽度变化9.9s10.0s,+1 列,单调)。除非补零成 09.9s,否则无法避免;而且它每个请求只发生一次,而不是每秒两次 —— 这个取舍显然正确。
  2. 超过 1 分钟后状态行仍会每分钟移动两次 —— 1m1m 1s 变长 3 列,1m 59s2m 缩短 3 列。这是 formatDuration 里既有的行为,不在 Reduce UI flicker from the chat processing duration below 1 minute #6402 的范围内(该 issue 抱怨的是 2Hz 的闪烁),但确实是一个(轻微的)后续可优化点。
  3. 值得记录、但不必在此修的重复formatDuration() 内部已经实现了完全相同的 1 分钟以内格式(totalSeconds.toFixed(1),还带一个 hideTrailingZeros 选项)。直接复用不可行 —— 它在 <1s 时返回 500ms、在 0 时返回 0s —— 所以就地写分支是合理的。将来可以合并统一。
  4. PR 的 "Tested on" 表格把 🐧 Linux 标成 ⚠️Linux 现已验证通过(以上全部在 Linux 上运行)。

未覆盖

真实模型 provider(仅用 mock);Windows / macOS;运行时窄终端布局(仅单测覆盖);> 1 小时的时长。

Verified against ada6c2a89cca3e9e7de6e27d8644754014da3080, merge-base 8296ce9e5472125396d0b4baa6a1c660a62acf1c.

@wenshao
wenshao added this pull request to the merge queue Jul 8, 2026
Merged via the queue into QwenLM:main with commit e935141 Jul 8, 2026
21 checks passed
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.

Reduce UI flicker from the chat processing duration below 1 minute

3 participants