oc_chat
Loading...
Searching...
No Matches
commands.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/commands.py
4
5"""
6Slash-command dispatch and handlers for the OC Chat REPL.
7
8Purpose
9-------
10Register handlers with the :func:`command` decorator; :func:`dispatch`
11routes raw user input to the appropriate handler. Includes commands
12for model switching, filesystem inspection, session management,
13agentic goals, and help.
14
15Responsibilities
16----------------
17- Maintain the ``COMMANDS`` registry and the ``COMMAND_HELP`` table.
18- :func:`dispatch` — route ``/<verb> <args>`` to the right handler,
19 returning ``True`` when the input was a slash command (so the REPL
20 knows to skip the LLM round-trip).
21- :class:`Context` — the mutable per-REPL state passed to every handler
22 (session, presenter, model manager, tool registry, in-flight goal).
23- Handlers themselves: ``/help``, ``/models``, ``/model``, ``/effort``,
24 ``/system``, ``/ls``, ``/read``, ``/context``, ``/session``, ``/ui``,
25 ``/budget``, ``/react``, ``/goal``, ``/stop``, etc.
26
27Diagnostics
28-----------
29Domain: OC_CHAT.MAIN
30Levels:
31 L2 — command-handler entry / exit
32 L3 — dispatch decisions and arg parsing
33 L4 — registry-table dumps
34
35Contracts
36---------
37- Handlers receive a :class:`Context` and the raw arg string; they
38 return ``None`` and mutate ``ctx`` in place.
39- :func:`dispatch` returns ``True`` for any input starting with ``/``
40 (even unknown verbs — handled with a friendly error), ``False``
41 otherwise.
42- Unknown verbs do not crash the REPL.
43
44Dependencies
45------------
46- oc_diagnostics (structured debug logging via _dbg)
47- oc_chat.core.config (shared config constants and ANSI styles)
48- oc_chat.core.session (lazy; ChatSession, CANONICAL_POLICY, build_system_prompt, _auto_name - the fresh session for /session new and clear)
49- oc_chat.core.defaults (default - the ui_reasoning_tail default)
50- oc_chat.core.session_transition (replace_session, load_session_into, rename_session and the endpoint activation pair - session and endpoint changes through the transition seam)
51- oc_chat.core.ask_menu (lazy; present_post_answer_menu - the menu after an /ask answer)
52- oc_chat.core.cache_usage (lazy; cache_hit_ratio_pct - the /cache hit ratio)
53- oc_chat.core.deployment_shape (lazy; check_deployment_shape - refuses a /sandbox set root that overlaps an importable root)
54- oc_chat.core.event_bootstrap (lazy; active_event_dir, _slug - where /packet writes its draft)
55- oc_chat.core.external_content_gate (lazy; is_canonical_record - the canonical-record check before a session is activated)
56- oc_chat.core.generation_options (lazy; GenerationOptions - the per-session /options and their default)
57- oc_chat.core.lane_picker (lazy; run_lane_picker, lane_options_from_registry - the /lane drill-down and list)
58- oc_chat.core.lane_switch (lazy; switch_lane, LaneKeyMissingError - switching onto the chosen lane)
59- oc_chat.core.persistence (lazy; session save, load, export and listing for /session and the palette, and the latest tool artifact for /open)
60- oc_chat.core.plan_inventory (lazy; TERMINAL_STATUSES, _expected_filename - plan state and file name for /plan)
61- oc_chat.core.plans (lazy; create, find, pause, continue, complete, abort and list plans for /plan)
62- oc_chat.core.provider_oauth (lazy; write_credentials - stores or removes the /login and /logout credential)
63- oc_chat.core.provider_plugin (lazy; registry - the lane named by /login and /logout)
64- oc_chat.core.resource_caps (lazy; the session cost hard cap and thresholds for /budget cost)
65- oc_chat.core.risk_profile (lazy; resolve_risk_profile - the profile a /sandbox set root is checked under)
66- oc_chat.core.session_checkpoints (lazy; tool_calls_auto_continue, cost_prompts_suppressed - the checkpoint latches for the status bar and /budget cost)
67- oc_chat.core.session_mode (lazy; the /mode confirmation mode and remembered answers)
68- oc_chat.core.startup (lazy; refresh_variable_context - refreshes the variable context after /cwd)
69- oc_chat.core.step_routing (lazy; resolve_output_tokens - the completion caps /budget prints)
70- oc_chat.core.tier2_session_grant (lazy; active_session_grant, revoke_session_grants - the Tier-2 grant for the status bar and /grants)
71- oc_chat.core.trace_view (lazy; read_session_events, format_trace, build_packet_draft - /trace and /packet)
72- oc_chat.tools._sandbox (lazy; set_session_sandbox_root_override, install_resolved - applies the session sandbox root)
73- oc_chat.tools.filesystem (lazy; list_dir, read_file, _sandbox_root_info - /ls, /read, /context and the sandbox display)
74- oc_chat.tools.planner (lazy; get_status, stop_plan - /status and /stop)
75- prompt_toolkit (lazy; DummyHistory - keeps the pasted login code out of the prompt input history)
76- rich (lazy; Console, Markdown - the /last pager and its Markdown rendering)
77
78See Also
79--------
80oc_chat/docs/conventions/CONTRACTS.md §1.5 Context — owner of the command-handler
81aggregate dataclass.
82"""
83from __future__ import annotations
84
85import uuid
86from collections.abc import Callable
87from dataclasses import dataclass, field
88from typing import Any, TypeVar
89
90from oc_diagnostics import _dbg
91
92_F = TypeVar("_F", bound=Callable[..., Any])
93
94from oc_chat.core.config import (
95 DEFAULT_MAX_REACT_ITERATIONS,
96 REASONING_EFFORT_LEVELS,
97 TOKEN_PROFILES,
98)
99from oc_chat.core.defaults import default
101 ChatLogLoadError,
102 TransitionRejectedError,
103 commit_endpoint_activation,
104 load_session_into,
105 prepare_endpoint_activation,
106 rename_session,
107 replace_session,
108)
109
110# ── Registry ──────────────────────────────────────────────────────────────────
111
112COMMANDS: dict = {}
113
114# Human-readable usage strings shown by /help (verb → "usage - description")
115COMMAND_HELP: dict[str, str] = {
116 "/help": "/help - show this help message",
117 "/models": "/models - list available models and their capabilities",
118 "/model": "/model <name> - switch to any model (keeps conversation)",
119 "/use": "/use <name> - alias for /model",
120 "/login": "/login <lane> - sign in using a pasted authorization code",
121 "/logout": "/logout <lane> - remove the stored OAuth credential",
122 "/lane": "/lane [list|<name>] - drill down to a lane and model, or switch to a named lane",
123 "/pro": "/pro - switch to the active provider's reasoning-tier model",
124 "/flash": "/flash - switch to the active provider's cheap-tier model",
125 "/effort": "/effort [low|medium|high]- show or set reasoning effort level",
126 "/sandbox": "/sandbox [set <path>|unset] - show or set the session sandbox root",
127 "/grants": "/grants [list|revoke] - show or revoke the session Tier-2 python grant",
128 "/mode": "/mode [ask|auto|forget] - confirmation mode for this session; 'forget' drops the remembered answers",
129 "/system": "/system [prompt] - show or set the system prompt",
130 "/ls": "/ls [-r] [path] - list files/dirs (add -r for recursive)",
131 "/read": "/read <file> - print a file's contents",
132 "/context": "/context <file> - inject file content into conversation (works with any model)",
133 "/cwd": "/cwd [<path>] - re-anchor this session to a new working directory",
134 "/open": "/open last - print the most recent tool artifact for this session",
135 "/last": "/last [answer|raw|reasoning|tool] - page the latest answer or related output",
136 "/session": "/session <subcommand> - manage sessions (new, list, current, save, load, export, switch, clear)",
137 "/ui": "/ui <subcommand> - configure the terminal UI (reasoning visibility; theme/stats in later phases)",
138 "/palette": "/palette [query] - fuzzy-search commands and recent sessions (Ctrl+P at the prompt)",
139 "/goal": "/goal <task> - give the agent an autonomous goal to execute",
140 "/status": "/status - show the current plan's step-by-step status",
141 "/stop": "/stop - halt the active plan",
142 "/cache": "/cache - prompt cache hit/miss (session + recent turns)",
143 "/trace": "/trace [N|all] - show this session's recorded event stream (default last 200)",
144 "/packet": "/packet - write a draft merge-readiness packet (packet-draft/v0) for this session",
145 "/budget": "/budget [cheap|balanced|deep|cost] - token profile for completion caps; 'cost' shows session spend, checkpoints and the hard cap",
146 "/react": "/react [N|reset] - show or set max ReAct iterations (1–99)",
147 "/ask": "/ask <question> - one-shot question (no session log, no tool calls)",
148 "/exit": "/exit (or /quit) - save conversation log and exit",
149}
150
151
153 from oc_chat.core.generation_options import GenerationOptions
154 return GenerationOptions()
155
156
157def _latest_message_of_role(messages: list[dict], role: str) -> dict | None:
158 """Return the latest message with *role*, or ``None``."""
159 func = "_latest_message_of_role"
160 for message in reversed(messages):
161 if message.get("role") == role:
162 _dbg("OC_CHAT.MAIN", func, f"found role={role}", 4)
163 return message
164 _dbg("OC_CHAT.MAIN", func, f"missing role={role}", 4)
165 return None
166
167
169 messages: list[dict],
170 role: str,
171 field_name: str,
172) -> dict | None:
173 """Return the latest role-matched message with a non-empty *field_name*."""
174 func = "_latest_message_with_field"
175 for message in reversed(messages):
176 if message.get("role") == role and message.get(field_name):
177 _dbg("OC_CHAT.MAIN", func, f"found role={role} field={field_name}", 4)
178 return message
179 _dbg("OC_CHAT.MAIN", func, f"missing role={role} field={field_name}", 4)
180 return None
181
182
183def _message_content_to_text(content: object) -> str:
184 """Convert stored message content into display text."""
185 func = "_message_content_to_text"
186 if content is None:
187 return ""
188 if isinstance(content, str):
189 return content
190 import json
191 try:
192 return json.dumps(content, ensure_ascii=False, indent=2)
193 except TypeError as exc:
194 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {type(exc).__name__}: {exc}", 3)
195 return str(content)
196
197
198def _page_last_content(content: str, *, markdown: bool) -> None:
199 """Render *content* in a pager when interactive, else print inline."""
200 func = "_page_last_content"
201 import sys
202 from rich.console import Console
203 from rich.markdown import Markdown
204
205 is_tty = bool(getattr(sys.stdout, "isatty", lambda: False)())
206 console = Console(
207 force_terminal=is_tty,
208 color_system="truecolor" if is_tty else None,
209 )
210
211 def _print_content() -> None:
212 if markdown:
213 console.print(Markdown(content))
214 else:
215 console.print(content, markup=False)
216
217 if not is_tty:
218 _dbg("OC_CHAT.MAIN", func, "non-tty fallback", 3)
219 _print_content()
220 return
221
222 _dbg("OC_CHAT.MAIN", func, f"pager markdown={markdown}", 3)
223 with console.pager(styles=True):
224 _print_content()
225
226
227def command(*names: str) -> Callable[[_F], _F]:
228 """Decorator that registers a handler for one or more command verbs."""
229 def deco(fn: _F) -> _F:
230 for name in names:
231 COMMANDS[name] = fn
232 return fn
233 return deco
234
235
236@dataclass
238 """Aggregate object passed to every command handler.
239
240 Bundles the live session, model manager, tool registry, presenter,
241 and API client so commands can interact with the full system.
242 """
243 session: object # ChatSession
244 model_manager: object # ModelManager
245 tool_registry: dict
246 presenter: object # ConsolePresenter
247 client: object # DeepSeekClient
248 tool_schemas: list = field(default_factory=list)
249
250 # ── /goal tracking ────────────────────────────────────────────────────
251 in_goal: bool = False
252 """``True`` when the current chat turn was triggered by ``/goal``."""
253 goal_description: str = ""
254 """The goal text passed to ``/goal``, used as the changelog scope."""
255 goal_files_changed: list = field(default_factory=list)
256 """File paths (relative strings) written during the current goal turn."""
257
258 # ── /plan tracking (Phase J) ───────────────────────────────────────────
259 in_plan: bool = False
260 """``True`` when the current chat turn was triggered by ``/plan``."""
261 plan_description: str = ""
262 """The description passed to ``/plan`` (or empty after a ``--continue``)."""
263 plan_files_changed: list = field(default_factory=list)
264 """File paths (relative strings) written during the current plan turn."""
265
266 # ── /options (Phase O4) ───────────────────────────────────────────────
267 generation_options: object = field(default_factory=lambda: _default_gen_opts())
268 """Per-session generation options (temperature, top_k, etc.)."""
269
270 # ── /endpoint (Phase O2) ──────────────────────────────────────────────
271 endpoint_registry: object = None
272 """Optional ``EndpointRegistry`` bound at startup in main.py. ``None``
273 when initialisation failed or no provider endpoints were registered;
274 ``/endpoint`` then surfaces a friendly hint."""
275
276 # ── /sandbox (M0.4) ──────────────────────────────────────────────
277 sandbox_root_override: str | None = None
278 """Optional per-session sandbox root set via ``/sandbox set``."""
279
280 # ── Iteration stats sink (Plan 152 D4) ─────────────────────────────────
281 iteration_stats_sink: object = None
282 """Optional process-owned ``IterationStatsSink`` opened at startup in
283 main.py when ``--stats-file`` is given. Sessions BORROW its writer at every
284 transition commit, so the knobs survive session replacement and the
285 finaliser closes exactly one handle instead of leaking one per session."""
286
287 # ── Realtime status bar (Plan 147 D1) ───────────────────────────────────
288 turn_stats: object = None
289 """Optional ``TurnStats`` (``oc_ui.terminal.input``) bound at startup in
290 main.py. Read by :func:`refresh_presenter_bindings` to build the
291 ``bind_status`` provider closure; ``None`` when no turn/session timing
292 is available (e.g. a headless caller that never built one)."""
293
294 # ── /lane (plan 217 T4) ────────────────────────────────────────────────
295 agent: object = None
296 """Optional back-reference to the owning ``Agent``, bound in main.py.
297
298 ``Agent.client`` is the object ``run_turn`` hands the provider, so a
299 lane switch that updated only ``ctx.client`` would leave the NEXT TURN
300 on the old lane while every command reported the new one. ``None`` for
301 headless callers that never build an Agent."""
302
303 prompt_driver: object = None
304 # OCT-LINT: disable=no_hardcoded_secrets reason="Context field docstring; high-entropy English prose, not a credential (operator-ruled 2026-08-31)"
305 """Optional live ``PromptDriver``, bound in main.py once the real one
306 exists. ``/lane`` reads its picker answers through it so the drill-down
307 shares the REPL's input backend; ``None`` falls back to ``input()``."""
308
309
310def _metadata_for_activation(ctx: "Context", name: str) -> tuple[dict, str]:
311 """Load *name*'s metadata at the LIVE load/switch boundary (plan 152 D9).
312
313 This is the ONLY boundary where in-process F67 trust inheritance is
314 possible — a fresh process starts with an empty trust set — so the
315 ``session_id -> canonical record`` rule is enforced here and startup, resume
316 and batch stay untouched.
317
318 A record is NON-canonical when any of these holds:
319
320 (a) its persisted name disagrees with the store location it was resolved
321 from — which is what a metadata file copied into another session's
322 directory looks like;
323 (b) it presents a ``session_id`` this process already binds to a DIFFERENT
324 record;
325 (c) it carries the rename ``superseded_by`` mark, making it non-canonical
326 by construction rather than by load order.
327
328 For a non-canonical record we mint a FRESH canonical id before hydration, so
329 ``apply_session_metadata_fields`` keeps that id and resets trust before the
330 identity is exposed. Minting explicitly matters here: load/switch hydrate
331 IN PLACE, so simply dropping the persisted key would leave the session
332 carrying the PREVIOUS session's id — and its trust.
333
334 Residual, stated honestly: a cross-run whole-directory clone with an edited
335 name is indistinguishable without a global uniqueness index. That closes
336 when the sessions store makes ``session_id`` a primary key.
337 """
338 func = "_metadata_for_activation"
339 from oc_chat.core.external_content_gate import is_canonical_record
340 from oc_chat.core.persistence import load_session_metadata_located
341
342 loaded = load_session_metadata_located(name)
343 meta = dict(loaded.metadata)
344 locator = loaded.canonical_record_locator
345 sid = meta.get("session_id")
346
347 persisted_name = meta.get("session_name")
348 reason = ""
349 if isinstance(persisted_name, str) and persisted_name and persisted_name != name:
350 reason = "record name disagrees with the store location it was resolved from"
351 elif meta.get("superseded_by"):
352 reason = "record was superseded by a rename"
353 elif not is_canonical_record(sid, locator):
354 reason = "session_id is already owned by a different record in this process"
355
356 if reason:
357 meta.pop("session_id", None)
358 ctx.session.session_id = str(uuid.uuid4())
359 _dbg("OC_CHAT.MAIN", func,
360 f"SECURITY_ERROR: non-canonical record for {name!r} ({reason}); "
361 f"minted a fresh identity and trust will reset before exposure", 2, override=True)
362 return meta, locator
363
364
365def refresh_presenter_bindings(ctx: "Context") -> None:
366 """Re-bind the presenter to the current session + status snapshot (Plan 147 D1).
367
368 Calls ``presenter.bind_session(ctx.session)`` and
369 ``presenter.bind_status(provider)`` together. Route EVERY session-
370 mutation path through this single helper — startup, the pre-turn
371 rebind, and every ``/session`` subcommand that replaces or reloads
372 ``ctx.session`` — so presenter chrome (the enhanced header, the D3
373 status bar) never shows a stale session after ``/session new`` /
374 ``load`` / ``switch`` / ``clear``.
375
376 The ``bind_status`` provider is a fresh closure over *ctx* built on
377 every call — it reads ``ctx.session`` at RENDER time (late-bound),
378 never captures the ``ChatSession`` object itself, so a later
379 ``/session new`` (which reassigns ``ctx.session``) is picked up
380 without re-binding. ``TurnStats.rebind_session`` resets the session-
381 elapsed clock only when the session identity actually changed —
382 calling this helper on every turn is safe and idempotent.
383 """
384 presenter = ctx.presenter
385 if hasattr(presenter, "bind_session"):
386 presenter.bind_session(ctx.session)
387 if ctx.turn_stats is not None:
388 ctx.turn_stats.rebind_session(ctx.session.session_id)
389
390 def _status_provider():
391 session = ctx.session
392 turn_stats = ctx.turn_stats
393 if session is None or turn_stats is None:
394 return None
395 session_tools = session.stats.tool_call_count if hasattr(session, "stats") else 0
396 # Plan 235 (SHELL-02): read at RENDER time like every other field, so
397 # taking or revoking a grant shows up on the very next repaint without
398 # a second wiring path to keep in sync.
399 from oc_chat.core.tier2_session_grant import active_session_grant
400 granted = active_session_grant()
401 # Plan 237 (CAPS-01): money and the checkpoint latch, read at RENDER
402 # time for the same reason the grant is — the very next repaint after
403 # a priced sample or an "a" keystroke shows it, with no second wiring
404 # path to keep in sync. ``None`` cost renders nothing at all.
405 from oc_chat.core.session_checkpoints import tool_calls_auto_continue
406 _cost = getattr(session, "stats", None)
407 _cost = getattr(_cost, "cost", None)
408 _priced = getattr(_cost, "priced_usd", 0.0) if _cost is not None else 0.0
409 _unpriced = getattr(_cost, "unpriced_samples", 0) if _cost is not None else 0
410 return turn_stats.build_status(
411 model=session.model,
412 session_name=session.name,
413 effort=getattr(session, "reasoning_effort", "high"),
414 session_tools=session_tools,
415 session_grant=granted.name if granted is not None else None,
416 session_cost_usd=(_priced if (_priced or _unpriced) else None),
417 unpriced_samples=_unpriced,
418 checkpoint_auto=tool_calls_auto_continue(),
419 )
420
421 presenter.bind_status(_status_provider)
422
423
424#: The four outcomes ``dispatch`` can actually observe (plan 152 ADR-7).
425NOT_HANDLED = "NOT_HANDLED"
426HANDLED_NO_TURN = "HANDLED_NO_TURN"
427HANDLED_TURN_QUEUED = "HANDLED_TURN_QUEUED"
428INTERNAL_FAILURE = "INTERNAL_FAILURE"
429
430#: What a failing handler left behind.
431EFFECT_NONE = "NONE"
432EFFECT_COMMITTED = "COMMITTED"
433EFFECT_UNKNOWN = "UNKNOWN"
434
435#: Handlers that mutate state AND queue a turn, so they carry their own
436#: catch/classify/compensate boundary and are covered by dispatch's backstop.
437#: Every OTHER handler keeps today's propagation semantics exactly — several of
438#: them mutate too, and a blanket catch would re-prompt into an unclassified
439#: live hybrid, which is worse than today's exit (plan 152 R11-01).
440INSTRUMENTED_VERBS = frozenset({"/goal", "/plan"})
441
442
443@dataclass(frozen=True)
445 """What ``dispatch`` observed, as a typed value rather than a bool.
446
447 ``kind`` is one of the four constants above. ``HANDLED_TURN_QUEUED`` is
448 produced ONLY by a handler explicitly returning a ``queued_turn_token`` — it
449 is NEVER derived from a message-count delta. That heuristic looked
450 equivalent but was not: an in-place ``/session load`` whose restored log ends
451 in a user message would have fired an unrequested LLM turn, and ``/context``
452 (a fourth appender) would have fired one on its own instead of letting its
453 injected content ride into the next user-initiated turn.
454
455 ``effect_disposition`` says what a failure left behind: ``NONE`` (the handler
456 guarantees no mutation ran), ``COMMITTED`` (honour it — never compensate it
457 away), or ``UNKNOWN`` (fail closed and report a reconciliation path).
458
459 Truthiness matches the old ``bool`` return, so existing ``if handled:``
460 call sites and tests keep working.
461 """
462
463 kind: str
464 effect_disposition: str = EFFECT_NONE
465 queued_turn_token: object = None
466 warning: str = ""
467
468 def __bool__(self) -> bool:
469 """``True`` when the input was consumed as a command."""
470 return self.kind != NOT_HANDLED
471
472
473def dispatch(ctx: "Context", raw_input: str) -> DispatchResult:
474 """Dispatch a ``/command``. Returns a typed :class:`DispatchResult`.
475
476 The backstop below is scoped to :data:`INSTRUMENTED_VERBS` ONLY. It is a
477 LAST-RESORT net for a handler that raised past its own handler-local
478 containment: it classifies nothing and fails closed with
479 ``effect_disposition=UNKNOWN``. An exception escaping any OTHER handler
480 propagates exactly as it does today — no new containment, no behaviour
481 change. ``SystemExit`` is never caught.
482 """
483 func = "dispatch"
484 parts = raw_input.split()
485 if not parts:
486 return DispatchResult(kind=NOT_HANDLED)
487 verb = parts[0].lower()
488 _dbg("OC_CHAT.MAIN", func, f"verb={verb}", 2)
489 handler = COMMANDS.get(verb)
490 if handler:
491 if verb in INSTRUMENTED_VERBS:
492 try:
493 outcome = handler(ctx, raw_input)
494 except Exception as exc:
495 _dbg("OC_CHAT.MAIN", func,
496 f"SYSTEM_ERROR: {verb} raised past its own containment: "
497 f"{type(exc).__name__}: {exc}", 1, override=True)
498 ctx.presenter.show_error(
499 f"{verb} failed unexpectedly: {type(exc).__name__}: {exc}. "
500 f"Its effects are INDETERMINATE — check /plan --list and the "
501 f"session state before retrying."
502 )
503 return DispatchResult(
504 kind=INTERNAL_FAILURE, effect_disposition=EFFECT_UNKNOWN,
505 )
506 if isinstance(outcome, DispatchResult):
507 return outcome
508 return DispatchResult(kind=HANDLED_NO_TURN)
509 handler(ctx, raw_input)
510 return DispatchResult(kind=HANDLED_NO_TURN)
511 if verb.startswith("/"):
512 ctx.presenter.show_error(
513 f"Unknown command: {verb}. Type /help for available commands."
514 )
515 return DispatchResult(kind=HANDLED_NO_TURN)
516 return DispatchResult(kind=NOT_HANDLED)
517
518
519# ── Model / session commands ──────────────────────────────────────────────────
520
521@command("/models")
522def cmd_models(ctx: "Context", _: str) -> None:
523 """List all known models and their capabilities."""
524 added = ctx.model_manager.fetch_live_if_needed(ctx.client)
525 if added > 0:
526 ctx.presenter.show_info(f"Loaded {added} new model(s) from API.")
527 models = ctx.model_manager.known_models()
528 ctx.presenter.show_available_models(models)
529
530
531def _warn_if_no_tools(ctx, model_name: str) -> None:
532 """Print a dimmed warning if *model_name* does not support tool use."""
533 caps = ctx.model_manager.capabilities(model_name)
534 if not caps.get("supports_tools", True):
535 from oc_chat.core.config import STYLE_DIM, STYLE_RESET
536 print(f"\n{STYLE_DIM}[Note: {model_name} does not support tool use — agent tools are disabled for this model]{STYLE_RESET}")
537
538
540 """Plan 134 N5: re-bind the session so a context-aware presenter (enhanced)
541 refreshes its ctx-gauge window for the NEW model. bind_session is idempotent
542 and safe; presenters without it (plain console) are skipped.
543 """
544 bind = getattr(ctx.presenter, "bind_session", None)
545 if callable(bind):
546 bind(ctx.session)
547
548
549def _switch_to_tier(ctx, tier_name: str) -> None:
550 """Switch the session model to whatever the active provider maps the
551 given semantic tier to. If the provider has no model for that tier,
552 surface a friendly message and leave the session model unchanged.
553 """
554 target = ctx.model_manager.provider.tier(tier_name)
555 if not target:
556 ctx.presenter.show_info(
557 f"Active provider has no '{tier_name}' tier — command unavailable."
558 )
559 return
560 ctx.session.set_model(target) # plan 135 §3.5: refreshes the env block in place
561 ctx.model_manager.ensure(target)
563 ctx.presenter.show_info(f"Switched to: {target} (conversation preserved)")
564 _warn_if_no_tools(ctx, target)
565
566
567@command("/pro")
568def cmd_pro(ctx: "Context", _: str) -> None:
569 """Switch to the active provider's reasoning-tier model."""
570 _switch_to_tier(ctx, "reasoning")
571
572
573@command("/flash")
574def cmd_flash(ctx: "Context", _: str) -> None:
575 """Switch to the active provider's cheap-tier model."""
576 _switch_to_tier(ctx, "cheap")
577
578
579@command("/model", "/use")
580def cmd_switch_model(ctx: "Context", raw: str) -> None:
581 """Switch to a model specified by name."""
582 parts = raw.split(maxsplit=1)
583 if len(parts) < 2:
584 ctx.presenter.show_info("Usage: /model <model_name>")
585 return
586 name = parts[1].strip()
587 if name not in ctx.model_manager.known_models():
588 # Try fetching live models in case this is a new API model
589 ctx.model_manager.fetch_live_if_needed(ctx.client)
590 if name not in ctx.model_manager.known_models():
591 ctx.presenter.show_info(f"Unknown model: {name}. Use /models to list available.")
592 return
593 ctx.session.set_model(name) # plan 135 §3.5: sets dirty + refreshes the env block
595 ctx.presenter.show_info(f"Switched to: {name} (conversation preserved)")
596 _warn_if_no_tools(ctx, name)
597
598
599def _lane_options_for(ctx: "Context"):
600 """Level-one rows for the live registry (plan 217 T4)."""
601 from oc_chat.core.lane_picker import lane_options_from_registry
602
603 return lane_options_from_registry()
604
605
606def _apply_lane_selection(ctx: "Context", lane_name: str, model_id: str = "") -> bool:
607 """Switch *ctx* onto *lane_name*; report and return ``False`` on refusal.
608
609 Every failure mode surfaces as a message rather than a traceback: an
610 unset key names the variable and points at the wizard (P3), and an
611 unknown lane lists what is available.
612 """
613 func = "_apply_lane_selection"
614 from oc_chat.core.lane_switch import LaneKeyMissingError, switch_lane
615
616 try:
617 prepared = switch_lane(ctx, lane_name, model_id)
618 except LaneKeyMissingError as exc:
619 # SECURITY_ERROR-adjacent (S6 L1 "refuse, never route around"), but the
620 # operator-facing fix is mundane, so the message carries it.
621 _dbg("OC_CHAT.MAIN", func, f"refused lane {lane_name!r}: no key", 2)
622 ctx.presenter.show_error(f" {exc}")
623 return False
624 except KeyError as exc:
625 # VALIDATION_ERROR (S6 L3) — a name that is not a registered lane.
626 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {exc}", 3)
627 ctx.presenter.show_error(f" {exc}")
628 return False
629 except (PermissionError, OSError, ValueError) as exc:
630 # EXTERNAL_ERROR (S6 L2) — the lane's client refused to build.
631 _dbg("OC_CHAT.MAIN", func,
632 f"EXTERNAL_ERROR: {type(exc).__name__}: {exc}", 2, override=True)
633 ctx.presenter.show_error(f" Could not switch to {lane_name!r}: {exc}")
634 return False
635
637 ctx.presenter.show_info(
638 f"Switched to lane {prepared.lane_name} "
639 f"(model: {prepared.model_id}, conversation preserved)"
640 )
641 _warn_if_no_tools(ctx, prepared.model_id)
642 return True
643
644
645@command("/lane")
646def cmd_lane(ctx: "Context", raw: str) -> None:
647 """Choose a provider lane and model, or switch to a named lane.
648
649 /lane — open the drill-down picker (lane, free/paid, family, model)
650 /lane list — list every registered lane with its key state
651 /lane <name> — switch straight to a lane, on its default model
652
653 The picker mutates nothing until its final confirmation, and stepping
654 back off the first level leaves the session exactly as it was
655 (plan 217 P4).
656 """
657 from oc_chat.core.lane_picker import run_lane_picker
658
659 parts = raw.split(maxsplit=1)
660 argument = parts[1].strip() if len(parts) > 1 else ""
661
662 if argument.lower() in ("list", "ls"):
663 lines = ["Provider lanes:"]
664 for option in _lane_options_for(ctx):
665 state = "ready" if option.has_key else "no key"
666 key_note = option.api_key_env_var or "(no key needed)"
667 lines.append(
668 f" {option.name:<18}{option.display_label:<30}"
669 f"{option.badge:<5} {len(option.models):>3} curated "
670 f"{state} [{key_note}]"
671 )
672 ctx.presenter.show_info("\n".join(lines))
673 return
674
675 if argument:
676 _apply_lane_selection(ctx, argument)
677 return
678
679 selection = run_lane_picker(
680 ctx.presenter, prompt_driver=getattr(ctx, "prompt_driver", None)
681 )
682 if selection is None:
683 ctx.presenter.show_info("Lane unchanged.")
684 return
685 _apply_lane_selection(ctx, selection.lane_name, selection.model_id)
686
687
689 """Render provider callbacks through the existing terminal seams."""
690
691 def __init__(self, ctx: "Context") -> None:
692 self.ctx = ctx
693
694 def on_auth(self, url: str) -> None:
695 self.ctx.presenter.show_info(url)
696
697 def on_prompt(self, prompt: str) -> str:
698 driver = getattr(self.ctx, 'prompt_driver', None)
699 if driver is None or getattr(driver, 'using_prompt_toolkit', None) is False:
700 from getpass import getpass
701 value = getpass(prompt)
702 elif getattr(driver, 'using_prompt_toolkit', None) is True:
703 from prompt_toolkit.history import DummyHistory
704 terminal = driver._pt
705 previous = (terminal.history, terminal.default_buffer.history,
706 terminal.is_password, terminal.completer, terminal.auto_suggest)
707 terminal.history = terminal.default_buffer.history = DummyHistory()
708 terminal.is_password = True
709 terminal.completer = terminal.auto_suggest = None
710 try:
711 value = driver.read_user_input(None, prompt_text=prompt)
712 finally:
713 # Clear the code before restoring the ordinary chat history.
714 terminal.default_buffer.reset()
715 (terminal.history, terminal.default_buffer.history,
716 terminal.is_password, terminal.completer, terminal.auto_suggest) = previous
717 else:
718 value = driver.read_user_input(None, prompt_text=prompt)
719 if not value or not value.strip():
720 raise EOFError
721 return value.strip()
722
723
724def _oauth_provider(ctx: "Context", raw: str) -> Any:
725 """Select exactly one known lane without echoing untrusted arguments."""
726 from oc_chat.core.provider_plugin import registry
727 parts = raw.split()
728 if len(parts) != 2:
729 ctx.presenter.show_error('Use /login <lane> or /logout <lane>.')
730 return None
731 try:
732 return registry.get(parts[1])
733 except KeyError:
734 ctx.presenter.show_error('Unknown provider lane.')
735 return None
736
737
738@command("/login")
739def cmd_login(ctx: "Context", raw: str) -> None:
740 """Run paste-only provider login and atomically store its credential."""
741 from oc_chat.core.provider_oauth import write_credentials
742 provider = _oauth_provider(ctx, raw)
743 if provider is None:
744 return
745 capability = getattr(provider, 'oauth', None)
746 if capability is None:
747 ctx.presenter.show_error('This lane does not support OAuth login.')
748 return
749 try:
750 credentials = capability.oauth_login(_OAuthCallbacks(ctx))
751 write_credentials(provider.name, credentials)
752 except (EOFError, KeyboardInterrupt):
753 ctx.presenter.show_info('Login cancelled.')
754 return
755 except Exception:
756 # Provider and storage errors may embed a credential. Do not render them.
757 ctx.presenter.show_error('Login failed; no new credential was saved.')
758 return
759 ctx.presenter.show_info(f'Signed in to {provider.name}. Use /lane {provider.name} to select it.')
760
761
762@command("/logout")
763def cmd_logout(ctx: "Context", raw: str) -> None:
764 """Remove only the selected lane from the OAuth sidecar."""
765 from oc_chat.core.provider_oauth import write_credentials
766 provider = _oauth_provider(ctx, raw)
767 if provider is None:
768 return
769 try:
770 write_credentials(provider.name, None)
771 except Exception:
772 ctx.presenter.show_error('Logout failed; check the OAuth sidecar.')
773 return
774 ctx.presenter.show_info('Stored OAuth credential removed. Existing clients keep their current key until rebuilt.')
775
776
777@command("/model-info")
778def cmd_model_info(ctx: "Context", raw: str) -> None:
779 """Show detailed metadata for a model from the provider.
780
781 /model-info — info for the current model
782 /model-info <name> — info for any named model
783 """
784 parts = raw.split(None, 1)
785 model = parts[1].strip() if len(parts) > 1 else ctx.session.model
786 info = ctx.client.provider.model_info(model, ctx.client._client)
787 if info is None:
788 ctx.presenter.show_info(
789 f"Provider {ctx.client.provider.name!r} does not support model info."
790 )
791 return
792 lines = [
793 f"Name: {info.name}",
794 f"Family: {info.family or '—'}",
795 f"Parameters: {info.parameter_count or '—'}",
796 f"Quantization: {info.quantization or '—'}",
797 f"Context window: {info.context_window or '—'}",
798 ]
799 ctx.presenter.show_info("\n".join(lines))
800
801
802@command("/effort")
803def cmd_effort(ctx: "Context", raw: str) -> None:
804 """Show or set the reasoning effort level."""
805 parts = raw.split()
806 if len(parts) == 1:
807 ctx.presenter.show_info(
808 f"Reasoning effort: {ctx.session.reasoning_effort} "
809 f"(options: {', '.join(REASONING_EFFORT_LEVELS)})"
810 )
811 else:
812 level = parts[1].lower()
813 if level in REASONING_EFFORT_LEVELS:
814 ctx.session.reasoning_effort = level
815 ctx.session.dirty = True
816 ctx.presenter.show_info(f"Reasoning effort set to: {level}")
817 else:
818 ctx.presenter.show_info(f"Invalid level. Choose from: {', '.join(REASONING_EFFORT_LEVELS)}")
819
820
821# NOTE: /clear has been removed as a standalone command.
822# Use /session clear instead — it provides verification, backup, and save options.
823
824
825@command("/endpoint")
826def cmd_endpoint(ctx: "Context", raw: str) -> None:
827 """Show, list, or switch the active provider endpoint (Phase O2).
828
829 /endpoint — show the current endpoint
830 /endpoint list — list every known endpoint with LAN policy
831 /endpoint use <name> — switch the active endpoint and invalidate caches
832 """
833 registry = getattr(ctx, "endpoint_registry", None)
834 if registry is None or not registry.entries:
835 ctx.presenter.show_info(
836 # OCT-LINT: disable=no_hardcoded_secrets reason="Command help strings and templates, not secrets"
837 "No endpoint registry configured for the current provider. "
838 "Set OLLAMA_BASE_URLS or populate ~/.oc_chat/endpoints.toml."
839 )
840 return
841
842 parts = raw.split(maxsplit=2)
843 sub = parts[1].strip().lower() if len(parts) > 1 else ""
844
845 if sub == "" or sub == "show":
846 active = registry.active()
847 ctx.presenter.show_info(
848 f"Active endpoint: {active.name} -> {active.base_url} [{active.lan_policy_status}]"
849 )
850 return
851
852 if sub == "list":
853 lines = ["Endpoints:"]
854 active_name = registry.active_name
855 for name in sorted(registry.entries):
856 ep = registry.entries[name]
857 marker = "*" if name == active_name else " "
858 lines.append(f" {marker} {name:<16} {ep.base_url} [{ep.lan_policy_status}]")
859 ctx.presenter.show_info("\n".join(lines))
860 return
861
862 if sub == "use":
863 if len(parts) < 3 or not parts[2].strip():
864 ctx.presenter.show_info("Usage: /endpoint use <name>")
865 return
866 target = parts[2].strip()
867 # Plan 152 R10-03 — route through the SHARED coordinator instead of the
868 # old five-step split commit (switch -> rebind_to -> invalidate, plus the
869 # session field and dirty flag). Those five installs ran under the same
870 # unguarded dispatch as the session seam, so a Ctrl+C between any two of
871 # them split the endpoint tuple exactly like the session desync. Now every
872 # fallible step happens in PREPARE and the installs commit together under
873 # the transition guard, so the tuple is all-OLD or all-NEW.
874 #
875 # It also closes a policy hole: the old path reached `entries[name]` +
876 # `rebind_to`, and `rebind_to` enforces only the forbidden-host check,
877 # NOT the "blocked" LAN policy. `resolve` enforces both, in the owner.
878 try:
879 prepared = prepare_endpoint_activation(ctx, target)
880 except KeyError as exc:
881 ctx.presenter.show_error(str(exc))
882 return
883 except PermissionError as exc:
884 ctx.presenter.show_error(str(exc))
885 return
886 commit_endpoint_activation(ctx, prepared)
887 new_active = prepared.endpoint
888 ctx.presenter.show_info(
889 f"Switched to endpoint '{new_active.name}' ({new_active.base_url})"
890 )
891 return
892
893 ctx.presenter.show_info(
894 "Usage: /endpoint | /endpoint list | /endpoint use <name>"
895 )
896
897
898# The /queue and /add-agent slash commands retired with the queue machinery
899# (plan 223 P3, design 4.8). They were the REPL surface of the Markdown queue
900# and the agent-pool sidecar, both gone: dispatch is now the only path that
901# assigns work, and the pool is a registry view rendered by
902# `python -m oc_chat.scripts.plan_manage agents active`.
903
904
905@command("/system")
906def cmd_system(ctx: "Context", raw: str) -> None:
907 """Show or set the system prompt."""
908 parts = raw.split(maxsplit=1)
909 if len(parts) < 2:
910 ctx.presenter.show_info(f"System prompt: {ctx.session.system_prompt}")
911 return
912 ctx.session.set_system_prompt(parts[1].strip())
913 ctx.presenter.show_info("System prompt updated.")
914
915
916# ── Filesystem commands ────────────────────────────────────────────────────────
917
918@command("/ls")
919def cmd_ls(ctx: "Context", raw: str) -> None:
920 """List files and directories (supports -r for recursive)."""
921 from oc_chat.tools.filesystem import list_dir
922 tokens = raw.split()[1:] # strip "/ls"
923 recursive = "--recursive" in tokens or "-r" in tokens
924 path_parts = [t for t in tokens if not t.startswith("-")]
925 path = path_parts[0] if path_parts else "."
926 max_depth = 5 if recursive else 0
927
928 try:
929 result = list_dir(path=path, max_depth=max_depth)
930 for dir_key, contents in sorted(result.items()):
931 print(f"\n{dir_key}/")
932 for d in contents["dirs"]:
933 print(f" {d}/")
934 for f in contents["files"]:
935 print(f" {f}")
936 except Exception as e:
937 ctx.presenter.show_error(str(e))
938
939
940@command("/read")
941def cmd_read(ctx: "Context", raw: str) -> None:
942 """Print a file's contents to the terminal."""
943 from oc_chat.tools.filesystem import read_file
944 parts = raw.split(maxsplit=1)
945 if len(parts) < 2:
946 ctx.presenter.show_info("Usage: /read <file_path>")
947 return
948 result = read_file(parts[1].strip())
949 if "error" in result:
950 ctx.presenter.show_error(result["error"])
951 else:
952 print(result["content"])
953 if result.get("truncated"):
954 ctx.presenter.show_info("[File truncated at limit]")
955
956
957@command("/open")
958def cmd_open(ctx: "Context", raw: str) -> None:
959 """Print the most-recently-spilled tool artifact for the current session.
960
961 Phase 1.C ships only ``/open last`` — useful after a long
962 ``read_file`` / ``run_command`` was spilled to a file in
963 ``oc_chat/sessions/<name>/artifacts/``.
964 """
965 parts = raw.split(maxsplit=1)
966 target = parts[1].strip() if len(parts) > 1 else "last"
967
968 if target.lower() != "last":
969 ctx.presenter.show_info(
970 f"Only '/open last' is supported in this phase (got: {target!r})."
971 )
972 return
973
974 from oc_chat.core.persistence import find_latest_artifact
975 path = find_latest_artifact(ctx.session.name)
976 if path is None:
977 ctx.presenter.show_info(
978 f"No tool artifacts found for session '{ctx.session.name}'."
979 )
980 return
981
982 try:
983 text = path.read_text(encoding="utf-8")
984 except OSError as exc:
985 ctx.presenter.show_error(f"Could not read {path}: {exc}")
986 return
987
988 ctx.presenter.show_info(f"Artifact: {path}")
989 print(text)
990
991
992@command("/last")
993def cmd_last(ctx: "Context", raw: str) -> None:
994 """Page the latest answer, raw answer, reasoning text, or tool result."""
995 func = "cmd_last"
996 parts = raw.split(maxsplit=1)
997 variant = parts[1].strip().lower() if len(parts) > 1 else "answer"
998 if not variant:
999 variant = "answer"
1000
1001 messages = getattr(ctx.session, "messages", []) or []
1002 markdown = False
1003 kind = "answer"
1004 content = ""
1005
1006 if variant == "answer":
1007 message = _latest_message_of_role(messages, "assistant")
1008 content = _message_content_to_text(message.get("content")) if message else ""
1009 markdown = True
1010 elif variant == "raw":
1011 message = _latest_message_of_role(messages, "assistant")
1012 content = _message_content_to_text(message.get("content")) if message else ""
1013 elif variant == "reasoning":
1014 kind = "reasoning"
1016 messages,
1017 "assistant",
1018 "reasoning_content",
1019 )
1020 content = (
1021 _message_content_to_text(message.get("reasoning_content"))
1022 if message else ""
1023 )
1024 elif variant == "tool":
1025 kind = "tool result"
1026 message = _latest_message_of_role(messages, "tool")
1027 content = _message_content_to_text(message.get("content")) if message else ""
1028 else:
1029 _dbg("OC_CHAT.MAIN", func, f"unknown variant={variant}", 3)
1030 ctx.presenter.show_error("Usage: /last [answer|raw|reasoning|tool]")
1031 return
1032
1033 if not content.strip():
1034 ctx.presenter.show_info(f"No {kind} available yet this session.")
1035 return
1036
1037 _page_last_content(content, markdown=markdown)
1038
1039
1040@command("/context")
1041def cmd_context(ctx: "Context", raw: str) -> None:
1042 """Inject a file's content into the conversation as a user message."""
1043 from oc_chat.tools.filesystem import read_file
1044 parts = raw.split(maxsplit=1)
1045 if len(parts) < 2:
1046 ctx.presenter.show_info("Usage: /context <file_path>")
1047 return
1048 path = parts[1].strip()
1049 result = read_file(path)
1050 if "error" in result:
1051 ctx.presenter.show_error(result["error"])
1052 return
1053 content = result["content"]
1054 ctx.session.messages.append({
1055 "role": "user",
1056 "content": f"File context — {path}:\n\n{content}",
1057 })
1058 ctx.session.dirty = True
1059 ctx.presenter.show_info(f"Injected {path} into conversation ({len(content)} chars).")
1060 if result.get("truncated"):
1061 ctx.presenter.show_info("[File was truncated at read limit]")
1062
1063
1064# ── Helper: format token count nicely ─────────────────────────────────────────
1065
1066def _fmt_tokens(n: int) -> str:
1067 """Format a token count with commas, switching to ``M`` for millions."""
1068 if n >= 1_000_000:
1069 return f"{n/1_000_000:.1f}M"
1070 return f"{n:,}"
1071
1072
1073def _fmt_tokens_display(stats) -> str:
1074 """Return token string, or ``'--'`` when tokens were never tracked for a session that has messages."""
1075 if stats.total_tokens == 0 and stats.message_count > 0:
1076 return "--"
1077 return _fmt_tokens(stats.total_tokens)
1078
1079
1080# ── /session command (unified session management) ─────────────────────────────
1081
1082def _sync_sandbox_override(ctx: "Context") -> None:
1083 """Sync the active session sandbox override into the sandbox helper."""
1084 override = getattr(ctx, "sandbox_root_override", None)
1085 if override is None:
1086 override = getattr(ctx.session, "sandbox_root_override", None)
1087 ctx.sandbox_root_override = override
1088 from oc_chat.tools._sandbox import set_session_sandbox_root_override
1089 set_session_sandbox_root_override(override)
1090
1091
1092def _cwd_inside_sandbox(root: object) -> bool:
1093 """Return True when cwd resolves under *root*."""
1094 from pathlib import Path
1095 cwd = Path.cwd().resolve(strict=False)
1096 try:
1097 cwd.relative_to(Path(root).resolve(strict=False))
1098 return True
1099 except ValueError:
1100 return False
1101
1102
1103@command("/sandbox")
1104def cmd_sandbox(ctx: "Context", raw: str) -> None:
1105 """Show or set the current session's filesystem sandbox root."""
1106 from pathlib import Path
1107 from oc_chat.tools import filesystem
1108 from oc_chat.tools._sandbox import set_session_sandbox_root_override
1109
1110 parts = raw.split(maxsplit=2)
1111 sub = parts[1].lower() if len(parts) > 1 else ""
1112
1113 if sub in {"", "show", "current"}:
1115 root, source = filesystem._sandbox_root_info()
1116 inside = "yes" if _cwd_inside_sandbox(root) else "no"
1117 ctx.presenter.show_info(
1118 f"Sandbox root: {root}\nSource: {source}\nCWD inside sandbox: {inside}"
1119 )
1120 return
1121
1122 if sub == "set":
1123 if len(parts) < 3 or not parts[2].strip():
1124 ctx.presenter.show_info("Usage: /sandbox set <existing-directory>")
1125 return
1126 candidate = Path(parts[2].strip().strip('"').strip("'")).expanduser()
1127 root = candidate.resolve(strict=False)
1128 if not root.is_dir():
1129 ctx.presenter.show_error(f"Sandbox root does not exist or is not a directory: {candidate}")
1130 return
1131 # WU-7 / G-1: under guarded/isolated, refuse a new sandbox that overlaps a
1132 # harness importable root (write->import in-process ACE). permissive: no-op.
1133 from oc_chat.core.risk_profile import resolve_risk_profile
1134 from oc_chat.core.deployment_shape import check_deployment_shape, DeploymentShapeError
1135 try:
1136 check_deployment_shape(root, resolve_risk_profile())
1137 except DeploymentShapeError as exc:
1138 ctx.presenter.show_error(f"Refused: {exc}")
1139 return
1140 override = str(root)
1141 ctx.sandbox_root_override = override
1142 ctx.session.sandbox_root_override = override
1143 ctx.session.dirty = True
1144 set_session_sandbox_root_override(override)
1145 ctx.presenter.show_info(f"Sandbox root set to {root}")
1146 return
1147
1148 if sub in {"unset", "reset"}:
1149 ctx.sandbox_root_override = None
1150 ctx.session.sandbox_root_override = None
1151 ctx.session.dirty = True
1152 set_session_sandbox_root_override(None)
1153 root, source = filesystem._sandbox_root_info()
1154 ctx.presenter.show_info(f"Sandbox override cleared. Effective root: {root} ({source})")
1155 return
1156
1157 ctx.presenter.show_info("Usage: /sandbox [set <existing-directory>|unset]")
1158
1159
1160@command("/grants")
1161def cmd_grants(ctx: "Context", raw: str) -> None:
1162 """Show or revoke the session-long Tier-2 python grant (plan 235 / SHELL-02).
1163
1164 /grants — print the granted directory, or "none"
1165 /grants list — alias for the above
1166 /grants revoke — drop the grant; the next python run asks the human again
1167
1168 The grant is what the third key at the Tier-2 confirm prompt records: for the
1169 rest of the session, python scripts resolving inside that one directory run
1170 without a keystroke. It is process-lifetime state — never persisted — and it
1171 also drops on ``/session clear`` and on a session load/switch.
1172 """
1173 from oc_chat.core.tier2_session_grant import active_session_grant, revoke_session_grants
1174
1175 parts = raw.split(maxsplit=1)
1176 sub = parts[1].strip().lower() if len(parts) > 1 else ""
1177
1178 if sub in {"", "list", "show"}:
1179 active = active_session_grant()
1180 ctx.presenter.show_info(
1181 f"Session Tier-2 python grant: {active}\n"
1182 " Any .py file the agent writes there runs without asking. "
1183 "Drop it with /grants revoke."
1184 if active else "Session Tier-2 python grant: none"
1185 )
1186 return
1187
1188 if sub == "revoke":
1189 active = active_session_grant()
1190 revoke_session_grants()
1191 ctx.presenter.show_info(
1192 f"Revoked the session Tier-2 python grant on {active}."
1193 if active else "No session Tier-2 python grant to revoke."
1194 )
1195 return
1196
1197 ctx.presenter.show_info("Usage: /grants [list|revoke]")
1198
1199
1200@command("/mode")
1201def cmd_mode(ctx: "Context", raw: str) -> None:
1202 """Show or set this session's confirmation mode (plan 283 / SHELL-04).
1203
1204 /mode — print the mode and every remembered answer
1205 /mode ask — every confirmable call reaches the human (the default)
1206 /mode auto — the routine asks are answered without a keystroke
1207 /mode forget — drop the remembered answers, so the human is asked again
1208
1209 This command, the ``--auto`` launch flag and the ``u`` key at the Tier-2 ask
1210 are the ONLY ways in. No tool, no model output, no settings file and no
1211 environment variable can set the mode — which is the point of it: it is the
1212 human's switch, not the agent's. Auto mode never answers a protected write,
1213 never turns an argv-policy denial into a run, and is inert outside the
1214 ``permissive`` risk profile.
1215 """
1216 func = "cmd_mode"
1217 from oc_chat.core import session_mode
1218
1219 parts = raw.split(maxsplit=1)
1220 sub = parts[1].strip().lower() if len(parts) > 1 else ""
1221
1222 if sub in {"", "list", "show"}:
1223 remembered = session_mode.remembered_programs()
1224 lines = [f"Confirmation mode: {session_mode.current_mode()}"]
1225 if session_mode.current_mode() == session_mode.AUTO and not session_mode.auto_mode_available():
1226 lines.append(" (inert here: Auto mode applies only under the "
1227 "permissive risk profile)")
1228 if remembered:
1229 lines.append(f" Remembered answers ({len(remembered)}), "
1230 f"drop them with /mode forget:")
1231 for program, answer in sorted(remembered.items()):
1232 verdict = (f"always run (budget {answer.budget_s} s)"
1233 if answer.run else "never run")
1234 lines.append(f" {program}: {verdict}")
1235 else:
1236 lines.append(" Remembered answers: none")
1237 ctx.presenter.show_info("\n".join(lines))
1238 return
1239
1240 if sub == "forget":
1241 dropped = session_mode.forget_remembered()
1242 ctx.presenter.show_info(
1243 f"Dropped {dropped} remembered answer(s); the next Tier-2 command "
1244 f"asks again." if dropped else "No remembered answers to drop.")
1245 return
1246
1247 if sub in session_mode.VALID_MODES:
1248 if sub == session_mode.AUTO and not session_mode.auto_mode_available():
1249 # Refuse where it could not apply, rather than setting a flag that
1250 # silently does nothing — the operator would read the confirmation
1251 # line as a change that happened.
1252 ctx.presenter.show_error(
1253 # OCT-LINT: disable=no_hardcoded_secrets reason="Operator-facing refusal prose naming an env var; high-entropy English, not a credential (operator-ruled 2026-09-16)"
1254 "Auto mode applies only under the permissive risk profile. "
1255 "The active profile is set out of band by OC_CHAT_PROFILE and "
1256 "cannot be changed from here; the mode is unchanged.")
1257 _dbg("OC_CHAT.MAIN", func, "refused /mode auto off permissive", 2)
1258 return
1259 session_mode.set_mode(sub)
1260 ctx.presenter.show_info(
1261 f"Confirmation mode: {sub}."
1262 + (" The routine asks are answered without a keystroke; a "
1263 "protected write still always reaches you. /mode ask switches back."
1264 if sub == session_mode.AUTO else
1265 " Every confirmable call reaches you again.")
1266 )
1267 return
1268
1269 ctx.presenter.show_info("Usage: /mode [ask|auto|forget]")
1270
1271
1272@command("/cwd")
1273def cmd_cwd(ctx: "Context", raw: str) -> None:
1274 """Re-anchor this oc_chat to a new working directory.
1275
1276 /cwd — print current anchors (cwd, sandbox root, source label)
1277 /cwd <path> — re-anchor to <path>, which must be an existing directory
1278
1279 Uses the 152 entry lease (transition_entry_lease) so a mid-turn or
1280 reentrant call is rejected with zero effects. Commits chdir + sandbox
1281 override (both mirrors) + dirty flag, then refreshes variable context.
1282 """
1283 import os as _os
1284 from pathlib import Path
1285 from oc_chat.tools import filesystem
1286 from oc_chat.tools import _sandbox
1288 TransitionRejectedError, transition_entry_lease,
1289 )
1290 from oc_chat.core.startup import refresh_variable_context
1291
1292 parts = raw.split(maxsplit=1)
1293
1294 if len(parts) == 1 or not parts[1].strip():
1295 # No args: print current anchors
1297 root, source = filesystem._sandbox_root_info()
1298 ctx.presenter.show_info(
1299 f"CWD: {Path.cwd()}\nSandbox root: {root}\nSource: {source}"
1300 )
1301 return
1302
1303 # Validate: resolve path, must be existing directory
1304 candidate = Path(parts[1].strip().strip('"').strip("'")).expanduser()
1305 resolved = candidate.resolve(strict=False)
1306
1307 if not resolved.is_dir():
1308 ctx.presenter.show_error(
1309 f"/cwd: {candidate} does not exist or is not a directory"
1310 )
1311 return
1312
1313 # Commit under the 152 entry lease
1314 original_cwd = _os.getcwd()
1315 try:
1316 with transition_entry_lease():
1317 _os.chdir(resolved)
1318 override = str(resolved)
1319 ctx.sandbox_root_override = override
1320 ctx.session.sandbox_root_override = override
1321 ctx.session.dirty = True
1322 _sandbox.install_resolved(resolved)
1323 # Sticky re-anchor (operator decision 2026-07-22): also set the
1324 # process-level sandbox root so a subsequent `/session new` — which
1325 # resets the per-session override to the fresh session's empty
1326 # projection (152 D-model) — still resolves to this directory via
1327 # the env rung (filesystem._sandbox_root_info precedence #2). An
1328 # explicit per-session override still wins over this. Delivers the
1329 # plan's "/cwd then /session new is born anchored" flow.
1330 _os.environ["OC_CHAT_SANDBOX_ROOT"] = override
1331
1332 # Post-transition refresh (best-effort)
1333 refresh_variable_context(ctx.session)
1334
1335 root, source = filesystem._sandbox_root_info()
1336 ctx.presenter.show_info(
1337 f"re-anchored \u2192 {resolved} (sandbox: {source})"
1338 )
1339 except TransitionRejectedError as exc:
1340 ctx.presenter.show_error(f"/cwd rejected: {exc}")
1341 except OSError as exc:
1342 # chdir failed — restore
1343 try:
1344 _os.chdir(original_cwd)
1345 except OSError:
1346 pass
1347 ctx.presenter.show_error(f"/cwd: cannot change to {resolved}: {exc}")
1348
1349
1351 """Print help for ``/session`` subcommands."""
1352 print("\n/session subcommands:")
1353 print(" /session new [name] - start a new named session")
1354 print(" /session list - list all saved sessions")
1355 print(" /session current - show current session info")
1356 print(" /session save [name] - save current session")
1357 print(" /session load <name> - load a saved session (auto-saves current)")
1358 print(" /session export [--html] [file] - export current session as Markdown (default) or HTML")
1359 print(" /session switch <name> - switch to another session (auto-saves current)")
1360 print(" /session clear - clear conversation history with verification & backup options")
1361 print()
1362
1363
1364@command("/session")
1365def cmd_session(ctx: "Context", raw: str) -> None:
1366 """Dispatch ``/session <subcommand> [...]``."""
1367 from oc_chat.core.persistence import (
1368 save_chat_log, save_session_metadata, load_session_metadata,
1369 export_markdown, list_sessions, session_exists, DEFAULT_SESSIONS_DIR,
1370 )
1371 from oc_chat.core.session import (
1372 _auto_name,
1373 CANONICAL_POLICY,
1374 ChatSession,
1375 build_system_prompt,
1376 )
1377
1378 parts = raw.split(maxsplit=2)
1379 sub = parts[1].lower() if len(parts) > 1 else ""
1380
1381 # ── /session new [name] ───────────────────────────────────────────────
1382 if sub == "new":
1383 name = parts[2].strip() if len(parts) > 2 else _auto_name()
1384 if session_exists(name):
1385 ctx.presenter.show_error(
1386 f"Session '{name}' already exists. "
1387 f"Use /session switch {name} to resume it, or pick a different name."
1388 )
1389 return
1390 # Auto-save current session if dirty
1391 if ctx.session.dirty:
1392 save_chat_log(ctx.session)
1393 save_session_metadata(ctx.session)
1394 ctx.presenter.show_info(f"Auto-saved current session.")
1395 # Create fresh session (immutable system prefix includes tool schemas)
1396 system_text = build_system_prompt(CANONICAL_POLICY, ctx.tool_schemas)
1397 new_session = ChatSession(
1398 name=name,
1399 messages=[{"role": "system", "content": system_text}],
1400 )
1401 new_session.created = name.split("_", 1)[1] if "_" in name else "unknown"
1402 # Plan 152 D3: replace through the transition seam, never by bare
1403 # assignment. `Context.session` is a plain dataclass field, so
1404 # `ctx.session = ...` invokes nothing — the agent, the event writer, the
1405 # F67 trust key, the sandbox global and the generation-options
1406 # projection all kept pointing at the OLD session, which is the desync
1407 # this plan exists to fix. The seam also performs the presenter refresh
1408 # (post-transaction, best-effort), so the explicit call is gone.
1409 replace_session(ctx, new_session)
1410 # M8.0 F67: external-content trust is session-scoped; the react-loop resets
1411 # it on the next turn (external_content_gate.bind_active_session sees the new
1412 # session), so a source trusted in the prior session does not carry over.
1413 ctx.presenter.show_info(f"New session: {name} (model: {new_session.model})")
1414 return
1415
1416 # ── /session list ─────────────────────────────────────────────────────
1417 if sub == "list":
1418 sessions = list_sessions()
1419 ctx.presenter.show_session_list(sessions, current_name=ctx.session.name)
1420 return
1421
1422 # ── /session current ──────────────────────────────────────────────────
1423 if sub == "current":
1424 s = ctx.session
1425 status = "(unsaved changes)" if s.dirty else "(clean)"
1426 if s.filepath is None and not s.dirty:
1427 status = "(never saved)"
1428 print(f"\nSession: {s.name}")
1429 print(f"Model: {s.model}")
1430 print(f"Reasoning effort: {s.reasoning_effort}")
1431 print(f"Messages: {s.stats.message_count} {status}")
1432 print(f"Tool calls: {s.stats.tool_call_count}")
1433 if s.stats.total_tokens == 0 and s.stats.message_count > 0:
1434 print("Token usage: -- (not tracked for this session)")
1435 else:
1436 print(f"Token usage: prompt={_fmt_tokens(s.stats.total_prompt_tokens)}, "
1437 f"completion={_fmt_tokens(s.stats.total_completion_tokens)}, "
1438 f"total={_fmt_tokens(s.stats.total_tokens)}")
1439 if s.chat_logs:
1440 print(f"Chat logs ({len(s.chat_logs)}):")
1441 for cl in s.chat_logs:
1442 print(f" - {cl}")
1443 if s.filepath:
1444 print(f"Metadata file: {s.filepath}")
1445 print()
1446 return
1447
1448 # ── /session save [name] ──────────────────────────────────────────────
1449 if sub == "save":
1450 name = parts[2].strip() if len(parts) > 2 else None
1451 if name and name != ctx.session.name and session_exists(name):
1452 ctx.presenter.show_error(
1453 f"Session '{name}' already exists. Use a different name."
1454 )
1455 return
1456 # Plan 152 D8 — rename/save-as is its OWN transition class. It must
1457 # capture the outgoing name's event slug BEFORE the name mutation, and
1458 # supersede-mark the old on-disk record so the same-session_id sibling a
1459 # rename inevitably creates is non-canonical by construction rather than
1460 # adjudicated by load order. Trust is NOT reset: the canonical
1461 # session_id is preserved, so this is the same identity renamed.
1462 log_path, meta_path = rename_session(ctx, name or ctx.session.name)
1463 ctx.presenter.show_info(f"Saved chat log to {log_path}")
1464 ctx.presenter.show_info(f"Saved metadata to {meta_path}")
1465 return
1466
1467 # ── /session load <name> ──────────────────────────────────────────────
1468 if sub == "load":
1469 if len(parts) < 3:
1470 ctx.presenter.show_info("Usage: /session load <name>")
1471 return
1472 name = parts[2].strip()
1473 if not session_exists(name):
1474 ctx.presenter.show_error(f"No session named '{name}' found in sessions directory.")
1475 return
1476 # Plan 152-b D2/D3 — the WHOLE load is one atomic prepare-then-commit
1477 # transition through the seam, replacing the pre-152-b in-place hydration.
1478 # The candidate is fully built and its event writer re-keyed to the LOADED
1479 # id (closing the audit-plane orphaning), the outgoing session is saved
1480 # from an immutable snapshot, and identity publishes last. A same-name
1481 # `/session load` is a full same-record reload through this path (D3).
1482 # Presenter notices are DEFERRED to post-commit: nothing is emitted until
1483 # the transition has actually succeeded.
1484 try:
1485 outcome = load_session_into(ctx, name)
1486 except (ChatLogLoadError, TransitionRejectedError) as e:
1487 ctx.presenter.show_error(str(e))
1488 return
1489 except Exception as e:
1490 ctx.presenter.show_error(str(e))
1491 return
1492 if outcome.outgoing_saved:
1493 ctx.presenter.show_info("Auto-saved current session.")
1494 if outcome.migration_notice:
1495 ctx.presenter.show_info(outcome.migration_notice)
1496 ctx.presenter.show_info(
1497 f"Loaded session '{ctx.session.name}' — "
1498 f"{ctx.session.stats.message_count} messages, "
1499 f"{ctx.session.stats.tool_call_count} tool calls, "
1500 f"{_fmt_tokens(ctx.session.stats.total_tokens)} tokens "
1501 f"(model: {ctx.session.model})"
1502 )
1503 return
1504
1505 # ── /session export [filename] ────────────────────────────────────────
1506 if sub == "export":
1507 # Parse: /session export [--html] [filename]
1508 rest = parts[2].strip() if len(parts) > 2 else ""
1509 tokens = rest.split()
1510 as_html = "--html" in tokens
1511 filename_tokens = [t for t in tokens if not t.startswith("--")]
1512 filename = filename_tokens[0] if filename_tokens else None
1513 try:
1514 if as_html:
1515 from oc_chat.core.persistence import export_html
1516 path = export_html(ctx.session, filepath=filename)
1517 else:
1518 path = export_markdown(ctx.session, filepath=filename)
1519 ctx.presenter.show_info(f"Exported to {path}")
1520 except ImportError as e:
1521 ctx.presenter.show_error(
1522 f"--html requires the optional `rich` dependency: {e}. "
1523 f"Install with: pip install -e .[rich]"
1524 )
1525 except Exception as e:
1526 ctx.presenter.show_error(str(e))
1527 return
1528
1529 # ── /session switch <name> ────────────────────────────────────────────
1530 if sub == "switch":
1531 if len(parts) < 3:
1532 ctx.presenter.show_info("Usage: /session switch <name>")
1533 return
1534 name = parts[2].strip()
1535 if name == ctx.session.name:
1536 ctx.presenter.show_info(f"Already on session '{name}'.")
1537 return
1538 if not session_exists(name):
1539 ctx.presenter.show_error(f"No session named '{name}' found in sessions directory.")
1540 return
1541 # Plan 152-b D2/D3 — `/session switch` reads the SAME persisted record
1542 # source as `/session load` (there is no in-memory switch source), so it
1543 # runs the identical atomic seam. The same-name early no-op above is
1544 # RETAINED for compatibility (D3); everything else is one prepare-then-
1545 # commit transition with deferred post-commit notices.
1546 try:
1547 outcome = load_session_into(ctx, name)
1548 except (ChatLogLoadError, TransitionRejectedError) as e:
1549 ctx.presenter.show_error(str(e))
1550 return
1551 except Exception as e:
1552 ctx.presenter.show_error(str(e))
1553 return
1554 if outcome.outgoing_saved:
1555 ctx.presenter.show_info("Auto-saved current session.")
1556 if outcome.migration_notice:
1557 ctx.presenter.show_info(outcome.migration_notice)
1558 ctx.presenter.show_info(
1559 f"Switched to session: {ctx.session.name} — "
1560 f"{ctx.session.stats.message_count} messages, "
1561 f"{ctx.session.stats.tool_call_count} tool calls, "
1562 f"{_fmt_tokens(ctx.session.stats.total_tokens)} tokens "
1563 f"(model: {ctx.session.model})"
1564 )
1565 return
1566
1567 # ── /session clear ────────────────────────────────────────────────────
1568 if sub == "clear":
1569 msg_count = ctx.session.stats.message_count
1570 print(f"\nSession '{ctx.session.name}' has {msg_count} message(s).")
1571 print("What would you like to do before clearing?")
1572 print()
1573 print(" 1) Save an archived backup, then clear")
1574 print(" 2) Save as a new session & switch to a fresh one")
1575 print(" 3) Clear without saving")
1576 print(" 4) Cancel")
1577
1578 choice = input("\nChoice (1-4): ").strip()
1579
1580 if choice == "1":
1581 # Save first to ensure the live files reflect the latest in-memory
1582 # state, then clear and save again. Each save archives the
1583 # previous version to oc_chat/sessions/archive/{name}/, so the
1584 # pre-clear conversation is preserved in archive/.
1585 save_chat_log(ctx.session)
1586 save_session_metadata(ctx.session)
1587 ctx.session.clear()
1588 save_chat_log(ctx.session)
1589 save_session_metadata(ctx.session)
1590 ctx.presenter.show_info(
1591 "Conversation history cleared. Pre-clear snapshot is in "
1592 f"oc_chat/sessions/archive/{ctx.session.name}/."
1593 )
1594
1595 elif choice == "2":
1596 # Save current session, then start a fresh one
1597 save_chat_log(ctx.session)
1598 save_session_metadata(ctx.session)
1599 ctx.presenter.show_info(f"Current session saved.")
1600 new_name = _auto_name()
1601 system_text = build_system_prompt(CANONICAL_POLICY, ctx.tool_schemas)
1602 new_session = ChatSession(
1603 name=new_name,
1604 messages=[{"role": "system", "content": system_text}],
1605 )
1606 new_session.created = new_name.split("_", 1)[1] if "_" in new_name else "unknown"
1607 # Plan 152 D3 — replacement path 2 of 3; see the note at `/session new`.
1608 replace_session(ctx, new_session)
1609 ctx.presenter.show_info(f"Started new session: {new_name} (model: {new_session.model})")
1610
1611 elif choice == "3":
1612 # Clear without saving — ask for final confirmation
1613 confirm = input("Clearing will permanently delete all messages. Are you sure? (y/N): ").strip().lower()
1614 if confirm in ("y", "yes"):
1615 ctx.session.clear()
1616 ctx.presenter.show_info("Conversation history cleared.")
1617 else:
1618 ctx.presenter.show_info("Clear cancelled.")
1619
1620 else:
1621 ctx.presenter.show_info("Clear cancelled.")
1622
1623 return
1624
1625 # ── unknown / missing subcommand ──────────────────────────────────────
1626 _session_help(ctx)
1627
1628
1629# ── UI configuration command ──────────────────────────────────────────────────
1630
1631# Valid values for /ui reasoning — kept in module scope so future phases can
1632# import the same source of truth from RichPresenter / TextualPresenter.
1633UI_REASONING_VALUES = ("show", "dim", "hide")
1634UI_REASONING_DEFAULT = "dim"
1635# Plan 147 D5: the 'dim' tail line count, persisted as the separate typed
1636# key ui.reasoning_tail — independent of ui.show_reasoning so switching
1637# modes never silently resets a previously-chosen tail length.
1638UI_REASONING_TAIL_DEFAULT = default("ui_reasoning_tail")
1639
1640
1641def _ui_help(ctx) -> None:
1642 """Print help for /ui subcommands."""
1643 print("\n/ui subcommands:")
1644 print(" /ui reasoning [show|dim [N]|hide] - control how reasoning / chain-of-thought is rendered")
1645 print(f" ('dim' shows the last N lines, default {UI_REASONING_TAIL_DEFAULT};")
1646 print(" takes effect with --ui rich; persisted regardless)")
1647 print()
1648 print(" Future phases will add: /ui theme <name>, /ui stats <compact|verbose|off>,")
1649 print(" /ui set <key> <value>")
1650 print()
1651
1652
1653def _read_ui_pref(session, key: str, default):
1654 """Read ``meta.json[ui][<key>]`` from a live session (None-safe)."""
1655 ui = getattr(session, "ui_prefs", None)
1656 if ui is None:
1657 return default
1658 return ui.get(key, default)
1659
1660
1661def _write_ui_pref(session, key: str, value) -> None:
1662 """Write ``meta.json[ui][<key>]`` and mark session dirty so it persists on save."""
1663 if not hasattr(session, "ui_prefs") or session.ui_prefs is None:
1664 session.ui_prefs = {}
1665 session.ui_prefs[key] = value
1666 session.dirty = True
1667
1668
1669@command("/ui")
1670def cmd_ui(ctx: "Context", raw: str) -> None:
1671 """Dispatch ``/ui <subcommand> [args...]``.
1672
1673 Phase 0 implements only the ``reasoning`` subcommand. The dispatcher
1674 is structured to mirror :func:`cmd_session` so Phases 1+ can add
1675 ``theme``, ``stats``, ``set``, etc. as one-clause additions.
1676 """
1677 parts = raw.split(maxsplit=2)
1678 sub = parts[1].lower() if len(parts) > 1 else ""
1679
1680 if sub == "":
1681 _ui_help(ctx)
1682 return
1683
1684 if sub == "reasoning":
1685 if len(parts) < 3:
1686 current = _read_ui_pref(ctx.session, "show_reasoning", UI_REASONING_DEFAULT)
1687 tail = _read_ui_pref(ctx.session, "reasoning_tail", UI_REASONING_TAIL_DEFAULT)
1688 ctx.presenter.show_info(
1689 f"reasoning: {current} (dim tail: {tail} lines) "
1690 f"(options: {', '.join(UI_REASONING_VALUES)})"
1691 )
1692 return
1693
1694 # /ui reasoning <mode> [N] — N is only meaningful (and only accepted) for 'dim'.
1695 arg_parts = parts[2].strip().split(maxsplit=1)
1696 value = arg_parts[0].lower()
1697 if value not in UI_REASONING_VALUES:
1698 ctx.presenter.show_error(
1699 f"Invalid value '{value}' for /ui reasoning. "
1700 f"Choose one of: {', '.join(UI_REASONING_VALUES)}"
1701 )
1702 return
1703
1704 tail_arg = arg_parts[1].strip() if len(arg_parts) > 1 else ""
1705 tail_note = ""
1706 if tail_arg:
1707 if value != "dim":
1708 ctx.presenter.show_error(
1709 f"A tail line count is only valid with 'dim' (got '{value} {tail_arg}')."
1710 )
1711 return
1712 try:
1713 tail_n = int(tail_arg)
1714 except ValueError:
1715 ctx.presenter.show_error(
1716 f"Invalid tail line count '{tail_arg}' — must be a positive integer."
1717 )
1718 return
1719 if tail_n < 1:
1720 ctx.presenter.show_error(f"Tail line count must be >= 1 (got {tail_n}).")
1721 return
1722 _write_ui_pref(ctx.session, "reasoning_tail", tail_n)
1723 tail_note = f" (tail: {tail_n} lines)"
1724
1725 _write_ui_pref(ctx.session, "show_reasoning", value)
1726 ctx.presenter.show_info(
1727 f"reasoning: {value}{tail_note} "
1728 f"(persisted in session metadata; takes visible effect with --ui rich)"
1729 )
1730 return
1731
1732 ctx.presenter.show_error(f"Unknown /ui subcommand: '{sub}'")
1733 _ui_help(ctx)
1734
1735
1736# ── Command palette ────────────────────────────────────────────────────────────
1737
1738@command("/palette")
1739def cmd_palette(ctx: "Context", raw: str) -> None:
1740 """Fuzzy-search commands and recent sessions.
1741
1742 This is the slash-command equivalent of ``Ctrl+P`` at the prompt.
1743 If a query is provided, matching commands and session-load actions
1744 are printed. Without a query, the full list is shown.
1745 """
1746 func = "cmd_palette"
1747 parts = raw.split(maxsplit=1)
1748 query = parts[1].strip().lower() if len(parts) > 1 else ""
1749
1750 # Collect palette entries
1751 entries: list[tuple[str, str]] = [] # (display, action)
1752
1753 for verb, help_text in COMMAND_HELP.items():
1754 display = help_text.split(" - ", 1)[1] if " - " in help_text else verb
1755 entries.append((f"{verb:<20} {display}", verb))
1756
1757 # Add recent session load actions
1758 try:
1759 from oc_chat.core.persistence import list_sessions
1760 for s in list_sessions()[:10]: # limit to 10 most recent
1761 name = s["name"]
1762 entries.append((
1763 f"/session load {name:<25} load saved session",
1764 f"/session load {name}",
1765 ))
1766 except Exception as e:
1767 # VALIDATION_ERROR — sessions dir absent / unreadable; degrade to verb-only palette
1768 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
1769
1770 # Filter by query
1771 if query:
1772 entries = [
1773 (display, action)
1774 for display, action in entries
1775 if query in display.lower() or query in action.lower()
1776 ]
1777
1778 if not entries:
1779 ctx.presenter.show_info("No matching commands or sessions found.")
1780 return
1781
1782 lines = [f"\n Command palette{' — ' + query if query else ''}:", ""]
1783 for display, _ in entries:
1784 lines.append(f" {display}")
1785 lines.append("")
1786 for line in lines:
1787 ctx.presenter.show_info(line)
1788 ctx.presenter.show_info(
1789 "Tip: Press Ctrl+P at the prompt for interactive fuzzy search."
1790 )
1791
1792
1793# ── Agentic commands ───────────────────────────────────────────────────────────
1794
1795@command("/goal")
1796def cmd_goal(ctx: "Context", raw: str) -> DispatchResult:
1797 """Inject an autonomous goal as a user message; main loop will run the turn.
1798
1799 Plan 152 D7/R11-04 — containment is HANDLER-LOCAL. The mutation unit (the
1800 goal flags plus the appended message) runs CONTIGUOUSLY and BEFORE any
1801 raising call, and this handler catches its own failures while its
1802 compensation tokens are still in scope. ``dispatch`` cannot do that job: a
1803 raised handler returns nothing, so the tokens would never reach it.
1804
1805 A post-commit REPORTING failure is NOT a command failure — the turn stays
1806 queued and runs exactly once, carrying a ``warning``. Mapping it to a
1807 no-turn failure would strand a committed queue.
1808 """
1809 func = "cmd_goal"
1810 parts = raw.split(maxsplit=1)
1811 if len(parts) < 2:
1812 ctx.presenter.show_info("Usage: /goal <task description>")
1813 return DispatchResult(kind=HANDLED_NO_TURN)
1814 goal = parts[1].strip()
1815
1816 # Compensation tokens, captured BEFORE the mutation unit.
1817 prior_in_goal = ctx.in_goal
1818 prior_description = ctx.goal_description
1819 prior_files = list(ctx.goal_files_changed or [])
1820 message_index = len(ctx.session.messages)
1821
1822 try:
1823 # ── The mutation unit: goal tracking + the queued message, contiguous ──
1824 ctx.in_goal = True
1825 ctx.goal_description = goal
1826 ctx.goal_files_changed = []
1827 ctx.session.add_user(
1828 f"Please accomplish this goal using the available tools. "
1829 f"Break it into steps, execute each step, and report results.\n\nGoal: {goal}"
1830 )
1831 except Exception as exc:
1832 # Mid-unit failure: unwind our own tokens so no orphan message and no
1833 # stranded in_goal flag poisons the next REPL iteration.
1834 _dbg("OC_CHAT.MAIN", func,
1835 f"SYSTEM_ERROR: goal unit failed: {type(exc).__name__}: {exc}", 1, override=True)
1836 ctx.in_goal = prior_in_goal
1837 ctx.goal_description = prior_description
1838 ctx.goal_files_changed = prior_files
1839 del ctx.session.messages[message_index:]
1840 ctx.presenter.show_error(f"Could not queue the goal: {exc}")
1841 return DispatchResult(kind=INTERNAL_FAILURE, effect_disposition=EFFECT_NONE)
1842
1843 # ── Post-commit reporting. A failure here does NOT cancel the queued turn ──
1844 warning = ""
1845 try:
1846 ctx.presenter.show_info(f"Goal queued: {goal}")
1847 except Exception as exc:
1848 warning = f"goal queued, but the confirmation could not be displayed: {exc}"
1849 _dbg("OC_CHAT.MAIN", func, f"EXTERNAL_ERROR: {warning}", 2, override=True)
1850 return DispatchResult(
1851 kind=HANDLED_TURN_QUEUED,
1852 effect_disposition=EFFECT_COMMITTED,
1853 queued_turn_token=message_index,
1854 warning=warning,
1855 )
1856
1857
1858@command("/plan")
1859def cmd_plan(ctx: "Context", raw: str) -> "DispatchResult | None":
1860 """Plan-document management — create, pause, resume, list, etc.
1861
1862 Usage:
1863 /plan <description> — create a new plan (alias: --new <desc>)
1864 /plan --status — show the active plan's state
1865 /plan --pause — pause the active plan
1866 /plan --continue — resume a paused plan
1867 /plan --complete — mark the active plan complete
1868 /plan --abort — abandon the active plan
1869 /plan --list — list all plans, grouped by status
1870 /plan --switch <id> — change the active plan
1871 """
1872 parts = raw.split(maxsplit=1)
1873 rest = parts[1].strip() if len(parts) > 1 else ""
1874
1875 # No-arg invocation prints usage
1876 if not rest:
1877 ctx.presenter.show_info(cmd_plan.__doc__ or "")
1878 return
1879
1880 # Flag-shaped invocation: dispatch to the matching sub-handler.
1881 # Bare invocation (`/plan <free text>`) creates a new plan.
1882 if rest.startswith("--new"):
1883 # Strip "--new" and optional whitespace, treat remainder as description
1884 desc = rest[len("--new"):].strip()
1885 return _cmd_plan_new(ctx, desc)
1886 if rest == "--status":
1887 return _cmd_plan_status(ctx)
1888 if rest == "--pause":
1889 return _cmd_plan_pause(ctx)
1890 if rest == "--continue":
1891 return _cmd_plan_continue(ctx)
1892 if rest == "--complete":
1893 return _cmd_plan_complete(ctx)
1894 if rest == "--abort":
1895 return _cmd_plan_abort(ctx)
1896 if rest == "--list":
1897 return _cmd_plan_list(ctx)
1898 if rest.startswith("--switch"):
1899 target = rest[len("--switch"):].strip()
1900 return _cmd_plan_switch(ctx, target)
1901 if rest.startswith("--"):
1902 ctx.presenter.show_info(
1903 "Unknown /plan flag. Run /plan without args for usage."
1904 )
1905 return
1906 # Free-text description → new plan
1907 return _cmd_plan_new(ctx, rest)
1908
1909
1910def _cmd_plan_new(ctx: "Context", desc: str) -> DispatchResult:
1911 """Create a new plan and enter plan mode."""
1912 func = "cmd_plan_new"
1913 if not desc:
1914 ctx.presenter.show_info("Usage: /plan <description>")
1915 return DispatchResult(kind=HANDLED_NO_TURN)
1916
1917 # Refuse if another plan is already active or paused in this session
1918 from oc_chat.core.plans import create_plan, find_plan_by_id
1919 from oc_chat.core.plan_inventory import TERMINAL_STATUSES
1920
1921 if ctx.session.active_plan_id:
1922 existing = find_plan_by_id(ctx.session.active_plan_id)
1923 if existing is not None and existing.status not in TERMINAL_STATUSES:
1924 ctx.presenter.show_info(
1925 f"Another plan is active in this session: "
1926 f"{ctx.session.active_plan_id} (status: {existing.status}). "
1927 "Run /plan --complete or /plan --abort first, "
1928 "or /plan --switch to a different plan."
1929 )
1930 return DispatchResult(kind=HANDLED_NO_TURN)
1931
1932 # Compensation tokens, captured BEFORE the durable call (plan 152 R11-04/R11-06).
1933 prior_active_plan_id = ctx.session.active_plan_id
1934 prior_dirty = ctx.session.dirty
1935 prior_in_plan = ctx.in_plan
1936 prior_plan_description = ctx.plan_description
1937 prior_plan_files = list(ctx.plan_files_changed or [])
1938 message_index = len(ctx.session.messages)
1939 plan_id = ""
1940
1941 try:
1942 # ── The mutation unit, contiguous and before any raising call ──────
1943 plan_id, path = create_plan(desc, session_name=ctx.session.name)
1944 ctx.session.active_plan_id = plan_id
1945 ctx.session.dirty = True
1946 ctx.in_plan = True
1947 ctx.plan_description = desc
1948 ctx.plan_files_changed = []
1949 ctx.session.add_user(
1950 # OCT-LINT: disable=no_hardcoded_secrets reason="Command help strings and templates, not secrets"
1951 f"Design an implementation plan for: {desc}\n\n"
1952 f"Follow the template at `oc_chat/plans/PLAN_TEMPLATE.md`. "
1953 f"Read only files necessary to design the plan; "
1954 f"**do NOT modify any source files**. "
1955 f"Output the complete plan as a single Markdown file to "
1956 f"`{path.as_posix()}` using `write_file`. "
1957 f"When finished, reply with the filename only."
1958 )
1959 except Exception as exc:
1960 _dbg("OC_CHAT.MAIN", func,
1961 f"SYSTEM_ERROR: plan-new unit failed: {type(exc).__name__}: {exc}",
1962 1, override=True)
1963 ctx.session.active_plan_id = prior_active_plan_id
1964 ctx.session.dirty = prior_dirty
1965 ctx.in_plan = prior_in_plan
1966 ctx.plan_description = prior_plan_description
1967 ctx.plan_files_changed = prior_plan_files
1968 del ctx.session.messages[message_index:]
1969 # Retract OUR OWN orphan via the owner, so the "another plan is active"
1970 # guard above is not poisoned for the next /plan --new. The record is
1971 # retained in a terminal state, never deleted — this is a --new orphan,
1972 # and ONLY a --new orphan may be retracted this way.
1973 disposition = EFFECT_NONE
1974 if plan_id:
1975 try:
1976 from oc_chat.core.plans import abort_plan
1977 abort_plan(plan_id)
1978 except Exception as abort_exc:
1979 disposition = EFFECT_UNKNOWN
1980 _dbg("OC_CHAT.MAIN", func,
1981 f"SYSTEM_ERROR: could not retract orphan {plan_id}: {abort_exc}",
1982 1, override=True)
1983 ctx.presenter.show_error(
1984 f"Could not create the plan: {exc}"
1985 + ("" if disposition == EFFECT_NONE else
1986 f" The orphaned record {plan_id} could NOT be retracted — its state is "
1987 f"indeterminate. Check /plan --list and clear it with /plan --abort "
1988 f"before creating another plan.")
1989 )
1990 return DispatchResult(kind=INTERNAL_FAILURE, effect_disposition=disposition)
1991
1992 warning = ""
1993 try:
1994 ctx.presenter.show_info(f"Plan queued: {plan_id} ({path.as_posix()})")
1995 except Exception as exc:
1996 warning = f"plan {plan_id} created, but the confirmation could not be displayed: {exc}"
1997 _dbg("OC_CHAT.MAIN", func, f"EXTERNAL_ERROR: {warning}", 2, override=True)
1998 _dbg("OC_CHAT.MAIN", func, f"created {plan_id!r}", 2)
1999 return DispatchResult(
2000 kind=HANDLED_TURN_QUEUED,
2001 effect_disposition=EFFECT_COMMITTED,
2002 queued_turn_token=message_index,
2003 warning=warning,
2004 )
2005
2006
2007def _plan_checkbox_tally(path) -> tuple[int, int]:
2008 """Count `- [ ]` (open) and `- [x]` (done) lines in a plan markdown file.
2009
2010 Returns ``(done, open)``; returns ``(0, 0)`` if the file is missing
2011 or unreadable.
2012 """
2013 try:
2014 content = path.read_text(encoding="utf-8")
2015 except (OSError, UnicodeDecodeError):
2016 return (0, 0)
2017 done = 0
2018 open_ = 0
2019 for line in content.splitlines():
2020 stripped = line.lstrip()
2021 if stripped.startswith("- [ ]"):
2022 open_ += 1
2023 elif stripped.startswith("- [x]") or stripped.startswith("- [X]"):
2024 done += 1
2025 return (done, open_)
2026
2027
2028def _active_record_or_warn(ctx: "Context"):
2029 """Resolve session.active_plan_id to a PlanRecord; warn if missing.
2030
2031 Returns ``None`` and prints a presenter message in the no-active-plan
2032 case, so callers can `if rec is None: return` cleanly.
2033 """
2034 from oc_chat.core.plans import find_plan_by_id
2035
2036 pid = ctx.session.active_plan_id
2037 if not pid:
2038 ctx.presenter.show_info("No active plan in this session.")
2039 return None
2040 rec = find_plan_by_id(pid)
2041 if rec is None:
2042 ctx.presenter.show_info(
2043 f"Inventory has no record for active plan {pid!r}. "
2044 "Run /plan --list to see existing plans."
2045 )
2046 return None
2047 return rec
2048
2049
2050def _cmd_plan_status(ctx: "Context") -> None:
2051 """Show the active plan's filename, state, description, timestamps,
2052 and checkbox tally."""
2053 from pathlib import Path
2054 from oc_chat.core.plans import DEFAULT_PLANS_DIR
2055 from oc_chat.core.plan_inventory import _expected_filename
2056
2057 rec = _active_record_or_warn(ctx)
2058 if rec is None:
2059 return
2060
2061 expected = _expected_filename(rec.plan_id, rec.status)
2062 path = DEFAULT_PLANS_DIR / expected
2063 if path.exists():
2064 done, open_ = _plan_checkbox_tally(path)
2065 tally = f"{done}/{done + open_} steps complete"
2066 else:
2067 tally = "file missing"
2068
2069 lines = [
2070 f"Plan: {rec.plan_id}",
2071 f"Status: {rec.status}",
2072 f"Description: {rec.description}",
2073 f"Filename: {expected}",
2074 f"Created: {rec.created_at}",
2075 ]
2076 if rec.completed_at:
2077 lines.append(f"Completed: {rec.completed_at}")
2078 if rec.aborted_at:
2079 lines.append(f"Aborted: {rec.aborted_at}")
2080 lines.append(f"Progress: {tally}")
2081 for line in lines:
2082 ctx.presenter.show_info(line)
2083
2084
2085def _cmd_plan_pause(ctx: "Context") -> None:
2086 """Pause the active plan."""
2087 from oc_chat.core.plans import pause_plan
2088
2089 rec = _active_record_or_warn(ctx)
2090 if rec is None:
2091 return
2092 try:
2093 new_rec = pause_plan(rec.plan_id)
2094 except ValueError as exc:
2095 ctx.presenter.show_info(f"Cannot pause: {exc}")
2096 return
2097 ctx.presenter.show_info(f"Paused: {new_rec.plan_id}")
2098
2099
2100def _cmd_plan_continue(ctx: "Context") -> DispatchResult:
2101 """Resume a paused plan and inject a continuation message.
2102
2103 Compensation here is NON-DESTRUCTIVE, and deliberately different from
2104 ``--new`` (plan 152 R11-06). A ``--new`` orphan may be abort-retracted
2105 because nothing valid existed before it. A ``--continue`` failure must NEVER
2106 abort the plan: the user has a real, valid, paused plan, and terminalising
2107 it because queue assembly or reporting failed would destroy work and make it
2108 unretryable. Either the plan stays paused and retryable, or — if the durable
2109 paused->active transition already committed and only later steps failed —
2110 the result reports COMMITTED and the queue reconciles. It never acquires
2111 ``aborted_at`` because a message could not be displayed.
2112 """
2113 func = "cmd_plan_continue"
2114 from oc_chat.core.plans import continue_plan
2115
2116 rec = _active_record_or_warn(ctx)
2117 if rec is None:
2118 return DispatchResult(kind=HANDLED_NO_TURN)
2119 try:
2120 new_rec = continue_plan(rec.plan_id)
2121 except ValueError as exc:
2122 # The durable transition did not happen; the plan is untouched.
2123 ctx.presenter.show_info(f"Cannot continue: {exc}")
2124 return DispatchResult(kind=HANDLED_NO_TURN)
2125
2126 # The durable paused->active transition HAS committed past this point.
2127 prior_in_plan = ctx.in_plan
2128 prior_plan_description = ctx.plan_description
2129 prior_plan_files = list(ctx.plan_files_changed or [])
2130 message_index = len(ctx.session.messages)
2131
2132 try:
2133 ctx.in_plan = True
2134 ctx.plan_description = new_rec.description
2135 ctx.plan_files_changed = []
2136 ctx.session.add_user(
2137 f"Continue work on plan `{new_rec.plan_id}.md`. "
2138 "Re-read it and proceed from where you left off."
2139 )
2140 except Exception as exc:
2141 _dbg("OC_CHAT.MAIN", func,
2142 f"SYSTEM_ERROR: continue unit failed after the durable transition: "
2143 f"{type(exc).__name__}: {exc}", 1, override=True)
2144 ctx.in_plan = prior_in_plan
2145 ctx.plan_description = prior_plan_description
2146 ctx.plan_files_changed = prior_plan_files
2147 del ctx.session.messages[message_index:]
2148 # NOT aborted. The plan is active on disk; say so and let the user retry.
2149 ctx.presenter.show_error(
2150 f"Plan {new_rec.plan_id} was resumed on disk, but the continuation "
2151 f"message could not be queued: {exc}. The plan is ACTIVE and "
2152 f"retryable — run /plan --continue again."
2153 )
2154 return DispatchResult(
2155 kind=INTERNAL_FAILURE, effect_disposition=EFFECT_COMMITTED,
2156 )
2157
2158 warning = ""
2159 try:
2160 ctx.presenter.show_info(f"Resumed: {new_rec.plan_id}")
2161 except Exception as exc:
2162 warning = f"plan {new_rec.plan_id} resumed, but the confirmation could not be displayed: {exc}"
2163 _dbg("OC_CHAT.MAIN", func, f"EXTERNAL_ERROR: {warning}", 2, override=True)
2164 return DispatchResult(
2165 kind=HANDLED_TURN_QUEUED,
2166 effect_disposition=EFFECT_COMMITTED,
2167 queued_turn_token=message_index,
2168 warning=warning,
2169 )
2170
2171
2172def _cmd_plan_complete(ctx: "Context") -> None:
2173 """Mark the active plan complete and clear the session pointer."""
2174 from oc_chat.core.plans import complete_plan
2175
2176 rec = _active_record_or_warn(ctx)
2177 if rec is None:
2178 return
2179 try:
2180 new_rec = complete_plan(rec.plan_id)
2181 except ValueError as exc:
2182 ctx.presenter.show_info(f"Cannot complete: {exc}")
2183 return
2184 ctx.session.active_plan_id = None
2185 ctx.session.dirty = True
2186 ctx.presenter.show_info(f"Completed: {new_rec.plan_id}")
2187
2188
2189def _cmd_plan_abort(ctx: "Context") -> None:
2190 """Abort the active plan and clear the session pointer."""
2191 from oc_chat.core.plans import abort_plan
2192
2193 rec = _active_record_or_warn(ctx)
2194 if rec is None:
2195 return
2196 try:
2197 new_rec = abort_plan(rec.plan_id)
2198 except ValueError as exc:
2199 ctx.presenter.show_info(f"Cannot abort: {exc}")
2200 return
2201 ctx.session.active_plan_id = None
2202 ctx.session.dirty = True
2203 ctx.presenter.show_info(f"Aborted: {new_rec.plan_id}")
2204
2205
2206def _cmd_plan_list(ctx: "Context") -> None:
2207 """List all plans grouped by status, marking the active one."""
2208 from oc_chat.core.plans import list_plans
2209
2210 records = list_plans()
2211 if not records:
2212 ctx.presenter.show_info("No plans found.")
2213 return
2214
2215 active_id = ctx.session.active_plan_id
2216 groups: dict[str, list] = {"active": [], "paused": [], "complete": [], "aborted": []}
2217 for r in records:
2218 groups.setdefault(r.status, []).append(r)
2219
2220 for status in ("active", "paused", "complete", "aborted"):
2221 bucket = groups.get(status, [])
2222 if not bucket:
2223 continue
2224 ctx.presenter.show_info(f"[{status}]")
2225 for r in bucket:
2226 marker = "*" if r.plan_id == active_id else " "
2227 desc = r.description if len(r.description) <= 60 else r.description[:57] + "..."
2228 ctx.presenter.show_info(f" {marker} {r.plan_id} {desc}")
2229
2230
2231def _cmd_plan_switch(ctx: "Context", target: str) -> None:
2232 """Change session.active_plan_id to a different (non-terminal) plan."""
2233 from oc_chat.core.plans import find_plan_by_id
2234 from oc_chat.core.plan_inventory import TERMINAL_STATUSES
2235
2236 if not target:
2237 ctx.presenter.show_info("Usage: /plan --switch <plan-id-or-prefix>")
2238 return
2239 try:
2240 rec = find_plan_by_id(target)
2241 except ValueError as exc:
2242 ctx.presenter.show_info(f"Cannot switch: {exc}")
2243 return
2244 if rec is None:
2245 ctx.presenter.show_info(f"No plan found for {target!r}.")
2246 return
2247 if rec.status in TERMINAL_STATUSES:
2248 ctx.presenter.show_info(
2249 f"Cannot switch to {rec.plan_id} — status is {rec.status!r} (terminal). "
2250 "Choose an active or paused plan instead."
2251 )
2252 return
2253 ctx.session.active_plan_id = rec.plan_id
2254 ctx.session.dirty = True
2255 ctx.presenter.show_info(f"Switched to: {rec.plan_id} (status: {rec.status})")
2256
2257
2258@command("/options")
2259def cmd_options(ctx: "Context", raw: str) -> None:
2260 """Manage per-session generation options (temperature, top_k, etc.).
2261
2262 /options — show current options
2263 /options set <field> <value> — set a field
2264 /options reset — clear all options
2265 """
2266 from oc_chat.core.generation_options import GenerationOptions
2267
2268 parts = raw.strip().split(None, 3)
2269 sub = parts[1].lower() if len(parts) > 1 else ""
2270
2271 if sub == "reset":
2272 ctx.generation_options = GenerationOptions()
2273 ctx.session.generation_options = {}
2274 ctx.presenter.show_info("Generation options cleared (using provider defaults).")
2275 return
2276
2277 if sub == "set":
2278 if len(parts) < 4:
2279 ctx.presenter.show_info("Usage: /options set <field> <value>")
2280 return
2281 fname, raw_val = parts[2].lower(), parts[3]
2282 if fname not in GenerationOptions.model_fields:
2283 valid = ", ".join(GenerationOptions.model_fields)
2284 ctx.presenter.show_info(f"Unknown option {fname!r}. Valid: {valid}")
2285 return
2286 try:
2287 ann = GenerationOptions.model_fields[fname].annotation
2288 # annotation may be e.g. float | None — pick the non-None type
2289 base = next((t for t in getattr(ann, "__args__", [ann]) if t is not type(None)), float)
2290 value = base(raw_val)
2291 except (ValueError, TypeError):
2292 ctx.presenter.show_info(f"Cannot convert {raw_val!r} for {fname}")
2293 return
2294 ctx.generation_options = ctx.generation_options.model_copy(update={fname: value})
2295 ctx.session.generation_options = ctx.generation_options.model_dump(exclude_none=True)
2296 ctx.presenter.show_info(f"Set {fname} = {value}")
2297 return
2298
2299 # Default: show current options
2300 opts = ctx.generation_options
2301 if not opts.any_set():
2302 ctx.presenter.show_info("No generation options set (using provider defaults).")
2303 else:
2304 lines = [f" {k} = {v}" for k, v in opts.model_dump(exclude_none=True).items()]
2305 ctx.presenter.show_info("Generation options:\n" + "\n".join(lines))
2306
2307
2308@command("/status")
2309def cmd_status(ctx: "Context", _: str) -> None:
2310 """Display the current plan's step-by-step status as JSON."""
2311 from oc_chat.tools.planner import get_status
2312 import json
2313 print(json.dumps(get_status(), indent=2, ensure_ascii=False))
2314
2315
2316@command("/stop")
2317def cmd_stop(ctx: "Context", _: str) -> None:
2318 """Halt the active plan."""
2319 from oc_chat.tools.planner import stop_plan
2320 stop_plan()
2321 ctx.presenter.show_info("Active plan stopped.")
2322
2323
2324# ── Phase 4: cache / budget / react caps ─────────────────────────────────────
2325
2326@command("/cache")
2327def cmd_cache(ctx: "Context", _raw: str) -> None:
2328 """Show prompt disk-cache stats for this session."""
2329 from oc_chat.core.cache_usage import cache_hit_ratio_pct
2330
2331 s = ctx.session
2332 st = s.stats
2333 hit, miss = st.prompt_cache_hit_tokens, st.prompt_cache_miss_tokens
2334 pct = cache_hit_ratio_pct(hit, miss)
2335 print("\n── Prompt cache (session cumulative) ──")
2336 print(f" cache_hit_tokens: {hit:,}")
2337 print(f" cache_miss_tokens: {miss:,}")
2338 if pct is not None:
2339 print(f" hit ratio: {pct:.1f}%")
2340 else:
2341 print(" hit ratio: (no cache data yet)")
2342
2343 snaps = list(getattr(s, "cache_turn_snapshots", []) or [])
2344 if snaps:
2345 print("\n── Recent turns (newest last) ──")
2346 tail = snaps[-12:]
2347 for i, row in enumerate(tail, start=len(snaps) - len(tail) + 1):
2348 h, m = int(row.get("hit", 0)), int(row.get("miss", 0))
2349 p = int(row.get("prompt", 0))
2350 r = cache_hit_ratio_pct(h, m)
2351 rs = f"{r:.0f}%" if r is not None else "—"
2352 print(f" turn {i:>2}: hit={h:>6} miss={m:>6} ratio={rs:>5} prompt≈{p:,}")
2353 else:
2354 print("\n(no per-turn cache snapshots yet — complete a model turn first)")
2355 print()
2356
2357
2358@command("/trace")
2359def cmd_trace(ctx: "Context", raw: str) -> None:
2360 """Render this session's persisted M2 event stream (M2.6 /trace)."""
2361 from oc_chat.core.trace_view import format_trace, read_session_events
2362
2363 parts = raw.split(maxsplit=1)
2364 limit = 200 # default tail of most-recent events
2365 if len(parts) > 1:
2366 arg = parts[1].strip().lower()
2367 if arg in ("all", "full"):
2368 limit = None
2369 elif arg.isdigit():
2370 limit = max(1, int(arg))
2371 events = read_session_events(ctx.session)
2372 print("\n" + format_trace(events, limit=limit) + "\n")
2373
2374
2375@command("/packet")
2376def cmd_packet(ctx: "Context", _raw: str) -> None:
2377 """Write a draft merge-readiness packet (packet-draft/v0) for this session (M2.6 /packet)."""
2378 import json
2379 from datetime import datetime
2380
2381 from oc_chat.core.event_bootstrap import _slug, active_event_dir
2382 from oc_chat.core.trace_view import build_packet_draft, read_session_events
2383
2384 s = ctx.session
2385 events = read_session_events(s)
2386 packet = build_packet_draft(s, events)
2387 out_dir = active_event_dir(s)
2388 out_dir.mkdir(parents=True, exist_ok=True)
2389 stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
2390 path = out_dir / f"{_slug(s.name)}-{stamp}.packet.json"
2391 path.write_text(json.dumps(packet, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
2392 print(f"\n[packet-draft written to {path}]")
2393 print(f" events: {sum(packet['event_counts'].values())} turns: {packet['turn_count']}\n")
2394
2395
2396def _show_resolved_output_caps(ctx: "Context") -> None:
2397 """Print the completion caps the profile actually resolves to.
2398
2399 The caps derive from the model's declared ``max_output_tokens`` and the
2400 operator ceiling, so the profile name alone does not tell you what you
2401 get. Printing the numbers also explains why ``deep`` can equal
2402 ``balanced``: a bucket already at the ceiling has nowhere to scale.
2403 """
2404 func = "_show_resolved_output_caps"
2405 try:
2406 from oc_chat.core.config import get_output_tokens_ceiling
2407 from oc_chat.core.step_routing import resolve_output_tokens
2408
2409 model = ctx.session.model
2410 profile = getattr(ctx.session, "token_profile", "balanced")
2411 rows = [
2412 ("planning / final answer", "planning"),
2413 ("tool iterations", "thinking"),
2414 ("after a plain read", "simple"),
2415 ]
2416 caps = ", ".join(
2417 f"{label} {resolve_output_tokens(ctx.model_manager, model, bucket, profile):,}"
2418 for label, bucket in rows
2419 )
2420 ctx.presenter.show_info(
2421 f"Completion caps for {model} at '{profile}': {caps} "
2422 f"(operator ceiling {get_output_tokens_ceiling():,})"
2423 )
2424 except Exception as e: # noqa: BLE001 — an informational line must never abort the command
2425 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
2426
2427
2428def _show_session_cost(ctx: "Context") -> None:
2429 """Print the plan-237 money block: spend, checkpoints, cap, and the knob.
2430
2431 Everything an operator needs to answer "how much has this cost, and what
2432 stops it?" without reading the source — including the residual, because a
2433 total that silently excludes unpriced samples is a number that lies by
2434 omission.
2435 """
2436 func = "_show_session_cost"
2437 try:
2438 from oc_chat.core.resource_caps import (
2439 _COST_CEILING_ENV_VAR, resolve_cost_thresholds_usd,
2440 resolve_session_cost_hard_cap_usd,
2441 )
2443 cost_prompts_suppressed, tool_calls_auto_continue,
2444 )
2445
2446 cost = ctx.session.stats.cost
2447 cap = resolve_session_cost_hard_cap_usd()
2448 thresholds = resolve_cost_thresholds_usd()
2449 crossed = [t for t in thresholds if cost.has_fired(t)]
2450 pending = [t for t in thresholds if not cost.has_fired(t)]
2451 _plural = cost.unpriced_samples != 1
2452 unpriced = (
2453 f" ({cost.unpriced_samples} sample{'s' if _plural else ''} could not "
2454 f"be priced and {'are' if _plural else 'is'} NOT included, so real "
2455 f"spend may be higher)"
2456 if cost.unpriced_samples else ""
2457 )
2458 latches = []
2459 if tool_calls_auto_continue():
2460 latches.append("tool-call checkpoint: not asking again this session")
2461 if cost_prompts_suppressed():
2462 latches.append("cost prompts: suppressed this session (alerts and the "
2463 "hard cap still apply)")
2464 lines = [
2465 f"Session spend: ${cost.priced_usd:.2f}{unpriced}",
2466 f" checkpoints crossed : "
2467 + (", ".join(f"${t:.2f}" for t in crossed) if crossed else "none"),
2468 f" checkpoints ahead : "
2469 + (", ".join(f"${t:.2f}" for t in pending) if pending else "none"),
2470 f" hard cap : ${cap} - at or above it no further provider "
2471 f"call is made this session",
2472 f" raise the cap : "
2473 + (f"{_COST_CEILING_ENV_VAR}=<whole dollars>, out of band only "
2474 f"(a settings file can only LOWER it)"
2475 if _COST_CEILING_ENV_VAR else "no out-of-band raise path"),
2476 ]
2477 lines.extend(f" {note}" for note in latches)
2478 ctx.presenter.show_info("\n".join(lines))
2479 except Exception as e: # noqa: BLE001 — an informational line must never abort the command
2480 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
2481
2482
2483@command("/budget")
2484def cmd_budget(ctx: "Context", raw: str) -> None:
2485 """Show or set the session token profile, and show the session money bounds.
2486
2487 Plan 237 (CAPS-01 P7) asked for a ``/budget`` that prints session spend, the
2488 checkpoints already crossed, the hard cap and the env variable that raises
2489 it. ``/budget`` was NOT a new command — it has selected the token profile
2490 since phase 4 — so rather than take the name and break that, the bare form
2491 now prints BOTH blocks (profile + money), ``/budget cost`` prints the money
2492 block alone, and ``/budget cheap|balanced|deep`` is unchanged.
2493 """
2494 parts = raw.split(maxsplit=1)
2495 s = ctx.session
2496 if len(parts) < 2:
2497 ctx.presenter.show_info(
2498 f"Token profile: {getattr(s, 'token_profile', 'balanced')} "
2499 f"(options: {', '.join(TOKEN_PROFILES)}). Example: /budget cheap"
2500 )
2503 return
2504 val = parts[1].strip().lower()
2505 if val == "cost":
2507 return
2508 if val not in TOKEN_PROFILES:
2509 ctx.presenter.show_error(
2510 f"Unknown profile. Use one of: {', '.join(TOKEN_PROFILES)} — "
2511 f"or 'cost' for this session's spend against the hard cap."
2512 )
2513 return
2514 s.token_profile = val
2515 s.dirty = True
2516 ctx.presenter.show_info(f"Token profile set to: {val}")
2518
2519
2520@command("/react")
2521def cmd_react(ctx: "Context", raw: str) -> None:
2522 """Inspect or override the ReAct iteration cap for this session."""
2523 parts = raw.split(maxsplit=1)
2524 s = ctx.session
2525 cap = getattr(s, "max_react_iterations", None)
2526 eff = cap if isinstance(cap, int) and cap >= 1 else DEFAULT_MAX_REACT_ITERATIONS
2527 if len(parts) < 2:
2528 ctx.presenter.show_info(
2529 f"ReAct cap: {eff} (default {DEFAULT_MAX_REACT_ITERATIONS}). "
2530 f"Set with /react 15 or /react reset"
2531 )
2532 return
2533 arg = parts[1].strip().lower()
2534 if arg == "reset":
2535 s.max_react_iterations = None
2536 s.dirty = True
2537 ctx.presenter.show_info(f"ReAct cap reset to default ({DEFAULT_MAX_REACT_ITERATIONS}).")
2538 return
2539 try:
2540 n = int(parts[1].strip())
2541 except ValueError:
2542 ctx.presenter.show_error("Usage: /react [1-99|reset]")
2543 return
2544 if not 1 <= n <= 99:
2545 ctx.presenter.show_error("ReAct cap must be between 1 and 99.")
2546 return
2547 s.max_react_iterations = n
2548 s.dirty = True
2549 ctx.presenter.show_info(f"ReAct cap set to {n} iterations for this session.")
2550
2551
2552# ── One-shot ask (K.5a) ───────────────────────────────────────────────────────
2553
2554@command("/ask")
2555def cmd_ask(ctx: "Context", raw: str) -> None:
2556 """Issue a single non-streaming question without touching the session log.
2557
2558 No ReAct iteration, no tool schemas in the system prompt (the model
2559 cannot dispatch tool calls in one-shot mode), and ``session.messages``
2560 /``session.stats`` are left untouched. Phase K.5b wires the
2561 post-answer menu onto this handler.
2562 """
2563 func = "cmd_ask"
2564 parts = raw.split(maxsplit=1)
2565 if len(parts) < 2 or not parts[1].strip():
2566 ctx.presenter.show_error("Usage: /ask <question>")
2567 return
2568 question = parts[1].strip()
2569 _dbg("OC_CHAT.MAIN", func, f"one-shot question_len={len(question)}", 2)
2570
2571 system_prompt = (
2572 getattr(ctx.session, "system_prompt", "")
2573 or "You are a helpful assistant."
2574 )
2575 messages = [
2576 {"role": "system", "content": system_prompt},
2577 {"role": "user", "content": question},
2578 ]
2579
2580 try:
2581 response = ctx.client.one_shot(
2582 messages,
2583 ctx.session.model,
2584 effort="low",
2585 max_tokens=4096,
2586 )
2587 except Exception as e: # noqa: BLE001 — surface every error uniformly
2588 _dbg("OC_CHAT.MAIN", func, f"one_shot raised {type(e).__name__}: {e}", 1, override=True)
2589 ctx.presenter.show_error(f"/ask failed: {type(e).__name__}: {e}")
2590 return
2591
2592 ctx.presenter.show_content(response)
2593 ctx.presenter.show_info("(one-shot — no session log written)")
2594 _dbg("OC_CHAT.MAIN", func, f"one-shot response_len={len(response)}", 2)
2595
2596 # K.5b — post-answer menu (5 options: follow-up / save / copy / new / quit).
2597 from oc_chat.core.ask_menu import present_post_answer_menu
2598 chosen = present_post_answer_menu(ctx, question, response)
2599 _dbg("OC_CHAT.MAIN", func, f"post-answer option chosen: {chosen}", 2)
2600
2601
2602# ── Help ───────────────────────────────────────────────────────────────────────
2603
2604@command("/help")
2605def cmd_help(ctx: "Context", _: str) -> None:
2606 """Print the full command listing, session subcommands, and registered tools."""
2607 tools = sorted(ctx.tool_registry) if ctx.tool_registry else None
2608 ctx.presenter.show_help(COMMAND_HELP, tools=tools)
2609 # The /session subcommand details are still printed plainly — they're a
2610 # fixed text block that doesn't benefit from a table. PR 1.B+ may move
2611 # these into the presenter too.
2612 _session_help(ctx)
None __init__(self, "Context" ctx)
Definition commands.py:691
str on_prompt(self, str prompt)
Definition commands.py:697
_read_ui_pref(session, str key, default)
Definition commands.py:1653
None cmd_last("Context" ctx, str raw)
Definition commands.py:993
None _show_session_cost("Context" ctx)
Definition commands.py:2428
None cmd_grants("Context" ctx, str raw)
Definition commands.py:1161
None cmd_effort("Context" ctx, str raw)
Definition commands.py:803
None cmd_context("Context" ctx, str raw)
Definition commands.py:1041
None cmd_help("Context" ctx, str _)
Definition commands.py:2605
tuple[int, int] _plan_checkbox_tally(path)
Definition commands.py:2007
str _fmt_tokens(int n)
Definition commands.py:1066
"DispatchResult | None" cmd_plan("Context" ctx, str raw)
Definition commands.py:1859
None cmd_status("Context" ctx, str _)
Definition commands.py:2309
None cmd_stop("Context" ctx, str _)
Definition commands.py:2317
DispatchResult cmd_goal("Context" ctx, str raw)
Definition commands.py:1796
None cmd_ls("Context" ctx, str raw)
Definition commands.py:919
None cmd_ui("Context" ctx, str raw)
Definition commands.py:1670
None cmd_ask("Context" ctx, str raw)
Definition commands.py:2555
Any _oauth_provider("Context" ctx, str raw)
Definition commands.py:724
None cmd_cache("Context" ctx, str _raw)
Definition commands.py:2327
bool _apply_lane_selection("Context" ctx, str lane_name, str model_id="")
Definition commands.py:606
None cmd_session("Context" ctx, str raw)
Definition commands.py:1365
None _refresh_presenter_for_model(ctx)
Definition commands.py:539
None _cmd_plan_status("Context" ctx)
Definition commands.py:2050
None _cmd_plan_complete("Context" ctx)
Definition commands.py:2172
None cmd_switch_model("Context" ctx, str raw)
Definition commands.py:580
None cmd_react("Context" ctx, str raw)
Definition commands.py:2521
None cmd_login("Context" ctx, str raw)
Definition commands.py:739
dict|None _latest_message_with_field(list[dict] messages, str role, str field_name)
Definition commands.py:172
_active_record_or_warn("Context" ctx)
Definition commands.py:2028
DispatchResult _cmd_plan_continue("Context" ctx)
Definition commands.py:2100
None _cmd_plan_switch("Context" ctx, str target)
Definition commands.py:2231
None _warn_if_no_tools(ctx, str model_name)
Definition commands.py:531
None _cmd_plan_pause("Context" ctx)
Definition commands.py:2085
None cmd_logout("Context" ctx, str raw)
Definition commands.py:763
_lane_options_for("Context" ctx)
Definition commands.py:599
tuple[dict, str] _metadata_for_activation("Context" ctx, str name)
Definition commands.py:310
None cmd_options("Context" ctx, str raw)
Definition commands.py:2259
None cmd_packet("Context" ctx, str _raw)
Definition commands.py:2376
None cmd_open("Context" ctx, str raw)
Definition commands.py:958
None cmd_cwd("Context" ctx, str raw)
Definition commands.py:1273
Callable[[_F], _F] command(*str names)
Definition commands.py:227
None refresh_presenter_bindings("Context" ctx)
Definition commands.py:365
None _switch_to_tier(ctx, str tier_name)
Definition commands.py:549
None _sync_sandbox_override("Context" ctx)
Definition commands.py:1082
str _message_content_to_text(object content)
Definition commands.py:183
None cmd_mode("Context" ctx, str raw)
Definition commands.py:1201
None cmd_budget("Context" ctx, str raw)
Definition commands.py:2484
None _write_ui_pref(session, str key, value)
Definition commands.py:1661
None _page_last_content(str content, *, bool markdown)
Definition commands.py:198
None cmd_read("Context" ctx, str raw)
Definition commands.py:941
dict|None _latest_message_of_role(list[dict] messages, str role)
Definition commands.py:157
None _show_resolved_output_caps("Context" ctx)
Definition commands.py:2396
DispatchResult _cmd_plan_new("Context" ctx, str desc)
Definition commands.py:1910
None cmd_model_info("Context" ctx, str raw)
Definition commands.py:778
None cmd_sandbox("Context" ctx, str raw)
Definition commands.py:1104
None cmd_trace("Context" ctx, str raw)
Definition commands.py:2359
None _cmd_plan_list("Context" ctx)
Definition commands.py:2206
None cmd_pro("Context" ctx, str _)
Definition commands.py:568
None cmd_system("Context" ctx, str raw)
Definition commands.py:906
None cmd_palette("Context" ctx, str raw)
Definition commands.py:1739
bool _cwd_inside_sandbox(object root)
Definition commands.py:1092
None cmd_endpoint("Context" ctx, str raw)
Definition commands.py:826
None cmd_flash("Context" ctx, str _)
Definition commands.py:574
None _cmd_plan_abort("Context" ctx)
Definition commands.py:2189
None cmd_lane("Context" ctx, str raw)
Definition commands.py:646
None cmd_models("Context" ctx, str _)
Definition commands.py:522
str _fmt_tokens_display(stats)
Definition commands.py:1073