oc_chat
Loading...
Searching...
No Matches
cli.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/cli.py
4
5"""
6Command-line shell helpers — argparse, presenter selection, terminal clear.
7
8Purpose
9-------
10The argparse front end, the ``--ui`` / ``--log-events`` resolution
11that picks between :class:`ConsolePresenter`, ``RichPresenter`` and
12:class:`LoggingPresenter`, and the cross-platform terminal-clear
13helper. These are pure shell concerns — sub-agents and headless
14:class:`~oc_chat.core.agent.Agent` callers never need them, so they
15sit one layer above the runtime rather than tangled inside
16``main.py`` (G.6.C separation per Plan G.6 §2).
17
18Responsibilities
19----------------
20- :func:`build_arg_parser` — construct the CLI ``argparse.ArgumentParser``
21 with ``--ui``, ``--no-color``, ``--workdir``, ``--verbose``, ``--log-events`` flags.
22- :func:`select_presenter` — resolve the presenter from the parsed
23 args (or env vars) with graceful fallback when ``rich`` is missing.
24- :func:`clear_terminal` — cross-platform terminal-clear; the REPL
25 calls it once at startup before printing the banner.
26
27Diagnostics
28-----------
29Domain: OC_CHAT.MAIN
30Levels:
31 L2 — lifecycle events (presenter resolution decisions).
32 L3 — semantic details.
33 L4 — deep tracing.
34
35Contracts
36---------
37- Presenter resolution precedence: ``--ui`` flag > ``OC_CHAT_UI``
38 env > built-in default, which is ``rich`` for an interactive launch
39 (the package imports AND stdout is a tty) and ``plain`` otherwise —
40 non-tty stdout, batch mode, or ``rich`` not installed. The previous
41 ``plain`` default stays reachable at any time via ``--ui plain`` or
42 ``OC_CHAT_UI=plain``; the full matrix is on
43 :func:`select_presenter` (plan 147 D3, pinned by plan 188 T3 in
44 ``tests/unit/test_188_rich_default.py``).
45- ``--log-events PATH`` wraps the resolved presenter in a
46 :class:`LoggingPresenter` that tees every event to PATH.
47- ``--workdir PATH`` is declared for documentation and ``--help``
48 display only; the actual ``os.chdir`` happens in the preflight
49 inside ``__main__.py`` before any ``oc_chat`` imports.
50- Falls back from ``rich`` → ``plain`` with a single stderr warning
51 when the optional dep is missing — never raises.
52- ``clear_terminal`` is best-effort; failure is silently ignored
53 (the banner that follows is fine even with a non-clearing
54 terminal).
55
56Dependencies
57------------
58- oc_diagnostics (structured debug logging via _dbg)
59- oc_ui.terminal.presenter (ConsolePresenter — plain-text fallback)
60- oc_ui.terminal.logging_presenter (lazy; LoggingPresenter — JSON-lines tee)
61- oc_chat.core.config (DEFAULT_CONTEXT_WINDOW_SIZE - the enhanced presenter's context-window fallback)
62- oc_chat.core.settings (lazy; load_settings - the settings verbose default)
63- oc_ui.presenter_base (lazy; Presenter - type hint for select_presenter's return)
64- oc_ui.terminal.enhanced_presenter (lazy; EnhancedPresenter - the enhanced UI with real-time statistics)
65- oc_ui.terminal.rich_presenter (lazy; RichPresenter - the rich UI, falling back to plain when rich is missing)
66- rich (lazy; presence probe for the implicit rich default)
67"""
68from __future__ import annotations
69
70import argparse
71import os
72from typing import TYPE_CHECKING
73
74from oc_diagnostics import _dbg
75
76from oc_chat.core.config import DEFAULT_CONTEXT_WINDOW_SIZE
77
78from oc_ui.terminal.presenter import ConsolePresenter
79
80if TYPE_CHECKING:
81 from oc_ui.presenter_base import Presenter
82
83
84_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
85
86
87def clear_terminal() -> None:
88 """Clear the terminal screen in a cross-platform way."""
89 import sys
90 if "--input" in sys.argv or "--output" in sys.argv:
91 return
92 if os.name == 'nt':
93 os.system('cls')
94 else:
95 os.system('clear')
96
97
98def build_arg_parser() -> argparse.ArgumentParser:
99 """Construct the CLI argument parser.
100
101 Precedence for any presenter / colour decision:
102 CLI flag > environment variable > config file > built-in default.
103 """
104 p = argparse.ArgumentParser(
105 prog="oc_chat",
106 description="Interactive Option C chat client with ReAct agentic loop.",
107 )
108 p.add_argument(
109 "--ui",
110 choices=["plain", "rich", "enhanced"],
111 default=None,
112 help="presenter mode (default: rich when the rich package is "
113 "installed and stdout is a tty, else plain; batch mode "
114 "(--input/--output) always defaults to plain; enhanced: "
115 "real-time statistics)",
116 )
117 p.add_argument(
118 "--enhanced-ui",
119 action="store_true",
120 help="Enable enhanced terminal UI with real-time statistics",
121 )
122 p.add_argument(
123 "--no-color", action="store_true",
124 help="No ANSI colours."
125 "Honoured by the standard NO_COLOR env var as well.",
126 )
127 p.add_argument(
128 "--workdir",
129 metavar="PATH",
130 default=None,
131 help="chdir here before anchoring (sandbox root, state dir, and "
132 "session anchors all follow); applied at preflight — lets "
133 "oc_chat be launched from anywhere",
134 )
135 # Plan 134 C1: tri-state so the settings `verbose` knob can drive the
136 # launch default. default=None means "unset" → fall back to settings;
137 # --verbose forces on, --no-verbose forces off (CLI wins over settings).
138 p.add_argument(
139 "--verbose", dest="verbose", action="store_true", default=None,
140 help="Show per-iteration token usage in addition to the compact "
141 "turn summary. Overrides the settings `verbose` default.",
142 )
143 p.add_argument(
144 "--no-verbose", dest="verbose", action="store_false",
145 help="Suppress per-iteration token usage (overrides the settings "
146 "`verbose` default).",
147 )
148 p.add_argument(
149 "--log-events", metavar="PATH", default=None,
150 help="Tee every presenter event to a JSON-lines log at PATH. "
151 "Useful for replay, CI diffing, and debugging UI glitches. "
152 "The wrapped presenter still renders normally.",
153 )
154 p.add_argument(
155 "--session",
156 metavar="NAME",
157 default=None,
158 help="Load or create a named session for this run.",
159 )
160 p.add_argument(
161 "--input",
162 metavar="PATH",
163 default=None,
164 help="Read the user prompt from this file, or '-' for stdin. "
165 "When set, OC Chat runs one turn non-interactively and skips the REPL.",
166 )
167 p.add_argument(
168 "--output",
169 metavar="PATH",
170 default=None,
171 help="Write the assistant response to this file, or '-' for stdout. "
172 "When set, OC Chat runs one turn non-interactively and silences banners.",
173 )
174 p.add_argument(
175 "--stats-file", metavar="PATH", default=None,
176 help="Write per-iteration stats as CSV lines to PATH. ",
177 )
178 p.add_argument(
179 "--max-context-size", metavar="SIZE", type=int, default=None,
180 # OCT-LINT: disable=no_hardcoded_secrets reason="argparse --help prose mis-flagged as a high-entropy string; not a secret"
181 help="Context-window size (tokens) for the enhanced-UI context gauge. "
182 "Default: auto-derive from the active model (plan 134 N5). An "
183 "explicit value overrides the model-derived window.",
184 )
185 p.add_argument(
186 "--no-iteration-stats", action="store_true",
187 help="Suppress per-iteration stat lines from terminal. ",
188 )
189 p.add_argument(
190 "--mode", metavar="NAME", default=None,
191 help="System-prompt composition mode from .oc_chat/oc_chat-settings.json "
192 "(e.g. standard, option-c). Overrides the file's default for this launch.",
193 )
194 p.add_argument(
195 "--option-c", action="store_true",
196 help="Convenience alias for --mode option-c (full Option C working context).",
197 )
198 # Plan 283 (SHELL-04). The plan spells this "--mode auto", but --mode was
199 # already taken above by the system-prompt COMPOSITION mode (standard /
200 # option-c), so the launch spelling of "/mode auto" is its own flag rather
201 # than a second meaning for an occupied one.
202 p.add_argument(
203 "--auto", action="store_true",
204 help="Start in Auto confirmation mode: the routine Tier-2 and "
205 "external-content asks are answered without a keystroke (the "
206 "launch spelling of /mode auto). A protected write still always "
207 "reaches you, and the mode applies only under the permissive risk "
208 "profile. /mode ask switches back at any time.",
209 )
210 return p
211
212
213def _resolve_verbose(args: argparse.Namespace) -> bool:
214 """Resolve effective verbosity (plan 134 C1, N1).
215
216 Precedence: explicit CLI flag (``--verbose`` / ``--no-verbose``) >
217 ``.local`` settings > committed settings > built-in default. ``args.verbose``
218 is tri-state: ``None`` means "unset" → fall back to the settings ``verbose``
219 knob; ``True``/``False`` came from an explicit flag and win.
220 """
221 cli = getattr(args, "verbose", None)
222 if cli is not None:
223 return bool(cli)
224 try:
225 from oc_chat.core.settings import load_settings
226 return bool(load_settings().verbose)
227 except Exception: # noqa: BLE001 — settings load is best-effort; default quiet
228 return False
229
230
231def _rich_importable() -> bool:
232 """True iff the optional ``rich`` package can be imported.
233
234 A cheap presence probe for the plan 147 D3 implicit-default matrix —
235 deliberately separate from actually constructing a ``RichPresenter``
236 (the explicit-``--ui rich`` path below still owns its own
237 ImportError-to-plain fallback + stderr warning).
238 """
239 try:
240 import rich # noqa: F401
241 return True
242 except ImportError:
243 return False
244
245
246def select_presenter(args: argparse.Namespace) -> "Presenter":
247 """Resolve the presenter to use — the plan 147 D3 pinned selection matrix.
248
249 Precedence:
250
251 - Explicit ``--ui`` flag or ``OC_CHAT_UI`` env var: honoured whenever
252 ``rich``/``enhanced`` import — even non-tty and even in batch mode
253 (uniform precedence, a defined outcome for captured/test runs) — via
254 the existing ImportError-to-plain fallback + stderr warning below.
255 - Implicit (neither set) + batch mode (``--input``/``--output`` present):
256 ``plain`` regardless of tty — batch is non-interactive by definition;
257 a globally exported ``OC_CHAT_UI`` would otherwise silently pollute
258 CI/headless captured output with implicit chrome (the explicit row
259 above still opts in deliberately).
260 - Implicit + interactive: ``rich`` when the package imports AND
261 ``sys.stdout.isatty()`` (stdout, not stdin, governs — Rich renders to
262 stdout); else ``plain``.
263 - ``NO_COLOR``: MONOCHROME-RICH (ratified 2026-07-12) — deliberately NOT
264 checked here. Whatever presenter the rows above resolve stays
265 resolved; ``rich.console.Console`` honours ``NO_COLOR`` natively
266 (verified: no_color=True / color_system=None with no explicit pass-
267 through needed), so this is never a presenter downgrade.
268 - The legacy ``--enhanced-ui`` flag keeps its pre-existing absolute-
269 override precedence (checked independently of ``want`` below,
270 unchanged from before D3).
271
272 Lazy-imports :class:`RichPresenter` on demand so the optional ``rich``
273 dependency is not required at module-import time.
274
275 If ``--log-events PATH`` was passed, the resolved presenter is
276 wrapped in a :class:`LoggingPresenter` that tees every event to PATH
277 in JSON-lines format.
278 """
279 import sys
280 func = "select_presenter"
281 verbose = _resolve_verbose(args)
282
283 explicit = args.ui or os.getenv("OC_CHAT_UI")
284 if explicit:
285 want = explicit
286 _dbg("OC_CHAT.MAIN", func, f"explicit ui={want!r}", 3)
287 else:
288 is_batch = getattr(args, "input", None) is not None or getattr(args, "output", None) is not None
289 if is_batch:
290 want = "plain"
291 else:
292 want = "rich" if (_rich_importable() and sys.stdout.isatty()) else "plain"
293 _dbg("OC_CHAT.MAIN", func, f"implicit ui={want!r} batch={is_batch}", 3)
294
295 if want == "rich":
296 try:
297 from oc_ui.terminal.rich_presenter import RichPresenter
298 inner = RichPresenter(verbose=verbose)
299 _dbg("OC_CHAT.MAIN", func, "selected RichPresenter", 2)
300 except ImportError as exc:
301 # VALIDATION_ERROR — optional dep missing; fall back to plain
302 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: rich not installed: {exc}", 3)
303 print(
304 f"[--ui rich requested but `rich` is not installed ({exc}). "
305 f"Install with: pip install -e .[rich]. Falling back to plain.]",
306 file=sys.stderr,
307 )
308 inner = ConsolePresenter(verbose=verbose)
309 elif want == "enhanced" or getattr(args, "enhanced_ui", False):
310 try:
311 from oc_ui.terminal.enhanced_presenter import EnhancedPresenter
312 inner = EnhancedPresenter(
313 model="deepseek-v4-pro",
314 max_context_size=getattr(args, "max_context_size", DEFAULT_CONTEXT_WINDOW_SIZE),
315 verbose=verbose,
316 )
317 _dbg("OC_CHAT.MAIN", func, "selected EnhancedPresenter", 2)
318 except ImportError as exc:
319 # VALIDATION_ERROR — optional dep missing; fall back to plain
320 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: enhanced UI not available: {exc}", 3)
321 print(
322 f"[--ui enhanced requested but components missing ({exc}). "
323 f"Falling back to plain.]",
324 file=sys.stderr,
325 )
326 inner = ConsolePresenter(verbose=verbose)
327 else:
328 inner = ConsolePresenter(verbose=verbose)
329 _dbg("OC_CHAT.MAIN", func, "selected ConsolePresenter", 2)
330
331 log_path = getattr(args, "log_events", None)
332 if log_path:
333 from oc_ui.terminal.logging_presenter import LoggingPresenter
334 return LoggingPresenter(inner, log_path)
335 return inner
bool _rich_importable()
Definition cli.py:231
argparse.ArgumentParser build_arg_parser()
Definition cli.py:98
None clear_terminal()
Definition cli.py:87
bool _resolve_verbose(argparse.Namespace args)
Definition cli.py:213
"Presenter" select_presenter(argparse.Namespace args)
Definition cli.py:246