oc_chat
Loading...
Searching...
No Matches
rich_presenter.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_ui/terminal/rich_presenter.py
4
5"""
6Rich-based presenter for OC Chat (Phase 1.A — skeleton).
7
8Purpose
9-------
10Implements the :class:`~oc_ui.presenter_base.Presenter` contract
11using the `rich <https://rich.readthedocs.io/>`_ library. This first
12slice (PR 1.A) ships:
13
14* Styled welcome banner via ``rich.panel.Panel``.
15* Pretty tables for ``/models``, ``/session list``, ``/help`` via
16 ``rich.table.Table`` — auto column-widths, overflow handling,
17 borders.
18* Consistent colour theme for info/error/compressed/turn-summary
19 lines.
20* No streaming overlay yet — :meth:`turn` is a no-op contextmanager
21 and :meth:`show_reasoning` / :meth:`show_content` print each chunk
22 via ``Console.print(end="")``. Live streaming + Markdown post-commit
23 arrive in PR 1.B.
24
25To enable, install the optional dep::
26
27 pip install -e .[rich]
28
29Then run::
30
31 python OC Chat.py --ui rich
32
33If ``rich`` is missing, ``oc_chat.main`` lazy-imports this module and
34falls back to :class:`~oc_ui.terminal.presenter.ConsolePresenter`
35with a warning.
36
37Responsibilities
38----------------
39- :class:`RichPresenter` — concrete :class:`Presenter` backed by
40 ``rich.console.Console``.
41- Themed output via :class:`rich.panel.Panel` and
42 :class:`rich.table.Table` for the structured commands.
43- Plain ``Console.print`` fallback for streaming chunks pending
44 the PR 1.B Live overlay.
45
46Diagnostics
47-----------
48Domain: OC_UI.TERMINAL
49Levels:
50 L2 — lifecycle events
51 L3 — semantic details
52 L4 — deep tracing
53
54Contracts
55---------
56- Importing this module without ``rich`` installed raises
57 :class:`ImportError`; ``main.py`` is responsible for the optional-
58 dep gate.
59- Theme colours are taken from one ``rich.theme.Theme`` instance so
60 callers can override centrally.
61- Behavioural parity with :class:`ConsolePresenter` for any non-
62 streaming hook — the rich version only adds visual polish.
63
64Dependencies
65------------
66- oc_diagnostics (structured debug logging via _dbg)
67- rich.console (terminal rendering — panels, tables, syntax highlighting)
68- rich.live (terminal rendering — panels, tables, syntax highlighting)
69- rich.markdown (terminal rendering — panels, tables, syntax highlighting)
70- rich.padding (terminal rendering — panels, tables, syntax highlighting)
71- rich.panel (terminal rendering — panels, tables, syntax highlighting)
72- rich.status (terminal rendering — panels, tables, syntax highlighting)
73- rich.table (terminal rendering — panels, tables, syntax highlighting)
74- rich.text (terminal rendering — panels, tables, syntax highlighting)
75- oc_ui.presenter_base (Presenter ABC contract)
76- oc_ui.terminal._render (RenderBatcher for batched Live refreshes while streaming)
77- oc_ui.terminal.activity (plan_progress_from_result for the plan progress line under planner results)
78- oc_ui.terminal.diffview (mutation argument pairing and the diff model for write_file and edit_file results)
79- oc_ui.terminal.statusline (StatusBarState and status line rendering for the Live status bar)
80- oc_ui.terminal.turn_layout (turn indent, rule composition and answer box titles)
81- oc_chat.core.commands (lazy; _fmt_tokens_display for the session list table)
82- oc_chat.core.config (lazy; REASONING_EFFORT_LEVELS for the welcome panel)
83- oc_chat.core.formatting (lazy; format_duration for the turn summary)
84- oc_chat.core.iteration_stat (lazy; iteration_stat_human_line for the answer box bottom border and stats line)
85- oc_chat.core.persistence (lazy; save_tool_artifact for spilling long tool results to a file)
86- oc_chat.core.session (lazy; ChatSession type hint for bind_session and show_welcome)
87- oc_chat.core.tool_activity (lazy; TOOL_ACTIVITY snapshot of the in-flight tool call for the status bar)
88- oc_ui.terminal.presenter (lazy; ConsolePresenter._fmt_turn_cost for the shared turn cost segment)
89- rich.spinner (animated spinners for the waiting indicator and dispatch chrome)
90"""
91from __future__ import annotations
92
93import json
94from contextlib import contextmanager
95from datetime import datetime
96from typing import TYPE_CHECKING, Callable, Iterator
97
98from oc_diagnostics import _dbg
99
100if TYPE_CHECKING:
101 from oc_chat.core.session import ChatSession
102
103import os
104
105from rich.console import Console, Group, RenderableType
106from rich.live import Live
107from rich.markdown import Markdown
108from rich.padding import Padding
109from rich.panel import Panel
110from rich.spinner import Spinner
111from rich.status import Status
112from rich.table import Table
113from rich.text import Text
114
115from oc_ui.presenter_base import INTERRUPT_NOTICES, Presenter, SpinnerHandle
116from oc_ui.terminal.activity import plan_progress_from_result
117from oc_ui.terminal.diffview import (
118 DiffRender,
119 MutationArgsLedger,
120 build_mutation_diff,
121 diff_footer_line,
122 diff_header_line,
123 summarize_mutation_call,
124)
125from oc_ui.terminal.statusline import (
126 StatusBarState,
127 busy_hint_text,
128 busy_label_with_activity,
129 busy_width_left,
130 render_status_line,
131)
132from oc_ui.terminal.turn_layout import (
133 ANSWER_BOX_TITLE,
134 REASONING_TITLE,
135 TURN_INDENT,
136 compose_rule,
137 rule_fits,
138 running_total_text,
139 start_rule_label,
140 turn_end_rule_segments,
141 turn_rule_label,
142)
143from ._render import RenderBatcher
144
145# How big a tool-result string must be before we spill it to a file
146# instead of dumping it to scrollback. Configurable per-session via
147# ``meta.json[ui][tool_spill_chars]``.
148DEFAULT_TOOL_SPILL_CHARS = 4000
149
150
151# ─── Theme ────────────────────────────────────────────────────────────────────
152# A single source of truth for colour choices. PR 1.D will make this
153# user-configurable via /ui theme; for PR 1.A the values are baked in.
154_THEME = {
155 "rule": "dim cyan",
156 "turn_label": "dim",
157 "waiting": "italic dim cyan",
158 "reasoning": "dim italic cyan",
159 "content": "default",
160 "tool_call": "yellow",
161 "tool_result": "green",
162 "tool_error": "red",
163 "info": "dim",
164 "error": "bold red",
165 "warning": "yellow",
166 "compressed": "dim",
167 "summary": "dim",
168 "panel_border": "cyan",
169 "panel_title": "bold cyan",
170 # Plan 188 T1 — diff view. Rich honours NO_COLOR natively, so these degrade
171 # to plain text without a separate code path.
172 "diff_add": "green",
173 "diff_del": "red",
174 "diff_hunk": "cyan",
175 "diff_header": "dim",
176}
177
178
179#: Rule-segment tag → theme key (plan 218 P3). The fill keeps the rule's
180#: colour; the label keeps the rule TITLE's colour (they were one style
181#: pre-218); the absorbed stats and the running total keep the summary
182#: colour the standalone stats line and the rprompt already used. Moving
183#: text into a new position must never repaint it.
184_RULE_TAG_STYLES = {
185 "fill": "rule",
186 "label": "rule",
187 "stats": "summary",
188 "trailer": "summary",
189}
190
191
192def build_answer_panel(body: RenderableType, subtitle: str = "") -> Panel:
193 """Build the plan-218 answer box.
194
195 P1 element 2/3: ``Assistant Answer:`` is cut into the TOP border
196 (left-aligned — the session box's own title-in-border mechanic, just
197 anchored left per the operator's mock) and the per-iteration metadata
198 line is cut into the BOTTOM border, so the numbers that describe the
199 answer are attached to the answer instead of floating under it.
200
201 Every style token is borrowed from the session box
202 (:meth:`RichPresenter.show_welcome`) rather than invented: same
203 ``panel_border`` colour, same default box, same ``(1, 2)`` padding.
204 P3 — no new colours.
205
206 *subtitle* is the metadata string; empty renders a plain bottom
207 border (a turn whose iteration stats are suppressed still closes its
208 box — it just closes it without numbers).
209 """
210 return Panel(
211 body,
212 title=ANSWER_BOX_TITLE,
213 title_align="left",
214 subtitle=subtitle or None,
215 border_style=_THEME["panel_border"],
216 padding=(1, 2),
217 )
218
219
221 """Time-aware Live-region renderable for the D3 status bar + D4 hint line.
222
223 Rich's ``Live`` auto-refresh (``refresh_per_second`` / ``auto_refresh``,
224 both left at their existing values per the D3 pin) re-renders whatever
225 renderable object is currently stored by calling its ``__rich_console__``
226 afresh on every tick — but a plain ``rich.text.Text`` bakes its string in
227 at construction time, so re-rendering the SAME ``Text`` object forever
228 reproduces the SAME string (the "never ticks" hazard). This class defers
229 every computation into ``__rich_console__`` itself so each Rich render
230 pass — not just each explicit ``Live.update()`` call — reads a fresh
231 :class:`~oc_ui.terminal.statusline.StatusBarState` snapshot and a fresh
232 elapsed duration. Reusable as the trailing element of both the streaming
233 Live group (regime b) and the dispatch-chrome Live group (regime c) —
234 "one renderable definition, three hosts" per the plan.
235 """
236
237 def __init__(self, presenter: "RichPresenter") -> None:
238 self._presenter = presenter
239
240 def _busy_label_with_activity(self, state, width: int) -> "str | None":
241 """The presenter's busy label, plus the in-flight tool call (plan 279 P4).
242
243 Read fresh on every render pass, exactly as the status snapshot and the
244 busy label already are, so the tool's elapsed time ticks for the same
245 reason the turn timer does. With no call in flight this returns the busy
246 label untouched and the bar renders byte-identically to the pre-279 one.
247
248 The tail is budgeted from what the bar has SPARE once the label and
249 duration are laid out, so it can only ever occupy space no state segment
250 wanted — it is the first thing to shrink and the first to go.
251 """
252 from oc_chat.core.tool_activity import TOOL_ACTIVITY
253
254 busy_label = self._presenter._busy_label
255 activity = TOOL_ACTIVITY.snapshot()
256 if activity is None:
257 return busy_label
258 label = busy_label or activity.name
259 elapsed_ms = activity.elapsed_ms()
260 base = busy_label_with_activity(label, elapsed_ms, None, width)
261 return busy_label_with_activity(
262 label, elapsed_ms, activity.last_line, busy_width_left(state, base, width))
263
264 def __rich_console__(self, console, options):
265 presenter = self._presenter
266 if presenter._status_provider is None:
267 return
268 state = presenter._status_provider()
269 if state is None:
270 return
271 busy_label = self._busy_label_with_activity(state, console.width)
272 _segments, line = render_status_line(state, busy_label, console.width)
273 # D3 pin: "never wrap" — render_status_line already drops segments to
274 # fit target_width, but the always-on core (model/name/busy/turn/
275 # session) can still overflow a very narrow console; no_wrap +
276 # ellipsis crops instead of letting Rich word-wrap to a second line.
277 yield Text(line, style=_THEME["summary"], no_wrap=True, overflow="ellipsis")
278 yield Text(busy_hint_text(busy=True), style=_THEME["info"],
279 no_wrap=True, overflow="ellipsis")
280
281
283 """Rich-styled console presenter.
284
285 Phase 1.A: feature-parity with :class:`ConsolePresenter` but rendered
286 through ``rich.console.Console``. The streaming loop still emits
287 chunks one at a time via ``Console.print(end="")`` — Live will arrive
288 in PR 1.B.
289
290 *verbose* toggles per-iteration ``show_usage`` rendering.
291 """
292
293 def __init__(self, verbose: bool = False, console: Console | None = None,
294 fps: int = 15, code_theme: str = "monokai") -> None:
295 func = "__init__"
296 # ``soft_wrap=True`` lets Rich respect the user's terminal width
297 # naturally. Outside of the Live region, chunks are printed with
298 # ``end=""`` so wrap decisions stay consistent.
299 self._console = console or Console(soft_wrap=True, highlight=False)
300 self.verbose = verbose
301 self._fps = fps
302 self._code_theme = code_theme
303 self._reasoning_shown = False
304 self._content_shown = False
305 _dbg("OC_UI.TERMINAL", func, f"RichPresenter verbose={verbose} fps={fps}", 2)
306 self._waiting_active = False
308 # Streaming state — set up by ``turn()`` and consumed by
309 # ``show_reasoning`` / ``show_content``.
310 self._live: Live | None = None
311 self._content_buf = ""
313 self._batcher: RenderBatcher | None = None
314 # PR 1.C: tool spinner + artifact spilling.
315 self._tool_status: Status | None = None
316 # Bound by ``main()`` after session selection so the presenter
317 # can write artifacts into the active session's directory. None
318 # means "skip spilling" — useful in tests.
319 self._session_name: str | None = None
320 # PR 1.C: reasoning visibility preference (mirrors meta.json[ui][show_reasoning]).
321 # Default is "dim" (truncated, dim italic). Env var overrides per-session.
322 self._show_reasoning_pref: str = "dim"
323 env_cot = os.getenv("OC_CHAT_SHOW_COT", "").strip().lower()
324 if env_cot in ("0", "false", "hide", "no", "off"):
325 self._show_reasoning_pref = "hide"
326 elif env_cot in ("1", "true", "show", "expand"):
327 self._show_reasoning_pref = "show"
328 # Plan 147 D5: 'dim' tail line count (mirrors meta.json[ui][reasoning_tail]).
329 # Literal default (matches commands.UI_REASONING_TAIL_DEFAULT) rather
330 # than importing oc_chat.core.commands here, same as the existing
331 # "show"/"dim"/"hide" literals above — avoids a module-load-time
332 # dependency from oc_ui back into oc_chat.core. No env override:
333 # OC_CHAT_SHOW_COT keeps its existing show|dim|hide-only grammar; the
334 # tail is session-pref-only.
335 self._reasoning_tail: int = 5
336 # Plan 147 D1/D3/D4: the late-binding status-snapshot provider (set
337 # via bind_status) and the presenter-owned current-activity label
338 # (set by show_waiting / spinner_start, cleared by spinner_stop /
339 # first-chunk). Neither is a StatusBarState field — see D1.
340 self._status_provider: Callable[[], StatusBarState | None] | None = None
341 self._busy_label: str | None = None
342 # Plan 188 T1: pairs a mutation tool's call arguments with its result so
343 # ``show_tool_result`` can render a diff instead of truncated JSON.
345 # Plan 218: turn-scoped layout state. ``_in_turn`` gates the indent
346 # (session-level chrome stays flush left); ``_answer_pending`` marks a
347 # buffered answer whose box is waiting for the metadata that belongs
348 # in its bottom border — see :meth:`_flush_answer_box`.
349 self._in_turn = False
350 self._answer_pending = False
351
352 # ── PR 1.C hooks (called by main / commands.py) ───────────────────────
353
354 def bind_session(self, session: ChatSession) -> None:
355 """Inform the presenter which session is currently active.
356
357 Call after :func:`oc_chat.core.startup._load_session_by_meta`
358 / ``/session load`` / ``/session new`` so reasoning preferences
359 and artifact paths are picked up. Idempotent — safe to call
360 on every turn.
361 """
362 func = "bind_session"
363 self._session_name = getattr(session, "name", None)
364 ui = getattr(session, "ui_prefs", {}) or {}
365 pref = ui.get("show_reasoning")
366 if pref in ("show", "dim", "hide"):
367 self._show_reasoning_pref = pref
368 # Plan 147 D5: reasoning_tail — a tampered/non-int/<1 persisted value
369 # degrades to the default with a _dbg note, never a crash.
370 tail = ui.get("reasoning_tail")
371 if tail is not None:
372 if isinstance(tail, bool) or not isinstance(tail, int) or tail < 1:
373 _dbg("OC_UI.TERMINAL", func,
374 f"invalid persisted reasoning_tail={tail!r}; using default", 2, override=True)
375 else:
376 self._reasoning_tail = tail
377
378 def bind_status(self, provider: Callable[[], StatusBarState | None]) -> None:
379 """Register the late-binding status-snapshot provider (Plan 147 D1).
380
381 Overrides the base no-op — the Live-region status bar (D3) and the
382 :class:`~oc_ui.terminal.enhanced_presenter.EnhancedPresenter` fixed
383 header both read ``self._status_provider()`` at render time.
384 """
385 self._status_provider = provider
386
387 # ── Turn-scoped layout helpers (Plan 218) ─────────────────────────────
388
389 def _now_ts(self) -> str:
390 """Wall-clock ``HH:MM:SS`` for the rule labels.
391
392 The single clock read behind every rule this presenter draws, so a
393 scripted render (``oc_chat.scripts.render_turn_layout``) can pin it
394 and produce a byte-reproducible capture.
395 """
396 return datetime.now().strftime("%H:%M:%S")
397
398 def _turn_print(self, renderable: RenderableType) -> None:
399 """Print a turn-scoped renderable, indented while a turn is open.
400
401 Plan 218 P1 element 5 / W4: everything between the turn rules —
402 the answer box, reasoning, tool calls and results, system lines —
403 carries the three-space indent; the rules themselves and the
404 status bar do not, so the turn body reads as contained.
405
406 Plan 273: the padded renderable is printed with ``soft_wrap=False``.
407 ``Padding`` renders its child at the padded width and then sets every
408 line to that width, and this console is ``soft_wrap=True`` — under
409 which Rich prints with ``no_wrap`` and ``overflow="ignore"`` and CROPS
410 each line at the console width. Folding inside the indented width
411 instead means a turn-body line longer than the terminal wraps and is
412 never cut. Outside a turn nothing changes.
413 """
414 if self._in_turn:
415 self._console.print(Padding(renderable, (0, 0, 0, len(TURN_INDENT))),
416 soft_wrap=False)
417 return
418 self._console.print(renderable)
419
420 def _print_rule(self, label: "str | list[tuple[str, str]]",
421 trailer: str = "") -> None:
422 """Render one composed rule line, each segment in its own style.
423
424 ``no_wrap`` mirrors the status bar's never-wrap discipline: a rule
425 that wrapped would read as two broken rules. Callers keep the line
426 within the width by construction (see :func:`rule_fits`).
427 """
428 segments = compose_rule(label, self._console.width, trailer)
429 self._console.print(Text.assemble(*[
430 (text, _THEME[_RULE_TAG_STYLES.get(tag, "rule")])
431 for text, tag in segments
432 ]), no_wrap=True, crop=True)
433
434 def _running_total(self) -> str:
435 """The ``↻ total · turn / session`` trailer, or ``""`` when unknown.
436
437 Read off the bound status snapshot, so the two numbers are the
438 same ones the between-turns rprompt shows. The session figure adds
439 the current turn because :meth:`~oc_ui.terminal.input.TurnStats.finish_turn`
440 — which folds the turn into the session total — runs AFTER
441 ``show_turn_summary``; without the addition the rule would report
442 the session as it stood one turn ago. No provider bound (plain
443 console in a test, a presenter with no session) renders NO
444 trailer rather than a zero, on the same honesty rule plan 188 T4
445 applied to cost.
446 """
447 func = "_running_total"
448 if self._status_provider is None:
449 return ""
450 try:
451 state = self._status_provider()
452 except Exception as exc:
453 # EXTERNAL_ERROR — a caller-supplied provider misbehaved; the
454 # rule renders without its trailer rather than failing the turn.
455 _dbg("OC_UI.TERMINAL", func,
456 f"EXTERNAL_ERROR: {type(exc).__name__}: {exc}", 2, override=True)
457 return ""
458 if state is None:
459 return ""
460 return running_total_text(state.turn_tokens,
461 state.session_tokens + state.turn_tokens)
462
463 def _flush_answer_box(self, stat: "IterationStat | None" = None,
464 error: str = "") -> None:
465 """Commit the buffered answer as a CLOSED box.
466
467 Plan 218 P2: the box is committed here rather than at ``turn()``
468 exit because the metadata that belongs in its bottom border
469 (``show_iteration_stat``) only arrives after the stream ends. The
470 deferral is bounded — every other emitting hook flushes first — so
471 the box can never be overtaken by output that logically follows it.
472
473 *error* replaces the body with the failure text (the W2 path: the
474 box still closes, and the partial answer is still dropped).
475 """
476 func = "_flush_answer_box"
477 if not self._answer_pending:
478 return
479 self._answer_pending = False
480
481 subtitle = ""
482 if stat is not None:
483 from oc_chat.core.iteration_stat import iteration_stat_human_line
484 subtitle = iteration_stat_human_line(stat)
485
486 if error:
487 body = Text(error, style=_THEME["error"])
488 else:
489 body = Markdown(self._content_buf, code_theme=self._code_theme)
490 try:
491 self._turn_print(build_answer_panel(body, subtitle))
492 except Exception as exc:
493 # SYSTEM_ERROR — Markdown render failed (rare — malformed input);
494 # fall back to plain styled Text so we never lose the answer AND
495 # the box still closes (W2: no unclosed border on any path).
496 _dbg("OC_UI.TERMINAL", func,
497 f"SYSTEM_ERROR: {type(exc).__name__}: {exc}", 1, override=True)
499 Text(self._content_buf, style=_THEME["content"]), subtitle))
500
501 # ── Per-turn lifecycle ────────────────────────────────────────────────
502
503 def show_session_start(self) -> None:
504 """Draw the ``start @ <time>`` rule that opens the session (P1.1)."""
505 self._console.print()
506 self._print_rule(start_rule_label(self._now_ts()))
507
508 @contextmanager
509 def turn(self) -> Iterator[None]:
510 """Open a ``rich.live.Live`` region for the duration of the turn.
511
512 While the contextmanager is active, ``show_reasoning`` and
513 ``show_content`` accumulate into per-turn buffers and trigger
514 batched refreshes via :class:`~oc_ui.terminal._render.RenderBatcher`.
515 On clean exit the Live region is cleared and the assistant
516 content is **re-rendered once** as Markdown so code blocks get
517 proper syntax highlighting and tables align cleanly.
518
519 On exception the Live region is still torn down (transient=True)
520 and the partial buffer is dropped to avoid leaving half-rendered
521 Markdown in scrollback — but plan 218 P2/W2 adds that the answer
522 BOX still closes, with the failure rendered inside it, so no
523 border is ever left open.
524 """
525 func = "turn"
526 # Plan 218: a previous iteration's box must never still be waiting
527 # when a new one starts streaming.
528 self._flush_answer_box()
529 # Reset per-turn state.
530 self._content_buf = ""
531 self._reasoning_buf = ""
532 self._batcher = RenderBatcher(fps=self._fps)
533
534 # ``transient=True`` clears the Live render on exit so we can
535 # commit the polished Markdown version below. ``redirect_stderr``
536 # is left at default — tool calls *outside* the streaming chunks
537 # (e.g. show_tool_call) will print into the Live region naturally.
538 live = Live(
539 self._render_live(),
540 console=self._console,
541 refresh_per_second=self._fps,
542 transient=True,
543 )
544 self._live = live
545 live.__enter__()
546 try:
547 yield
548 # Successful turn: clear Live, commit polished Markdown.
550 except Exception as e:
551 # SYSTEM_ERROR — surface stream/render failure with override; partial
552 # buffer is dropped to avoid half-rendered Markdown in scrollback.
553 _dbg("OC_UI.TERMINAL", func, f"SYSTEM_ERROR: {type(e).__name__}: {e}", 1, override=True)
554 # Plan 218 P2/W2 — close the box on the failure path too. The
555 # partial answer stays dropped (that is what this except branch
556 # has always been for); the error takes its place INSIDE the
557 # box, so the bottom border still prints. A box is only closed
558 # if one was opened — a failure before the first content chunk
559 # never drew one.
560 if self._content_buf:
561 if self._live is not None:
562 self._live.stop()
563 self._answer_pending = True
564 self._flush_answer_box(error=f"{type(e).__name__}: {e}")
565 raise
566 finally:
567 self._live = None
568 live.__exit__(None, None, None)
569 self._batcher = None
570 if self._waiting_active:
571 self._clear_waiting()
572
573 # ── Live rendering helpers ────────────────────────────────────────────
574
575 def _render_live(self) -> Group:
576 """Build the renderable shown inside the Live region.
577
578 A :class:`Group` of four regions (any of which may be empty):
579 waiting indicator, reasoning, the answer box, status bar (D3/D4 —
580 always last, "the bar is the last renderable in the Live group").
581
582 Plan 218 P2 renders the answer inside its box AS IT STREAMS: the
583 Live region redraws the box each frame, so the top border appears
584 with the first chunk, the body grows line by line, and the bottom
585 border tracks it down the screen — the "line-buffered box" the
586 plan describes, and never a half-drawn border on screen. The
587 metadata-bearing bottom border is committed once at turn end (see
588 :meth:`_flush_answer_box`). Only the answer is boxed; reasoning,
589 the spinner and the bar stay borderless as before, and everything
590 except the bar carries the turn indent (W4).
591
592 Reasoning visibility honours the ``ui[show_reasoning]`` pref:
593 ``"hide"`` → buffer is consumed but never rendered; ``"dim"``
594 (default) → only the last few lines are visible, dim italic;
595 ``"show"`` → the whole buffer is rendered.
596 """
597 pad = (0, 0, 0, len(TURN_INDENT))
598 elements = []
599 if self._waiting_active and not self._content_buf and not self._reasoning_buf:
600 elements.append(Padding(
601 Spinner("dots", Text(" Thinking…", style=_THEME["waiting"])), pad))
602 if self._reasoning_buf and self._show_reasoning_pref != "hide":
603 visible_reasoning = self._reasoning_buf
604 if self._show_reasoning_pref == "dim":
605 # Truncate to the configurable tail (/ui reasoning dim N;
606 # plan 147 D5) so long CoT doesn't push the answer off-screen.
607 # The full text still reaches scrollback — see
608 # _commit_turn_to_scrollback, which never truncates.
609 lines = visible_reasoning.splitlines()
610 if len(lines) > self._reasoning_tail:
611 hidden = len(lines) - self._reasoning_tail
612 visible_reasoning = (
613 f"… (+{hidden} lines — full CoT in scrollback; /ui reasoning show)\n"
614 + "\n".join(lines[-self._reasoning_tail:])
615 )
616 elements.append(Padding(
617 Text(REASONING_TITLE, style=_THEME["panel_title"]), pad))
618 elements.append(Padding(
619 Text(visible_reasoning, style=_THEME["reasoning"]), pad))
620 if self._content_buf:
621 if self._reasoning_buf and self._show_reasoning_pref != "hide":
622 elements.append(Text("")) # blank separator
623 # Plain Text during streaming — Markdown is applied once at
624 # commit time so we don't re-parse the whole buffer per chunk.
625 elements.append(Padding(build_answer_panel(
626 Text(self._content_buf, style=_THEME["content"])), pad))
627 # D3/D4: the status bar + busy hint are chrome — never written into
628 # scrollback (_commit_turn_to_scrollback below re-emits only the
629 # reasoning/content buffers, never this Group).
630 elements.append(_StatusBarLive(self))
631 return Group(*elements) if elements else Text("")
632
633 def _commit_turn_to_scrollback(self) -> None:
634 """After a successful turn: clear Live, hand the answer to its box.
635
636 Markdown rendering at commit time (rather than per chunk) is the
637 key perf win flagged by reviewers — the whole document is parsed
638 only once instead of every refresh. Plan 218 moves that one render
639 into :meth:`_flush_answer_box`, which runs a beat later so the
640 per-iteration metadata can land in the box's bottom border; the
641 reasoning commit below is unchanged apart from its indent.
642 """
643 if self._live is None:
644 return
645 # Stop showing the transient Live render so we can print into the
646 # scrollback without it being overwritten.
647 self._live.stop()
648 # Re-emit the reasoning header (dim) and the polished Markdown
649 # version of the content into the real scrollback. Honour the
650 # reasoning preference: "hide" suppresses the commit too;
651 # "dim" still commits the full text (the user can scroll back
652 # for context — only the live region is truncated).
653 if self._reasoning_buf and self._show_reasoning_pref != "hide":
654 self._turn_print(Text(REASONING_TITLE, style=_THEME["panel_title"]))
655 self._turn_print(Text(self._reasoning_buf.rstrip(),
656 style=_THEME["reasoning"]))
657 self._console.print()
658 if self._content_buf:
659 self._answer_pending = True
660
661 def start_turn(self) -> None:
662 """Print a styled rule + turn-number line."""
663 self._flush_answer_box()
664 self._reasoning_shown = False
665 self._content_shown = False
666 self._waiting_active = False
667 self._turn_number += 1
668 # Plan 188 T1: a tool call announced but never resulted (an exception
669 # mid-dispatch) must not pair with a later call's result.
670 self._mutation_args.clear()
671
672 # Plan 218: the rule closes the previous region and opens the turn
673 # body, so the indent starts AFTER it is drawn.
674 self._in_turn = False
675 self._console.print() # leading blank line
676 self._print_rule(turn_rule_label(self._turn_number, self._now_ts()))
677 self._in_turn = True
678
679 def end_turn(self) -> None:
680 """Finalise the current turn (newline)."""
681 self._flush_answer_box()
682 if self._waiting_active:
683 self._clear_waiting()
684 self._in_turn = False
685 self._console.print()
686
687 # ── Streaming events ──────────────────────────────────────────────────
688
689 def show_waiting(self) -> None:
690 """Show a transient animated "Thinking…" spinner before the first chunk.
691
692 Inside an active ``turn()`` Live region this is rendered as part
693 of the Live :class:`Group`; outside (e.g. during tests that don't
694 wrap the call) it falls back to a console print so output is
695 still visible. Sets the presenter-owned busy label (D1/D4) shown
696 in the status bar — cleared on the first chunk by
697 :meth:`_clear_waiting`.
698 """
699 self._waiting_active = True
700 self._busy_label = "waiting for response…"
701 if self._live is not None:
702 self._live.update(self._render_live())
703 else:
704 self._console.print(Text("⏳ Thinking…", style=_THEME["waiting"]),
705 end="")
706
707 def _clear_waiting(self) -> None:
708 """Erase the in-line waiting indicator (no-Live fallback only)."""
709 self._busy_label = None
710 if self._live is not None:
711 # Live owns the render; just clear the flag and let the next
712 # update() overwrite naturally.
713 self._waiting_active = False
714 self._live.update(self._render_live())
715 return
716 # No Live region: emit a CR + spaces to overwrite the line.
717 self._console.file.write("\r" + " " * 20 + "\r")
718 self._console.file.flush()
719 self._waiting_active = False
720
721 def show_reasoning(self, text: str) -> None:
722 """Stream a reasoning chunk.
723
724 Inside the Live region: append to ``_reasoning_buf`` and refresh
725 when the batcher allows. Outside: print immediately (legacy
726 path used by tests that drive ``show_reasoning`` outside ``turn()``).
727 """
728 if self._waiting_active:
729 self._clear_waiting()
730 if self._live is not None:
731 self._reasoning_buf += text
732 assert self._batcher is not None # paired with _live
733 if self._batcher.should_flush(text):
734 self._live.update(self._render_live())
735 return
736 # No-Live fallback (kept for parity with PR 1.A behaviour outside ``turn()``).
737 if not self._reasoning_shown:
738 self._turn_print(Text(REASONING_TITLE, style=_THEME["panel_title"]))
739 self._reasoning_shown = True
740 self._console.print(Text(text, style=_THEME["reasoning"]), end="")
741
742 def show_content(self, text: str) -> None:
743 """Stream an answer chunk.
744
745 Same dual mode as :meth:`show_reasoning`: accumulate inside
746 ``turn()``, print directly outside it. The no-Live fallback is
747 deliberately left un-boxed: it exists for callers that drive
748 ``show_content`` with no turn lifecycle at all, where there is no
749 turn end at which a box could be closed — and plan 218's rule is
750 that a box is never opened unless it can be closed.
751 """
752 if self._waiting_active:
753 self._clear_waiting()
754 if self._live is not None:
755 self._content_buf += text
756 assert self._batcher is not None
757 if self._batcher.should_flush(text):
758 self._live.update(self._render_live())
759 return
760 # No-Live fallback.
761 if not self._content_shown:
762 if self._reasoning_shown:
763 self._console.print()
764 self._console.print()
765 self._turn_print(Text(ANSWER_BOX_TITLE, style="bold"))
766 self._content_shown = True
767 self._console.print(Text(text, style=_THEME["content"]), end="")
768
769 # ── Tools ─────────────────────────────────────────────────────────────
770
771 def show_tool_call(self, name: str, arguments: str) -> None:
772 """Display a tool-call invocation."""
773 func = "show_tool_call"
774 self._flush_answer_box()
775 if self._waiting_active:
776 self._clear_waiting()
777 try:
778 args_pretty = json.dumps(json.loads(arguments or "{}"), ensure_ascii=False)
779 except (json.JSONDecodeError, ValueError) as e:
780 # VALIDATION_ERROR — model emitted non-JSON tool args; render raw string
781 _dbg("OC_UI.TERMINAL", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
782 args_pretty = arguments
783 # Plan 188 T1: remember mutation-tool arguments for the diff render that
784 # follows in show_tool_result, and elide the bulky payload here (the
785 # diff below IS the content view). Both no-ops for every other tool.
786 self._mutation_args.record(name, arguments)
787 args_pretty = summarize_mutation_call(name, arguments) or args_pretty
788 line = Text.assemble(
789 ("[Tool call → ", _THEME["tool_call"]),
790 (name, "bold " + _THEME["tool_call"]),
791 ("(", _THEME["tool_call"]),
792 (args_pretty, _THEME["tool_call"]),
793 (")]", _THEME["tool_call"]),
794 )
795 self._console.print()
796 self._turn_print(line)
797
798 def show_tool_progress(self, name: str) -> None:
799 """Start a ``rich.status.Status`` spinner indicating *name* is running.
800
801 Stopped by the next :meth:`show_tool_result` call. Inside the
802 Live region we do *not* start a separate Status (the Live owns
803 the screen); the announcement printed by :meth:`show_tool_call`
804 is enough. Outside Live, the spinner is genuinely useful.
805 """
806 if self._live is not None:
807 return # Live region already animates; double-spinner conflicts.
808 if self._tool_status is not None:
809 self._tool_status.stop()
810 self._tool_status = Status(
811 f"🔧 Running {name}…",
812 spinner="dots",
813 console=self._console,
814 )
815 self._tool_status.start()
816
817 def _stop_tool_status(self) -> None:
818 if self._tool_status is not None:
819 self._tool_status.stop()
820 self._tool_status = None
821
822 def _render_diff(self, diff: DiffRender) -> None:
823 """Print a :class:`DiffRender` as a colourised Rich group (plan 188 T1)."""
824 elements: list[Text] = [
825 Text(diff_header_line(diff), style=_THEME["diff_header"], no_wrap=False),
826 ]
827 _styles = {"add": _THEME["diff_add"], "del": _THEME["diff_del"],
828 "hunk": _THEME["diff_hunk"]}
829 for line in diff.lines:
830 elements.append(Text(line.text, style=_styles.get(line.kind, "")))
831 footer = diff_footer_line(diff)
832 if footer:
833 elements.append(Text(footer, style=_THEME["diff_header"]))
834 self._turn_print(Group(*elements))
835
836 def show_tool_result(self, name: str, result: object) -> None:
837 """Display a tool-call result.
838
839 Plan 188 T1: an applied ``write_file`` / ``edit_file`` renders as a
840 colourised unified diff instead of the truncated JSON line. Every
841 other tool — and any mutation result that is an error, a gate block,
842 or an unpaired call — keeps the legacy rendering (including the
843 artifact spill below) exactly.
844
845 Long results (> :data:`DEFAULT_TOOL_SPILL_CHARS` chars, or longer
846 than the per-session ``ui[tool_spill_chars]`` override) are
847 spilled to ``sessions/{name}/artifacts/...txt`` and only a
848 one-line summary appears in scrollback.
849 """
850 func = "show_tool_result"
851 self._flush_answer_box()
852 self._stop_tool_status()
853
854 if isinstance(result, dict) and result.get("requires_confirmation"):
855 msg = result.get("message", str(result))
856 self._turn_print(
857 Text(f"[⏳ Confirmation required — {msg}]", style=_THEME["warning"])
858 )
859 return
860
861 diff = build_mutation_diff(name, self._mutation_args.take(name), result)
862 if diff is not None:
863 self._render_diff(diff)
864 return
865
866 result_str = json.dumps(result, ensure_ascii=False)
867 is_error = isinstance(result, dict) and "error" in result
868 style = _THEME["tool_error"] if is_error else _THEME["tool_result"]
869
870 # Spill long results to a file — keeps scrollback scannable and
871 # matches the artifact convention in ``oc_chat/sessions/{name}/``.
872 threshold = DEFAULT_TOOL_SPILL_CHARS
873 if len(result_str) > threshold and self._session_name and not is_error:
874 try:
875 from oc_chat.core.persistence import save_tool_artifact
876 path = save_tool_artifact(self._session_name, name, result_str)
877 size_kb = len(result_str.encode("utf-8")) / 1024
878 preview = result_str[:200].rstrip()
879 self._turn_print(Text(
880 f"[{name} ← <output spilled, {size_kb:.1f} KB>] "
881 f"↳ saved to {path.name} (/open last to view)",
882 style=style,
883 ))
884 self._turn_print(Text(f" preview: {preview}…",
885 style=_THEME["info"]))
886 return
887 except Exception as e:
888 # SYSTEM_ERROR — artifact write failed; fall through to truncated
889 # inline display so the data isn't lost entirely.
890 _dbg("OC_UI.TERMINAL", func, f"SYSTEM_ERROR: {type(e).__name__}: {e}", 1, override=True)
891
892 if len(result_str) > 400:
893 result_str = result_str[:400] + "…"
894 # Prefix icon makes the success/error distinction visible without
895 # relying on colour alone (a11y / colour-blind / NO_COLOR users).
896 icon = "✗" if is_error else "✓"
897 self._turn_print(Text(f"[{icon} {name} ← {result_str}]", style=style))
898
899 # Plan 188 T2 — the plan lane: a planner tool just changed plan state,
900 # so show the REPL's current progress underneath its result.
901 progress = plan_progress_from_result(name, result)
902 if progress:
903 self.show_activity("plan", progress)
904
905 # ── Activity lane (Plan 188 T2) ───────────────────────────────────────
906
907 #: Tone → theme key. An unknown tone renders neutral (never raises).
908 _ACTIVITY_TONES = {
909 "info": "info", "ok": "tool_result",
910 "warn": "warning", "error": "tool_error",
911 }
912
913 def show_activity(self, label: str, detail: str = "", *, tone: str = "info") -> None:
914 """Render a compact one-line state update for a non-streaming lane."""
915 self._flush_answer_box()
916 style = _THEME.get(self._ACTIVITY_TONES.get(tone, "info"), _THEME["info"])
917 self._turn_print(Text.assemble(
918 (f"[{label}]", style),
919 (f" {detail}" if detail else "", _THEME["info"]),
920 ))
921
922 # ── Two-stage Ctrl+C notice (Plan 188 T5) ─────────────────────────────
923
924 def show_interrupt_notice(self, stage: str) -> None:
925 """Announce a Ctrl+C stage on the stream this presenter owns.
926
927 Called FROM THE SIGNAL HANDLER, so this must stay one short write.
928 It goes to ``self._console.file`` rather than ``stderr`` because
929 while a ``Live`` region is up Rich has swapped that stream for its
930 own proxy, which parks the text above the live region instead of
931 letting it be overwritten by the next refresh. This is the same
932 direct-write idiom :meth:`_clear_waiting` already uses; deliberately
933 NOT ``Console.print``, whose renderable pipeline is more work than a
934 signal handler should do.
935 """
936 text = INTERRUPT_NOTICES.get(stage)
937 if text is None:
938 return
939 self._console.file.write(text)
940 self._console.file.flush()
941
942 # ── Stats ─────────────────────────────────────────────────────────────
943
944 def show_usage(self, usage: dict) -> None:
945 """Per-iteration token usage — compact one-line format.
946
947 Plan 134 C1: render only in ``--verbose`` mode. This gates the
948 TERMINAL rendering only; structured emission/logging is unaffected
949 (``react_loop`` still records usage, ``LoggingPresenter`` still tees
950 the event to its JSONL before delegating here).
951 """
952 self._flush_answer_box()
953 if not self.verbose:
954 return
955 prompt = usage.get("prompt_tokens", 0)
956 completion = usage.get("completion_tokens", 0)
957 total = usage.get("total_tokens", 0)
958 cached = None
959 if isinstance(usage.get("prompt_tokens_details"), dict):
960 cached = usage["prompt_tokens_details"].get("cached_tokens")
961
962 cached_str = ""
963 if cached is not None:
964 cached_str = f" · cache:{self._fmt_compact_tokens(cached)}"
965
966 line = (
967 f" prompt:{self._fmt_compact_tokens(prompt)}"
968 f" · completion:{self._fmt_compact_tokens(completion)}"
969 f" · total:{self._fmt_compact_tokens(total)}"
970 f"{cached_str}"
971 )
972 self._turn_print(Text(line, style=_THEME["summary"]))
973
974 def show_iteration_stat(self, stat: "IterationStat") -> None:
975 """Render the per-iteration stats — in the answer box's bottom border.
976
977 Plan 218 P1 element 3: when this iteration produced an answer, the
978 stat line IS the box's bottom border, so the numbers sit against
979 the text they describe. An iteration that produced no answer (a
980 pure tool-call round) has no box to close, so the line renders as
981 before — just carrying the turn indent.
982 """
983 if self._answer_pending:
984 self._flush_answer_box(stat=stat)
985 return
986 from oc_chat.core.iteration_stat import iteration_stat_human_line
987 self._turn_print(Text(iteration_stat_human_line(stat),
988 style=_THEME["summary"]))
989
990 @staticmethod
991 def _fmt_compact_tokens(n: int) -> str:
992 if n >= 1_000_000:
993 return f"{n / 1_000_000:.2f}M"
994 if n >= 1_000:
995 return f"{n / 1_000:.1f}k"
996 return str(n)
997
998 def show_turn_summary(self, prompt: int, completion: int, total: int,
999 iterations: int, tool_calls: int,
1000 prompt_cached: int | None = None, *,
1001 cache_hit_pct: float | None = None,
1002 cache_saved_tokens: int | None = None,
1003 performance_stats: object = None,
1004 model: str | None = None,
1005 context_used_pct: float | None = None,
1006 duration_ms: int | None = None,
1007 turn_cost: object = None) -> None:
1008 """Print the end-of-turn rule, with the turn stats absorbed into it.
1009
1010 Plan 218 P1 element 4: the stats string that used to occupy its own
1011 line under the rule is now the rule's own text, and the running
1012 total that used to appear only in the between-turns rprompt rides
1013 in the right-hand fill run. Wording and styling are unchanged —
1014 only the position is (P3).
1015
1016 Plan 188 T4: the ``~$`` estimate is appended when *turn_cost* is
1017 present and omitted entirely when it is ``None``. Formatting is
1018 delegated to :meth:`ConsolePresenter._fmt_turn_cost` so the honesty
1019 markers are identical across presenters.
1020 """
1021 self._flush_answer_box()
1022 self._in_turn = False
1023
1024 parts = [f"prompt:{self._fmt_compact_tokens(prompt)}"]
1025 parts.append(f"completion:{self._fmt_compact_tokens(completion)}")
1026 parts.append(f"total:{self._fmt_compact_tokens(total)}")
1027 if prompt_cached is not None and prompt_cached > 0:
1028 parts.append(f"cache:{self._fmt_compact_tokens(prompt_cached)}")
1029 # Plan 134 C2: honest labels — cache-hit ratio vs real context saturation.
1030 if cache_hit_pct is not None:
1031 parts.append(f"cache-hit:{cache_hit_pct:.0f}%")
1032 if context_used_pct is not None:
1033 _ctx = f"ctx:{context_used_pct:.0f}%"
1034 if model:
1035 _ctx += f" of {model}"
1036 parts.append(_ctx)
1037 if performance_stats is not None and not performance_stats.is_default():
1038 tps = performance_stats.tokens_per_second
1039 if tps is not None:
1040 parts.append(f"{performance_stats.eval_count}tok@{tps:.1f}tok/s")
1041 if duration_ms is not None:
1042 from oc_chat.core.formatting import format_duration
1043 parts.append(format_duration(duration_ms))
1044 stats = " this turn: " + " \u00b7 ".join(parts)
1045 from oc_ui.terminal.presenter import ConsolePresenter
1046 stats += ConsolePresenter._fmt_turn_cost(turn_cost, verbose=self.verbose)
1047 ts = self._now_ts()
1048 absorbed = turn_end_rule_segments(self._turn_number, ts, stats)
1049 if rule_fits(absorbed, self._console.width):
1050 self._print_rule(absorbed, trailer=self._running_total())
1051 return
1052 # Terminal too narrow to absorb the stats — degrade to the pre-218
1053 # two-line form rather than wrap the rule or crop a gauge in half.
1054 self._print_rule(turn_end_rule_segments(self._turn_number, ts))
1055 self._console.print(Text(stats, style=_THEME["summary"]))
1056
1057 def show_welcome(self, session: ChatSession) -> None:
1058 """Display the welcome banner inside a styled :class:`Panel`."""
1059 from oc_chat.core.config import REASONING_EFFORT_LEVELS
1060
1061 body = Text.assemble(
1062 ("Model: ", "bold"), (session.model, "cyan"), ("\n",),
1063 ("Reasoning effort: ", "bold"),
1064 (session.reasoning_effort, "cyan"),
1065 (f" (options: {', '.join(REASONING_EFFORT_LEVELS)})", "dim"), ("\n\n",),
1066 ("Type ",), ("/help", "yellow"),
1067 (" for full command details and session subcommands.",),
1068 )
1069 self._console.print()
1070 self._console.print(Panel(
1071 body,
1072 title="OC Chat",
1073 border_style=_THEME["panel_border"],
1074 padding=(1, 2),
1075 ))
1076
1077 def show_available_models(self, models: dict) -> None:
1078 """Display models as a styled :class:`rich.table.Table`."""
1079 table = Table(title="Available models", title_style=_THEME["panel_title"],
1080 show_lines=False, show_edge=True)
1081 table.add_column("Name", style="cyan", no_wrap=True)
1082 table.add_column("Thinking", justify="center")
1083 table.add_column("Reasoning", justify="center")
1084 table.add_column("Tools", justify="center")
1085
1086 for name, caps in models.items():
1087 table.add_row(
1088 name,
1089 _check(caps.get("supports_thinking")),
1090 _check(caps.get("supports_reasoning")),
1091 _check(caps.get("supports_tools", True)),
1092 )
1093 self._console.print()
1094 self._console.print(table)
1095
1096 def show_session_list(self, sessions: list, current_name: str | None = None) -> None:
1097 """Display saved sessions as a styled :class:`rich.table.Table`."""
1098 if not sessions:
1099 self._console.print(Text("(no saved sessions)", style=_THEME["info"]))
1100 return
1101 from oc_chat.core.commands import _fmt_tokens_display
1102
1103 table = Table(title="Saved sessions", title_style=_THEME["panel_title"],
1104 show_lines=False)
1105 table.add_column("", width=1, no_wrap=True) # current marker
1106 table.add_column("Name", style="cyan", overflow="fold")
1107 table.add_column("Model", overflow="ellipsis")
1108 table.add_column("Msgs", justify="right")
1109 table.add_column("Tools", justify="right")
1110 table.add_column("Tokens", justify="right")
1111 table.add_column("Last modified")
1112
1113 for s in sessions:
1114 marker = "★" if s["name"] == current_name else ""
1115 stats = s["stats"]
1116 table.add_row(
1117 marker,
1118 s["name"],
1119 s["model"],
1120 str(stats.message_count),
1121 str(stats.tool_call_count),
1122 _fmt_tokens_display(stats),
1123 s["last_modified"],
1124 )
1125 self._console.print()
1126 self._console.print(table)
1127
1128 def show_help(self, command_help: dict, tools: list[str] | None = None) -> None:
1129 """Render the help table(s) — commands and (optionally) agent tools."""
1130 cmd_table = Table(title="Slash commands", title_style=_THEME["panel_title"],
1131 show_lines=False)
1132 cmd_table.add_column("Command", style="yellow", no_wrap=True)
1133 cmd_table.add_column("Description", overflow="fold")
1134
1135 for verb, line in command_help.items():
1136 # COMMAND_HELP entries are formatted as "/verb <args> - description".
1137 # Split on the first " - " to separate usage from description.
1138 if " - " in line:
1139 usage, _, description = line.partition(" - ")
1140 else:
1141 usage, description = line, ""
1142 cmd_table.add_row(usage.rstrip(), description.strip())
1143
1144 self._console.print()
1145 self._console.print(cmd_table)
1146
1147 if tools:
1148 tool_table = Table(title="Agent tools (callable by the model)",
1149 title_style=_THEME["panel_title"], show_lines=False)
1150 tool_table.add_column("Name", style="green", no_wrap=True)
1151 for name in tools:
1152 tool_table.add_row(name)
1153 self._console.print()
1154 self._console.print(tool_table)
1155
1156 def show_compressed(self) -> None:
1157 """Notify the user that context compression occurred."""
1158 self._flush_answer_box()
1159 self._turn_print(
1160 Text("[Context compressed — older messages summarised]",
1161 style=_THEME["compressed"])
1162 )
1163
1164 def show_error(self, msg: str) -> None:
1165 """Display an error message in bold red."""
1166 self._flush_answer_box()
1167 self._console.print()
1168 self._turn_print(Text(f"Error: {msg}", style=_THEME["error"]))
1169
1170 def show_info(self, msg: str) -> None:
1171 """Display an informational message."""
1172 self._flush_answer_box()
1173 self._console.print()
1174 self._turn_print(Text(msg, style=_THEME["info"]))
1175
1176 # ── Progress spinner + dispatch chrome (Phase H.4 / Plan 147 D3-c/D4) ──
1177
1178 def spinner_start(self, label: str) -> SpinnerHandle:
1179 """Start the dispatch-chrome Live display — the single busy-chrome
1180 owner outside the streaming Live region.
1181
1182 Renders ``Group(spinner + label, status bar, hint line)`` via a
1183 small ``rich.live.Live`` — NOT ``rich.status.Status`` (which only
1184 ever shows one line) — so tool dispatch gets the same D3 bar +
1185 D4 hint as the streaming regime ("one renderable definition,
1186 three hosts"). Only ever called while ``self._live`` (the turn's
1187 streaming Live) is ``None`` — react_loop brackets dispatch strictly
1188 after ``turn()`` exits — so this never stacks a second live display
1189 on top of the streaming one (the single-live-display regression).
1190 """
1191 func = "spinner_start"
1192 _dbg("OC_UI.TERMINAL", func, f"label={label!r}", 4)
1193 self._busy_label = label
1194 live = Live(
1195 Group(Spinner("dots", Text(f" {label}", style=_THEME["waiting"])),
1196 _StatusBarLive(self)),
1197 console=self._console,
1198 refresh_per_second=self._fps,
1199 transient=True,
1200 )
1201 live.start()
1202 return SpinnerHandle(status=live)
1203
1204 def spinner_stop(self, handle: SpinnerHandle) -> None:
1205 """Stop the underlying dispatch-chrome ``Live``; idempotent on repeated calls."""
1206 func = "spinner_stop"
1207 if handle.status is None:
1208 return
1209 try:
1210 handle.status.stop()
1211 except Exception as exc: # noqa: BLE001 — terminal teardown best-effort
1212 _dbg("OC_UI.TERMINAL", func, f"stop ignored: {type(exc).__name__}: {exc}", 3)
1213 handle.status = None
1214 self._busy_label = None
1215
1216
1217# ─── Helpers ──────────────────────────────────────────────────────────────────
1218
1219def _check(flag) -> Text:
1220 """Return a styled ✓ / ✗ marker for boolean capability flags."""
1221 if flag:
1222 return Text("✓", style="green")
1223 return Text("✗", style="red")
None show_activity(self, str label, str detail="", *, str tone="info")
"str | None" _busy_label_with_activity(self, state, int width)
None __init__(self, "RichPresenter" presenter)
SpinnerHandle spinner_start(self, str label)
None bind_session(self, ChatSession session)
None __init__(self, bool verbose=False, Console|None console=None, int fps=15, str code_theme="monokai")
None _print_rule(self, "str | list[tuple[str, str]]" label, str trailer="")
None bind_status(self, Callable[[], StatusBarState|None] provider)
None show_iteration_stat(self, "IterationStat" stat)
None show_activity(self, str label, str detail="", *, str tone="info")
Callable[[], StatusBarState|None]|None _status_provider
None show_turn_summary(self, int prompt, int completion, int total, int iterations, int tool_calls, int|None prompt_cached=None, *, float|None cache_hit_pct=None, int|None cache_saved_tokens=None, object performance_stats=None, str|None model=None, float|None context_used_pct=None, int|None duration_ms=None, object turn_cost=None)
None show_welcome(self, ChatSession session)
None show_tool_call(self, str name, str arguments)
None show_tool_result(self, str name, object result)
None spinner_stop(self, SpinnerHandle handle)
None _turn_print(self, RenderableType renderable)
None _flush_answer_box(self, "IterationStat | None" stat=None, str error="")
None show_help(self, dict command_help, list[str]|None tools=None)
None show_session_list(self, list sessions, str|None current_name=None)
Panel build_answer_panel(RenderableType body, str subtitle="")