oc_chat
Loading...
Searching...
No Matches
presenter.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_ui/terminal/presenter.py
4
5"""
6Plain-ANSI console presenter for OC Chat.
7
8Purpose
9-------
10:class:`ConsolePresenter` implements the
11:class:`~oc_ui.presenter_base.Presenter` contract using simple
12``print`` calls and ANSI escape codes from
13:mod:`oc_chat.core.config`. It is the default presenter and the
14fallback used by CI, headless runs, and any environment without
15optional ``rich`` / ``textual`` dependencies installed.
16
17The Phase 0 quick wins live here:
18
19- Horizontal rule + turn number + timestamp at :meth:`start_turn`.
20- :meth:`show_waiting` indicator that is cleared by the first
21 reasoning / content chunk.
22- Compact :meth:`show_turn_summary` (one dim line) — full
23 per-iteration breakdown only when ``verbose`` is set.
24- ``NO_COLOR`` / ``FORCE_COLOR`` honoured via the STYLE_* constants
25 in :mod:`oc_chat.core.config`.
26
27Responsibilities
28----------------
29- :class:`ConsolePresenter` — concrete :class:`Presenter`
30 implementation backed by ``print``.
31- :func:`_color_enabled` — env-var-based ANSI gating.
32- All ``show_*`` event hooks for reasoning / content / tool / usage
33 / turn-summary / compressed / welcome.
34- :meth:`turn` returns a no-op ``contextmanager`` (no live region).
35
36Diagnostics
37-----------
38Domain: OC_UI.TERMINAL
39Levels:
40 L2 — lifecycle events
41 L3 — semantic details
42 L4 — deep tracing
43
44Contracts
45---------
46- Output goes to ``stdout`` exclusively; diagnostics and warnings
47 live on ``stderr`` via ``oc_diagnostics``.
48- ``NO_COLOR`` set (any value) disables every ANSI sequence.
49- ``FORCE_COLOR`` overrides ``NO_COLOR`` and forces ANSI on.
50- ``show_*`` hooks are append-only — the presenter never rewinds.
51- Safe to use under CI / non-TTY: ``shutil.get_terminal_size`` is
52 used with a fallback when the size is undetermined.
53
54Dependencies
55------------
56- oc_diagnostics (structured debug logging via _dbg)
57- oc_ui.presenter_base (Presenter ABC contract)
58- oc_ui.terminal.activity (plan_progress_from_result for the plan progress line under planner results)
59- oc_ui.terminal.diffview (mutation argument pairing and plain unified diff rendering for write_file and edit_file)
60- oc_ui.terminal.turn_layout (turn indent, rule labels, answer box markers and line indenters)
61- oc_chat.core.commands (lazy; _fmt_tokens_display for the session list table)
62- oc_chat.core.formatting (lazy; format_duration for the turn summary)
63- oc_chat.core.iteration_stat (lazy; iteration_stat_human_line for the per-iteration stats line)
64- oc_chat.core.session (lazy; ChatSession type hint for show_welcome)
65- oc_ui.terminal.statusline (lazy; StatusBarState type hint for bind_status)
66"""
67from __future__ import annotations
68
69import json
70import shutil
71from contextlib import contextmanager
72from datetime import datetime
73from typing import TYPE_CHECKING, Callable, Iterator
74
75from oc_diagnostics import _dbg
76
77from oc_ui.presenter_base import Presenter, SpinnerHandle
78from oc_ui.terminal.activity import plan_progress_from_result
79from oc_ui.terminal.diffview import (
80 MutationArgsLedger,
81 build_mutation_diff,
82 render_diff_plain,
83 summarize_mutation_call,
84)
86 ANSWER_BOX_TITLE,
87 PLAIN_RULE_FILL,
88 REASONING_TITLE,
89 TURN_INDENT,
90 LineIndenter,
91 plain_box_bottom,
92 plain_box_top,
93 rule_fits,
94 rule_line,
95 running_total_text,
96 start_rule_label,
97 turn_end_rule_label,
98 turn_rule_label,
99)
100
101if TYPE_CHECKING:
102 from oc_chat.core.session import ChatSession
103 from oc_ui.terminal.statusline import StatusBarState
104
105# Define styles locally to avoid circular dependency
106def _color_enabled() -> bool:
107 """Return True iff ANSI styles should be emitted."""
108 import os
109 if os.getenv("FORCE_COLOR"):
110 return True
111 if os.getenv("NO_COLOR") is not None:
112 return False
113 return True
114
116STYLE_RESET = "\033[0m" if _COLOR else ""
117STYLE_DIM = "\033[2m" if _COLOR else ""
118STYLE_ITALIC = "\033[3m" if _COLOR else ""
119STYLE_BOLD = "\033[1m" if _COLOR else ""
120STYLE_CYAN = "\033[36m" if _COLOR else ""
121STYLE_YELLOW = "\033[33m" if _COLOR else ""
122STYLE_GREEN = "\033[32m" if _COLOR else ""
123STYLE_RED = "\033[31m" if _COLOR else ""
124REASONING_EFFORT_LEVELS = ["low", "medium", "high"]
125
126
128 """Terminal UI presenter using plain ANSI escape codes.
129
130 Always satisfies the :class:`Presenter` contract with no optional
131 dependencies. Suitable for CI, headless servers, and any environment
132 where Rich/Textual aren't appropriate.
133
134 *verbose* (default ``False``) controls whether per-iteration
135 :meth:`show_usage` is emitted. When ``False``, only the compact turn
136 summary is printed at the end of each turn.
137 """
138
139 def __init__(self, verbose: bool = False) -> None:
140 self._reasoning_shown = False
141 self._content_shown = False
142 self._waiting_shown = False
144 self.verbose = verbose
145 # Plan 188 T1: pairs a mutation tool's call arguments with its result so
146 # ``show_tool_result`` can render a diff instead of truncated JSON.
148 # Plan 218: turn-scoped layout state. ``_in_turn`` gates the indent;
149 # ``_answer_open`` marks a degraded answer box awaiting its closing
150 # marker; the indenters buffer partial streamed lines so the indent
151 # lands at line starts and never mid-sentence.
152 self._in_turn = False
153 self._answer_open = False
156 self._status_provider: Callable[[], "StatusBarState | None"] | None = None
157
158 # ── Turn-scoped layout helpers (Plan 218) ──────────────────────────────
159
160 def bind_status(self, provider: Callable[[], "StatusBarState | None"]) -> None:
161 """Store the status-snapshot provider (plan 147 D1 / plan 218 T2).
162
163 The plain console has no live chrome to refresh, so this was a
164 no-op before plan 218. It now keeps the provider because the
165 turn-end rule's running total is read from that snapshot — the
166 plain presenter must be able to show the same two numbers the
167 rich one does, not a degraded rule missing them.
168 """
169 self._status_provider = provider
170
171 @staticmethod
172 def _width() -> int:
173 """Terminal width, with the module's long-standing 80x24 fallback."""
174 return max(20, shutil.get_terminal_size((80, 24)).columns)
175
176 def _now_ts(self) -> str:
177 """Wall-clock ``HH:MM:SS`` for the rule labels — the one clock read."""
178 return datetime.now().strftime("%H:%M:%S")
179
180 def _indent(self) -> str:
181 """The turn indent while a turn is open; nothing outside one."""
182 return TURN_INDENT if self._in_turn else ""
183
184 def _print_rule(self, label: "str | list[tuple[str, str]]",
185 trailer: str = "") -> None:
186 """Print the two-line plain separator: full rule, then the label line.
187
188 Structure and both style tokens are exactly the pre-218 ones; only
189 the label's CONTENT grows (plan 218 P1 element 4) and the trailer
190 is new. The label line keeps its ``·`` fill.
191 """
192 width = self._width()
193 print(f"\n{STYLE_DIM}{'─' * width}{STYLE_RESET}")
194 print(f"{STYLE_DIM}"
195 f"{rule_line(label, width, trailer, PLAIN_RULE_FILL)}"
196 f"{STYLE_RESET}")
197
198 def _running_total(self) -> str:
199 """The ``↻ total · turn / session`` trailer, or ``""`` when unknown.
200
201 Same contract as the rich presenter's: read off the bound status
202 snapshot, add this turn to the session figure (``finish_turn``
203 folds it in only after the summary renders), and render NOTHING
204 rather than a zero when no provider is bound.
205 """
206 func = "_running_total"
207 if self._status_provider is None:
208 return ""
209 try:
210 state = self._status_provider()
211 except Exception as exc:
212 # EXTERNAL_ERROR — a caller-supplied provider misbehaved; the
213 # rule renders without its trailer rather than failing the turn.
214 _dbg("OC_UI.TERMINAL", func,
215 f"EXTERNAL_ERROR: {type(exc).__name__}: {exc}", 2, override=True)
216 return ""
217 if state is None:
218 return ""
219 return running_total_text(state.turn_tokens,
220 state.session_tokens + state.turn_tokens)
221
222 def _close_answer_box(self, subtitle: str = "", error: str = "") -> None:
223 """Emit the degraded box's closing marker (plan 218 P2/W5).
224
225 Called from every hook that can follow the answer, so the box is
226 closed before anything that logically comes after it prints — and
227 it is closed on the error path too, carrying the failure text
228 inside. Idempotent: a second call with no open box does nothing.
229 """
230 if not self._answer_open:
231 return
232 self._answer_open = False
233 for line in self._content_indenter.flush():
234 print(line)
235 if error:
236 print(f"{TURN_INDENT}{STYLE_RED}{error}{STYLE_RESET}")
237 marker = plain_box_bottom(self._width() - len(TURN_INDENT), subtitle,
238 indent="")
239 print(f"{TURN_INDENT}{STYLE_DIM}{marker}{STYLE_RESET}")
240
241 # ── Per-turn lifecycle ─────────────────────────────────────────────────
242
243 def show_session_start(self) -> None:
244 """Draw the ``start @ <time>`` rule that opens the session (P1.1)."""
245 self._print_rule(start_rule_label(self._now_ts()))
246
247 @contextmanager
248 def turn(self) -> Iterator[None]:
249 """Context manager wrapping the streamed portion of a turn.
250
251 The plain console has no Live region — every chunk prints as it
252 arrives — but plan 218 P2 still needs the answer box CLOSED on
253 every exit path, so the teardown does that here rather than
254 relying on a later hook that an exception may never reach.
255 """
256 try:
257 yield
258 except Exception as exc:
259 # Plan 218 P2/W2: close the box with the failure inside it, so
260 # no opening marker is ever left dangling.
261 self._close_answer_box(error=f"{type(exc).__name__}: {exc}")
262 raise
263 finally:
264 # Belt-and-braces: if a turn ended without clearing the waiting
265 # indicator, clear it now so it doesn't pollute the next prompt.
266 if self._waiting_shown:
267 self._clear_waiting()
268
269 def start_turn(self) -> None:
270 """Reset per-turn flags and print the turn separator."""
271 func = "start_turn"
272 _dbg("OC_UI.TERMINAL", func, f"turn={self._turn_number + 1}", 4)
273 self._close_answer_box()
274 self._reasoning_shown = False
275 self._content_shown = False
276 self._waiting_shown = False
277 self._turn_number += 1
278 # Plan 188 T1: a tool call announced but never resulted (an exception
279 # mid-dispatch) must not pair with a later call's result.
280 self._mutation_args.clear()
281
282 # Plan 218: the rule closes the previous region and opens the turn
283 # body, so the indent starts AFTER it is drawn.
284 self._in_turn = False
285 self._print_rule(turn_rule_label(self._turn_number, self._now_ts()))
286 self._in_turn = True
287
288 def end_turn(self) -> None:
289 """Finalise the current turn (trailing newline)."""
290 self._close_answer_box()
291 if self._waiting_shown:
292 self._clear_waiting()
293 self._in_turn = False
294 print() # newline after streamed response
295
296 # ── Streaming events ──────────────────────────────────────────────────
297
298 def show_waiting(self) -> None:
299 """Print a transient "Thinking…" indicator before the first chunk."""
300 # Use a distinguishable prefix and dim style; cleared by first chunk.
301 print(f"{STYLE_DIM}{STYLE_ITALIC}⏳ Thinking…{STYLE_RESET}",
302 end="", flush=True)
303 self._waiting_shown = True
304
305 def _clear_waiting(self) -> None:
306 """Erase the in-line waiting indicator with carriage-return + spaces."""
307 # Width 20 covers '⏳ Thinking…' plus a margin.
308 print("\r" + " " * 20 + "\r", end="", flush=True)
309 self._waiting_shown = False
310
311 def show_reasoning(self, text: str) -> None:
312 """Print chain-of-thought (reasoning) text as it streams.
313
314 Plan 218: line-buffered so the turn indent lands at line starts —
315 a chunk that stops mid-sentence waits for its newline rather than
316 splicing three spaces into the middle of a word.
317 """
318 if self._waiting_shown:
319 self._clear_waiting()
320 if not self._reasoning_shown:
321 print(f"{self._indent()}{STYLE_CYAN}{REASONING_TITLE}{STYLE_RESET}")
322 self._reasoning_shown = True
323 for line in self._reasoning_indenter.feed(text):
324 print(f"{STYLE_DIM}{STYLE_ITALIC}{line}{STYLE_RESET}", flush=True)
325
326 def show_content(self, text: str) -> None:
327 """Stream the answer inside the degraded (glyph-free) answer box.
328
329 Plan 218 P2/P4: the opening marker prints with the first chunk,
330 each completed line prints indented as it lands, and the closing
331 marker — carrying the iteration metadata — prints at turn end via
332 :meth:`_close_answer_box`. No box-drawing glyph is used, so a dumb
333 terminal renders the same information faithfully (W5).
334 """
335 if self._waiting_shown:
336 self._clear_waiting()
337 if not self._content_shown:
338 for line in self._reasoning_indenter.flush():
339 print(f"{STYLE_DIM}{STYLE_ITALIC}{line}{STYLE_RESET}")
340 if self._reasoning_shown:
341 print() # blank separator
342 marker = plain_box_top(self._width() - len(TURN_INDENT), indent="")
343 print(f"{TURN_INDENT}{STYLE_BOLD}{marker}{STYLE_RESET}")
344 self._content_shown = True
345 self._answer_open = True
346 for line in self._content_indenter.feed(text):
347 print(line, flush=True)
348
349 # ── Tools ─────────────────────────────────────────────────────────────
350
351 def show_tool_call(self, name: str, arguments: str) -> None:
352 """Display a tool-call invocation with pretty-printed arguments."""
353 func = "show_tool_call"
354 self._close_answer_box()
355 if self._waiting_shown:
356 self._clear_waiting()
357 try:
358 args_pretty = json.dumps(json.loads(arguments or "{}"), ensure_ascii=False)
359 except (json.JSONDecodeError, ValueError) as e:
360 # VALIDATION_ERROR — model emitted non-JSON tool args; render raw string
361 _dbg("OC_UI.TERMINAL", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
362 args_pretty = arguments
363 # Plan 188 T1: remember mutation-tool arguments for the diff render that
364 # follows in show_tool_result, and elide the bulky payload here (the
365 # diff below IS the content view). Both no-ops for every other tool.
366 self._mutation_args.record(name, arguments)
367 args_pretty = summarize_mutation_call(name, arguments) or args_pretty
368 print(f"\n{self._indent()}{STYLE_YELLOW}"
369 f"[Tool call → {name}({args_pretty})]{STYLE_RESET}")
370
371 def show_tool_progress(self, name: str) -> None:
372 """No-op for the plain console; rich/textual presenters spin here."""
373 # Plain console has no animation primitive; the show_tool_call line
374 # is enough indication that the tool is running.
375
376 def show_tool_result(self, name: str, result: object) -> None:
377 """Display the result of a tool call (truncated if > 400 chars).
378
379 Plan 188 T1: an applied ``write_file`` / ``edit_file`` renders as a
380 plain unified diff (ANSI-coloured when colour is enabled) instead of
381 the truncated JSON line. Everything else — every other tool, and any
382 mutation result that is an error, a gate block, or an unpaired call —
383 keeps the legacy rendering exactly.
384
385 If *result* is a ``requires_confirmation`` dict (from Tier-2
386 shell commands), it is displayed as a warning prompt.
387 """
388 self._close_answer_box()
389 indent = self._indent()
390 if isinstance(result, dict) and result.get("requires_confirmation"):
391 msg = result.get("message", str(result))
392 print(f"{indent}{STYLE_YELLOW}"
393 f"[⏳ Confirmation required — {msg}]{STYLE_RESET}")
394 return
395
396 diff = build_mutation_diff(name, self._mutation_args.take(name), result)
397 if diff is not None:
398 for line in render_diff_plain(diff, color=_COLOR):
399 print(f"{indent}{line}")
400 return
401
402 result_str = json.dumps(result, ensure_ascii=False)
403 if len(result_str) > 400:
404 result_str = result_str[:400] + "…"
405 print(f"{indent}{STYLE_GREEN}[{name} ← {result_str}]{STYLE_RESET}")
406
407 # Plan 188 T2 — the plan lane: a planner tool just changed plan state,
408 # so show the REPL's current progress underneath its result.
409 progress = plan_progress_from_result(name, result)
410 if progress:
411 self.show_activity("plan", progress)
412
413 # ── Activity lane (Plan 188 T2) ───────────────────────────────────────
414
415 #: Tone → ANSI style. An unknown tone renders neutral (never raises).
416 _ACTIVITY_STYLES = {
417 "info": STYLE_DIM,
418 "ok": STYLE_GREEN,
419 "warn": STYLE_YELLOW,
420 "error": STYLE_RED,
421 }
422
423 def show_activity(self, label: str, detail: str = "", *, tone: str = "info") -> None:
424 """Print a compact one-line state update for a non-streaming lane."""
425 self._close_answer_box()
426 style = self._ACTIVITY_STYLES.get(tone, "")
427 body = f" {detail}" if detail else ""
428 print(f"{self._indent()}{style}[{label}]{STYLE_RESET}"
429 f"{STYLE_DIM}{body}{STYLE_RESET}")
430
431 # ── Stats ─────────────────────────────────────────────────────────────
432
433 @staticmethod
434 def _fmt_usage_line(label: str, prompt: int, completion: int, total: int,
435 prompt_cached=None, reasoning=None, content=None) -> str:
436 """Build a one-line usage summary string."""
437 parts = [f"{label}: prompt={prompt}"]
438 if prompt_cached is not None:
439 parts.append(f"(cache hits: {prompt_cached})")
440 parts.append(f" completion={completion}")
441 if reasoning is not None and content is not None:
442 parts.append(f"(reasoning:{reasoning} content:{content})")
443 parts.append(f" total={total}")
444 return "".join(parts)
445
446 def show_usage(self, usage: dict) -> None:
447 """Per-iteration token usage — compact one-line format.
448
449 Plan 134 C1: render only in ``--verbose`` mode (restores the contract
450 this class's own docstring already promised). Terminal rendering only;
451 structured emission/logging is unaffected.
452 """
453 self._close_answer_box()
454 if not self.verbose:
455 return
456 prompt = usage.get("prompt_tokens", 0)
457 completion = usage.get("completion_tokens", 0)
458 total = usage.get("total_tokens", 0)
459 prompt_cached = None
460 if usage.get("prompt_tokens_details"):
461 pd = usage["prompt_tokens_details"]
462 if isinstance(pd, dict):
463 prompt_cached = pd.get("cached_tokens")
464
465 cached = ""
466 if prompt_cached:
467 cached = self._fmt_compact_tokens(prompt_cached)
468 cached = f" · cache:{cached}"
469
470 line = (
471 f"{self._indent()}{STYLE_DIM} prompt:{self._fmt_compact_tokens(prompt)}"
472 f" · completion:{self._fmt_compact_tokens(completion)}"
473 f" · total:{self._fmt_compact_tokens(total)}"
474 f"{cached}{STYLE_RESET}"
475 )
476 print(line)
477
478 def show_iteration_stat(self, stat: "IterationStat") -> None:
479 """Render the per-iteration stats — in the answer box's closing marker.
480
481 Plan 218 P1 element 3, degraded per P4: when this iteration
482 produced an answer, the stat line IS the box's closing marker.
483 An iteration with no answer (a pure tool-call round) has no box to
484 close, so the line renders as before, just carrying the indent.
485 """
486 from oc_chat.core.iteration_stat import iteration_stat_human_line
487 line = iteration_stat_human_line(stat)
488 if self._answer_open:
489 self._close_answer_box(subtitle=line)
490 return
491 print(f"{self._indent()}{STYLE_DIM}{line}{STYLE_RESET}")
492
493 @staticmethod
494 def _fmt_compact_tokens(n: int) -> str:
495 """Render a token count compactly (``12.4k`` / ``1.6M``)."""
496 if n >= 1_000_000:
497 return f"{n / 1_000_000:.2f}M"
498 if n >= 1_000:
499 return f"{n / 1_000:.1f}k"
500 return str(n)
501
502 def show_turn_summary(self, prompt: int, completion: int, total: int,
503 iterations: int, tool_calls: int,
504 prompt_cached: int | None = None, *,
505 cache_hit_pct: float | None = None,
506 cache_saved_tokens: int | None = None,
507 performance_stats: object = None,
508 model: str | None = None,
509 context_used_pct: float | None = None,
510 duration_ms: int | None = None,
511 turn_cost: object = None) -> None:
512 """Print the end-of-turn rule, with the turn stats absorbed into it.
513
514 Plan 218 P1 element 4: the stats string that used to occupy its own
515 line under the rule is now part of the rule's label, and the
516 running total rides in the right-hand fill run. Wording and both
517 style tokens are unchanged \u2014 only the position is (P3).
518
519 Plan 188 T4: when *turn_cost* is present the compact line gains a
520 ``~$`` estimate; when it is ``None`` no cost is rendered at all (an
521 unpriceable turn shows nothing rather than a wrong figure). The
522 price table's capture date is appended in verbose mode, where the
523 provenance is worth the width.
524 """
525 self._close_answer_box()
526 self._in_turn = False
527
528 cached_str = ""
529 if prompt_cached is not None and prompt_cached > 0:
530 cached_str = f" \u00b7 cache:{self._fmt_compact_tokens(prompt_cached)}"
531 # Plan 134 C2: honest labels \u2014 cache-hit ratio vs real context saturation.
532 cache_hit_str = ""
533 if cache_hit_pct is not None:
534 cache_hit_str = f" \u00b7 cache-hit:{cache_hit_pct:.0f}%"
535 ctx_str = ""
536 if context_used_pct is not None:
537 ctx_str = f" \u00b7 ctx:{context_used_pct:.0f}%"
538 if model:
539 ctx_str += f" of {model}"
540 duration_str = ""
541 if duration_ms is not None:
542 from oc_chat.core.formatting import format_duration
543 duration_str = f" \u00b7 {format_duration(duration_ms)}"
544 cost_str = self._fmt_turn_cost(turn_cost, verbose=self.verbose)
545 stats = (
546 f" this turn: prompt:{self._fmt_compact_tokens(prompt)}"
547 f" \u00b7 completion:{self._fmt_compact_tokens(completion)}"
548 f" \u00b7 total:{self._fmt_compact_tokens(total)}"
549 f"{cached_str}{cache_hit_str}{ctx_str}{duration_str}{cost_str}"
550 )
551 ts = self._now_ts()
552 absorbed = turn_end_rule_label(self._turn_number, ts, stats)
553 if rule_fits(absorbed, self._width()):
554 self._print_rule(absorbed, trailer=self._running_total())
555 return
556 # Terminal too narrow to absorb the stats — degrade to the pre-218
557 # two-line form rather than let the rule wrap.
558 self._print_rule(turn_end_rule_label(self._turn_number, ts))
559 print(f"{STYLE_DIM}{stats}{STYLE_RESET}")
560
561 @staticmethod
562 def _fmt_turn_cost(turn_cost: object, *, verbose: bool = False) -> str:
563 """Format the plan 188 T4 cost segment, or ``""`` when unpriceable.
564
565 Shared by every terminal presenter (Rich subclasses reuse it) so the
566 honesty markers cannot drift apart: the leading ``~`` says modelled,
567 not billed, and verbose adds the rate table's capture date.
568 """
569 total = getattr(turn_cost, "total_usd", None)
570 if total is None:
571 return ""
572 out = f" \u00b7 ~${total:.4f}"
573 as_of = getattr(turn_cost, "as_of", "")
574 if verbose and as_of:
575 out += f" (prices as of {as_of})"
576 return out
577
578 def show_welcome(self, session: ChatSession) -> None:
579 """Display the welcome banner with model info and available commands."""
580 print(f"\nChat with DeepSeek — model: {session.model}")
581 print(f"Reasoning effort: {session.reasoning_effort} "
582 f"(options: {', '.join(REASONING_EFFORT_LEVELS)})")
583 # print("Commands: /help /models /model <name> /pro /flash /effort [level]")
584 # print(" /system [prompt] /ls [-r] [path] /read <file> /context <file>")
585 # print(" /session <subcommand> /ui <subcommand> /goal <task> /status /stop /exit")
586 print("Type /help for full command details and session subcommands.")
587 print("-" * 60)
588
589 def show_available_models(self, models: dict) -> None:
590 """Print a table of available models with capability indicators."""
591 print("\nAvailable models:")
592 for name, caps in models.items():
593 thinking = "✓" if caps.get("supports_thinking") else "✗"
594 reasoning = "✓" if caps.get("supports_reasoning") else "✗"
595 tools = "✓" if caps.get("supports_tools", True) else "✗"
596 print(f" {name:<25} thinking:{thinking} reasoning:{reasoning} tools:{tools}")
597 print()
598
599 def show_session_list(self, sessions: list, current_name: str | None = None) -> None:
600 """Print the saved-sessions table for ``/session list``."""
601 if not sessions:
602 print("(no saved sessions)")
603 return
604 from oc_chat.core.commands import _fmt_tokens_display
605
606 header = (
607 f"{'Name':<30} {'Model':<22} {'Msgs':>4} {'Tools':>5} "
608 f"{'Tokens':>10} Last Modified"
609 )
610 print(f"\n{header}")
611 print("-" * 90)
612 for s in sessions:
613 marker = "*" if s["name"] == current_name else " "
614 stats = s["stats"]
615 tokens_str = _fmt_tokens_display(stats)
616 print(
617 f"{marker}{s['name']:<29} {s['model']:<22} "
618 f"{stats.message_count:>4} {stats.tool_call_count:>5} "
619 f"{tokens_str:>10} {s['last_modified']}"
620 )
621 print()
622
623 def show_help(self, command_help: dict, tools: list[str] | None = None) -> None:
624 """Print the ``/help`` listing of slash commands and registered tools."""
625 print("\nCommands:")
626 for line in command_help.values():
627 print(f" {line}")
628 if tools:
629 print("\nAgent tools (callable by the model via tool-use):")
630 for name in tools:
631 print(f" {name}")
632 print()
633
634 def show_compressed(self) -> None:
635 """Notify the user that context compression just occurred."""
636 self._close_answer_box()
637 print(f"\n{self._indent()}{STYLE_DIM}"
638 f"[Context compressed — older messages summarised]{STYLE_RESET}")
639
640 def show_error(self, msg: str) -> None:
641 """Display an error message in red."""
642 self._close_answer_box()
643 print(f"\n{self._indent()}{STYLE_RED}Error: {msg}{STYLE_RESET}")
644
645 def show_info(self, msg: str) -> None:
646 """Display an informational message."""
647 self._close_answer_box()
648 print(f"\n{self._indent()}{msg}")
649
650 # ── Progress spinner (Phase H.4) ──────────────────────────────────────
651
652 def spinner_start(self, label: str) -> SpinnerHandle:
653 """No-op on the plain console — returns a handle with ``status=None``."""
654 return SpinnerHandle(status=None)
655
656 def spinner_stop(self, handle: SpinnerHandle) -> None:
657 """No-op on the plain console."""
658 return None
None show_activity(self, str label, str detail="", *, str tone="info")
None _close_answer_box(self, str subtitle="", str error="")
Definition presenter.py:222
None __init__(self, bool verbose=False)
Definition presenter.py:139
None show_help(self, dict command_help, list[str]|None tools=None)
Definition presenter.py:623
None show_activity(self, str label, str detail="", *, str tone="info")
Definition presenter.py:423
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)
Definition presenter.py:511
SpinnerHandle spinner_start(self, str label)
Definition presenter.py:652
None show_available_models(self, dict models)
Definition presenter.py:589
None show_tool_call(self, str name, str arguments)
Definition presenter.py:351
str _fmt_usage_line(str label, int prompt, int completion, int total, prompt_cached=None, reasoning=None, content=None)
Definition presenter.py:435
str _fmt_turn_cost(object turn_cost, *, bool verbose=False)
Definition presenter.py:562
None spinner_stop(self, SpinnerHandle handle)
Definition presenter.py:656
None _print_rule(self, "str | list[tuple[str, str]]" label, str trailer="")
Definition presenter.py:185
None show_iteration_stat(self, "IterationStat" stat)
Definition presenter.py:478
None show_welcome(self, ChatSession session)
Definition presenter.py:578
None bind_status(self, Callable[[], "StatusBarState | None"] provider)
Definition presenter.py:160
None show_session_list(self, list sessions, str|None current_name=None)
Definition presenter.py:599
None show_tool_result(self, str name, object result)
Definition presenter.py:376