6Shared status-bar data model and formatter — the realtime chrome spine.
10Owns the one pure snapshot type (:class:`StatusBarState`) and the one
11shared string-formatting logic for the always-visible status bar that
12plan 147 renders across all three UI regimes (idle prompt, streaming
13Live, tool-dispatch chrome). Framework-free by design — imports
14neither ``rich`` nor ``prompt_toolkit`` — so both
15:class:`~oc_ui.terminal.input.PromptDriver` (prompt_toolkit) and
16:class:`~oc_ui.terminal.rich_presenter.RichPresenter` (rich) render
17from the SAME segment/format logic instead of hand-rolling their own
22- :class:`StatusBarState` — frozen, slotted, pure data snapshot. No
23 busy/activity fields — that state is presenter-owned (see
24 :meth:`~oc_ui.presenter_base.Presenter.bind_status`).
25- :func:`fmt_tokens_short` — compact token-count formatting (``1.2K``).
26- :func:`render_status_line` — build the ordered segments for a
27 snapshot + busy label, then join/drop them to fit a target width.
28- :func:`busy_label_with_activity` — compose a busy label with the
29 in-flight tool call's elapsed time and last output line (plan 279).
30- :func:`busy_width_left` — the columns the bar still has free once it
31 has been rendered with a given busy label.
32- :func:`busy_hint_text` — the dim Ctrl+C/Ctrl+D hint line shown
45- Framework-free: no ``rich`` / ``prompt_toolkit`` imports here — both
46 presenters format through this module instead of redefining bar
48- ``render_status_line`` never wraps; it drops segments right-to-left
49 in the pinned order (effort -> cache-saved -> cache% -> tools ->
50 iters, then -- for a bar that carries an activity tail -- the session
51 cluster, then the turn cluster) until the rendered line fits
52 ``target_width``, then stops (the remaining core segments may still
53 overflow a very narrow width).
54- The activity tail OUTRANKS every droppable segment and both clusters
55 (operator ruling TUI-06, 2026-09-16; amended 2026-09-17): the bar gives
56 up effort, the cache percentages, the saved-token segments, the tool
57 and iteration counters and then the session and turn clusters -- in
58 that order -- before it will shrink the tail or drop it. The model, the
59 session name, the busy label with its elapsed timer, and the standing
60 markers (a live grant, the session's spend, the checkpoint latch) are
62- A bar with no in-flight tool call carries no tail: it stops dropping at
63 the end of ``_DROPPABLE`` and renders byte-identically to the pre-280
65- ``StatusBarState`` carries no busy/activity fields — presenters pass
66 ``busy_label`` as a separate argument; it is never captured on the
67 snapshot itself. Plan 279's tool timer and tail are composed INTO
68 that busy label by :func:`busy_label_with_activity`, so the snapshot
69 and the segment table are unchanged by it.
70- Width comparisons count wide/fullwidth Unicode (CJK) as two terminal
71 columns via :func:`unicodedata.east_asian_width`, not raw ``len()``.
75- oc_diagnostics (structured debug logging via _dbg)
76- oc_chat.core.formatting (lazy; format_duration for the turn, session and tool call timers)
78from __future__
import annotations
81from dataclasses
import dataclass
83from oc_diagnostics
import _dbg
85_dbg(
"OC_UI.TERMINAL",
"module",
"loaded", 4)
88@dataclass(frozen=True, slots=True)
90 """Pure snapshot of status-bar data for one render.
92 Busy/activity state is deliberately NOT here — it is presenter-owned
93 (driven by ``show_waiting`` / ``spinner_start`` / ``spinner_stop``)
94 and passed to :func:`render_status_line` as a separate ``busy_label``
95 argument, never captured on the snapshot. All duration fields are
96 integer milliseconds; all cache-hit fields are a 0-100 percentage or
97 ``None`` when no cache data is available yet.
101 """Active model id."""
103 """Session display name."""
105 """Active reasoning-effort level (``low`` / ``medium`` / ``high``)."""
108 """Total tokens (prompt + completion) for the in-progress or last turn."""
110 """ReAct iteration count for the in-progress or last turn."""
112 """Tool-call count for the in-progress or last turn."""
113 turn_cache_hit_pct: float |
None
114 """Cache-hit ratio (0-100) for the in-progress or last turn; ``None`` = no cache data."""
115 turn_cache_saved_tokens: int
116 """Prompt tokens saved by cache hits, in-progress or last turn."""
118 """Milliseconds elapsed in the in-progress turn, or the last completed turn's total duration when idle."""
121 """Total tokens (prompt + completion) accumulated this session."""
123 """Total tool calls accumulated this session."""
124 session_cache_hit_pct: float |
None
125 """Cache-hit ratio (0-100) across the session; ``None`` = no cache data."""
126 session_cache_saved_tokens: int
127 """Prompt tokens saved by cache hits across the session."""
128 session_elapsed_ms: int
129 """Milliseconds since the active session became current in this process. Resets whenever the active
130 session is replaced or (re)loaded — see :func:`oc_chat.core.commands.refresh_presenter_bindings`."""
132 session_grant: str |
None =
None
133 """Short name of an active session Tier-2 python grant (plan 235 / SHELL-02), or ``None``.
135 The only optional field on this snapshot: a status bar that has never seen a
136 grant renders byte-identically to the pre-235 one. When set (the granted
137 directory's name, e.g. ``_work``), it marks that Tier-2 python runs in that
138 directory currently skip the human prompt — a standing authority the operator
139 must be able to see without asking for it."""
141 session_cost_usd: float |
None =
None
142 """Priced session spend in USD (plan 237 / CAPS-01), or ``None`` when
143 nothing has been priced yet. ``None`` renders nothing at all, so a status
144 bar that has never seen a priced sample is byte-identical to the pre-237 one."""
145 unpriced_samples: int = 0
146 """Usage samples that could NOT be priced this session. Rendered beside the
147 dollar figure when non-zero, because that figure is a LOWER BOUND and a
148 reader who cannot see how many samples are missing would take it for the
149 whole story — the residual the hard cap cannot cover."""
150 checkpoint_auto: bool =
False
151 """True while the tool-call checkpoint is in "don't ask again this session"
152 (the operator's ``a``). Like ``session_grant`` this is a standing decision
153 to stop asking, so it stays visible for as long as it holds."""
157 """Format a token count compactly: ``1.2K``, ``430K``, ``1.5M``."""
159 return f
"{n / 1_000_000:.1f}M"
161 return f
"{n / 1_000:.1f}K"
166 """Return the dim hint line shown adjacent to the status bar.
168 *busy* — ``True`` while a turn is streaming or tools are dispatching
169 (the wording is verified against the two-stage SIGINT handler); Ctrl+D
170 is never advertised while busy since it does nothing there. ``False``
174 return "Ctrl+C stop current action (press twice to force-stop)"
175 return "Ctrl+D exit · Ctrl+P palette"
179 """Approximate terminal column width — wide/fullwidth CJK counts as 2 columns."""
180 return sum(2
if unicodedata.east_asian_width(ch)
in (
"W",
"F")
else 1
for ch
in text)
184_TAIL_SEPARATOR =
" · "
194 """The longest prefix of *text* that fits in *columns* terminal columns."""
200 width = 2
if unicodedata.east_asian_width(ch)
in (
"W",
"F")
else 1
201 if used + width > columns:
209 """*last_line* with control characters removed, ready for a Live region.
211 A child's output is arbitrary bytes: a carriage return would rewind the
212 cursor mid-bar and an ANSI escape would restyle everything after it, so the
213 C0/C1 ranges are dropped rather than passed through to the terminal.
218 " " if (ch
in "\t\r\n" or unicodedata.category(ch) ==
"Cc")
else ch
221 return " ".join(cleaned.split())
227 last_line:
"str | None",
230 """Compose the busy segment for an in-flight tool call (plan 279 P4).
232 Returns ``"<label> <duration>"``, plus ``" · <last line>"` when the bar has
233 room for it. *width_left* is the number of columns the bar still has free
234 once it has been rendered with the label and duration alone
235 (:func:`busy_width_left` computes it), so a tail added here can never push
236 a state segment off the line: it either fits in the space nothing else
237 wanted, is truncated with a visible ``…`` to fit it, or is dropped whole.
239 Pure and framework-free, so the truncate-then-drop behaviour is testable at
240 any width without a terminal. The elapsed time is rendered by
241 :func:`~oc_chat.core.formatting.format_duration`, the same formatter the
242 turn and session timers use, so all three durations on the bar read alike.
246 base = f
"{label} {format_duration(elapsed_ms)}" if label
else format_duration(elapsed_ms)
251 if budget < _MIN_TAIL_COLUMNS:
255 return base + _TAIL_SEPARATOR + tail
259 state: StatusBarState, busy_label:
"str | None", target_width: int,
261 """Columns *target_width* still has free after the bar is rendered without a tail.
263 The counterpart to :func:`busy_label_with_activity`: render once with the
264 label alone to learn what the state segments actually took, then spend only
265 what is left on the tail. Never negative — a bar whose always-on core already
266 overflows a very narrow terminal has nothing to spare.
268 The tail outranks every droppable segment and both clusters (TUI-06), so a
269 bar with less than the tail's minimum room gives those up, one rank at a
270 time and in the pinned order, until the tail has its place — or until
271 nothing droppable is left, in which case the room returned stays below the
272 caller's floor and :func:`busy_label_with_activity` drops the tail.
275 _segments, line, dropped =
_fit(state, busy_label, target_width, _DROPPABLE)
279 for field
in _BEHIND_THE_TAIL:
281 _segments, line, _dropped =
_fit(
282 state, busy_label, target_width, _DROPPABLE, frozenset(dropped))
291_DROPPABLE = (
"effort",
"cache_saved",
"cache_pct",
"tools",
"iters")
297_BEHIND_THE_TAIL = (
"session",
"turn")
301 """The activity tail carried on *busy_label*, or ``""`` when there is none.
303 :func:`busy_label_with_activity` is the only thing that appends
304 ``_TAIL_SEPARATOR`` to a busy label, so the separator IS the tail's marker
305 (no production label contains it: ``waiting for response…`` and
306 ``running <tool>…`` are the two the tree builds).
308 if busy_label
and _TAIL_SEPARATOR
in busy_label:
309 return busy_label.partition(_TAIL_SEPARATOR)[2]
314 """The ranks this bar may give up, in the pinned order.
316 A bar with no tail stops at :data:`_DROPPABLE`, which is what keeps the
317 no-activity bar byte-identical to the pre-280 one at every width.
320 return _DROPPABLE + _BEHIND_THE_TAIL
325 state: StatusBarState,
326 busy_label:
"str | None",
328 order: tuple[str, ...],
329 given_up: frozenset[str] = frozenset(),
330) ->
"tuple[list[str], str, set[str]]":
331 """Build the segments, then drop *order* rank by rank until the line fits.
333 *given_up* are ranks already conceded before the loop starts (the tail's
334 callers hand it the prefix it has decided to yield). Returns the kept
335 segments, the line, and the full set of dropped ranks.
337 dropped = set(given_up)
339 line =
" │ ".join(segments)
347 line =
" │ ".join(segments)
348 return segments, line, dropped
354 parts: list[str] = []
355 if state.turn_tokens > 0:
357 if state.turn_iterations > 0
and "iters" not in drop:
358 n = state.turn_iterations
359 parts.append(f
"{n} iter{'s' if n != 1 else ''}")
360 if state.turn_tools > 0
and "tools" not in drop:
362 parts.append(f
"{n} tool{'s' if n != 1 else ''}")
363 if state.turn_cache_hit_pct
is not None and "cache_pct" not in drop:
364 parts.append(f
"{state.turn_cache_hit_pct:.0f}% cache")
365 if state.turn_cache_hit_pct
is not None and "cache_saved" not in drop:
366 parts.append(f
"saved ~{fmt_tokens_short(state.turn_cache_saved_tokens)} tok")
367 if state.turn_elapsed_ms > 0:
368 parts.append(format_duration(state.turn_elapsed_ms))
369 return "this turn: " +
" · ".join(parts)
if parts
else ""
373 """The session cluster's never-droppable markers, in render order.
375 Plan 235 (SHELL-02): a live Tier-2 python grant is a standing authority.
376 Plan 237 (CAPS-01): money, and the honesty that has to travel with it.
377 Deliberately NOT in ``_DROPPABLE`` for the same reason: a narrow terminal
378 may lose a cache percentage, never what the session is spending, the fact
379 that a checkpoint has stopped asking, or the fact that a confirmation gate
380 is down. Plan 280 keeps them when the session cluster itself yields to the
381 activity tail — the cluster is droppable, these are not.
383 parts: list[str] = []
384 if state.session_grant:
385 parts.append(f
"grant:{state.session_grant}")
386 if state.session_cost_usd
is not None:
387 cost = f
"${state.session_cost_usd:.2f}"
388 if state.unpriced_samples > 0:
389 cost += f
" +{state.unpriced_samples} unpriced"
391 if state.checkpoint_auto:
399 parts: list[str] = []
400 if state.session_tokens > 0:
402 if state.session_tools > 0
and "tools" not in drop:
403 n = state.session_tools
404 parts.append(f
"{n} tool{'s' if n != 1 else ''}")
405 if state.session_cache_hit_pct
is not None and "cache_pct" not in drop:
406 parts.append(f
"{state.session_cache_hit_pct:.0f}% cache")
407 if state.session_cache_hit_pct
is not None and "cache_saved" not in drop:
408 parts.append(f
"saved ~{fmt_tokens_short(state.session_cache_saved_tokens)} tok")
411 parts.append(format_duration(state.session_elapsed_ms))
413 return "session: " +
" · ".join(parts)
417 state: StatusBarState, busy_label: str |
None, drop: frozenset[str],
419 """The bar's segments, in fixed order, with every dropped rank left out.
421 ``drop`` holds droppable field names (:data:`_DROPPABLE`) and, for a bar
422 carrying an activity tail, the cluster ranks (plan 280). The model, the
423 session name, the busy label and the standing markers are never in it.
425 segments: list[str] = [f
"model: {state.model}", f
"name: {state.session_name}"]
427 segments.append(busy_label)
428 if "turn" not in drop:
431 segments.append(turn_seg)
432 if "session" in drop:
436 segments.append(
"session: " +
" · ".join(markers))
439 if "effort" not in drop:
440 segments.append(f
"effort: {state.effort}")
445 state: StatusBarState,
446 busy_label: str |
None,
448) -> tuple[list[str], str]:
449 """Render the status bar, dropping segments right-to-left to fit *target_width*.
451 Returns ``(kept_segments, rendered_line)``. Never wraps: once every
452 droppable field (:data:`_DROPPABLE`) is gone — and, for a bar carrying an
453 activity tail, both clusters as well — the line is returned as-is even if
454 the always-on core (model/name/busy/tokens/durations) still overflows
457 The drop order is the pinned one: effort, cache-saved, cache%, tools,
458 iters, and — only for the bar that carries plan 279's activity tail — the
459 session cluster and then the turn cluster. The tail outranks all of them
460 (TUI-06 ruling, 2026-09-16, amended 2026-09-17).
462 segments, line, _dropped =
_fit(
463 state, busy_label, target_width,
_drop_order(busy_label))
464 return segments, line
str fmt_tokens_short(int n)
int _display_width(str text)
tuple[str,...] _drop_order("str | None" busy_label)
str _session_cluster(StatusBarState state, frozenset[str] drop)
str _tail_of("str | None" busy_label)
list[str] _build_segments(StatusBarState state, str|None busy_label, frozenset[str] drop)
str busy_label_with_activity(str label, int elapsed_ms, "str | None" last_line, int width_left)
str busy_hint_text(bool busy)
str _sanitized_tail("str | None" last_line)
str _truncate_to_width(str text, int columns)
str _turn_cluster(StatusBarState state, frozenset[str] drop)
list[str] _standing_markers(StatusBarState state)
tuple[list[str], str] render_status_line(StatusBarState state, str|None busy_label, int target_width)
"tuple[list[str], str, set[str]]" _fit(StatusBarState state, "str | None" busy_label, int target_width, tuple[str,...] order, frozenset[str] given_up=frozenset())
int busy_width_left(StatusBarState state, "str | None" busy_label, int target_width)