oc_chat
Loading...
Searching...
No Matches
startup.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/startup.py
4
5"""
6Interactive REPL bootstrap — the session-selection menu and helpers.
7
8Purpose
9-------
10:func:`startup_session_prompt` is the front door the human sees at
11``oc_chat`` launch when no session was specified on the command
12line: continue the most recently modified session, choose another
13saved one, start fresh, or quit. Together with the four internal
14helpers (:func:`_load_session_by_meta`,
15:func:`_show_history_snippet`, :func:`_ingest_context_file`,
16:func:`_create_new_session`) it owns everything that happens
17between argparse and the first call to
18:func:`oc_chat.core.react_loop.run_turn`.
19
20Sub-agents skip all of this — they receive a constructed
21:class:`ChatSession` directly. Splitting startup out of
22``oc_chat/main.py`` is the single change that makes headless usage
23trivial; the upcoming :class:`Agent` abstraction (G.6.C) reuses the
24helpers here only when an interactive shell wants them.
25
26Responsibilities
27----------------
28- :func:`startup_session_prompt` — public entry point. Shows the
29 startup menu, returns a :class:`ChatSession` ready for the REPL.
30- :func:`_load_session_by_meta` — hydrate a saved session
31 (metadata + chat log) into a usable :class:`ChatSession`.
32- :func:`_show_history_snippet` — print the trailing N user /
33 assistant messages as a context-recall aid (currently unused;
34 kept for the option to re-enable it on session reload).
35- :func:`_read_workspace_file` — read an ``include_files`` entry for
36 prompt composition, hardened to the sandbox + protected-read class
37 (M8 F73; WU-2b).
38- :func:`_create_new_session` — lane/model drill-down, name prompt,
39 and :class:`ChatSession` construction.
40- :func:`_pick_lane_and_model` — the plan-217 picker as the
41 new-session lane/model choice (plan 220 P1); the flat
42 ``Available models:`` list it replaces is retired outright.
43- :func:`confirm_quit_or_continue` / :func:`_quit_confirmer` /
44 :func:`_interruptible` — the pre-session Ctrl+C state machine
45 (plan 220 P3).
46- :func:`_show_block` — emit a run of lines as ONE section so the
47 startup render is single-spaced (plan 220 P2).
48- :func:`_render_provider_lanes` / :func:`_lane_rows` — the opening
49 menu's provider-lane panel (plan 215 T3): every catalogue-backed
50 lane with its FREE/PAID badge, its honest verification tier, and
51 its key-availability marker.
52
53Diagnostics
54-----------
55Domain: OC_CHAT.MAIN
56Levels:
57 L2 — lifecycle events (menu shown, session loaded / created).
58 L3 — semantic details (model selection, name resolution).
59 L4 — deep tracing.
60
61Contracts
62---------
63- *version* is passed in by the caller (``main()``) rather than
64 resolved here. This keeps :mod:`oc_chat.core.startup` free of an
65 inverse dependency on :mod:`oc_chat.main` (where ``_VERSION`` is
66 bound at import time today; G.6.C relocates it to
67 ``oc_chat/core/_version.py``).
68- ``include_files`` ingestion is best-effort and sandbox-clamped
69 (M8 F73; WU-2b): a missing / binary / absolute / escaping /
70 secret-class file is logged and skipped, never raised and never
71 inserted into the provider-bound prompt.
72- Quitting the menu (option 4, EOF on the prompt, or a CONFIRMED
73 Ctrl+C) raises :class:`SystemExit(0)` — caller's ``except
74 SystemExit`` is the intended exit path for OC Chat.
75- **No interrupt sequence ends inside a session (plan 220 P3).** A
76 Ctrl+C at any pre-session prompt asks ``Quit OC Chat? (y/N)``;
77 ``N`` re-shows that same prompt unchanged, ``y`` and a second
78 interrupt both exit. Every branch therefore leads either back to
79 the prompt the operator interrupted or out of the program — never
80 onward — which is the property TUI-02 finding 3 was filed against.
81 The in-turn two-stage Ctrl+C machinery
82 (:mod:`oc_chat.core.session_transition`, the SIGINT handler) is
83 SEPARATE and untouched: it governs a session that already exists.
84- **One presenter call per section (plan 220 P2).** Every presenter
85 opens ``show_info`` with a blank line, so a menu emitted row by
86 row is a double-spaced menu. Sections go through
87 :func:`_show_block`; blank lines then separate sections only.
88- The plan-215 lane panel is ADDITIVE and duck-typed: it renders
89 nothing when no catalogue-backed provider is registered, so a
90 deepseek/ollama-only install produces a byte-identical menu.
91 ``startup`` never imports a provider bundle, and it reads only the
92 PRESENCE of an API-key env var, never its value.
93
94Dependencies
95------------
96- oc_diagnostics (structured debug logging via _dbg)
97- oc_chat.tools.filesystem (lazy; read_prompt_file — hardened include_files / system_prompt_file read, M8 F73)
98- oc_chat.core.persistence (lazy; session metadata and chat-log save/load)
99- oc_chat.core.session (ChatSession, CANONICAL_POLICY, helpers)
100- oc_chat.core.git_helpers (lazy; branch/status for the messages[1] environment block)
101- oc_chat.core.lane_tree (section_text and vendor_detail - single-spaced menu sections and the chosen model's vendor detail)
102- oc_ui.terminal.formatting (fmt_model_caps and fmt_tokens_or_size - the model and volume columns of the saved-session tables)
103- oc_chat.core.client_interface (lazy; ClientInterface, for type hints only)
104- oc_chat.core.external_content_gate (lazy; seed_record_binding - binds a resumed session id to its canonical record)
105- oc_chat.core.lane_overlay (lazy; register_overlay_lanes and OverlayError - operator-added overlay lanes at startup)
106- oc_chat.core.lane_picker (lazy; run_lane_picker - the lane and model drill-down for a new session and menu option 6)
107- oc_chat.core.models (lazy; ModelManager, for type hints only)
108- oc_chat.core.prompt_layers (lazy; session_anchors_block - the self-location block injected with the project tree)
109- oc_chat.core.provider_plugin (lazy; the provider registry behind the lane panel, and ProviderPlugin for type hints)
110- oc_chat.core.provider_secrets (lazy; apply_to_environ - sidecar API keys into the environment before the lane panel renders)
111- oc_chat.core.provider_wizard (lazy; run_add_provider_wizard - the Add provider menu entry)
112- oc_chat.core.settings (lazy; the active mode profile, runtime settings, mode name and system prompt for new sessions and the variable context)
113- oc_ui.presenter_base (lazy; Presenter, for type hints only)
114- oc_ui.terminal.input (lazy; read_single_key - single-key menu choices, and PromptDriver for type hints)
115- tzlocal (lazy; the optional IANA timezone name for the environment block)
116"""
117from __future__ import annotations
118
119import os
120from datetime import datetime
121from pathlib import Path
122from typing import TYPE_CHECKING
123
124from oc_diagnostics import _dbg
125
126from oc_chat.core.lane_tree import section_text, vendor_detail
127from oc_chat.core.session import (
128 ChatSession,
129 CANONICAL_POLICY,
130 apply_session_metadata_fields,
131 build_system_prompt,
132 ensure_system_includes_tool_reference,
133 _migrate_legacy_system_prompt,
134)
135from oc_ui.terminal.formatting import fmt_model_caps, fmt_tokens_or_size
136
137if TYPE_CHECKING:
138 from typing import Callable
139
140 from oc_chat.core.client_interface import ClientInterface
141 from oc_chat.core.models import ModelManager
142 from oc_chat.core.provider_plugin import ProviderPlugin
143 from oc_ui.presenter_base import Presenter
144 from oc_ui.terminal.input import PromptDriver
145
146
147_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
148
149
151 meta: dict,
152 presenter: "Presenter",
153 tool_schemas: list | None = None,
154) -> ChatSession:
155 """Load a full :class:`ChatSession` from a session metadata dict.
156
157 Reads the metadata and the latest chat log to populate messages,
158 stats, model, reasoning effort, and associated log paths.
159 """
160 func = "_load_session_by_meta"
161 from oc_chat.core.external_content_gate import seed_record_binding
162 from oc_chat.core.persistence import (
163 DEFAULT_SESSIONS_DIR, canonical_record_locator, load_chat_log,
164 )
165 from oc_chat.core.session import TokenStats
166
167 name = meta["name"]
168 # Plan 135 §3.5/§4.7: set the persisted model via the constructor — model
169 # writes go through ChatSession.set_model, never a raw attribute assignment.
170 # The variable context is refreshed in place after messages hydrate (below).
171 model = meta.get("model")
172 session = ChatSession(name=name, model=model) if model else ChatSession(name=name)
173 session.reasoning_effort = meta.get("reasoning_effort", session.reasoning_effort)
174 session.chat_logs = meta.get("chat_logs", [])
175 session.stats = TokenStats.from_dict(meta.get("stats", {}))
176 session.filepath = meta.get("filepath", None)
177 session.created = name.split("_", 1)[1] if "_" in name else "unknown"
178 session.ui_prefs = dict(meta.get("ui", {}) or {})
179 apply_session_metadata_fields(session, meta)
180 # Plan 152 R11-10 — seed the id -> canonical-record binding at this resume
181 # activation site. Without it, a process that starts on a COPIED record and
182 # establishes external-content trust would later accept the ORIGINAL record
183 # presenting the same id WITHOUT rotating that trust; "a fresh process starts
184 # with an empty trust set" stops applying once trust precedes the first live
185 # load. This is the one authorised addition here — the metadata call above and
186 # its sandbox projection are deliberately unchanged.
187 # ``filepath`` is the meta path synthesised by ``list_sessions``; the locator
188 # is its owning session DIRECTORY. Fall back to the store path for the bare
189 # -metadata callers that carry no filepath. Read-only: nothing is created.
190 _record = session.filepath or str(Path(DEFAULT_SESSIONS_DIR) / name)
191 seed_record_binding(session.session_id, canonical_record_locator(Path(_record)))
192 session.dirty = False
193
194 # Load the most-recent chat log inside the session directory.
195 try:
196 log_data = load_chat_log(name)
197 session.messages = log_data.get("messages", session.messages)
198 except FileNotFoundError as e:
199 # VALIDATION_ERROR — metadata exists but log is missing on disk; start with fresh context
200 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {name}: {type(e).__name__}: {e}", 3)
201 presenter.show_info(
202 f"[Note: Session '{name}' has no chat log on disk. Starting with fresh context.]"
203 )
204 except Exception as exc:
205 # SYSTEM_ERROR — a chat log exists but couldn't be parsed; flag load_failed so saves
206 # don't overwrite the on-disk file, then surface to the user.
207 _dbg("OC_CHAT.MAIN", func,
208 f"SYSTEM_ERROR: parse {name}: {type(exc).__name__}: {exc}", 1, override=True)
209 session.load_failed = True
210 presenter.show_error(
211 f"[Warning: Could not parse chat log for session '{name}' ({exc}). "
212 f"On-disk chat log will NOT be overwritten on save until reload succeeds.]"
213 )
214
215 presenter.show_info(
216 f"Loaded session '{name}' — "
217 f"{session.stats.message_count} messages, "
218 f"{session.stats.tool_call_count} tool calls, "
219 f"{session.stats.total_tokens:,} tokens "
220 f"(model: {session.model})"
221 )
222 # Plan 135 §4.3: convert any legacy embedded-schema messages[0] (saved by a
223 # pre-135 build) to the slim policy + compact reference, then ensure the
224 # reference is present. Both steps are idempotent on already-migrated sessions.
225 _migrate_legacy_system_prompt(session, tool_schemas)
226 ensure_system_includes_tool_reference(session, tool_schemas)
227
228 # Plan 135 §3.5: refresh the loaded variable context IN PLACE (preserves all
229 # conversation history; no index-shifting pop + re-append). Inject only when
230 # the saved session predates variable context (no sentinel anywhere).
231 from oc_chat.core.session import VARIABLE_CONTEXT_SENTINEL
232 has_varctx = any(
233 m.get("role") == "user"
234 and (m.get("content") or "").startswith(VARIABLE_CONTEXT_SENTINEL)
235 for m in session.messages
236 )
237 if has_varctx:
239 else:
241
242 # _show_history_snippet(session, presenter, n=3)
243 return session
244
245
246def _show_history_snippet(session: ChatSession, presenter: "Presenter", n: int = 3) -> None:
247 """Display the last *n* user/assistant messages as a context-recall aid.
248
249 Tool messages and system prompts are skipped; very long content is
250 truncated to keep the snippet scannable.
251 """
252 visible = [m for m in session.messages if m.get("role") in ("user", "assistant")]
253 if not visible:
254 return
255 trailing = visible[-n:]
256 omitted = len(visible) - len(trailing)
257 header = f"(... {omitted} earlier message(s); last {len(trailing)} shown:)" if omitted else \
258 f"(showing last {len(trailing)} message(s):)"
259 presenter.show_info(f"\n{header}")
260 for m in trailing:
261 role = m.get("role", "?")
262 content = (m.get("content") or "").strip()
263 if not content:
264 continue
265 first_line = content.splitlines()[0]
266 if len(first_line) > 100:
267 first_line = first_line[:97] + "…"
268 label = "You" if role == "user" else "Assistant"
269 presenter.show_info(f" {label:<10} {first_line}")
270
271
272def _read_workspace_file(filepath: str) -> str | None:
273 """Best-effort read of an ``include_files`` entry for prompt composition.
274
275 Hardened to the effective sandbox root + protected-read class (M8 F73; M8.0
276 WU-2b): an absolute path, a sandbox escape, or a secret-class file is refused
277 and never inserted into the provider-bound prompt. Returns the file's
278 (secret-redacted) text, or ``None`` when missing / empty / refused.
279 """
280 func = "_read_workspace_file"
281 try:
282 from oc_chat.tools.filesystem import read_prompt_file
283 return read_prompt_file(filepath)
284 except PermissionError as exc:
285 _dbg("OC_CHAT.MAIN", func, f"refused prompt-composition read {filepath!r}: {exc}", 2)
286 return None
287 except (OSError, UnicodeDecodeError):
288 return None
289
290
291def _local_tz_name() -> str:
292 """Best-effort local timezone name (no fabrication).
293
294 Prefers an IANA name via the optional ``tzlocal`` dependency; otherwise
295 falls back to the system's local tzname. The ISO datetime in the ``Date:``
296 field already carries the numeric UTC offset, so this is supplementary.
297 """
298 try: # optional — only if the environment ships tzlocal
299 from tzlocal import get_localzone_name # type: ignore[import-not-found]
300
301 name = get_localzone_name()
302 if name:
303 return name
304 except Exception: # noqa: BLE001 — degrade to the stdlib name, never raise
305 pass
306 return datetime.now().astimezone().tzname() or "UTC"
307
308
309#: Canonical render order for the messages[1] ``## Environment`` block (plan 135
310#: §3.5). Rendering follows this order regardless of the mode's ``env_fields``
311#: list order so the variable layer stays stable across renders.
312_ENV_FIELD_ORDER: tuple[str, ...] = (
313 "date", "timezone", "cwd", "branch", "git", "model", "mode",
314)
315
316
317def _render_environment_block(session: ChatSession, profile: dict) -> str | None:
318 """Render the active mode's ``## Environment`` sublayer, or ``None`` (plan 135 §3.5).
319
320 Emits only the fields named in the mode's ``env_fields`` (in the canonical
321 ``_ENV_FIELD_ORDER``). Volatile facts live here in ``messages[1]`` — never in
322 the immutable ``messages[0]`` prefix. Git facts come from :mod:`git_helpers`
323 (short-timeout, never raises). Returns ``None`` when ``include_env`` is false
324 or no recognised fields are selected.
325 """
326 func = "_render_environment_block"
327 if not profile.get("include_env", False):
328 return None
329 selected = {str(f).strip() for f in (profile.get("env_fields") or [])}
330 if not selected:
331 return None
332
333 from oc_chat.core.git_helpers import git_current_branch, git_status_summary
334 from oc_chat.core.settings import runtime_mode
335
336 renderers = {
337 "date": lambda: f"Date: {datetime.now().astimezone().replace(microsecond=0).isoformat()}",
338 "timezone": lambda: f"Timezone: {_local_tz_name()}",
339 "cwd": lambda: f"CWD: {Path.cwd()}",
340 "branch": lambda: f"Git branch: {git_current_branch()}",
341 "git": lambda: f"Git status: {git_status_summary()}",
342 "model": lambda: f"Model: {session.model}",
343 "mode": lambda: f"Mode: {runtime_mode()}",
344 }
345 lines = [renderers[f]() for f in _ENV_FIELD_ORDER if f in selected]
346 if not lines:
347 _dbg("OC_CHAT.MAIN", func, f"env_fields had no recognised field: {sorted(selected)}", 3)
348 return None
349 return "## Environment\n" + "\n".join(lines)
350
351
352def _compose_variable_context(session: ChatSession) -> str | None:
353 """Compose the full ``messages[1]`` content (sentinel + env + tree + files).
354
355 Mode-driven (plan 133/135): the active mode's profile decides the optional
356 ``## Environment`` block (date/cwd/branch/git/model/mode per ``env_fields``),
357 whether to inject the project tree, and which files (``include_files``) to
358 ingest. Returns ``None`` when the active mode contributes nothing beyond the
359 sentinel (a context-free mode). All volatile facts live here, never in the
360 immutable ``messages[0]`` prefix.
361 """
362 func = "_compose_variable_context"
363 from oc_chat.core.session import VARIABLE_CONTEXT_SENTINEL, generate_project_tree
364 from oc_chat.core.settings import active_profile, runtime_settings
365
366 profile = active_profile()
367 settings = runtime_settings()
368 parts: list[str] = [VARIABLE_CONTEXT_SENTINEL]
369
370 env_block = _render_environment_block(session, profile)
371 if env_block:
372 parts.append(env_block)
373
374 if profile.get("include_project_tree"):
375 pt = settings.project_tree
376 tree = generate_project_tree(
377 max_depth=int(pt.get("max_depth", 2)),
378 exclude_dirs=pt.get("exclude_dirs"),
379 exclude_suffixes=pt.get("exclude_suffixes"),
380 stub_dirs=pt.get("stub_dirs"),
381 honor_gitignore=pt.get("honor_gitignore"),
382 work_dir=pt.get("work_dir"),
383 )
384 if tree:
385 parts.append(tree)
386 # Plan 157 (gate-wired): the session-anchors block rides WITH the
387 # project tree — every tree-injecting mode gets self-location (mining
388 # finding F8: workers could not answer "where am I?"); context-free
389 # modes stay context-free.
390 from oc_chat.core.prompt_layers import session_anchors_block
391 parts.append(session_anchors_block())
392
393 for filepath in (profile.get("include_files") or []):
394 filepath = str(filepath).strip()
395 if not filepath:
396 continue
397 content = _read_workspace_file(filepath)
398 if content:
399 parts.append(f"[{filepath} — auto-ingested at session start]\n\n{content}")
400 else:
401 _dbg("OC_CHAT.MAIN", func, f"include_files: {filepath!r} not found / empty", 3)
402
403 if len(parts) <= 1: # nothing beyond the sentinel
404 return None
405 return "\n\n".join(parts)
406
407
408def _inject_variable_context(session: ChatSession) -> None:
409 """Append the active mode's variable context as a user message at ``[1]``.
410
411 No-op when the active mode contributes nothing (context-free mode). Used on
412 session create/load; for in-place refresh on model switch see
413 :func:`refresh_variable_context`.
414 """
415 content = _compose_variable_context(session)
416 if content is not None:
417 session.messages.append({"role": "user", "content": content})
418 session.dirty = True
419
420
421def refresh_variable_context(session: ChatSession) -> None:
422 """Re-render the sentinel-marked ``messages[1]`` in place (plan 135 §3.5).
423
424 Locates the variable-context message by ``VARIABLE_CONTEXT_SENTINEL`` and
425 replaces ONLY that message, preserving all conversation history. No-op when
426 no sentinel-marked message exists — never inserts into an active conversation
427 (that would shift indices). The choke-point :meth:`ChatSession.set_model`
428 calls this so ``Model:`` (and the git/dirty facts) stay accurate after a
429 ``/model`` / ``/pro`` / ``/flash`` / resume switch.
430 """
431 from oc_chat.core.session import VARIABLE_CONTEXT_SENTINEL
432
433 sentinel = VARIABLE_CONTEXT_SENTINEL
434 idx = next(
435 (
436 i for i, m in enumerate(session.messages)
437 if m.get("role") == "user"
438 and (m.get("content") or "").startswith(sentinel)
439 ),
440 None,
441 )
442 if idx is None:
443 return # no variable context to refresh — do not insert one
444 content = _compose_variable_context(session)
445 if content is None:
446 return # mode now contributes nothing; leave the existing message intact
447 if session.messages[idx].get("content") != content:
448 session.messages[idx]["content"] = content
449 session.dirty = True
450
451
453 session: ChatSession,
454 provider: "ProviderPlugin",
455 presenter: "Presenter | None" = None,
456 *,
457 capture: bool = False,
458) -> "str | None":
459 """Migrate a retired session model id to its replacement on load (plan 135 §4.6).
460
461 Provider-agnostic: delegates to ``provider.migrate_model_id``. On a hit it
462 switches through the :meth:`ChatSession.set_model` choke-point (which refreshes
463 the env block) and surfaces the provider's distinct one-line notice. No-op for
464 live model ids — the common case, with zero overhead.
465
466 ``capture`` (plan 152-b D2) is passed ONLY by the load/switch transition
467 seam, which hydrates a CANDIDATE during PREPARE where the presenter must stay
468 silent. When ``capture=True`` the notice is RETURNED instead of shown (and the
469 seam replays it post-commit), and ``presenter`` may be ``None``. The default
470 (``capture=False``) is byte-compatible: it calls ``presenter.show_info`` and
471 returns ``None``, exactly as before, for its three non-seam callers.
472 """
473 func = "migrate_session_model"
474 result = provider.migrate_model_id(session.model)
475 if result is None:
476 return None
477 new_id, notice = result
478 session.set_model(new_id)
479 _dbg("OC_CHAT.MAIN", func, f"migrated retired session model -> {new_id}", 2)
480 if capture:
481 return notice
482 if presenter is not None:
483 presenter.show_info(notice)
484 return None
485
486
488 presenter: "Presenter",
489 prompt_driver: "PromptDriver | None",
490 model_manager: "ModelManager | None",
491 lane_switch: "Callable[[object], object] | None",
492 on_interrupt: "Callable[[], None]",
493) -> "str | None":
494 """Choose the new session's lane and model through the plan-217 picker.
495
496 Plan 220 P1 / TUI-02 finding 1. What used to stand here was a flat
497 ``Available models:`` list — 396 rows deep on the OpenRouter lane,
498 which is a list nobody reads and a keystroke nobody can undo. The
499 drill-down is the same one ``[6]`` and ``/lane`` already use, so the
500 new-session choice and the mid-session switch are one surface with one
501 set of habits, and the flat list is retired rather than merely bypassed.
502
503 Returns the chosen model id, or ``None`` when the operator abandoned
504 the traversal or the lane refused the switch. Both are DECISIONS, not
505 absences, so the caller goes back to the menu instead of defaulting
506 into a session — the fall-through half of finding 3.
507 """
508 func = "_pick_lane_and_model"
509 from oc_chat.core.lane_picker import run_lane_picker
510
511 selection = run_lane_picker(
512 presenter, prompt_driver=prompt_driver, on_interrupt=on_interrupt
513 )
514 if selection is None:
515 _dbg("OC_CHAT.MAIN", func, "picker abandoned - no new session", 2)
516 return None
517
518 if lane_switch is not None:
519 try:
520 lane_switch(selection)
521 except Exception as exc: # noqa: BLE001 — a refused lane must not kill startup
522 # EXTERNAL_ERROR / VALIDATION_ERROR (S6 L2/L3) depending on the
523 # lane's complaint; either way nothing was installed and the
524 # opening menu is still usable.
525 _dbg("OC_CHAT.MAIN", func,
526 f"lane switch refused: {type(exc).__name__}: {exc}", 2, override=True)
527 presenter.show_error(f" Could not switch lane: {exc}")
528 return None
529 elif model_manager is not None and hasattr(model_manager, "ensure"):
530 # No applier wired (a headless or directly-constructed caller): the
531 # active lane cannot be swapped, but the model registry can still
532 # learn the id the session is about to be created on.
533 model_manager.ensure(selection.model_id)
534
535 detail = vendor_detail(selection.model_id)
536 presenter.show_info(
537 f"Lane: {selection.lane_name} model: {selection.model_id}"
538 + (f" ({detail})" if detail else "")
539 )
540 _dbg("OC_CHAT.MAIN", func,
541 f"new session lane={selection.lane_name} model={selection.model_id}", 2)
542 return selection.model_id
543
544
546 presenter: "Presenter",
547 tool_schemas: list | None = None,
548 prompt_driver: "PromptDriver | None" = None,
549 model_manager: "ModelManager | None" = None,
550 client: "ClientInterface | None" = None,
551 *,
552 model: str = "",
553 lane_switch: "Callable[[object], object] | None" = None,
554 on_interrupt: "Callable[[], None] | None" = None,
555) -> "ChatSession | None":
556 """Pick a lane and model in the picker, name the session, and build it.
557
558 Plan 220 P1: the model choice is :func:`_pick_lane_and_model` now, so
559 ``client`` is no longer used to fetch a live list at startup — the
560 picker refreshes on its explicit action only. The parameter stays for
561 the callers that already pass it.
562
563 *model* names the model outright and SKIPS the picker (plan 220 P1).
564 It exists for the non-interactive caller that already knows its answer
565 — :func:`oc_chat.benchmarks.runner._compose_session`, which composes a
566 measured session through this real path. Before 220 that caller said so
567 by scripting the flat list's prompt with a canned driver; with the list
568 retired, scripting an interactive drill-down instead would be both
569 fragile and a lie about what it wants. Saying it directly is the honest
570 form, and it keeps the benchmark composing through the same function
571 the REPL does rather than a divergent copy.
572
573 Returns ``None`` when the operator abandoned the lane choice: there is
574 no model to open a session on, and inventing one is precisely the
575 fall-through TUI-02 finding 3 is about.
576 """
577 func = "_create_new_session"
578 from oc_chat.core.session import _auto_name
579
580 if on_interrupt is None:
581 on_interrupt = _quit_confirmer(presenter, prompt_driver)
582
583 if model:
584 selected_model = model
585 _dbg("OC_CHAT.MAIN", func, f"model supplied by caller: {model!r}", 3)
586 else:
587 selected_model = _pick_lane_and_model(
588 presenter, prompt_driver, model_manager, lane_switch, on_interrupt
589 )
590 if selected_model is None:
591 return None
592
593 def _read_name() -> str:
594 return _read_line(
595 prompt_driver,
596 "Enter a name for the new session (or press Enter for auto-generated): ",
597 )
598
599 name = _interruptible(_read_name, on_interrupt).strip()
600 if not name:
601 name = _auto_name()
602 presenter.show_info(f"Using auto-generated name: {name}")
603
604 # Compose the system prompt per the active mode's profile (plan 133):
605 # persona + tools are gated; the variable context (tree + files) is added
606 # by _inject_variable_context, also mode-driven.
607 from oc_chat.core.settings import (
608 active_profile, runtime_settings, runtime_mode, resolve_system_prompt,
609 )
610 profile = active_profile()
611 settings = runtime_settings()
612 base = resolve_system_prompt(settings, default=CANONICAL_POLICY) if profile.get("include_persona") else ""
613 schemas = tool_schemas if profile.get("include_tools") else None
614 system_text = build_system_prompt(base, schemas)
615 session = ChatSession(
616 name=name,
617 messages=[{"role": "system", "content": system_text}],
618 model=selected_model,
619 )
620 session.created = name.split("_", 1)[1] if "_" in name else "unknown"
621 for w in settings.drain_warnings():
622 presenter.show_info(f"[settings] {w}")
623 _dbg("OC_CHAT.MAIN", func, f"new session={name} model={selected_model} mode={runtime_mode()}", 2)
624 presenter.show_info(f"Starting new session: {name} (model: {session.model}, mode: {runtime_mode()})")
626 return session
627
628
629def _menu_key(prompt_driver, prompt_text: str, *, valid: str, default: str) -> str:
630 """Read a single-key menu choice (no Enter needed); Enter → *default*.
631
632 Engages raw single-key mode on an interactive TTY; otherwise falls back to a
633 line read via *prompt_driver* (or :func:`input`) so tests / pipes / headless
634 runs stay behaviour-identical. *valid* restricts the accepted keys.
635
636 Plan 220 P3 — THE swallow point. On the fallback path a
637 :class:`~oc_ui.terminal.input.PromptDriver` reports a Ctrl+C by
638 RETURNING ``None``, which :func:`read_single_key` then read as an empty
639 line and this function turned into *default*. That is how an interrupt
640 became a selection, and how two of them walked the operator into a
641 session. ``None`` is now raised as the :class:`KeyboardInterrupt` it
642 always was; every pre-session caller answers it through
643 :func:`confirm_quit_or_continue`.
644 """
645 from oc_ui.terminal.input import read_single_key
646
647 def _fallback() -> str:
648 if prompt_driver is not None:
649 raw = prompt_driver.read_user_input(None, prompt_text="")
650 if raw is None:
651 raise KeyboardInterrupt
652 return raw
653 return input("")
654
655 key = read_single_key(prompt_text, valid=valid, fallback=_fallback)
656 return key or default
657
658
659def _read_line(prompt_driver, prompt_text: str) -> str:
660 """Read one line at a pre-session prompt; a cancel raises, never defaults.
661
662 The line-read twin of :func:`_menu_key`'s fallback, and the same fix:
663 ``read_user_input`` returns ``None`` for a Ctrl+C at a menu prompt, and
664 every pre-session caller used to coalesce that with an empty line via
665 ``or ""``. An empty line means "take the default"; an interrupt means
666 the opposite, and conflating them is TUI-02 finding 3.
667 """
668 if prompt_driver is None:
669 return input(prompt_text)
670 raw = prompt_driver.read_user_input(None, prompt_text=prompt_text)
671 if raw is None:
672 raise KeyboardInterrupt
673 return raw
674
675
677 presenter: "Presenter", prompt_driver: "PromptDriver | None"
678) -> None:
679 """Answer a pre-session Ctrl+C with ``Quit OC Chat? (y/N)`` (plan 220 P3).
680
681 Returns on ``N`` / a bare Enter, and the caller then re-shows the prompt
682 that was interrupted with its state untouched — an interrupt asks a
683 question, it never answers one.
684
685 Raises :class:`SystemExit(0)` on ``y``, and also on a second interrupt
686 or an EOF at the confirmation itself: the safe direction out of an
687 interrupted pre-session prompt is OUT of the program, never onward into
688 a session. That asymmetry is the whole point of the finding — the
689 operator could press Ctrl+C repeatedly and end up in a chat.
690
691 Public because ``main`` owns two pre-session prompts of its own (the
692 crash auto-reload and the incomplete-tail recovery) and must answer an
693 interrupt at them the same way this module does.
694 """
695 func = "confirm_quit_or_continue"
696 try:
697 answer = _read_line(prompt_driver, "Quit OC Chat? (y/N): ").strip().lower()
698 except (EOFError, KeyboardInterrupt):
699 _dbg("OC_CHAT.MAIN", func, "interrupt at the quit confirmation - exiting", 2)
700 presenter.show_info("Exiting.")
701 raise SystemExit(0)
702 if answer in ("y", "yes"):
703 _dbg("OC_CHAT.MAIN", func, "quit confirmed at a pre-session prompt", 2)
704 presenter.show_info("Goodbye!")
705 raise SystemExit(0)
706 _dbg("OC_CHAT.MAIN", func, "quit declined - re-showing the interrupted prompt", 3)
707
708
710 presenter: "Presenter", prompt_driver: "PromptDriver | None"
711) -> "Callable[[], None]":
712 """Bind :func:`confirm_quit_or_continue` to this menu's presenter/driver."""
713
714 def _handler() -> None:
715 confirm_quit_or_continue(presenter, prompt_driver)
716
717 return _handler
718
719
720def _interruptible(read: "Callable[[], str]", on_interrupt: "Callable[[], None]") -> str:
721 """Run *read*; a Ctrl+C is answered by *on_interrupt*, then re-asked.
722
723 *read* is re-invoked rather than resumed, which is what makes "``N``
724 re-shows the interrupted prompt UNCHANGED" structural: the closure is
725 the prompt, so re-running it cannot disagree with what was on screen.
726 """
727 while True:
728 try:
729 return read()
730 except KeyboardInterrupt:
731 on_interrupt()
732
733
734def _show_block(presenter: "Presenter", lines: "list[str]") -> None:
735 """Emit *lines* as ONE section — single-spaced, one blank line above it.
736
737 See :func:`~oc_chat.core.lane_tree.section_text` for why: the blank line
738 every presenter opens ``show_info`` with belongs once per SECTION, not
739 once per row (plan 220 P2 / TUI-02 finding 2). An empty run shows
740 nothing at all, which is what keeps the plan-215 lane panel invisible
741 without a catalogue-backed bundle.
742 """
743 block = section_text(lines)
744 if block:
745 presenter.show_info(block)
746
747
748#: Column widths for the lane panel. Fixed rather than computed so the panel
749#: does not reflow when a lane is added or a key is set (plan 215 T3).
750_LANE_NAME_W = 18
751_LANE_LABEL_W = 30
752
753
754def _lane_rows(providers: "list[ProviderPlugin]") -> list:
755 """Return the catalogue-backed lane rows among *providers*, in menu order.
756
757 Duck-typed on purpose (plan 215 P3): a provider bundle contributes a lane
758 row by exposing a ``lane`` attribute carrying the menu metadata. This
759 keeps ``startup`` free of any import of the optional bundle — the panel
760 simply has nothing to render when the bundle is not installed, which is
761 what makes the deepseek/ollama menu byte-identical to its pre-215 render.
762
763 Menu order is a PRESENTATION decision owned here, not by the catalogue:
764 free lanes first, then alphabetically within each group. The free lanes
765 are the ones a new operator can actually try today, so they lead; the
766 ordering is total and deterministic, so the panel never reflows because
767 a registry happened to enumerate differently.
768 """
769 rows = []
770 for provider in providers:
771 lane = getattr(provider, "lane", None)
772 if lane is None:
773 continue
774 if not all(hasattr(lane, f) for f in ("display_label", "free", "verification")):
775 continue
776 rows.append(lane)
777 return sorted(rows, key=lambda lane: (not lane.free, lane.name))
778
779
781 """Whether the opening menu should offer the "Add provider" entry (plan 216).
782
783 True exactly when a catalogue-backed lane is registered — i.e. when
784 there is a catalogue for the wizard to extend. This is not a
785 convenience: an overlay row is registered as an
786 ``OpenAICompatProvider``, so without the bundle the wizard has nothing
787 to write into. It is also what preserves plan 215's additive property
788 — a deepseek/ollama-only install grows no fifth menu entry and its
789 render stays byte-identical to the pre-215 golden.
790 """
791 from oc_chat.core.provider_plugin import registry
792
793 return bool(_lane_rows([registry.get(name) for name in registry.names()]))
794
795
796def _load_provider_overlays(presenter: "Presenter") -> None:
797 """Startup hook (plan 216): sidecar keys into the environment, overlay lanes in.
798
799 Runs before the lane panel renders so a wizard-added lane shows up
800 with its key already marked ``ready``. Both halves are total:
801
802 - The sidecar load never raises and never overwrites a variable the
803 real environment already carries (P1).
804 - A malformed overlay is REPORTED rather than swallowed — a lane the
805 operator added must not vanish silently — but it does not stop the
806 REPL from starting.
807
808 Silent on the common path, which is what keeps the menu golden intact.
809 """
810 func = "_load_provider_overlays"
811 from oc_chat.core.lane_overlay import OverlayError, register_overlay_lanes
812 from oc_chat.core.provider_secrets import apply_to_environ
813
814 apply_to_environ()
815 try:
816 registered = register_overlay_lanes()
817 except OverlayError as exc:
818 # VALIDATION_ERROR — operator-authored data; surface it, keep going.
819 presenter.show_error(f"[lane overlay] {exc}")
820 return
821 if registered:
822 _dbg("OC_CHAT.MAIN", func,
823 f"overlay contributed {len(registered)} lane(s)", 2)
824
825
826def _render_provider_lanes(env: "dict | None" = None) -> list[str]:
827 """Render the opening menu's provider-lane panel as a list of lines.
828
829 Returns ``[]`` when no catalogue-backed lane is registered, so a install
830 without the ``openai_compat`` bundle renders the menu exactly as it did
831 before plan 215.
832
833 Every registered lane is shown — never hidden (plan 215 P5). A lane whose
834 API-key env var is unset is marked ``no key (VAR_NAME)`` and stays
835 visible, because a lane the operator cannot see is a lane they cannot fix.
836 Badges are read from the catalogue row: ``FREE`` on a free lane, ``PAID``
837 otherwise, plus the honest ``verification`` tier (``chat`` until a human
838 has run the conformance runner against a live key).
839
840 *env* defaults to the live environment; tests pass an explicit mapping.
841 Only the PRESENCE of each key variable is read — never its value.
842 """
843 func = "_render_provider_lanes"
844 from oc_chat.core.provider_plugin import registry
845
846 environ = os.environ if env is None else env
847 providers = [registry.get(name) for name in registry.names()]
848 lanes = _lane_rows(providers)
849 if not lanes:
850 return []
851
852 # OCT-LINT: disable=no_hardcoded_secrets reason="Menu header text naming the selection env var; high-entropy English, not a credential"
853 lines = ["", " Provider lanes - select one with OC_CHAT_PROVIDER=<name>:"]
854 for lane in lanes:
855 badge = "FREE" if lane.free else "PAID"
856 # Presence only: the VALUE is never read into the menu, printed, or
857 # retained anywhere (plan 215 P6).
858 has_key = bool((environ.get(lane.api_key_env_var) or "").strip())
859 status = "ready" if has_key else f"no key ({lane.api_key_env_var})"
860 lines.append(
861 f" {lane.name:<{_LANE_NAME_W}}{lane.display_label:<{_LANE_LABEL_W}}"
862 f"{badge:<6}verified: {lane.verification:<7}{status}"
863 )
864 lines.append("")
865 _dbg("OC_CHAT.MAIN", func, f"rendered {len(lanes)} provider lane(s)", 3)
866 return lines
867
868
869def _sort_sessions(sessions: list[dict], mode: str) -> list[dict]:
870 """Return *sessions* sorted by *mode*.
871
872 ``"date"`` (default) → most-recently-modified first; ``"name"`` →
873 case-insensitive alphabetical. Date uses the metadata file mtime, the same
874 key :func:`startup_session_prompt` uses to pick the most-recent session.
875 """
876 if mode == "name":
877 return sorted(sessions, key=lambda s: s["name"].lower())
878 return sorted(sessions, key=lambda s: os.path.getmtime(s["filepath"]), reverse=True)
879
880
882 sessions: list[dict],
883 presenter: "Presenter",
884 model_manager: "ModelManager | None",
885 tool_schemas: list | None,
886 prompt_driver: "PromptDriver | None",
887 on_interrupt: "Callable[[], None] | None" = None,
888) -> "ChatSession | None":
889 """Option 3 — list the saved sessions (sortable) and load the chosen one.
890
891 Returns the loaded :class:`ChatSession`, or ``None`` to go back to the main
892 menu. The list defaults to most-recent-first; ``n`` re-sorts by name and
893 ``d`` back to date (the displayed ``#`` always indexes the shown order).
894
895 Plan 220: the table is one section (P2), and a Ctrl+C at its prompt is a
896 pre-session interrupt like any other (P3) — a bare Enter is what means
897 "back", and an interrupt must not be quietly read as one.
898 """
899 from oc_chat.core.persistence import session_exists
900
901 if on_interrupt is None:
902 on_interrupt = _quit_confirmer(presenter, prompt_driver)
903
904 sort_mode = "date"
905 while True:
906 ordered = _sort_sessions(sessions, sort_mode)
907 sort_label = "name (A–Z)" if sort_mode == "name" else "date (newest first)"
908 lines = [
909 "",
910 f"Saved sessions — sorted by {sort_label}:",
911 f"{'#':>3} {'Name':<30} {'Model':<34} {'Msgs':>4} {'Tools':>5} "
912 f"{'Volume':>14} Last Modified",
913 "-" * 112,
914 ]
915 for i, s in enumerate(ordered, 1):
916 stats = s["stats"]
917 size = s.get("chat_log_size_bytes", 0)
918 model_label = fmt_model_caps(
919 s["model"], s.get("reasoning_effort", "high"), model_manager
920 )
921 if len(model_label) > 34:
922 model_label = model_label[:33] + "…"
923 vol = fmt_tokens_or_size(stats, size)
924 lines.append(
925 f"{i:>3} {s['name']:<30} {model_label:<34} "
926 f"{stats.message_count:>4} {stats.tool_call_count:>5} "
927 f"{vol:>14} {s['last_modified']}"
928 )
929 lines.append("")
930 _show_block(presenter, lines)
931
932 prompt = "Enter session # or name · 'n'=sort by name · 'd'=by date · Enter=back: "
933
934 def _read_choice() -> str:
935 return _read_line(prompt_driver, prompt)
936
937 try:
938 raw = _interruptible(_read_choice, on_interrupt).strip()
939 except EOFError:
940 # A closed stdin is not an operator decision — go back, silently.
941 return None
942
943 if not raw:
944 presenter.show_info("Returning to session selection menu...")
945 return None
946
947 low = raw.lower()
948 if low == "n":
949 sort_mode = "name"
950 continue
951 if low == "d":
952 sort_mode = "date"
953 continue
954
955 # Numeric shortcut: 1–3 digit number indexes the displayed (sorted) table.
956 if raw.isdigit() and len(raw) <= 3:
957 idx = int(raw)
958 if 1 <= idx <= len(ordered):
959 return _load_session_by_meta(ordered[idx - 1], presenter, tool_schemas)
960 presenter.show_info(f" No session at index {idx} (range 1–{len(ordered)}).")
961 continue
962
963 if not session_exists(raw):
964 presenter.show_info(f"Session '{raw}' not found. (Enter to go back.)")
965 continue
966 match = next((s for s in ordered if s["name"] == raw), None)
967 if match:
968 return _load_session_by_meta(match, presenter, tool_schemas)
969
970
972 presenter: "Presenter",
973 prompt_driver: "PromptDriver | None",
974 lane_switch: "Callable[[object], object] | None",
975 on_interrupt: "Callable[[], None] | None" = None,
976) -> "tuple[ModelManager | None, ClientInterface | None] | None":
977 """Menu option 6 (plan 217 T4): drill down to a lane + model, then switch.
978
979 Returns the ``(model_manager, client)`` pair the applier rebuilt, so the
980 caller can rebind its own locals — a stale ``model_manager`` here would
981 make the very next new-session prompt list the OLD lane's models.
982 ``None`` means nothing changed: the operator abandoned the picker, or
983 the switch was refused.
984
985 The picker itself mutates nothing (plan 217 P4); everything that does
986 happens inside *lane_switch*, which is only ever reached with a
987 confirmed selection.
988 """
989 func = "_run_lane_picker_entry"
990 from oc_chat.core.lane_picker import run_lane_picker
991
992 selection = run_lane_picker(
993 presenter, prompt_driver=prompt_driver, on_interrupt=on_interrupt
994 )
995 if selection is None or lane_switch is None:
996 return None
997 try:
998 rebuilt = lane_switch(selection)
999 except Exception as exc: # noqa: BLE001 — a refused switch must not kill the menu
1000 # EXTERNAL_ERROR / VALIDATION_ERROR (S6 L2/L3) depending on the lane's
1001 # complaint; either way the old lane is intact and the menu carries on.
1002 _dbg("OC_CHAT.MAIN", func,
1003 f"lane switch refused: {type(exc).__name__}: {exc}", 2, override=True)
1004 presenter.show_error(f" Could not switch lane: {exc}")
1005 return None
1006 presenter.show_info(
1007 f" Lane set to {selection.lane_name} (model: {selection.model_id})."
1008 )
1009 _dbg("OC_CHAT.MAIN", func,
1010 f"switched to {selection.lane_name}/{selection.model_id}", 2)
1011 return rebuilt # type: ignore[return-value]
1012
1013
1015 presenter: "Presenter",
1016 *,
1017 version: str,
1018 model_manager: "ModelManager | None" = None,
1019 tool_schemas: list | None = None,
1020 prompt_driver: "PromptDriver | None" = None,
1021 client: "ClientInterface | None" = None,
1022 lane_switch: "Callable[[object], object] | None" = None,
1023) -> ChatSession:
1024 """Present the startup session-selection menu at OC Chat launch.
1025
1026 If saved sessions exist, the user can:
1027 1. Start a brand-new session (the default — a bare Enter picks it).
1028 2. Resume the most recently modified session.
1029 3. Choose another saved session (sortable by date or name).
1030 4. Quit OC Chat.
1031 5. Add a provider (only with a catalogue-backed lane registered).
1032 6. Choose a lane and model (only with both a lane AND *lane_switch*).
1033
1034 Menu choices read as a single keypress on an interactive TTY (no Enter
1035 needed); Enter alone selects option 1. If no sessions exist, the user is
1036 taken directly to the new-session lane picker and name prompt.
1037
1038 Plan 220: option 1 (and the no-sessions path) drill down through the
1039 plan-217 picker rather than printing a flat model list (P1); the menu
1040 is emitted as ONE section so it renders single-spaced (P2); and a
1041 Ctrl+C at ANY prompt below asks ``Quit OC Chat? (y/N)`` instead of
1042 firing a default (P3). The invariant those three protect together is
1043 that no interrupt sequence, of any length, ends inside a session.
1044
1045 *lane_switch* (plan 217 T4) applies a confirmed
1046 :class:`~oc_chat.core.lane_picker.LaneSelection` and returns the
1047 rebuilt ``(model_manager, client)`` pair. It is supplied by ``main()``,
1048 which owns the :class:`~oc_chat.core.agent.Agent` whose engine objects
1049 the switch replaces. Entry 6 is offered ONLY when it is present: a
1050 caller that cannot apply a switch must not offer to make one.
1051
1052 Returns a :class:`ChatSession` ready for the REPL — either loaded
1053 from disk (with full message history) or freshly created.
1054 """
1055 func = "startup_session_prompt"
1056 from oc_chat.core.persistence import list_sessions
1057
1058 sessions = list_sessions()
1059
1060 # Plan 220 P3: one handler for every pre-session prompt below, so a
1061 # Ctrl+C means the same thing at all of them.
1062 on_interrupt = _quit_confirmer(presenter, prompt_driver)
1063
1064 # Plan 216: sidecar keys into the environment and overlay lanes into the
1065 # registry, BEFORE the panel renders — a lane the wizard added last run
1066 # comes back marked 'ready' rather than 'no key'. Silent on the common path.
1067 _load_provider_overlays(presenter)
1068
1069 # Plan 215 T3: the provider-lane panel. Empty (and therefore invisible)
1070 # unless a catalogue-backed bundle is installed, so the menu below is
1071 # unchanged for a deepseek/ollama-only install.
1073
1074 if not sessions:
1075 _dbg("OC_CHAT.MAIN", func, "no existing sessions — going straight to new-session prompt", 2)
1076 presenter.show_info("No existing sessions found.")
1077 created = _create_new_session(
1078 presenter, tool_schemas, prompt_driver=prompt_driver,
1079 model_manager=model_manager, client=client,
1080 lane_switch=lane_switch, on_interrupt=on_interrupt,
1081 )
1082 if created is not None:
1083 return created
1084 # The lane choice was abandoned and there is no menu to fall back
1085 # to on a fresh install. Guessing a lane here is the fall-through
1086 # this plan exists to remove, so the honest move is out (P1/P3).
1087 _dbg("OC_CHAT.MAIN", func, "no lane chosen and no saved session - exiting", 2)
1088 presenter.show_info("No lane chosen — nothing to open. Goodbye!")
1089 raise SystemExit(0)
1090
1091 # Find the most recent session by metadata file modification time
1092 most_recent = max(sessions, key=lambda s: os.path.getmtime(s["filepath"]))
1093
1094 mr_stats = most_recent["stats"]
1095 mr_size = most_recent.get("chat_log_size_bytes", 0)
1096 mr_volume = fmt_tokens_or_size(mr_stats, mr_size)
1097 mr_model = fmt_model_caps(
1098 most_recent["model"],
1099 most_recent.get("reasoning_effort", "high"),
1100 model_manager,
1101 verbose=True,
1102 )
1103
1104 # Plan 220 P2: the whole menu is ONE section. Emitted row by row it
1105 # picked up a blank line per row from the presenter — the double
1106 # spacing TUI-02 filed as finding 2.
1107 menu = [
1108 "=" * 60,
1109 f" OC Chat — v{version}",
1110 "=" * 60,
1111 " [1] Start a new session",
1112 " [2] Resume previous session",
1113 f" → {most_recent['name']} "
1114 f"msgs: {mr_stats.message_count}, tools: {mr_stats.tool_call_count}, "
1115 f"{mr_volume}",
1116 f" model: {mr_model}",
1117 f" last modified: {most_recent['last_modified']}",
1118 f" [3] Choose another saved session ({len(sessions)} available)",
1119 " [4] Quit OC Chat",
1120 ]
1121 # Plan 216 T3: offered only when a catalogue-backed lane is registered, so
1122 # the four-entry menu above stays byte-identical without the bundle.
1123 add_provider = _add_provider_available()
1124 if add_provider:
1125 menu.append(" [5] Add provider (onboard a new lane)")
1126 # Plan 217 P5: the picker appears only where lanes exist AND the caller can
1127 # apply a switch, so the four-entry menu stays byte-identical without the
1128 # bundle — the same additive property plan 215 established and 216 kept.
1129 pick_lane = add_provider and lane_switch is not None
1130 if pick_lane:
1131 menu.append(" [6] Choose lane and model (switch provider)")
1132 _show_block(presenter, menu)
1133
1134 last_entry = 6 if pick_lane else (5 if add_provider else 4)
1135 valid_keys = "".join(str(n) for n in range(1, last_entry + 1))
1136
1137 def _read_choice() -> str:
1138 # Plan 220 P2: the prompt is indented to the menu's own column and
1139 # sits directly under the last entry. It is the menu's question, so
1140 # it belongs to the menu's section — a blank line between them would
1141 # be the very separator this plan is reclaiming.
1142 return _menu_key(
1143 prompt_driver,
1144 f" Choice (1-{last_entry}) [1]: ",
1145 valid=valid_keys,
1146 default="1",
1147 )
1148
1149 while True:
1150 try:
1151 # Plan 220 P3: EOF still exits — a closed stdin leaves nobody to
1152 # confirm with. A Ctrl+C is the opposite case and is asked about.
1153 choice = _interruptible(_read_choice, on_interrupt)
1154 except EOFError:
1155 presenter.show_info("Exiting.")
1156 raise SystemExit(0)
1157
1158 if choice == "1":
1159 created = _create_new_session(
1160 presenter, tool_schemas, prompt_driver=prompt_driver,
1161 model_manager=model_manager, client=client,
1162 lane_switch=lane_switch, on_interrupt=on_interrupt,
1163 )
1164 if created is not None:
1165 return created
1166 # None → the lane choice was abandoned or refused; back to the
1167 # menu prompt. Never a session on a model nobody picked.
1168
1169 elif choice == "2":
1170 return _load_session_by_meta(most_recent, presenter, tool_schemas)
1171
1172 elif choice == "3":
1173 selected = _choose_saved_session(
1174 sessions, presenter, model_manager, tool_schemas, prompt_driver,
1175 on_interrupt,
1176 )
1177 if selected is not None:
1178 return selected
1179 # None → user chose "back"; redisplay the menu prompt.
1180
1181 elif choice == "4":
1182 presenter.show_info("Goodbye!")
1183 raise SystemExit(0)
1184
1185 elif choice == "5" and add_provider:
1186 # Plan 216 P5: run the wizard, then re-render the lane panel so the
1187 # new lane is visible immediately, and fall back to the menu prompt.
1188 from oc_chat.core.provider_wizard import run_add_provider_wizard
1189
1190 added = run_add_provider_wizard(presenter, prompt_driver=prompt_driver)
1191 if added is not None:
1192 _load_provider_overlays(presenter)
1194
1195 elif choice == "6" and pick_lane:
1196 # Plan 217 T4: drill down, then rebind the locals the switch
1197 # rebuilt so option 1 below prompts against the NEW lane.
1198 rebuilt = _run_lane_picker_entry(
1199 presenter, prompt_driver, lane_switch, on_interrupt
1200 )
1201 if rebuilt is not None:
1202 model_manager, client = rebuilt
1203
1204 else:
1205 presenter.show_info(f"Invalid choice. Please enter 1-{last_entry}.")
str _read_line(prompt_driver, str prompt_text)
Definition startup.py:659
list[str] _render_provider_lanes("dict | None" env=None)
Definition startup.py:826
"str | None" migrate_session_model(ChatSession session, "ProviderPlugin" provider, "Presenter | None" presenter=None, *, bool capture=False)
Definition startup.py:458
ChatSession startup_session_prompt("Presenter" presenter, *, str version, "ModelManager | None" model_manager=None, list|None tool_schemas=None, "PromptDriver | None" prompt_driver=None, "ClientInterface | None" client=None, "Callable[[object], object] | None" lane_switch=None)
Definition startup.py:1023
None _load_provider_overlays("Presenter" presenter)
Definition startup.py:796
str _interruptible("Callable[[], str]" read, "Callable[[], None]" on_interrupt)
Definition startup.py:720
None _show_history_snippet(ChatSession session, "Presenter" presenter, int n=3)
Definition startup.py:246
ChatSession _load_session_by_meta(dict meta, "Presenter" presenter, list|None tool_schemas=None)
Definition startup.py:154
"ChatSession | None" _choose_saved_session(list[dict] sessions, "Presenter" presenter, "ModelManager | None" model_manager, list|None tool_schemas, "PromptDriver | None" prompt_driver, "Callable[[], None] | None" on_interrupt=None)
Definition startup.py:888
str|None _render_environment_block(ChatSession session, dict profile)
Definition startup.py:317
"str | None" _pick_lane_and_model("Presenter" presenter, "PromptDriver | None" prompt_driver, "ModelManager | None" model_manager, "Callable[[object], object] | None" lane_switch, "Callable[[], None]" on_interrupt)
Definition startup.py:493
"tuple[ModelManager | None, ClientInterface | None] | None" _run_lane_picker_entry("Presenter" presenter, "PromptDriver | None" prompt_driver, "Callable[[object], object] | None" lane_switch, "Callable[[], None] | None" on_interrupt=None)
Definition startup.py:976
bool _add_provider_available()
Definition startup.py:780
None _inject_variable_context(ChatSession session)
Definition startup.py:408
list _lane_rows("list[ProviderPlugin]" providers)
Definition startup.py:754
"Callable[[], None]" _quit_confirmer("Presenter" presenter, "PromptDriver | None" prompt_driver)
Definition startup.py:711
None _show_block("Presenter" presenter, "list[str]" lines)
Definition startup.py:734
None refresh_variable_context(ChatSession session)
Definition startup.py:421
str _menu_key(prompt_driver, str prompt_text, *, str valid, str default)
Definition startup.py:629
list[dict] _sort_sessions(list[dict] sessions, str mode)
Definition startup.py:869
str|None _read_workspace_file(str filepath)
Definition startup.py:272
None confirm_quit_or_continue("Presenter" presenter, "PromptDriver | None" prompt_driver)
Definition startup.py:678
"ChatSession | None" _create_new_session("Presenter" presenter, list|None tool_schemas=None, "PromptDriver | None" prompt_driver=None, "ModelManager | None" model_manager=None, "ClientInterface | None" client=None, *, str model="", "Callable[[object], object] | None" lane_switch=None, "Callable[[], None] | None" on_interrupt=None)
Definition startup.py:555
str|None _compose_variable_context(ChatSession session)
Definition startup.py:352