6Decorator presenter that tees every event to a JSON-lines file.
10Wraps any concrete :class:`~oc_ui.presenter_base.Presenter` and emits
11one JSON object per call to *<path>*. The wrapped presenter still
12renders normally — :class:`LoggingPresenter` is a strict pass-through
17* **Replay** — drive a different presenter through a recorded session
18 to compare rendering changes without re-running the model.
19* **CI diffing** — capture the event stream for ``ConsolePresenter``
20 and ``RichPresenter`` against the same scripted input and assert
21 they emit the same logical events.
22* **Debugging** — when a user reports a UI glitch, asking them to
23 re-run with ``--log-events sessions/<name>/events.jsonl`` produces
26The format is intentionally minimal — one ``{event, args, ts}``
27object per line, using :func:`json.dumps` with ``ensure_ascii=False``.
28The file is opened in append mode so multiple turns concatenate into
29the same log; ``flush=True`` after each event keeps the log usable
30from ``tail -f`` for live monitoring.
34- :class:`LoggingPresenter` — implement every :class:`Presenter` hook
35 by writing a JSON line to the configured path *and* delegating to
36 the wrapped presenter.
37- Provide a ``close`` method so callers can flush the log
38 deterministically at session shutdown.
50- The decorator is *strict pass-through*: every event reaches the
51 inner presenter, in order, with unmodified arguments.
52- Each line is a complete JSON object terminated by ``\\n``; partial
53 writes are not permitted.
54- The log file is append-mode — never truncated.
55- Failures while writing the log are surfaced as exceptions; the
56 decorator does not silently drop events.
60- oc_diagnostics (structured debug logging via _dbg)
61- oc_ui.presenter_base (Presenter ABC contract)
62- oc_chat.core.iteration_stat (lazy; show_iteration_stat imports its JSON and human-line formatters, currently unused)
63- oc_chat.core.session (lazy; ChatSession type hint for show_welcome and bind_session)
64- oc_ui.terminal.statusline (lazy; StatusBarState type hint for bind_status)
66from __future__
import annotations
70from contextlib
import AbstractContextManager
71from pathlib
import Path
72from typing
import TYPE_CHECKING, Any, Callable
74from oc_diagnostics
import _dbg
84 """Wrap an inner contextmanager + record entry/exit events."""
86 def __init__(self, inner: AbstractContextManager,
87 emit, kind: str =
"turn") ->
None:
93 self.
_emit(f
"{self._kind}.enter", {})
97 self.
_emit(f
"{self._kind}.exit", {
98 "exc_type": exc_type.__name__
if exc_type
else None,
104 """A :class:`Presenter` that tees every event to a JSONL log.
106 All :class:`Presenter` methods are pass-through to *inner*; in
107 addition, each call appends a JSON line to *path* of the form::
109 {"ts": 1714809000.123, "event": "show_content", "args": {"text": "..."}}
111 *truncate* opens the log fresh (overwriting any previous content);
112 the default is to append.
115 def __init__(self, inner: Presenter, path: str, truncate: bool =
False) ->
None:
119 self.
_path.parent.mkdir(parents=
True, exist_ok=
True)
120 mode =
"w" if truncate
else "a"
122 self.
_file = self.
_path.open(mode, encoding=
"utf-8", buffering=1)
123 _dbg(
"OC_UI.TERMINAL", func, f
"LoggingPresenter path={path} truncate={truncate}", 2)
127 def _emit(self, event: str, args: dict[str, Any]) ->
None:
129 "ts": round(time.time(), 3),
134 line = json.dumps(record, ensure_ascii=
False)
135 except (TypeError, ValueError):
139 safe_args = {k: repr(v)
for k, v
in args.items()}
140 line = json.dumps({
"ts": record[
"ts"],
"event": event,
141 "args": safe_args,
"_repr_fallback":
True},
143 self.
_file.write(line +
"\n")
146 """Close the underlying log file. Safe to call multiple times."""
147 if not self.
_file.closed:
152 def turn(self) -> AbstractContextManager:
156 self.
_emit(
"start_turn", {})
160 self.
_emit(
"end_turn", {})
164 self.
_emit(
"show_waiting", {})
168 self.
_emit(
"show_reasoning", {
"text": text})
172 self.
_emit(
"show_content", {
"text": text})
176 self.
_emit(
"show_tool_call", {
"name": name,
"arguments": arguments})
180 self.
_emit(
"show_tool_progress", {
"name": name})
184 self.
_emit(
"show_tool_result", {
"name": name,
"result": result})
188 self.
_emit(
"show_usage", {
"usage": usage})
191 def show_activity(self, label: str, detail: str =
"", *, tone: str =
"info") ->
None:
192 """Record the plan 188 T2 activity line and delegate.
194 Strict pass-through like every other hook — the decorator records
195 the logical event and the wrapped presenter renders it.
197 self.
_emit(
"show_activity", {
"label": label,
"detail": detail,
"tone": tone})
201 """Record the plan 218 session-opening rule and delegate.
203 Strict pass-through like every other hook: the decorator records
204 the logical event and the wrapped presenter draws the rule.
206 self.
_emit(
"show_session_start", {})
210 """Record the plan 188 T5 Ctrl+C stage and delegate.
212 Signal-handler context: ``_emit`` is one buffered line write and the
213 inner presenter owns the terminal write, so this stays within the
214 one-short-write budget the hook's contract sets.
216 self.
_emit(
"show_interrupt_notice", {
"stage": stage})
221 """Log and delegate iteration stat."""
223 self.
_emit(
"show_iteration_stat", {
"stat": stat})
226 iterations: int, tool_calls: int,
227 prompt_cached: int |
None =
None, *,
228 cache_hit_pct: float |
None =
None,
229 cache_saved_tokens: int |
None =
None,
230 performance_stats: object =
None,
231 model: str |
None =
None,
232 context_used_pct: float |
None =
None,
233 duration_ms: int |
None =
None,
234 turn_cost: object =
None) ->
None:
240 if turn_cost
is not None:
242 "total_usd": getattr(turn_cost,
"total_usd",
None),
243 "model": getattr(turn_cost,
"model",
None),
244 "provider": getattr(turn_cost,
"provider",
None),
245 "as_of": getattr(turn_cost,
"as_of",
None),
246 "source": getattr(turn_cost,
"source",
None),
248 self.
_emit(
"show_turn_summary", {
249 "prompt": prompt,
"completion": completion,
"total": total,
250 "iterations": iterations,
"tool_calls": tool_calls,
251 "prompt_cached": prompt_cached,
252 "cache_hit_pct": cache_hit_pct,
253 "cache_saved_tokens": cache_saved_tokens,
256 "context_used_pct": context_used_pct,
258 "duration_ms": duration_ms,
263 prompt, completion, total, iterations, tool_calls, prompt_cached,
264 cache_hit_pct=cache_hit_pct, cache_saved_tokens=cache_saved_tokens,
265 performance_stats=performance_stats,
266 model=model, context_used_pct=context_used_pct,
267 duration_ms=duration_ms, turn_cost=turn_cost,
271 self.
_emit(
"show_welcome", {
"name": getattr(session,
"name",
"?"),
272 "model": getattr(session,
"model",
"?")})
276 self.
_emit(
"show_available_models", {
"count": len(models)})
280 self.
_emit(
"show_session_list",
281 {
"count": len(sessions),
"current": current_name})
284 def show_help(self, command_help: dict, tools: list[str] |
None =
None) ->
None:
285 self.
_emit(
"show_help",
286 {
"command_count": len(command_help),
287 "tool_count": len(tools
or [])})
291 self.
_emit(
"show_compressed", {})
295 self.
_emit(
"show_error", {
"msg": msg})
299 self.
_emit(
"show_info", {
"msg": msg})
305 """Record the spinner_start event and delegate to the wrapped presenter."""
306 self.
_emit(
"spinner_start", {
"label": label})
310 """Record the spinner_stop event and delegate to the wrapped presenter."""
311 self.
_emit(
"spinner_stop", {})
317 """Forward to the wrapped presenter if it implements it.
319 Not part of the abstract contract — only Rich/Textual implement
320 this — but :func:`main._select_presenter` checks via ``hasattr``.
322 self.
_emit(
"bind_session", {
"name": getattr(session,
"name",
"?")})
323 if hasattr(self.
_inner,
"bind_session"):
326 def bind_status(self, provider: Callable[[], StatusBarState |
None]) ->
None:
327 """Record the bind_status event and delegate to the wrapped presenter.
329 Plan 147 D1: pass-through only — a ``LoggingPresenter`` records
330 logical events and cannot see rendering, so it never calls
331 *provider* itself; it just hands it to ``self._inner``.
333 self.
_emit(
"bind_status", {})
None __init__(self, AbstractContextManager inner, emit, str kind="turn")
__exit__(self, exc_type, exc, tb)
None show_help(self, dict command_help, list[str]|None tools=None)
None show_compressed(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_tool_progress(self, str name)
None show_iteration_stat(self, "IterationStat" stat)
None show_info(self, str msg)
None show_session_list(self, list sessions, str|None current_name=None)
None show_tool_call(self, str name, str arguments)
None bind_session(self, ChatSession session)
None show_activity(self, str label, str detail="", *, str tone="info")
None show_available_models(self, dict models)
None spinner_stop(self, SpinnerHandle handle)
None show_error(self, str msg)
AbstractContextManager turn(self)
None show_content(self, str text)
None show_session_start(self)
None show_interrupt_notice(self, str stage)
None show_reasoning(self, str text)
None show_usage(self, dict usage)
SpinnerHandle spinner_start(self, str label)
None show_welcome(self, ChatSession session)
None __init__(self, Presenter inner, str path, bool truncate=False)
None bind_status(self, Callable[[], StatusBarState|None] provider)
None show_tool_result(self, str name, object result)