From e45df2e81ec818d2fb6767c0ba4eb29ed573a799 Mon Sep 17 00:00:00 2001 From: adybag14-cyber <252811164+adybag14-cyber@users.noreply.github.com> Date: Wed, 6 May 2026 00:52:09 +0100 Subject: [PATCH] fix(ui): reduce status-line jitter while scrolling --- cli.py | 7 +++++-- tests/cli/test_cli_status_bar.py | 19 +++++++++++++++++++ ui-tui/src/__tests__/statusBarTicker.test.ts | 18 ++++++++++++++++++ ui-tui/src/components/appChrome.tsx | 9 +++++++-- 4 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 ui-tui/src/__tests__/statusBarTicker.test.ts diff --git a/cli.py b/cli.py index e17516cf26..9f86e3e3a4 100644 --- a/cli.py +++ b/cli.py @@ -2589,9 +2589,12 @@ class HermesCLI: elapsed = time.monotonic() - t0 if elapsed >= 60: _m, _s = int(elapsed // 60), int(elapsed % 60) - elapsed_str = f"{_m}m {_s}s" + # Fixed-width timer to avoid status-line wrap jitter while + # scrolling/repainting (e.g. 01m05s, 12m09s). + elapsed_str = f"{_m:02d}m{_s:02d}s" else: - elapsed_str = f"{elapsed:.1f}s" + # Keep width stable before the 60s rollover as well. + elapsed_str = f"{elapsed:5.1f}s" return f" {txt} ({elapsed_str})" return f" {txt}" diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index f5c18bfc4d..ff99856a89 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -1,3 +1,4 @@ +import time from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -244,6 +245,24 @@ class TestCLIStatusBar: assert cli_obj._spinner_widget_height(width=64) == 2 + def test_spinner_elapsed_format_is_fixed_width_to_reduce_wrap_jitter(self): + cli_obj = _make_cli() + cli_obj._spinner_text = "running tool" + + # <60s path + cli_obj._tool_start_time = time.monotonic() - 9.2 + short = cli_obj._render_spinner_text() + + # >=60s path + cli_obj._tool_start_time = time.monotonic() - 65.2 + long = cli_obj._render_spinner_text() + + short_elapsed = short.split("(", 1)[1].rstrip(")") + long_elapsed = long.split("(", 1)[1].rstrip(")") + + assert len(short_elapsed) == len(long_elapsed) + assert "m" in long_elapsed and "s" in long_elapsed + def test_voice_status_bar_compacts_on_narrow_terminals(self): cli_obj = _make_cli() cli_obj._voice_mode = True diff --git a/ui-tui/src/__tests__/statusBarTicker.test.ts b/ui-tui/src/__tests__/statusBarTicker.test.ts new file mode 100644 index 0000000000..4f3369bfa3 --- /dev/null +++ b/ui-tui/src/__tests__/statusBarTicker.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { padVerb, VERB_PAD_LEN } from '../components/appChrome.js' +import { VERBS } from '../content/verbs.js' + +describe('FaceTicker verb padding', () => { + it('pads every verb to the same width', () => { + for (const verb of VERBS) { + expect(padVerb(verb)).toHaveLength(VERB_PAD_LEN) + } + }) + + it('keeps trailing ellipsis attached', () => { + for (const verb of VERBS) { + expect(padVerb(verb).startsWith(`${verb}…`)).toBe(true) + } + }) +}) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index cf8328bc8f..74dba682fe 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -20,6 +20,11 @@ import type { Msg, Usage } from '../types.js' const FACE_TICK_MS = 2500 const HEART_COLORS = ['#ff5fa2', '#ff4d6d'] +// Keep verb segment width stable so status-bar content to the right doesn't +// jitter when the ticker rotates between short/long verbs. +export const VERB_PAD_LEN = VERBS.reduce((max, v) => Math.max(max, v.length), 0) + 1 // + ellipsis +export const padVerb = (verb: string) => `${verb}…`.padEnd(VERB_PAD_LEN, ' ') + // Compact alternates for the `emoji` and `ascii` indicator styles. // Each entry is a fixed-width (display-width) glyph. const EMOJI_FRAMES = ['⚕ ', '🌀', '🤔', '✨', '🍵', '🔮'] @@ -102,8 +107,8 @@ function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | nu const { frame } = renderIndicator(style, tick) const verb = VERBS[verbTick % VERBS.length] ?? '' - const verbSegment = showVerb ? ` ${verb}…` : '' - const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : '' + const verbSegment = showVerb ? ` ${padVerb(verb)}` : '' + const durationSegment = startedAt ? `· ${fmtDuration(now - startedAt)}` : '' return (