oc_chat
Loading...
Searching...
No Matches
presenter_base.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_ui/presenter_base.py
4
5"""
6Presenter contract — the abstract interface every presenter must satisfy.
7
8Purpose
9-------
10A *presenter* is everything OC Chat knows about how to render the
11chat to a human. The contract is intentionally minimal and procedural
12so that:
13
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
20 widgets.
21
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.
26
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
30enables.
31
32Responsibilities
33----------------
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``,
39 etc.).
40- Document the streaming-event contract so subclasses don't have to
41 guess about ordering or completion.
42
43Diagnostics
44-----------
45Domain: OC_UI.TERMINAL
46Levels:
47 L2 — lifecycle events
48 L3 — semantic details
49 L4 — deep tracing
50
51Contracts
52---------
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
56 serialise internally.
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.
60
61Dependencies
62------------
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)
66
67See Also
68--------
69oc_chat/docs/conventions/CONTRACTS.md §2.3 Presenter — owner of the 19-method
70presenter ABC pinned by tests/unit/test_presenter_contract.py.
71"""
72from __future__ import annotations
73
74from abc import ABC, abstractmethod
75from contextlib import AbstractContextManager
76from dataclasses import dataclass
77from typing import TYPE_CHECKING
78
79from oc_diagnostics import _dbg
80
81if TYPE_CHECKING:
82 from typing import Callable
83
84 from oc_chat.core.session import ChatSession
85 from oc_ui.terminal.statusline import StatusBarState
86
87_dbg("OC_UI.TERMINAL", "module", "loaded", 4)
88
89
90#: Plan 188 T5 — the two-stage Ctrl+C notices, owned here so every presenter
91#: says the same thing on the same stream discipline. Both are single strings
92#: written in ONE call: :meth:`Presenter.show_interrupt_notice` runs inside a
93#: signal handler, where a multi-write render could interleave with whatever
94#: the main thread was printing.
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",
99}
100
101
102@dataclass
104 """Opaque handle returned by :meth:`Presenter.spinner_start`.
105
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.
111
112 Stored as :class:`object` rather than the concrete Rich type so this
113 module does not require ``rich`` to be importable.
114 """
115
116 status: object | None = None
117
118
119class Presenter(ABC):
120 """Abstract base class for all OC Chat terminal presenters.
121
122 Implementations: :class:`~oc_ui.terminal.presenter.ConsolePresenter`
123 (always available); ``RichPresenter`` (Phase 1, optional dep);
124 ``TextualPresenter`` (Phase 3).
125
126 Lifecycle (per chat turn):
127
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(...)
133 presenter.end_turn()
134 """
135
136 # ── Per-turn lifecycle ────────────────────────────────────────────────
137
138 @abstractmethod
139 def turn(self) -> AbstractContextManager:
140 """Return a context manager wrapping the streaming portion of a turn.
141
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__``.
145 """
146
147 @abstractmethod
148 def start_turn(self) -> None:
149 """Reset per-turn flags / display the turn separator."""
150
151 @abstractmethod
152 def end_turn(self) -> None:
153 """Finalise the current turn (newline, cleanup)."""
154
155 # ── Streaming events ──────────────────────────────────────────────────
156
157 @abstractmethod
158 def show_waiting(self) -> None:
159 """Indicate the request is in flight before any tokens arrive.
160
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.
164 """
165
166 @abstractmethod
167 def show_reasoning(self, text: str) -> None:
168 """Stream a chunk of chain-of-thought / reasoning text."""
169
170 @abstractmethod
171 def show_content(self, text: str) -> None:
172 """Stream a chunk of the assistant's answer."""
173
174 # ── Tools ─────────────────────────────────────────────────────────────
175
176 @abstractmethod
177 def show_tool_call(self, name: str, arguments: str) -> None:
178 """Display the *invocation* of a tool, before it runs."""
179
180 @abstractmethod
181 def show_tool_progress(self, name: str) -> None:
182 """Indicate that a tool is currently executing.
183
184 Plain presenters typically no-op. Rich/textual presenters show a
185 spinner that is replaced by :meth:`show_tool_result` when the tool
186 finishes.
187 """
188
189 @abstractmethod
190 def show_tool_result(self, name: str, result: object) -> None:
191 """Display the result of a completed tool call."""
192
193 # ── Stats ─────────────────────────────────────────────────────────────
194
195 @abstractmethod
196 def show_usage(self, usage: dict) -> None:
197 """Render per-iteration usage stats.
198
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.
204 """
205
206
207 @abstractmethod
208 def show_iteration_stat(self, stat: "IterationStat") -> None:
209 """Render a timestamped per-iteration analytics stat line.
210
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.
214 """
215 @abstractmethod
217 self,
218 prompt: int,
219 completion: int,
220 total: int,
221 iterations: int,
222 tool_calls: int,
223 prompt_cached: int | None = None,
224 *,
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,
232 ) -> None:
233 """Render the aggregated turn-level summary.
234
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.
240
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).
246
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.
257 """
258
259 # ── Static UI ─────────────────────────────────────────────────────────
260
261 @abstractmethod
262 def show_welcome(self, session: ChatSession) -> None:
263 """Display the startup banner."""
264
265 @abstractmethod
266 def show_available_models(self, models: dict) -> None:
267 """Display the model list / capability table."""
268
269 @abstractmethod
270 def show_session_list(self, sessions: list, current_name: str | None = None) -> None:
271 """Display the list of saved sessions for ``/session list``.
272
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.
276 """
277
278 @abstractmethod
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).
281
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.
285 """
286
287 @abstractmethod
288 def show_compressed(self) -> None:
289 """Notify the user that context compression occurred."""
290
291 @abstractmethod
292 def show_error(self, msg: str) -> None:
293 """Display an error message."""
294
295 @abstractmethod
296 def show_info(self, msg: str) -> None:
297 """Display an informational message."""
298
299 # ── Progress spinner (Phase H.4) ──────────────────────────────────────
300 # NOT abstract: default to no-op so backward-compat with every existing
301 # Presenter subclass (including in-test recording mocks) is preserved.
302 # :class:`RichPresenter` overrides with a real ``rich.status.Status``.
303
304 def spinner_start(self, label: str) -> SpinnerHandle:
305 """Start an animated progress spinner with the given label.
306
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.
314 """
315 return SpinnerHandle(status=None)
316
317 def spinner_stop(self, handle: SpinnerHandle) -> None:
318 """Stop the spinner identified by *handle*.
319
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).
324 """
325 return None
326
327 # ── Activity lane (Plan 188 T2) ───────────────────────────────────────
328 # NOT abstract: default to no-op so every existing Presenter subclass
329 # (including in-test recording mocks) keeps working without modification,
330 # exactly like spinner_start / bind_status above. The 19-method abstract
331 # contract is deliberately unchanged.
332
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.
335
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`).
342
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.
347
348 No new event kind and no bus semantic change: this hook renders
349 existing signal (synthesis §2 — observability, not control).
350 """
351 return None
352
353 # ── Session-opening rule (Plan 218 T2) ────────────────────────────────
354 # NOT abstract: default to no-op so every existing Presenter subclass
355 # (including in-test recording mocks) keeps working without modification,
356 # exactly like show_activity / spinner_start / bind_status above. The
357 # 19-method abstract contract is deliberately unchanged.
358
359 def show_session_start(self) -> None:
360 """Draw the ``start @ <time>`` rule that opens the session.
361
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.
368
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.
374 """
375 return None
376
377 # ── Two-stage Ctrl+C notice (Plan 188 T5) ─────────────────────────────
378 # NOT abstract, same rationale as above.
379
380 def show_interrupt_notice(self, stage: str) -> None:
381 """Announce a Ctrl+C stage — called FROM THE SIGNAL HANDLER.
382
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.
387
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.
396 """
397 import sys
398 text = INTERRUPT_NOTICES.get(stage)
399 if text is None:
400 return None
401 sys.stderr.write(text)
402 sys.stderr.flush()
403 return None
404
405 # ── Realtime status bar (Plan 147 D1) ───────────────────────────────────
406 # NOT abstract: default to no-op so every existing Presenter subclass
407 # (including in-test recording mocks predating plan 147) keeps working
408 # without modification. :class:`RichPresenter` overrides to render the
409 # shared statusline formatter; :class:`LoggingPresenter` passes through.
410
411 def bind_status(self, provider: Callable[[], StatusBarState | None]) -> None:
412 """Register the late-binding status-snapshot provider for this presenter.
413
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.
424 """
425 return None
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_interrupt_notice(self, str stage)
None show_help(self, dict command_help, list[str]|None tools=None)
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_tool_progress(self, str name)
None show_welcome(self, ChatSession session)