6Rich-based presenter for OC Chat (Phase 1.A — skeleton).
10Implements the :class:`~oc_ui.presenter_base.Presenter` contract
11using the `rich <https://rich.readthedocs.io/>`_ library. This first
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,
18* Consistent colour theme for info/error/compressed/turn-summary
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
25To enable, install the optional dep::
27 pip install -e .[rich]
31 python OC Chat.py --ui rich
33If ``rich`` is missing, ``oc_chat.main`` lazy-imports this module and
34falls back to :class:`~oc_ui.terminal.presenter.ConsolePresenter`
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.
56- Importing this module without ``rich`` installed raises
57 :class:`ImportError`; ``main.py`` is responsible for the optional-
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.
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)
91from __future__
import annotations
94from contextlib
import contextmanager
95from datetime
import datetime
96from typing
import TYPE_CHECKING, Callable, Iterator
98from oc_diagnostics
import _dbg
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
123 summarize_mutation_call,
128 busy_label_with_activity,
140 turn_end_rule_segments,
143from ._render
import RenderBatcher
148DEFAULT_TOOL_SPILL_CHARS = 4000
157 "waiting":
"italic dim cyan",
158 "reasoning":
"dim italic cyan",
159 "content":
"default",
160 "tool_call":
"yellow",
161 "tool_result":
"green",
168 "panel_border":
"cyan",
169 "panel_title":
"bold cyan",
175 "diff_header":
"dim",
188 "trailer":
"summary",
193 """Build the plan-218 answer box.
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.
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.
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).
212 title=ANSWER_BOX_TITLE,
214 subtitle=subtitle
or None,
215 border_style=_THEME[
"panel_border"],
221 """Time-aware Live-region renderable for the D3 status bar + D4 hint line.
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.
237 def __init__(self, presenter:
"RichPresenter") ->
None:
241 """The presenter's busy label, plus the in-flight tool call (plan 279 P4).
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.
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.
255 activity = TOOL_ACTIVITY.snapshot()
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))
266 if presenter._status_provider
is None:
268 state = presenter._status_provider()
272 _segments, line = render_status_line(state, busy_label, console.width)
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")
283 """Rich-styled console presenter.
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
290 *verbose* toggles per-iteration ``show_usage`` rendering.
293 def __init__(self, verbose: bool =
False, console: Console |
None =
None,
294 fps: int = 15, code_theme: str =
"monokai") ->
None:
299 self.
_console = console
or Console(soft_wrap=
True, highlight=
False)
305 _dbg(
"OC_UI.TERMINAL", func, f
"RichPresenter verbose={verbose} fps={fps}", 2)
323 env_cot = os.getenv(
"OC_CHAT_SHOW_COT",
"").strip().lower()
324 if env_cot
in (
"0",
"false",
"hide",
"no",
"off"):
326 elif env_cot
in (
"1",
"true",
"show",
"expand"):
355 """Inform the presenter which session is currently active.
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
362 func =
"bind_session"
364 ui = getattr(session,
"ui_prefs", {})
or {}
365 pref = ui.get(
"show_reasoning")
366 if pref
in (
"show",
"dim",
"hide"):
370 tail = ui.get(
"reasoning_tail")
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)
378 def bind_status(self, provider: Callable[[], StatusBarState |
None]) ->
None:
379 """Register the late-binding status-snapshot provider (Plan 147 D1).
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.
390 """Wall-clock ``HH:MM:SS`` for the rule labels.
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.
396 return datetime.now().strftime(
"%H:%M:%S")
399 """Print a turn-scoped renderable, indented while a turn is open.
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.
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.
415 self.
_console.print(Padding(renderable, (0, 0, 0, len(TURN_INDENT))),
421 trailer: str =
"") ->
None:
422 """Render one composed rule line, each segment in its own style.
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`).
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)
435 """The ``↻ total · turn / session`` trailer, or ``""`` when unknown.
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
447 func =
"_running_total"
452 except Exception
as exc:
455 _dbg(
"OC_UI.TERMINAL", func,
456 f
"EXTERNAL_ERROR: {type(exc).__name__}: {exc}", 2, override=
True)
460 return running_total_text(state.turn_tokens,
461 state.session_tokens + state.turn_tokens)
464 error: str =
"") ->
None:
465 """Commit the buffered answer as a CLOSED box.
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.
473 *error* replaces the body with the failure text (the W2 path: the
474 box still closes, and the partial answer is still dropped).
476 func =
"_flush_answer_box"
484 subtitle = iteration_stat_human_line(stat)
487 body = Text(error, style=_THEME[
"error"])
492 except Exception
as exc:
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))
504 """Draw the ``start @ <time>`` rule that opens the session (P1.1)."""
509 def turn(self) -> Iterator[None]:
510 """Open a ``rich.live.Live`` region for the duration of the turn.
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.
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.
541 refresh_per_second=self.
_fps,
550 except Exception
as e:
553 _dbg(
"OC_UI.TERMINAL", func, f
"SYSTEM_ERROR: {type(e).__name__}: {e}", 1, override=
True)
561 if self.
_live is not None:
568 live.__exit__(
None,
None,
None)
576 """Build the renderable shown inside the Live region.
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").
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).
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.
597 pad = (0, 0, 0, len(TURN_INDENT))
600 elements.append(Padding(
601 Spinner(
"dots", Text(
" Thinking…", style=_THEME[
"waiting"])), pad))
609 lines = visible_reasoning.splitlines()
612 visible_reasoning = (
613 f
"… (+{hidden} lines — full CoT in scrollback; /ui reasoning show)\n"
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))
622 elements.append(Text(
""))
626 Text(self.
_content_buf, style=_THEME[
"content"])), pad))
631 return Group(*elements)
if elements
else Text(
"")
634 """After a successful turn: clear Live, hand the answer to its box.
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.
643 if self.
_live is None:
654 self.
_turn_print(Text(REASONING_TITLE, style=_THEME[
"panel_title"]))
656 style=_THEME[
"reasoning"]))
662 """Print a styled rule + turn-number line."""
680 """Finalise the current turn (newline)."""
690 """Show a transient animated "Thinking…" spinner before the first chunk.
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`.
701 if self.
_live is not None:
704 self.
_console.print(Text(
"⏳ Thinking…", style=_THEME[
"waiting"]),
708 """Erase the in-line waiting indicator (no-Live fallback only)."""
710 if self.
_live is not None:
717 self.
_console.file.write(
"\r" +
" " * 20 +
"\r")
722 """Stream a reasoning chunk.
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()``).
730 if self.
_live is not None:
733 if self.
_batcher.should_flush(text):
738 self.
_turn_print(Text(REASONING_TITLE, style=_THEME[
"panel_title"]))
740 self.
_console.print(Text(text, style=_THEME[
"reasoning"]), end=
"")
743 """Stream an answer chunk.
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.
754 if self.
_live is not None:
757 if self.
_batcher.should_flush(text):
765 self.
_turn_print(Text(ANSWER_BOX_TITLE, style=
"bold"))
767 self.
_console.print(Text(text, style=_THEME[
"content"]), end=
"")
772 """Display a tool-call invocation."""
773 func =
"show_tool_call"
778 args_pretty = json.dumps(json.loads(arguments
or "{}"), ensure_ascii=
False)
779 except (json.JSONDecodeError, ValueError)
as e:
781 _dbg(
"OC_UI.TERMINAL", func, f
"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
782 args_pretty = 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"]),
799 """Start a ``rich.status.Status`` spinner indicating *name* is running.
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.
806 if self.
_live is not None:
811 f
"🔧 Running {name}…",
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),
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)
833 elements.append(Text(footer, style=_THEME[
"diff_header"]))
837 """Display a tool-call result.
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.
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.
850 func =
"show_tool_result"
854 if isinstance(result, dict)
and result.get(
"requires_confirmation"):
855 msg = result.get(
"message", str(result))
857 Text(f
"[⏳ Confirmation required — {msg}]", style=_THEME[
"warning"])
861 diff = build_mutation_diff(name, self.
_mutation_args.take(name), result)
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"]
872 threshold = DEFAULT_TOOL_SPILL_CHARS
873 if len(result_str) > threshold
and self.
_session_name and not is_error:
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()
880 f
"[{name} ← <output spilled, {size_kb:.1f} KB>] "
881 f
"↳ saved to {path.name} (/open last to view)",
885 style=_THEME[
"info"]))
887 except Exception
as e:
890 _dbg(
"OC_UI.TERMINAL", func, f
"SYSTEM_ERROR: {type(e).__name__}: {e}", 1, override=
True)
892 if len(result_str) > 400:
893 result_str = result_str[:400] +
"…"
896 icon =
"✗" if is_error
else "✓"
897 self.
_turn_print(Text(f
"[{icon} {name} ← {result_str}]", style=style))
901 progress = plan_progress_from_result(name, result)
909 "info":
"info",
"ok":
"tool_result",
910 "warn":
"warning",
"error":
"tool_error",
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."""
916 style = _THEME.get(self.
_ACTIVITY_TONES.get(tone,
"info"), _THEME[
"info"])
918 (f
"[{label}]", style),
919 (f
" {detail}" if detail
else "", _THEME[
"info"]),
925 """Announce a Ctrl+C stage on the stream this presenter owns.
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.
936 text = INTERRUPT_NOTICES.get(stage)
945 """Per-iteration token usage — compact one-line format.
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).
955 prompt = usage.get(
"prompt_tokens", 0)
956 completion = usage.get(
"completion_tokens", 0)
957 total = usage.get(
"total_tokens", 0)
959 if isinstance(usage.get(
"prompt_tokens_details"), dict):
960 cached = usage[
"prompt_tokens_details"].get(
"cached_tokens")
963 if cached
is not None:
964 cached_str = f
" · cache:{self._fmt_compact_tokens(cached)}"
967 f
" prompt:{self._fmt_compact_tokens(prompt)}"
968 f
" · completion:{self._fmt_compact_tokens(completion)}"
969 f
" · total:{self._fmt_compact_tokens(total)}"
972 self.
_turn_print(Text(line, style=_THEME[
"summary"]))
975 """Render the per-iteration stats — in the answer box's bottom border.
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.
987 self.
_turn_print(Text(iteration_stat_human_line(stat),
988 style=_THEME[
"summary"]))
993 return f
"{n / 1_000_000:.2f}M"
995 return f
"{n / 1_000:.1f}k"
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.
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).
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.
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)}")
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}%"
1035 _ctx += f
" of {model}"
1037 if performance_stats
is not None and not performance_stats.is_default():
1038 tps = performance_stats.tokens_per_second
1040 parts.append(f
"{performance_stats.eval_count}tok@{tps:.1f}tok/s")
1041 if duration_ms
is not None:
1043 parts.append(format_duration(duration_ms))
1044 stats =
" this turn: " +
" \u00b7 ".join(parts)
1046 stats += ConsolePresenter._fmt_turn_cost(turn_cost, verbose=self.
verbose)
1048 absorbed = turn_end_rule_segments(self.
_turn_number, ts, stats)
1049 if rule_fits(absorbed, self.
_console.width):
1055 self.
_console.print(Text(stats, style=_THEME[
"summary"]))
1058 """Display the welcome banner inside a styled :class:`Panel`."""
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.",),
1073 border_style=_THEME[
"panel_border"],
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")
1086 for name, caps
in models.items():
1089 _check(caps.get(
"supports_thinking")),
1090 _check(caps.get(
"supports_reasoning")),
1091 _check(caps.get(
"supports_tools",
True)),
1097 """Display saved sessions as a styled :class:`rich.table.Table`."""
1099 self.
_console.print(Text(
"(no saved sessions)", style=_THEME[
"info"]))
1103 table = Table(title=
"Saved sessions", title_style=_THEME[
"panel_title"],
1105 table.add_column(
"", width=1, no_wrap=
True)
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")
1114 marker =
"★" if s[
"name"] == current_name
else ""
1120 str(stats.message_count),
1121 str(stats.tool_call_count),
1122 _fmt_tokens_display(stats),
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"],
1132 cmd_table.add_column(
"Command", style=
"yellow", no_wrap=
True)
1133 cmd_table.add_column(
"Description", overflow=
"fold")
1135 for verb, line
in command_help.items():
1139 usage, _, description = line.partition(
" - ")
1141 usage, description = line,
""
1142 cmd_table.add_row(usage.rstrip(), description.strip())
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)
1152 tool_table.add_row(name)
1157 """Notify the user that context compression occurred."""
1160 Text(
"[Context compressed — older messages summarised]",
1161 style=_THEME[
"compressed"])
1165 """Display an error message in bold red."""
1168 self.
_turn_print(Text(f
"Error: {msg}", style=_THEME[
"error"]))
1171 """Display an informational message."""
1179 """Start the dispatch-chrome Live display — the single busy-chrome
1180 owner outside the streaming Live region.
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).
1191 func =
"spinner_start"
1192 _dbg(
"OC_UI.TERMINAL", func, f
"label={label!r}", 4)
1195 Group(Spinner(
"dots", Text(f
" {label}", style=_THEME[
"waiting"])),
1198 refresh_per_second=self.
_fps,
1205 """Stop the underlying dispatch-chrome ``Live``; idempotent on repeated calls."""
1206 func =
"spinner_stop"
1207 if handle.status
is None:
1210 handle.status.stop()
1211 except Exception
as exc:
1212 _dbg(
"OC_UI.TERMINAL", func, f
"stop ignored: {type(exc).__name__}: {exc}", 3)
1213 handle.status =
None
1220 """Return a styled ✓ / ✗ marker for boolean capability flags."""
1222 return Text(
"✓", style=
"green")
1223 return Text(
"✗", style=
"red")
None show_activity(self, str label, str detail="", *, str tone="info")
__rich_console__(self, console, options)
"str | None" _busy_label_with_activity(self, state, int width)
None __init__(self, "RichPresenter" presenter)
None show_reasoning(self, str text)
None show_content(self, str text)
Iterator[None] turn(self)
None _clear_waiting(self)
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 _commit_turn_to_scrollback(self)
None show_iteration_stat(self, "IterationStat" stat)
str _fmt_compact_tokens(int n)
None show_usage(self, dict usage)
None _render_diff(self, DiffRender diff)
None show_activity(self, str label, str detail="", *, str tone="info")
RenderBatcher|None _batcher
None show_compressed(self)
None show_tool_progress(self, str name)
Callable[[], StatusBarState|None]|None _status_provider
None _stop_tool_status(self)
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_available_models(self, dict models)
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 show_interrupt_notice(self, str stage)
None spinner_stop(self, SpinnerHandle handle)
None _turn_print(self, RenderableType renderable)
None show_error(self, str msg)
None show_info(self, str msg)
None show_session_start(self)
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="")