oc_chat
Loading...
Searching...
No Matches
enhanced_presenter.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_ui/terminal/enhanced_presenter.py
4
5"""
6Enhanced terminal presenter with statistics hooks and optional fixed header.
7
8Purpose
9-------
10Subclass of :class:`RichPresenter` that adds a thread-safe
11:class:`StatisticsTracker` for turn / session / context-usage counters
12and (when the terminal is interactive) installs a fixed-position
13ANSI-coloured header at the top of the scroll region. Selected via
14``--ui enhanced``.
15
16Responsibilities
17----------------
18- :class:`EnhancedPresenter` — Rich-styled presenter with stats hooks.
19- ``_install_fixed_header`` / ``_redraw_fixed_header`` — manage the
20 fixed top region and re-render after each turn or context change.
21- Pass-through hooks for agent / sub-loop tracking that delegate to
22 :class:`StatisticsTracker`.
23
24Diagnostics
25-----------
26Domain: OC_UI.TERMINAL
27Levels:
28 L2 — lifecycle events (init, close)
29 L3 — per-turn / context-usage updates
30 L4 — deep tracing (per-redraw decisions)
31
32Contracts
33---------
34- All rendering still goes through the parent :class:`RichPresenter`;
35 no presenter API surface is added or removed.
36- The fixed header is opt-in: skipped when ``stdout`` is not a TTY or
37 when ``OC_CHAT_ENHANCED_FIXED_HEADER`` is falsy.
38- ``close()`` always restores the terminal's full scroll region.
39
40Dependencies
41------------
42- oc_diagnostics (structured debug logging via _dbg)
43- oc_ui.terminal.stats (StatisticsTracker)
44- oc_ui.terminal.rich_presenter (RichPresenter base class)
45- oc_chat.core.config (lazy; get_max_context_tokens for the context gauge window size)
46- oc_chat.core.formatting (lazy; format_duration for the fixed header turn and session durations)
47- oc_chat.core.session (lazy; ChatSession type hint for bind_session and show_welcome)
48"""
49
50from __future__ import annotations
51
52import os
53import shutil
54from typing import Any, TYPE_CHECKING
55
56from oc_diagnostics import _dbg
57
58from oc_ui.terminal.stats import StatisticsTracker
59from oc_ui.terminal.rich_presenter import RichPresenter
60
61if TYPE_CHECKING:
62 from oc_chat.core.session import ChatSession
63
64
66 """Rich-styled presenter with enhanced statistics hooks."""
67
69 self,
70 model: str | None = "deepseek-v4-pro",
71 max_context_size: int | None = None,
72 *,
73 verbose: bool = False,
74 **rich_kwargs: Any,
75 ) -> None:
76 func = "EnhancedPresenter.__init__"
77 super().__init__(verbose=verbose, **rich_kwargs)
78 self._model = model or ""
79 # Plan 134 C2/N5: the context gauge must divide by the REAL model
80 # window, not a hardcoded 128k default. None → auto-derive from the
81 # model via get_max_context_tokens (and refresh at bind_session + on
82 # model switch); an explicit value is an override that always wins.
83 self._auto_context_size = max_context_size is None
84 if max_context_size is None:
85 from oc_chat.core.config import get_max_context_tokens
86 max_context_size = get_max_context_tokens(self._model)
87 self._max_context_size = max_context_size
89 self._stats.set_context_window_size(max_context_size)
90 self._header_session = None
94 self._console.is_terminal
95 and os.getenv("OC_CHAT_ENHANCED_FIXED_HEADER", "1").lower()
96 not in {"0", "false", "no", "off"}
97 )
98 _dbg("OC_UI.TERMINAL", func, "initialized", 2)
99
100 def bind_session(self, session: "ChatSession") -> None:
101 """Bind the active session and keep the display model + context window current.
102
103 Plan 134 N5: ``session.model`` is only known here (the presenter is
104 built before the session resolves), and it changes on /model /pro
105 /flash. When the window size is auto (no explicit --max-context-size),
106 refresh the gauge denominator from the real model so it agrees with the
107 turn-summary ctx:% gauge instead of diverging on a 128k default.
108 """
109 super().bind_session(session)
110 self._model = getattr(session, "model", self._model) or self._model
111 self._header_session = session
112 if self._auto_context_size:
113 from oc_chat.core.config import get_max_context_tokens
114 win = get_max_context_tokens(self._model)
115 self._max_context_size = win
116 self._stats.set_context_window_size(win)
118
119 def show_welcome(self, session: "ChatSession") -> None:
120 """Display the welcome panel or install the fixed enhanced header."""
121 self._model = getattr(session, "model", self._model) or self._model
122 self._header_session = session
123 if self._fixed_header_enabled:
125 return
126 super().show_welcome(session)
127
128 def _install_fixed_header(self) -> None:
129 """Reserve a fixed top terminal region and draw the header there."""
130 columns, lines = shutil.get_terminal_size((100, 30))
131 if lines <= self._fixed_header_height + 4:
132 super().show_welcome(self._header_session)
133 return
134
135 self._fixed_header_active = True
136 self._console.file.write("\x1b[2J\x1b[H")
137 self._console.file.write(f"\x1b[{self._fixed_header_height + 1};{lines}r")
138 self._console.file.write(f"\x1b[{self._fixed_header_height + 1};1H")
139 self._console.file.flush()
141
142 def _redraw_fixed_header(self) -> None:
143 """Redraw the fixed header without moving the scroll-region cursor."""
144 if not self._fixed_header_active or self._header_session is None:
145 return
146
147 columns, lines = shutil.get_terminal_size((100, 30))
148 if lines <= self._fixed_header_height + 4:
149 return
150
151 header = self._fixed_header_lines(columns)
152 self._console.file.write("\x1b[s")
153 self._console.file.write("\x1b[1;1H")
154 for idx in range(self._fixed_header_height):
155 self._console.file.write("\x1b[2K")
156 if idx < len(header):
157 self._console.file.write(header[idx])
158 if idx < self._fixed_header_height - 1:
159 self._console.file.write("\n")
160 self._console.file.write("\x1b[u")
161 self._console.file.flush()
162
163 def _fixed_header_lines(self, columns: int) -> list[str]:
164 """Build ANSI-coloured header lines sized to the terminal width."""
165 session = self._header_session
166 stats = self._stats.get_stats_snapshot()
167 width = max(50, columns)
168 inner = width - 2
169 blue = "\033[36m"
170 cyan = "\033[96m"
171 yellow = "\033[33m"
172 dim = "\033[2m"
173 reset = "\033[0m"
174
175 model = getattr(session, "model", self._model) or self._model
176 effort = getattr(session, "reasoning_effort", "")
177 name = getattr(session, "name", "session")
178 title = " OC Chat "
179
180 turn_total = stats["last_turn"]["total"]
181 turn_tools = stats["last_turn"]["tool_calls"]
182 turn_cache = stats["last_turn"]["cache_hit_rate"]
183 sess_total = stats["session"]["tokens"]["total"]
184 sess_tools = stats["session"]["timing"]["tool_calls"]
185 sess_cache = stats["session"]["tokens"]["cache_hit_rate"]
186 context_pct = stats["context"]["usage_percent"]
187
188 # Plan 147 D1/D2: real turn/session durations from the bound status
189 # provider — blank until the first refresh_presenter_bindings call
190 # (e.g. before startup finishes binding).
191 turn_duration = ""
192 session_duration = ""
193 status = self._status_provider() if self._status_provider is not None else None
194 if status is not None:
195 from oc_chat.core.formatting import format_duration
196 turn_duration = f" · {format_duration(status.turn_elapsed_ms)}"
197 session_duration = f" · {format_duration(status.session_elapsed_ms)}"
198
199 top_fill = max(0, inner - len(title))
200 top_left = top_fill // 2
201 top_right = top_fill - top_left
202
203 def cell(text: str = "") -> str:
204 plain = text[:inner]
205 return f"{blue}│{reset}{plain.ljust(inner)}{blue}│{reset}"
206
207 return [
208 f"{blue}╭{'─' * top_left}{title}{'─' * top_right}╮{reset}",
209 cell(),
210 cell(f" Model: {cyan}{model}{reset}"),
211 cell(f" Reasoning effort: {cyan}{effort}{reset} {dim}(name: {name}){reset}"),
212 cell(),
213 cell(
214 " "
215 f"{yellow}this turn:{reset} {self._fmt_compact_tokens(turn_total)} · "
216 f"{turn_tools} tools · {turn_cache:.0f}% cache · ctx {context_pct:.0f}%{turn_duration} "
217 f"{yellow}session:{reset} {self._fmt_compact_tokens(sess_total)} · "
218 f"{sess_tools} tools · {sess_cache:.0f}% cache{session_duration}"
219 ),
220 cell(f" Type {yellow}/help{reset} for full command details and session subcommands."),
221 f"{blue}╰{'─' * inner}╯{reset}",
222 ]
223
225 self,
226 prompt: int,
227 completion: int,
228 cache_hits: int = 0,
229 duration_ms: int = 0,
230 tool_calls: int = 0,
231 ) -> None:
232 """Update enhanced statistics after a turn or instrumentation event."""
234 prompt,
235 completion,
236 cache_hits,
237 duration_ms,
238 tool_calls,
239 )
241
242 func = "update_turn_stats"
243 _dbg(
244 "OC_UI.TERMINAL",
245 func,
246 f"updated: prompt={prompt}, completion={completion}, "
247 f"cache_hits={cache_hits}, duration={duration_ms}ms",
248 3,
249 )
250
252 self,
253 prompt: int,
254 completion: int,
255 total: int,
256 iterations: int,
257 tool_calls: int,
258 prompt_cached: int | None = None,
259 *,
260 cache_hit_pct: float | None = None,
261 cache_saved_tokens: int | None = None,
262 performance_stats: object = None,
263 model: str | None = None,
264 context_used_pct: float | None = None,
265 duration_ms: int | None = None,
266 turn_cost: object = None,
267 ) -> None:
268 """Render the rich summary and mirror it into enhanced stats.
269
270 Plan 188 T4: *turn_cost* is pure pass-through — the stats tracker
271 counts tokens, and the ``~$`` estimate is rendered by the Rich base.
272 """
274 prompt,
275 completion,
276 cache_saved_tokens or prompt_cached or 0,
277 duration_ms or 0,
278 tool_calls,
279 )
281 super().show_turn_summary(
282 prompt,
283 completion,
284 total,
285 iterations,
286 tool_calls,
287 prompt_cached,
288 cache_hit_pct=cache_hit_pct,
289 cache_saved_tokens=cache_saved_tokens,
290 performance_stats=performance_stats,
291 model=model,
292 context_used_pct=context_used_pct,
293 duration_ms=duration_ms,
294 turn_cost=turn_cost,
295 )
296
297 def update_context_usage(self, used_tokens: int) -> None:
298 """Update context-window usage in the enhanced tracker."""
299 self._stats.update_context_usage(used_tokens)
301
302 func = "update_context_usage"
303 _dbg(
304 "OC_UI.TERMINAL",
305 func,
306 f"context usage: {used_tokens}/{self._max_context_size}",
307 3,
308 )
309
310 def add_agent(self) -> None:
311 """Add a new agent to tracking."""
312 self._stats.add_agent()
314
315 def finish_agent(self) -> None:
316 """Mark an agent as finished."""
317 self._stats.finish_agent()
319
320 def add_sub_loop(self) -> None:
321 """Add a new sub-loop to tracking."""
322 self._stats.add_sub_loop()
324
325 def finish_sub_loop(self) -> None:
326 """Mark a sub-loop as finished."""
329
330 def get_stats_tracker(self) -> StatisticsTracker:
331 """Return the enhanced statistics tracker."""
332 return self._stats
333
334 def show_sub_loop_trigger(self, model: str, task: str) -> None:
335 """Display that a sub-loop was triggered using rich styling."""
336 self._console.print(f"[cyan]Sub-loop:[/] {model}")
337
338 def close(self) -> None:
339 """Reset the terminal scroll region on shutdown."""
340 if self._fixed_header_active:
341 self._console.file.write("\x1b[r")
342 self._console.file.write(f"\x1b[{self._fixed_header_height + 1};1H")
343 self._console.file.flush()
344 self._fixed_header_active = False
None update_turn_stats(self, int prompt, int completion, int cache_hits=0, int duration_ms=0, int tool_calls=0)
None __init__(self, str|None model="deepseek-v4-pro", int|None max_context_size=None, *, bool verbose=False, **Any rich_kwargs)
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)
Callable[[], StatusBarState|None]|None _status_provider