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"
|
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):
|
def test_get_usage_includes_active_subagents(monkeypatch):
|
||||||
import tools.async_delegation as ad_mod
|
import tools.async_delegation as ad_mod
|
||||||
monkeypatch.setattr(ad_mod, "active_count", lambda: 4)
|
monkeypatch.setattr(ad_mod, "active_count", lambda: 4)
|
||||||
|
|||||||
@@ -7156,6 +7156,39 @@ def _get_usage(agent) -> dict:
|
|||||||
usage["context_max"] = ctx_max
|
usage["context_max"] = ctx_max
|
||||||
usage["context_percent"] = max(0, min(100, round(last_prompt / ctx_max * 100)))
|
usage["context_percent"] = max(0, min(100, round(last_prompt / ctx_max * 100)))
|
||||||
usage["compressions"] = getattr(comp, "compression_count", 0) or 0
|
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
|
# Live count of background/async subagents still running (delegate_task
|
||||||
# batches + background single delegations). Mirrors the classic CLI status
|
# batches + background single delegations). Mirrors the classic CLI status
|
||||||
# bar's ⛓ indicator; sourced from the same async_delegation registry.
|
# 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()
|
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,
|
compressions: true,
|
||||||
voice: true,
|
voice: true,
|
||||||
bg: true,
|
bg: true,
|
||||||
subagents: true
|
subagents: true,
|
||||||
|
cacheHit: true,
|
||||||
|
latency: true,
|
||||||
|
tps: true
|
||||||
} satisfies StatusBarSegments)
|
} 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', () => {
|
it('collapses the context bar to a token count on narrow terminals', () => {
|
||||||
const s = statusBarSegments(60)
|
const s = statusBarSegments(60)
|
||||||
|
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ export interface UiState {
|
|||||||
sid: null | string
|
sid: null | string
|
||||||
status: string
|
status: string
|
||||||
statusBar: StatusBarMode
|
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
|
streaming: boolean
|
||||||
theme: Theme
|
theme: Theme
|
||||||
// `display.timestamps` — dim [HH:MM] labels on user/assistant transcript
|
// `display.timestamps` — dim [HH:MM] labels on user/assistant transcript
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const buildUiState = (): UiState => ({
|
|||||||
sid: null,
|
sid: null,
|
||||||
status: 'summoning hermes…',
|
status: 'summoning hermes…',
|
||||||
statusBar: 'top',
|
statusBar: 'top',
|
||||||
|
statusBarFields: null,
|
||||||
streaming: true,
|
streaming: true,
|
||||||
timestamps: false,
|
timestamps: false,
|
||||||
// Last session's resolved theme paints frame one (flash-free boot, like
|
// 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 =>
|
export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
|
||||||
raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top'
|
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'])
|
const BUSY_MODES = new Set<BusyInputMode>(['interrupt', 'queue', 'steer'])
|
||||||
|
|
||||||
// TUI defaults to `queue` even though the framework default
|
// TUI defaults to `queue` even though the framework default
|
||||||
@@ -289,6 +303,7 @@ export const applyDisplay = (
|
|||||||
sections: resolveSections(d.sections),
|
sections: resolveSections(d.sections),
|
||||||
showReasoning: !!d.show_reasoning,
|
showReasoning: !!d.show_reasoning,
|
||||||
statusBar: normalizeStatusBar(d.tui_statusbar),
|
statusBar: normalizeStatusBar(d.tui_statusbar),
|
||||||
|
statusBarFields: normalizeStatusBarFields(d.status_bar?.fields),
|
||||||
streaming: d.streaming !== false,
|
streaming: d.streaming !== false,
|
||||||
// The SAME key that stamps [HH:MM] on classic-CLI labels (#41531) —
|
// The SAME key that stamps [HH:MM] on classic-CLI labels (#41531) —
|
||||||
// no separate TUI knob.
|
// no separate TUI knob.
|
||||||
|
|||||||
@@ -291,10 +291,13 @@ export function statusRuleWidths(cols: number, cwdLabel: string, minLeftContent
|
|||||||
export interface StatusBarSegments {
|
export interface StatusBarSegments {
|
||||||
bar: boolean
|
bar: boolean
|
||||||
bg: boolean
|
bg: boolean
|
||||||
|
cacheHit: boolean
|
||||||
compactCtx: boolean
|
compactCtx: boolean
|
||||||
compressions: boolean
|
compressions: boolean
|
||||||
duration: boolean
|
duration: boolean
|
||||||
|
latency: boolean
|
||||||
subagents: boolean
|
subagents: boolean
|
||||||
|
tps: boolean
|
||||||
voice: boolean
|
voice: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +311,10 @@ export function statusBarSegments(cols: number): StatusBarSegments {
|
|||||||
compressions: w >= 80,
|
compressions: w >= 80,
|
||||||
voice: w >= 84,
|
voice: w >= 84,
|
||||||
bg: w >= 88,
|
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,
|
cols,
|
||||||
busy,
|
busy,
|
||||||
status,
|
status,
|
||||||
|
statusBarFields = null,
|
||||||
statusColor,
|
statusColor,
|
||||||
model,
|
model,
|
||||||
modelFast,
|
modelFast,
|
||||||
@@ -491,21 +498,28 @@ export function StatusRule({
|
|||||||
const barColor = ctxBarColor(pct, t)
|
const barColor = ctxBarColor(pct, t)
|
||||||
const segs = statusBarSegments(cols)
|
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
|
// On narrow terminals the context read-out collapses to a bare token count
|
||||||
// (`12k tok`) and the visual fill bar is dropped entirely.
|
// (`12k tok`) and the visual fill bar is dropped entirely.
|
||||||
const ctxLabel = usage.context_max
|
const ctxLabel =
|
||||||
? segs.compactCtx
|
ok('context_detail') || ok('context_pct')
|
||||||
? `${fmtK(usage.context_used ?? 0)} tok`
|
? usage.context_max
|
||||||
: `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}`
|
? segs.compactCtx
|
||||||
: usage.total > 0
|
? `${fmtK(usage.context_used ?? 0)} tok`
|
||||||
? `${fmtK(usage.total)} 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)
|
const modelText = modelLabel(model, modelReasoningEffort, modelFast)
|
||||||
|
|
||||||
// Battery read-out — the first (pinned) status-bar element when enabled.
|
// 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 batteryText = showBattery ? batteryLabel(battery!) : ''
|
||||||
const batteryColorVal = showBattery ? batteryColor(battery!, t) : ''
|
const batteryColorVal = showBattery ? batteryColor(battery!, t) : ''
|
||||||
const batteryWidth = showBattery ? stringWidth(`${batteryText} │ `) : 0
|
const batteryWidth = showBattery ? stringWidth(`${batteryText} │ `) : 0
|
||||||
@@ -541,7 +555,7 @@ export function StatusRule({
|
|||||||
stringWidth(modelText) +
|
stringWidth(modelText) +
|
||||||
(ctxLabel ? stringWidth(' │ ') + stringWidth(ctxLabel) : 0)
|
(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)
|
const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, rightLabel, essentialWidth)
|
||||||
|
|
||||||
// Whole-segment progressive disclosure for the tail: a segment renders only
|
// 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 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
|
// 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
|
// (the FaceTicker's elapsed tail covers the live turn) and before the first
|
||||||
@@ -583,12 +597,26 @@ export function StatusRule({
|
|||||||
const showIdle =
|
const showIdle =
|
||||||
segs.duration && !busy && lastTurnEndedAt != null && fits(SEP + stringWidth('✓ ') + MAX_DURATION_WIDTH)
|
segs.duration && !busy && lastTurnEndedAt != null && fits(SEP + stringWidth('✓ ') + MAX_DURATION_WIDTH)
|
||||||
|
|
||||||
const showCompressions = segs.compressions && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`))
|
const showCompressions =
|
||||||
const showVoice = segs.voice && !!voiceLabel && fits(SEP + stringWidth(voiceLabel))
|
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 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 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
|
// Parked-background reassurance: a top-level delegate_task runs in the
|
||||||
// background, so the turn ends (idle) while the subagent keeps working and its
|
// background, so the turn ends (idle) while the subagent keeps working and its
|
||||||
@@ -705,6 +733,34 @@ export function StatusRule({
|
|||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : 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 ? (
|
{showVoice ? (
|
||||||
<Text
|
<Text
|
||||||
color={
|
color={
|
||||||
@@ -872,6 +928,9 @@ interface StatusRuleProps {
|
|||||||
sessionStartedAt?: null | number
|
sessionStartedAt?: null | number
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
status: 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
|
statusColor: string
|
||||||
t: Theme
|
t: Theme
|
||||||
turnStartedAt?: null | number
|
turnStartedAt?: null | number
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ const StatusRulePane = memo(function StatusRulePane({
|
|||||||
sessionStartedAt={status.sessionStartedAt}
|
sessionStartedAt={status.sessionStartedAt}
|
||||||
sessionTitle={status.sessionTitle}
|
sessionTitle={status.sessionTitle}
|
||||||
status={ui.status}
|
status={ui.status}
|
||||||
|
statusBarFields={ui.statusBarFields}
|
||||||
statusColor={status.statusColor}
|
statusColor={status.statusColor}
|
||||||
t={ui.theme}
|
t={ui.theme}
|
||||||
turnStartedAt={status.turnStartedAt}
|
turnStartedAt={status.turnStartedAt}
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ export interface ConfigDisplayConfig {
|
|||||||
sections?: Record<string, string>
|
sections?: Record<string, string>
|
||||||
show_cost?: boolean
|
show_cost?: boolean
|
||||||
show_reasoning?: 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
|
streaming?: boolean
|
||||||
thinking_mode?: string
|
thinking_mode?: string
|
||||||
/** Show [HH:MM] timestamps on transcript rows — same key the classic CLI
|
/** Show [HH:MM] timestamps on transcript rows — same key the classic CLI
|
||||||
@@ -270,6 +274,9 @@ export interface SessionUndoResponse {
|
|||||||
|
|
||||||
export interface SessionUsageResponse {
|
export interface SessionUsageResponse {
|
||||||
active_subagents?: number
|
active_subagents?: number
|
||||||
|
avg_latency_s?: number
|
||||||
|
avg_tps?: number
|
||||||
|
cache_hit_pct?: number
|
||||||
cache_read?: number
|
cache_read?: number
|
||||||
cache_write?: number
|
cache_write?: number
|
||||||
calls?: number
|
calls?: number
|
||||||
|
|||||||
@@ -207,6 +207,12 @@ export interface SessionInfo {
|
|||||||
|
|
||||||
export interface Usage {
|
export interface Usage {
|
||||||
active_subagents?: number
|
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
|
calls: number
|
||||||
compressions?: number
|
compressions?: number
|
||||||
context_max?: 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).
|
- 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).
|
- `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.
|
- `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.
|
- Display-only: no effect on prompt caching or request payloads. Changes take effect on the next session start.
|
||||||
|
|
||||||
### Runtime-metadata footer (gateway only)
|
### Runtime-metadata footer (gateway only)
|
||||||
|
|||||||
Reference in New Issue
Block a user