From 86a2fdc6347b0468ae611851ddc236c4119ffea2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:59:06 -0700 Subject: [PATCH] feat(tui): status rule shows cache-hit %, latency, t/s and honors display.status_bar.fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends PR #98250's classic-CLI status-bar upgrades to the Ink TUI: - tui_gateway/server.py _get_usage() now emits cache_hit_pct, avg_latency_s, avg_tps (reads the same per-call deque history from agent/conversation_loop.py; keys omitted when no data — Codex app-server has no latency, zero cache reads show no %) - StatusRule renders the three read-outs as width-budgeted tail segments (breakpoints 96/104/110 cols, lowest priority — they shed first on narrow terminals) - display.status_bar.fields (the SAME key the classic CLI honors) filters TUI segments too: cache_hit, latency, tps, duration, compressions, bg_tasks, bg_subagents, voice, battery, title, context_pct, context_detail - values ride the existing usage payload/ticker; constants between events so the usage==last dedup keeps suppressing repaints - 3 new server tests, 5 new TUI tests; full ui-tui suite 1727 green --- tests/test_tui_gateway_server.py | 45 ++++++++++ tui_gateway/server.py | 33 +++++++ .../__tests__/appChromeStatusRule.test.tsx | 57 ++++++++++++ ui-tui/src/__tests__/statusRule.test.ts | 13 ++- ui-tui/src/app/interfaces.ts | 4 + ui-tui/src/app/uiStore.ts | 1 + ui-tui/src/app/useConfigSync.ts | 15 ++++ ui-tui/src/components/appChrome.tsx | 89 +++++++++++++++---- ui-tui/src/components/appLayout.tsx | 1 + ui-tui/src/gatewayTypes.ts | 7 ++ ui-tui/src/types.ts | 6 ++ website/docs/user-guide/configuration.md | 1 + 12 files changed, 256 insertions(+), 16 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index c9e65bc002..6166c5e57f 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -18802,6 +18802,51 @@ class _BareAgent: model = "x" +def test_get_usage_perf_readouts_present(): + """cache_hit_pct / avg_latency_s / avg_tps mirror the classic CLI bar.""" + from collections import deque + + class _PerfAgent: + model = "x" + session_prompt_tokens = 27_873 + session_cache_read_tokens = 24_369 + _api_latency_history = deque([2.1, 4.3], maxlen=10) + _api_output_history = deque([130, 190], maxlen=10) + + usage = server._get_usage(_PerfAgent()) + assert usage["cache_hit_pct"] == 87 + assert usage["avg_latency_s"] == 3.2 + assert usage["avg_tps"] == 50.0 # true throughput sum(out)/sum(lat), not mean of ratios + + +def test_get_usage_perf_readouts_omitted_without_data(): + """Zero cache reads / empty history omit the keys — never fabricate 0s.""" + + class _ColdAgent: + model = "x" + session_prompt_tokens = 100 + session_cache_read_tokens = 0 + + usage = server._get_usage(_ColdAgent()) + assert "cache_hit_pct" not in usage + assert "avg_latency_s" not in usage + assert "avg_tps" not in usage + + +def test_get_usage_perf_readouts_guard_negative_latency(): + """Odd provider timings (negative durations seen in logs) are dropped.""" + from collections import deque + + class _WeirdAgent: + model = "x" + _api_latency_history = deque([-0.8], maxlen=10) + _api_output_history = deque([100], maxlen=10) + + usage = server._get_usage(_WeirdAgent()) + assert "avg_latency_s" not in usage + assert "avg_tps" not in usage + + def test_get_usage_includes_active_subagents(monkeypatch): import tools.async_delegation as ad_mod monkeypatch.setattr(ad_mod, "active_count", lambda: 4) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 887994458a..0536cb056b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -7156,6 +7156,39 @@ def _get_usage(agent) -> dict: usage["context_max"] = ctx_max usage["context_percent"] = max(0, min(100, round(last_prompt / ctx_max * 100))) usage["compressions"] = getattr(comp, "compression_count", 0) or 0 + # Cache-hit ratio + rolling latency/throughput for the TUI status bar. + # Mirrors the classic CLI bar (cli.py _get_status_bar_snapshot / PR #98250): + # hit = session_cache_read_tokens / session_prompt_tokens + # (CanonicalUsage.prompt_tokens = input + cache_read + cache_write) + # latency/tps read the deque(maxlen=10) history maintained per API call in + # agent/conversation_loop.py. Values are omitted (not fabricated) when no + # data exists — e.g. Codex app-server reports no latency, and a session + # with zero cache reads shows no hit% rather than an alarming 0. + try: + _prompt_total = int(getattr(agent, "session_prompt_tokens", 0) or 0) + _cache_read = int(getattr(agent, "session_cache_read_tokens", 0) or 0) + if _prompt_total > 0 and _cache_read > 0: + usage["cache_hit_pct"] = max(0, min(100, round(_cache_read / _prompt_total * 100))) + except Exception: + pass + try: + _lhist = list(getattr(agent, "_api_latency_history", []) or []) + _ohist = list(getattr(agent, "_api_output_history", []) or []) + _n = min(len(_lhist), len(_ohist)) + if _n: + _lhist = _lhist[-_n:] + _ohist = _ohist[-_n:] + _avg_lat = sum(_lhist) / _n + _total_lat = sum(_lhist) + _avg_vel = (sum(_ohist) / _total_lat) if _total_lat > 0 else None + # Guard NaN/negative/absurd values from odd provider timings. + if _avg_lat == _avg_lat and 0 < _avg_lat < 1e6: + usage["avg_latency_s"] = round(float(_avg_lat), 1) + if _avg_vel is not None and _avg_vel == _avg_vel and 0 < _avg_vel < 1e6: + usage["avg_tps"] = round(float(_avg_vel), 1) + except Exception: + # A status-bar readout must never break usage reporting. + pass # Live count of background/async subagents still running (delegate_task # batches + background single delegations). Mirrors the classic CLI status # bar's ⛓ indicator; sourced from the same async_delegation registry. diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index 07fc468768..017ce92371 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -491,3 +491,60 @@ describe('StatusRule idle-since read-out', () => { expect(findComponentByName(element, 'IdleSince')).toBeNull() }) }) + + +describe('StatusRule perf read-outs (cache hit / latency / tps)', () => { + const perfUsage = { + ...baseProps.usage, + avg_latency_s: 3.2, + avg_tps: 50.4, + cache_hit_pct: 87, + calls: 4, + input: 1000, + output: 500 + } + + it('renders all three segments on a wide terminal', () => { + const element = StatusRule({ ...baseProps, cols: 160, usage: perfUsage }) + const rendered = textContent(element) + + expect(rendered).toContain('◎ 87%') + expect(rendered).toContain('◷ 3.2s') + expect(rendered).toContain('↑ 50 t/s') + }) + + it('self-hides when the server omits the keys', () => { + const element = StatusRule({ ...baseProps, cols: 160 }) + const rendered = textContent(element) + + expect(rendered).not.toContain('◎') + expect(rendered).not.toContain('◷') + expect(rendered).not.toContain('t/s') + }) + + it('honors the display.status_bar.fields visibility filter', () => { + const element = StatusRule({ + ...baseProps, + cols: 160, + statusBarFields: new Set(['model', 'context_pct', 'cache_hit']), + usage: perfUsage + }) + + const rendered = textContent(element) + + expect(rendered).toContain('◎ 87%') + expect(rendered).not.toContain('◷') + expect(rendered).not.toContain('t/s') + }) + + it('hides the session title badge when the fields filter omits title', () => { + const element = StatusRule({ + ...baseProps, + cols: 160, + sessionTitle: 'weekly-digest', + statusBarFields: new Set(['model', 'context_pct']) + }) + + expect(textContent(element)).not.toContain('weekly-digest') + }) +}) diff --git a/ui-tui/src/__tests__/statusRule.test.ts b/ui-tui/src/__tests__/statusRule.test.ts index 1a5334eb13..3b4b25db4e 100644 --- a/ui-tui/src/__tests__/statusRule.test.ts +++ b/ui-tui/src/__tests__/statusRule.test.ts @@ -69,10 +69,21 @@ describe('statusBarSegments', () => { compressions: true, voice: true, bg: true, - subagents: true + subagents: true, + cacheHit: true, + latency: true, + tps: true } satisfies StatusBarSegments) }) + it('sheds cache/latency/tps read-outs first as the terminal narrows', () => { + // 96/104/110-col breakpoints: these are the lowest-priority perf + // read-outs, so they disappear before any pre-existing segment. + expect(statusBarSegments(108)).toMatchObject({ cacheHit: true, latency: true, tps: false }) + expect(statusBarSegments(100)).toMatchObject({ cacheHit: true, latency: false, tps: false }) + expect(statusBarSegments(94)).toMatchObject({ cacheHit: false, latency: false, tps: false, subagents: true }) + }) + it('collapses the context bar to a token count on narrow terminals', () => { const s = statusBarSegments(60) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 69689b74a0..f0c8ef0c2b 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -343,6 +343,10 @@ export interface UiState { sid: null | string status: string statusBar: StatusBarMode + // display.status_bar.fields — visibility filter for status-rule segments, + // shared with the classic CLI bar. null = user has not customized (show + // the default set). + statusBarFields: null | ReadonlySet streaming: boolean theme: Theme // `display.timestamps` — dim [HH:MM] labels on user/assistant transcript diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 581d576a48..e924708c06 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -32,6 +32,7 @@ const buildUiState = (): UiState => ({ sid: null, status: 'summoning hermes…', statusBar: 'top', + statusBarFields: null, streaming: true, timestamps: false, // Last session's resolved theme paints frame one (flash-free boot, like diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index cf471c4633..32e5b4f462 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -28,6 +28,20 @@ const STATUSBAR_ALIAS: Record = { export const normalizeStatusBar = (raw: unknown): StatusBarMode => raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top' +// `display.status_bar.fields` — the SAME key the classic CLI bar honors +// (PR #98250). A non-empty list filters status-rule segments; missing/empty/ +// malformed = null (user hasn't customized → show the default set). Unknown +// names pass through harmlessly — the renderer only tests membership. +export const normalizeStatusBarFields = (raw: unknown): null | ReadonlySet => { + if (!Array.isArray(raw) || raw.length === 0) { + return null + } + + const cleaned = raw.map(v => String(v).trim().toLowerCase()).filter(Boolean) + + return cleaned.length ? new Set(cleaned) : null +} + const BUSY_MODES = new Set(['interrupt', 'queue', 'steer']) // TUI defaults to `queue` even though the framework default @@ -289,6 +303,7 @@ export const applyDisplay = ( sections: resolveSections(d.sections), showReasoning: !!d.show_reasoning, statusBar: normalizeStatusBar(d.tui_statusbar), + statusBarFields: normalizeStatusBarFields(d.status_bar?.fields), streaming: d.streaming !== false, // The SAME key that stamps [HH:MM] on classic-CLI labels (#41531) — // no separate TUI knob. diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 708391eea6..c989678f2a 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -291,10 +291,13 @@ export function statusRuleWidths(cols: number, cwdLabel: string, minLeftContent export interface StatusBarSegments { bar: boolean bg: boolean + cacheHit: boolean compactCtx: boolean compressions: boolean duration: boolean + latency: boolean subagents: boolean + tps: boolean voice: boolean } @@ -308,7 +311,10 @@ export function statusBarSegments(cols: number): StatusBarSegments { compressions: w >= 80, voice: w >= 84, bg: w >= 88, - subagents: w >= 92 + subagents: w >= 92, + cacheHit: w >= 96, + latency: w >= 104, + tps: w >= 110 } } @@ -470,6 +476,7 @@ export function StatusRule({ cols, busy, status, + statusBarFields = null, statusColor, model, modelFast, @@ -491,21 +498,28 @@ export function StatusRule({ const barColor = ctxBarColor(pct, t) const segs = statusBarSegments(cols) + // display.status_bar.fields visibility gate (same key + names as the + // classic CLI bar). null = user hasn't customized → everything shows. + const ok = (name: string) => statusBarFields === null || statusBarFields.has(name) + // On narrow terminals the context read-out collapses to a bare token count // (`12k tok`) and the visual fill bar is dropped entirely. - const ctxLabel = usage.context_max - ? segs.compactCtx - ? `${fmtK(usage.context_used ?? 0)} tok` - : `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}` - : usage.total > 0 - ? `${fmtK(usage.total)} tok` + const ctxLabel = + ok('context_detail') || ok('context_pct') + ? usage.context_max + ? segs.compactCtx + ? `${fmtK(usage.context_used ?? 0)} tok` + : `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}` + : usage.total > 0 + ? `${fmtK(usage.total)} tok` + : '' : '' - const bar = !segs.compactCtx && usage.context_max ? ctxBar(pct) : '' + const bar = !segs.compactCtx && usage.context_max && ok('context_pct') ? ctxBar(pct) : '' const modelText = modelLabel(model, modelReasoningEffort, modelFast) // Battery read-out — the first (pinned) status-bar element when enabled. - const showBattery = !!battery && battery.available && battery.percent != null + const showBattery = !!battery && battery.available && battery.percent != null && ok('battery') const batteryText = showBattery ? batteryLabel(battery!) : '' const batteryColorVal = showBattery ? batteryColor(battery!, t) : '' const batteryWidth = showBattery ? stringWidth(`${batteryText} │ `) : 0 @@ -541,7 +555,7 @@ export function StatusRule({ stringWidth(modelText) + (ctxLabel ? stringWidth(' │ ') + stringWidth(ctxLabel) : 0) - const rightLabel = sessionTitle ? ` ${sessionTitle} ` : cwdLabel + const rightLabel = sessionTitle && ok('title') ? ` ${sessionTitle} ` : cwdLabel const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, rightLabel, essentialWidth) // Whole-segment progressive disclosure for the tail: a segment renders only @@ -575,7 +589,7 @@ export function StatusRule({ : '' const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`)) - const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH) + const showDuration = segs.duration && ok('duration') && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH) // Idle clock — time since the last final agent response. Hidden while busy // (the FaceTicker's elapsed tail covers the live turn) and before the first @@ -583,12 +597,26 @@ export function StatusRule({ const showIdle = segs.duration && !busy && lastTurnEndedAt != null && fits(SEP + stringWidth('✓ ') + MAX_DURATION_WIDTH) - const showCompressions = segs.compressions && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`)) - const showVoice = segs.voice && !!voiceLabel && fits(SEP + stringWidth(voiceLabel)) + const showCompressions = + segs.compressions && ok('compressions') && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`)) + + // Cache-hit % + rolling latency / tokens-per-sec — mirrored from the classic + // CLI bar (PR #98250). The server omits the keys when no data exists (zero + // cache reads, Codex app-server with no latency), so these self-hide. + const cacheHitText = typeof usage.cache_hit_pct === 'number' ? `◎ ${usage.cache_hit_pct}%` : '' + const showCacheHit = segs.cacheHit && ok('cache_hit') && !!cacheHitText && fits(SEP + stringWidth(cacheHitText)) + const latencyText = typeof usage.avg_latency_s === 'number' ? `◷ ${usage.avg_latency_s.toFixed(1)}s` : '' + const showLatency = segs.latency && ok('latency') && !!latencyText && fits(SEP + stringWidth(latencyText)) + const tpsText = typeof usage.avg_tps === 'number' ? `↑ ${Math.round(usage.avg_tps)} t/s` : '' + const showTps = segs.tps && ok('tps') && !!tpsText && fits(SEP + stringWidth(tpsText)) + + const showVoice = segs.voice && ok('voice') && !!voiceLabel && fits(SEP + stringWidth(voiceLabel)) const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText)) - const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`)) + const showBg = segs.bg && ok('bg_tasks') && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`)) const subagentCount = typeof usage.active_subagents === 'number' ? usage.active_subagents : 0 - const showSubagents = segs.subagents && subagentCount > 0 && fits(SEP + stringWidth(`⛓ ${subagentCount}`)) + + const showSubagents = + segs.subagents && ok('bg_subagents') && subagentCount > 0 && fits(SEP + stringWidth(`⛓ ${subagentCount}`)) // Parked-background reassurance: a top-level delegate_task runs in the // background, so the turn ends (idle) while the subagent keeps working and its @@ -705,6 +733,34 @@ export function StatusRule({ ) : null} + {showCacheHit ? ( + + {' │ '} + = 70 + ? t.color.statusGood + : usage.cache_hit_pct! >= 40 + ? t.color.statusWarn + : t.color.muted + } + > + {cacheHitText} + + + ) : null} + {showLatency ? ( + + {' │ '} + {latencyText} + + ) : null} + {showTps ? ( + + {' │ '} + {tpsText} + + ) : null} {showVoice ? ( statusColor: string t: Theme turnStartedAt?: null | number diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index f358097699..47eef82a26 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -506,6 +506,7 @@ const StatusRulePane = memo(function StatusRulePane({ sessionStartedAt={status.sessionStartedAt} sessionTitle={status.sessionTitle} status={ui.status} + statusBarFields={ui.statusBarFields} statusColor={status.statusColor} t={ui.theme} turnStartedAt={status.turnStartedAt} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 73ddee0a17..bc08637c14 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -88,6 +88,10 @@ export interface ConfigDisplayConfig { sections?: Record show_cost?: boolean show_reasoning?: boolean + /** CLI/TUI status-bar field visibility filter (shared with the classic + * CLI bar — see display.status_bar.fields in configuration docs). + * Raw YAML: callers must runtime-validate entries. */ + status_bar?: { fields?: unknown } streaming?: boolean thinking_mode?: string /** Show [HH:MM] timestamps on transcript rows — same key the classic CLI @@ -270,6 +274,9 @@ export interface SessionUndoResponse { export interface SessionUsageResponse { active_subagents?: number + avg_latency_s?: number + avg_tps?: number + cache_hit_pct?: number cache_read?: number cache_write?: number calls?: number diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 4da7cb5882..1803402bb5 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -207,6 +207,12 @@ export interface SessionInfo { export interface Usage { active_subagents?: number + /** Rolling mean API latency over the last 10 calls (seconds). */ + avg_latency_s?: number + /** Rolling output tokens/sec over the last 10 calls. */ + avg_tps?: number + /** Session prompt-cache hit ratio (cache_read / prompt tokens, %). */ + cache_hit_pct?: number calls: number compressions?: number context_max?: number diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 27ff51bd54..539bd0bf8d 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1989,6 +1989,7 @@ Notes: - Narrow terminals still drop wide-mode-only fields (`context_detail`, `cache_hit`, `latency`, `tps`, `prompt_elapsed`, `idle_since`) regardless of config (`cache_hit` also shows in the medium ≥52-col tier). - `latency`/`tps` stay hidden until API calls have been recorded (e.g. the Codex app-server backend reports no latency). - `battery` and `title` visibility here compose with their own toggles (`/battery`, `/title`) — both must be on for the segment to show. +- The same key also filters the **Ink TUI** status rule (`hermes tui`), where `cache_hit`, `latency`, and `tps` render as width-budgeted tail segments (◎ / ◷ / ↑) on terminals ≥96/104/110 columns respectively. - Display-only: no effect on prompt caching or request payloads. Changes take effect on the next session start. ### Runtime-metadata footer (gateway only)