oc_chat
Loading...
Searching...
No Matches
statusline.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_ui/terminal/statusline.py
4
5"""
6Shared status-bar data model and formatter — the realtime chrome spine.
7
8Purpose
9-------
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
18bar strings.
19
20Responsibilities
21----------------
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
33 adjacent to the bar.
34
35Diagnostics
36-----------
37Domain: OC_UI.TERMINAL
38Levels:
39 L2 — lifecycle events
40 L3 — semantic details
41 L4 — deep tracing
42
43Contracts
44---------
45- Framework-free: no ``rich`` / ``prompt_toolkit`` imports here — both
46 presenters format through this module instead of redefining bar
47 strings.
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
61 never dropped.
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
64 bar at every width.
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()``.
72
73Dependencies
74------------
75- oc_diagnostics (structured debug logging via _dbg)
76- oc_chat.core.formatting (lazy; format_duration for the turn, session and tool call timers)
77"""
78from __future__ import annotations
79
80import unicodedata
81from dataclasses import dataclass
82
83from oc_diagnostics import _dbg
84
85_dbg("OC_UI.TERMINAL", "module", "loaded", 4)
86
87
88@dataclass(frozen=True, slots=True)
90 """Pure snapshot of status-bar data for one render.
91
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.
98 """
99
100 model: str
101 """Active model id."""
102 session_name: str
103 """Session display name."""
104 effort: str
105 """Active reasoning-effort level (``low`` / ``medium`` / ``high``)."""
106
107 turn_tokens: int
108 """Total tokens (prompt + completion) for the in-progress or last turn."""
109 turn_iterations: int
110 """ReAct iteration count for the in-progress or last turn."""
111 turn_tools: int
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."""
117 turn_elapsed_ms: int
118 """Milliseconds elapsed in the in-progress turn, or the last completed turn's total duration when idle."""
119
120 session_tokens: int
121 """Total tokens (prompt + completion) accumulated this session."""
122 session_tools: int
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`."""
131
132 session_grant: str | None = None
133 """Short name of an active session Tier-2 python grant (plan 235 / SHELL-02), or ``None``.
134
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."""
140
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."""
154
155
156def fmt_tokens_short(n: int) -> str:
157 """Format a token count compactly: ``1.2K``, ``430K``, ``1.5M``."""
158 if n >= 1_000_000:
159 return f"{n / 1_000_000:.1f}M"
160 if n >= 1_000:
161 return f"{n / 1_000:.1f}K"
162 return str(n)
163
164
165def busy_hint_text(busy: bool) -> str:
166 """Return the dim hint line shown adjacent to the status bar.
167
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``
171 at the idle prompt.
172 """
173 if busy:
174 return "Ctrl+C stop current action (press twice to force-stop)"
175 return "Ctrl+D exit · Ctrl+P palette"
176
177
178def _display_width(text: str) -> int:
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)
181
182
183#: What separates the activity tail from the label+duration it follows.
184_TAIL_SEPARATOR = " · "
185
186#: Columns below which a tail is not worth showing at all. A two- or
187#: three-character stub plus an ellipsis tells the reader nothing and costs the
188#: bar the same space the session cluster could have used, so the tail is
189#: dropped whole instead.
190_MIN_TAIL_COLUMNS = 8
191
192
193def _truncate_to_width(text: str, columns: int) -> str:
194 """The longest prefix of *text* that fits in *columns* terminal columns."""
195 if columns <= 0:
196 return ""
197 kept: list[str] = []
198 used = 0
199 for ch in text:
200 width = 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
201 if used + width > columns:
202 break
203 kept.append(ch)
204 used += width
205 return "".join(kept)
206
207
208def _sanitized_tail(last_line: "str | None") -> str:
209 """*last_line* with control characters removed, ready for a Live region.
210
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.
214 """
215 if not last_line:
216 return ""
217 cleaned = "".join(
218 " " if (ch in "\t\r\n" or unicodedata.category(ch) == "Cc") else ch
219 for ch in last_line
220 )
221 return " ".join(cleaned.split())
222
223
225 label: str,
226 elapsed_ms: int,
227 last_line: "str | None",
228 width_left: int,
229) -> str:
230 """Compose the busy segment for an in-flight tool call (plan 279 P4).
231
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.
238
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.
243 """
244 from oc_chat.core.formatting import format_duration
245
246 base = f"{label} {format_duration(elapsed_ms)}" if label else format_duration(elapsed_ms)
247 tail = _sanitized_tail(last_line)
248 if not tail:
249 return base
250 budget = width_left - _display_width(_TAIL_SEPARATOR)
251 if budget < _MIN_TAIL_COLUMNS:
252 return base
253 if _display_width(tail) > budget:
254 tail = _truncate_to_width(tail, budget - 1) + "…"
255 return base + _TAIL_SEPARATOR + tail
256
257
259 state: StatusBarState, busy_label: "str | None", target_width: int,
260) -> int:
261 """Columns *target_width* still has free after the bar is rendered without a tail.
262
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.
267
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.
273 """
274 floor = _MIN_TAIL_COLUMNS + _display_width(_TAIL_SEPARATOR)
275 _segments, line, dropped = _fit(state, busy_label, target_width, _DROPPABLE)
276 room = max(0, target_width - _display_width(line))
277 if room >= floor:
278 return room
279 for field in _BEHIND_THE_TAIL:
280 dropped.add(field)
281 _segments, line, _dropped = _fit(
282 state, busy_label, target_width, _DROPPABLE, frozenset(dropped))
283 room = max(0, target_width - _display_width(line))
284 if room >= floor:
285 return room
286 return room
287
288
289# Droppable segment fields, in the pinned right-to-left drop priority
290# (first entry is dropped first under a narrow target width).
291_DROPPABLE = ("effort", "cache_saved", "cache_pct", "tools", "iters")
292
293#: The ranks that yield to the activity tail, and only to it (TUI-06): a bar
294#: carrying a tail gives up the session cluster and then the turn cluster —
295#: after every field in :data:`_DROPPABLE` is already gone — rather than shrink
296#: the tail or lose it. A bar with no tail never drops either cluster.
297_BEHIND_THE_TAIL = ("session", "turn")
298
299
300def _tail_of(busy_label: "str | None") -> str:
301 """The activity tail carried on *busy_label*, or ``""`` when there is none.
302
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).
307 """
308 if busy_label and _TAIL_SEPARATOR in busy_label:
309 return busy_label.partition(_TAIL_SEPARATOR)[2]
310 return ""
311
312
313def _drop_order(busy_label: "str | None") -> tuple[str, ...]:
314 """The ranks this bar may give up, in the pinned order.
315
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.
318 """
319 if _tail_of(busy_label):
320 return _DROPPABLE + _BEHIND_THE_TAIL
321 return _DROPPABLE
322
323
325 state: StatusBarState,
326 busy_label: "str | None",
327 target_width: int,
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.
332
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.
336 """
337 dropped = set(given_up)
338 segments = _build_segments(state, busy_label, frozenset(dropped))
339 line = " │ ".join(segments)
340 for field in order:
341 if _display_width(line) <= target_width:
342 break
343 if field in dropped:
344 continue
345 dropped.add(field)
346 segments = _build_segments(state, busy_label, frozenset(dropped))
347 line = " │ ".join(segments)
348 return segments, line, dropped
349
350
351def _turn_cluster(state: StatusBarState, drop: frozenset[str]) -> str:
352 from oc_chat.core.formatting import format_duration
353
354 parts: list[str] = []
355 if state.turn_tokens > 0:
356 parts.append(fmt_tokens_short(state.turn_tokens))
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:
361 n = state.turn_tools
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 ""
370
371
372def _standing_markers(state: StatusBarState) -> list[str]:
373 """The session cluster's never-droppable markers, in render order.
374
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.
382 """
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"
390 parts.append(cost)
391 if state.checkpoint_auto:
392 parts.append("auto")
393 return parts
394
395
396def _session_cluster(state: StatusBarState, drop: frozenset[str]) -> str:
397 from oc_chat.core.formatting import format_duration
398
399 parts: list[str] = []
400 if state.session_tokens > 0:
401 parts.append(fmt_tokens_short(state.session_tokens))
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")
409 # The session timer is the always-visible realtime element (D3) — it
410 # renders even at zero tokens, unlike the turn cluster above.
411 parts.append(format_duration(state.session_elapsed_ms))
412 parts.extend(_standing_markers(state))
413 return "session: " + " · ".join(parts)
414
415
417 state: StatusBarState, busy_label: str | None, drop: frozenset[str],
418) -> list[str]:
419 """The bar's segments, in fixed order, with every dropped rank left out.
420
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.
424 """
425 segments: list[str] = [f"model: {state.model}", f"name: {state.session_name}"]
426 if busy_label:
427 segments.append(busy_label)
428 if "turn" not in drop:
429 turn_seg = _turn_cluster(state, drop)
430 if turn_seg:
431 segments.append(turn_seg)
432 if "session" in drop:
433 # The cluster yields to the tail; its standing markers do not.
434 markers = _standing_markers(state)
435 if markers:
436 segments.append("session: " + " · ".join(markers))
437 else:
438 segments.append(_session_cluster(state, drop))
439 if "effort" not in drop:
440 segments.append(f"effort: {state.effort}")
441 return segments
442
443
445 state: StatusBarState,
446 busy_label: str | None,
447 target_width: int,
448) -> tuple[list[str], str]:
449 """Render the status bar, dropping segments right-to-left to fit *target_width*.
450
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
455 *target_width*.
456
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).
461 """
462 segments, line, _dropped = _fit(
463 state, busy_label, target_width, _drop_order(busy_label))
464 return segments, line
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)