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:
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user