feat(tui): status rule shows cache-hit %, latency, t/s and honors display.status_bar.fields

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
This commit is contained in:
Teknium
2026-08-29 18:59:06 -07:00
parent 2215fb0e35
commit 86a2fdc634
12 changed files with 256 additions and 16 deletions
+45
View File
@@ -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)
+33
View File
@@ -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.
@@ -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')
})
})
+12 -1
View File
@@ -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)
+4
View File
@@ -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<string>
streaming: boolean
theme: Theme
// `display.timestamps` — dim [HH:MM] labels on user/assistant transcript
+1
View File
@@ -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
+15
View File
@@ -28,6 +28,20 @@ const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
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<string> => {
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<BusyInputMode>(['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.
+74 -15
View File
@@ -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({
</Text>
</Text>
) : null}
{showCacheHit ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
<Text
color={
usage.cache_hit_pct! >= 70
? t.color.statusGood
: usage.cache_hit_pct! >= 40
? t.color.statusWarn
: t.color.muted
}
>
{cacheHitText}
</Text>
</Text>
) : null}
{showLatency ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{latencyText}
</Text>
) : null}
{showTps ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
{tpsText}
</Text>
) : null}
{showVoice ? (
<Text
color={
@@ -872,6 +928,9 @@ interface StatusRuleProps {
sessionStartedAt?: null | number
sessionTitle?: string
status: string
// display.status_bar.fields — segment visibility filter shared with the
// classic CLI bar. null = defaults (everything shows).
statusBarFields?: null | ReadonlySet<string>
statusColor: string
t: Theme
turnStartedAt?: null | number
+1
View File
@@ -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}
+7
View File
@@ -88,6 +88,10 @@ export interface ConfigDisplayConfig {
sections?: Record<string, string>
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
+6
View File
@@ -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
+1
View File
@@ -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)