6Presenter contract — the abstract interface every presenter must satisfy.
10A *presenter* is everything OC Chat knows about how to render the
11chat to a human. The contract is intentionally minimal and procedural
14- :class:`~oc_ui.terminal.presenter.ConsolePresenter` (plain ANSI)
15 can satisfy it with simple ``print`` calls.
16- A future ``RichPresenter`` (Phase 1) can satisfy the same interface
17 using ``rich.live.Live`` to render token-by-token streaming inside
18 a borderless region without scrolling history off screen.
19- A future ``TextualPresenter`` (Phase 3) can satisfy it via async
22The single non-trivial method is :meth:`Presenter.turn`: it returns
23a context manager wrapping a chat turn so streaming presenters have
24an unambiguous place to open and close their live region.
25``ConsolePresenter`` implements it as a no-op contextmanager.
27See ``oc_chat/docs/Terminal_and_UI_design (Synthesis).md`` for the
28full rationale and ``oc_chat/docs/Terminal_and_UI_design
29(Implementation Plan).md`` for the multi-phase rollout this contract
34- :class:`Presenter` — abstract base with the per-turn lifecycle
35 hooks (``start_turn`` / ``turn`` contextmanager / ``end_turn``)
36 and event hooks (``show_reasoning``, ``show_content``,
37 ``show_tool_call``, ``show_tool_result``, ``show_usage``,
38 ``show_turn_summary``, ``show_compressed``, ``show_welcome``,
40- Document the streaming-event contract so subclasses don't have to
41 guess about ordering or completion.
53- All hooks are ``@abstractmethod`` — subclasses must implement them.
54- Implementations must be safe to call from a single thread; any
55 presenter that wants to support background streaming must
57- ``turn()`` always returns a context manager, even if it's a no-op.
58- ``show_content`` / ``show_reasoning`` chunks are append-only — the
59 presenter never sees a "rewind" event.
63- oc_diagnostics (structured debug logging via _dbg)
64- oc_chat.core.session (lazy; ChatSession, for type checking the show_welcome session argument)
65- oc_ui.terminal.statusline (lazy; StatusBarState, for type checking the bind_status provider)
69oc_chat/docs/conventions/CONTRACTS.md §2.3 Presenter — owner of the 19-method
70presenter ABC pinned by tests/unit/test_presenter_contract.py.
72from __future__
import annotations
74from abc
import ABC, abstractmethod
75from contextlib
import AbstractContextManager
76from dataclasses
import dataclass
77from typing
import TYPE_CHECKING
79from oc_diagnostics
import _dbg
82 from typing
import Callable
87_dbg(
"OC_UI.TERMINAL",
"module",
"loaded", 4)
95INTERRUPT_NOTICES: dict[str, str] = {
96 "cancel":
"\n[Ctrl+C — will ask to stop at the next safe point; the "
97 "current tool keeps running. Ctrl+C again to force-stop now]\n",
98 "force":
"\n[Ctrl+C — forcing stop…]\n",
104 """Opaque handle returned by :meth:`Presenter.spinner_start`.
106 Holds a backend object that knows how to render the spinner (typically
107 a ``rich.status.Status`` instance for :class:`RichPresenter`). For
108 presenters that cannot render an animated spinner (plain console,
109 headless CI, JSON-lines logger), the ``status`` field is ``None`` and
110 :meth:`Presenter.spinner_stop` is a no-op.
112 Stored as :class:`object` rather than the concrete Rich type so this
113 module does not require ``rich`` to be importable.
116 status: object |
None =
None
120 """Abstract base class for all OC Chat terminal presenters.
122 Implementations: :class:`~oc_ui.terminal.presenter.ConsolePresenter`
123 (always available); ``RichPresenter`` (Phase 1, optional dep);
124 ``TextualPresenter`` (Phase 3).
126 Lifecycle (per chat turn):
128 presenter.start_turn()
129 with presenter.turn():
130 presenter.show_waiting()
131 # … streaming events: show_reasoning / show_content / show_tool_*
132 presenter.show_turn_summary(...)
139 def turn(self) -> AbstractContextManager:
140 """Return a context manager wrapping the streaming portion of a turn.
142 Plain presenters return a no-op contextmanager. Streaming
143 presenters use this to open a Live region on ``__enter__`` and
144 commit it (or clear it) on ``__exit__``.
149 """Reset per-turn flags / display the turn separator."""
153 """Finalise the current turn (newline, cleanup)."""
159 """Indicate the request is in flight before any tokens arrive.
161 Should be cleared automatically by the first reasoning/content
162 chunk. Plain presenters print a transient line; rich presenters
163 show an animated spinner.
168 """Stream a chunk of chain-of-thought / reasoning text."""
172 """Stream a chunk of the assistant's answer."""
178 """Display the *invocation* of a tool, before it runs."""
182 """Indicate that a tool is currently executing.
184 Plain presenters typically no-op. Rich/textual presenters show a
185 spinner that is replaced by :meth:`show_tool_result` when the tool
191 """Display the result of a completed tool call."""
197 """Render per-iteration usage stats.
199 Plan 134 C1: terminal presenters render this ONLY in ``--verbose``
200 mode (the render is gated, not the event). The structured usage signal
201 is preserved elsewhere — ``react_loop`` records it and
202 ``LoggingPresenter`` tees it to its JSONL log unconditionally before
203 delegating — so quiet mode loses only the on-screen line.
209 """Render a timestamped per-iteration analytics stat line.
211 Called once per ReAct iteration with a structured
212 :class:`IterationStat` record. Implementations may print
213 a compact one-liner, write JSON to a file, or both.
223 prompt_cached: int |
None =
None,
225 cache_hit_pct: float |
None =
None,
226 cache_saved_tokens: int |
None =
None,
227 performance_stats: object =
None,
228 model: str |
None =
None,
229 context_used_pct: float |
None =
None,
230 duration_ms: int |
None =
None,
231 turn_cost: object =
None,
233 """Render the aggregated turn-level summary.
235 Plan 134 C2: *cache_hit_pct* is the cache-hit ratio (label it
236 ``cache-hit:`` — NOT ``ctx:``). *context_used_pct* is the real
237 context-window saturation (main-loop prompt high-water ÷ the model's
238 window), already computed by the caller; *model* names the window.
239 Presenters render what they're given and do no config/caps lookup.
241 Plan 147 D1: *duration_ms* is the real wall-clock turn duration,
242 computed by the caller directly from a turn-start monotonic
243 timestamp (never derived from :class:`~oc_ui.terminal.input.TurnStats`
244 finish-turn state — see the summary-ordering pin). ``None`` when the
245 caller has no timing available (e.g. a synthetic/test turn).
247 Plan 188 T4: *turn_cost* is a
248 :class:`~oc_chat.core.turn_cost.TurnCost` — the modelled USD spend
249 plus the price table's provenance — or ``None``. Typed as ``object``
250 for the same reason as *performance_stats*: presenters read it
251 duck-typed and ``oc_ui`` never imports ``oc_chat.core`` at module
252 load. ``None`` means the turn could NOT be priced honestly (unknown
253 model, unaccounted cache split, separately-billed reasoning) and
254 presenters MUST then render no cost at all — a missing line is the
255 designed outcome, not a degraded one, and rendering ``$0.00`` in its
256 place would be exactly the false line T4 forbids.
263 """Display the startup banner."""
267 """Display the model list / capability table."""
271 """Display the list of saved sessions for ``/session list``.
273 *sessions* is the return value of :func:`oc_chat.core.persistence.list_sessions`.
274 Plain console: a hand-padded text table. Rich: a styled
275 ``rich.table.Table`` with proper column sizing and overflow.
279 def show_help(self, command_help: dict, tools: list[str] |
None =
None) ->
None:
280 """Display the ``/help`` listing of commands (and optionally agent tools).
282 *command_help* maps verb → usage string (the ``COMMAND_HELP``
283 registry). *tools* is the sorted list of registered agent-tool
284 names; ``None`` skips the tools section.
289 """Notify the user that context compression occurred."""
293 """Display an error message."""
297 """Display an informational message."""
305 """Start an animated progress spinner with the given label.
307 Used by long-running operations (slow tool dispatch, sub-loop
308 provider calls) to indicate liveness so users don't wonder if the
309 app has hung. Returns a :class:`SpinnerHandle` the caller passes
310 back to :meth:`spinner_stop`. The base implementation is a no-op
311 returning a handle with ``status=None`` — appropriate for plain
312 console, headless CI, and tests; :class:`RichPresenter` overrides
313 with a real animated spinner.
318 """Stop the spinner identified by *handle*.
320 Idempotent — safe to call multiple times on the same handle (e.g.
321 once on first-chunk arrival and again in a ``finally`` block).
322 The base implementation is a no-op (handle's ``status`` is
323 ``None`` by default).
333 def show_activity(self, label: str, detail: str =
"", *, tone: str =
"info") ->
None:
334 """Render a compact one-line state update for a non-streaming lane.
336 Plan 188 T2 surfaces state the loop ALREADY produces but never
337 showed: the pending-approval lane (``permission_decision``), the
338 sub-agent lane (``subagent_started`` / ``subagent_completed``) —
339 both fed by the :mod:`oc_chat.core.presenter_events` bus
340 subscriber — and plan progress, fed presenter-side from the
341 planner singleton (see :mod:`oc_ui.terminal.activity`).
343 *label* names the lane (``"approval"`` / ``"sub-agent"`` /
344 ``"plan"``); *detail* is the already-formatted body; *tone* is one
345 of :data:`~oc_ui.terminal.activity.TONES` and selects styling
346 only — an unknown tone must render neutral, never raise.
348 No new event kind and no bus semantic change: this hook renders
349 existing signal (synthesis §2 — observability, not control).
360 """Draw the ``start @ <time>`` rule that opens the session.
362 Plan 218 P1 element 1: the REPL's first prompt used to float in
363 unbounded space — the first horizontal rule only appeared once the
364 user had already pressed Enter. Emitting a rule at session start
365 means every prompt echo, including the first, sits inside a
366 bounded region, which is what makes the three-space turn indent
367 read as containment rather than as a stray margin.
369 Called once by :func:`oc_chat.main.main`, after the welcome banner
370 and the pre-turn notices and before the REPL loop. Terminal
371 presenters draw their existing rule; the base implementation is a
372 no-op — appropriate for headless, JSON-lines and recording
373 presenters that have no rules to draw.
381 """Announce a Ctrl+C stage — called FROM THE SIGNAL HANDLER.
383 *stage* is ``"cancel"`` (first press: a stop-confirmation is now
384 pending at the turn's next safe checkpoint; nothing is cancelled
385 yet and the in-flight tool keeps running) or ``"force"`` (second
386 press: hard interrupt). Any other value is ignored.
388 **Signal-safety contract.** This runs inside
389 :func:`oc_chat.main._o3_sigint_handler`, so an implementation must
390 do at most ONE short write and must never block, prompt, or raise.
391 The default writes the notice to ``stderr`` — which is exactly
392 what the handler did inline before plan 188, so presenters that do
393 not override this keep byte-identical behaviour; the point of the
394 hook is that the presenter which OWNS the terminal (Rich, with a
395 live region) gets to choose the stream.
398 text = INTERRUPT_NOTICES.get(stage)
401 sys.stderr.write(text)
411 def bind_status(self, provider: Callable[[], StatusBarState |
None]) ->
None:
412 """Register the late-binding status-snapshot provider for this presenter.
414 *provider* is a zero-arg callable a presenter invokes AT RENDER TIME
415 to obtain the current :class:`~oc_ui.terminal.statusline.StatusBarState`
416 (or ``None`` when no snapshot is available yet). It MUST NOT be a
417 closure that captures a ``ChatSession`` object — sessions are
418 replaced at runtime (``/session new`` / ``clear``) — it should read
419 current state (e.g. off a ``Context``) fresh on every call. Called
420 via :func:`~oc_chat.core.commands.refresh_presenter_bindings` on
421 every session-mutation path, alongside ``bind_session``. The base
422 implementation is a no-op — appropriate for :class:`ConsolePresenter`
423 and any presenter with no persistent chrome to refresh.
None show_content(self, str text)
None spinner_stop(self, SpinnerHandle handle)
AbstractContextManager turn(self)
None show_available_models(self, dict models)
None show_tool_result(self, str name, object result)
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 bind_status(self, Callable[[], StatusBarState|None] provider)
None show_session_list(self, list sessions, str|None current_name=None)
None show_reasoning(self, str text)
None show_tool_call(self, str name, str arguments)
None show_session_start(self)
None show_info(self, str msg)
None show_interrupt_notice(self, str stage)
None show_help(self, dict command_help, list[str]|None tools=None)
None show_compressed(self)
None show_usage(self, dict usage)
SpinnerHandle spinner_start(self, str label)
None show_iteration_stat(self, "IterationStat" stat)
None show_activity(self, str label, str detail="", *, str tone="info")
None show_error(self, str msg)
None show_tool_progress(self, str name)
None show_welcome(self, ChatSession session)