6Interactive REPL bootstrap — the session-selection menu and helpers.
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`.
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.
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
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
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.
57 L2 — lifecycle events (menu shown, session loaded / created).
58 L3 — semantic details (model selection, name resolution).
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.
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)
117from __future__
import annotations
120from datetime
import datetime
121from pathlib
import Path
122from typing
import TYPE_CHECKING
124from oc_diagnostics
import _dbg
130 apply_session_metadata_fields,
132 ensure_system_includes_tool_reference,
133 _migrate_legacy_system_prompt,
138 from typing
import Callable
147_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
152 presenter:
"Presenter",
153 tool_schemas: list |
None =
None,
155 """Load a full :class:`ChatSession` from a session metadata dict.
157 Reads the metadata and the latest chat log to populate messages,
158 stats, model, reasoning effort, and associated log paths.
160 func =
"_load_session_by_meta"
163 DEFAULT_SESSIONS_DIR, canonical_record_locator, load_chat_log,
171 model = meta.get(
"model")
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)
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
196 log_data = load_chat_log(name)
197 session.messages = log_data.get(
"messages", session.messages)
198 except FileNotFoundError
as e:
200 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {name}: {type(e).__name__}: {e}", 3)
202 f
"[Note: Session '{name}' has no chat log on disk. Starting with fresh context.]"
204 except Exception
as exc:
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.]"
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})"
225 _migrate_legacy_system_prompt(session, tool_schemas)
226 ensure_system_includes_tool_reference(session, tool_schemas)
233 m.get(
"role") ==
"user"
234 and (m.get(
"content")
or "").startswith(VARIABLE_CONTEXT_SENTINEL)
235 for m
in session.messages
247 """Display the last *n* user/assistant messages as a context-recall aid.
249 Tool messages and system prompts are skipped; very long content is
250 truncated to keep the snippet scannable.
252 visible = [m
for m
in session.messages
if m.get(
"role")
in (
"user",
"assistant")]
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}")
261 role = m.get(
"role",
"?")
262 content = (m.get(
"content")
or "").strip()
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}")
273 """Best-effort read of an ``include_files`` entry for prompt composition.
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.
280 func =
"_read_workspace_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)
287 except (OSError, UnicodeDecodeError):
292 """Best-effort local timezone name (no fabrication).
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.
299 from tzlocal
import get_localzone_name
301 name = get_localzone_name()
306 return datetime.now().astimezone().tzname()
or "UTC"
312_ENV_FIELD_ORDER: tuple[str, ...] = (
313 "date",
"timezone",
"cwd",
"branch",
"git",
"model",
"mode",
318 """Render the active mode's ``## Environment`` sublayer, or ``None`` (plan 135 §3.5).
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.
326 func =
"_render_environment_block"
327 if not profile.get(
"include_env",
False):
329 selected = {str(f).strip()
for f
in (profile.get(
"env_fields")
or [])}
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()}",
345 lines = [renderers[f]()
for f
in _ENV_FIELD_ORDER
if f
in selected]
347 _dbg(
"OC_CHAT.MAIN", func, f
"env_fields had no recognised field: {sorted(selected)}", 3)
349 return "## Environment\n" +
"\n".join(lines)
353 """Compose the full ``messages[1]`` content (sentinel + env + tree + files).
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.
362 func =
"_compose_variable_context"
366 profile = active_profile()
367 settings = runtime_settings()
368 parts: list[str] = [VARIABLE_CONTEXT_SENTINEL]
372 parts.append(env_block)
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"),
391 parts.append(session_anchors_block())
393 for filepath
in (profile.get(
"include_files")
or []):
394 filepath = str(filepath).strip()
399 parts.append(f
"[{filepath} — auto-ingested at session start]\n\n{content}")
401 _dbg(
"OC_CHAT.MAIN", func, f
"include_files: {filepath!r} not found / empty", 3)
405 return "\n\n".join(parts)
409 """Append the active mode's variable context as a user message at ``[1]``.
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`.
416 if content
is not None:
417 session.messages.append({
"role":
"user",
"content": content})
422 """Re-render the sentinel-marked ``messages[1]`` in place (plan 135 §3.5).
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.
433 sentinel = VARIABLE_CONTEXT_SENTINEL
436 i
for i, m
in enumerate(session.messages)
437 if m.get(
"role") ==
"user"
438 and (m.get(
"content")
or "").startswith(sentinel)
447 if session.messages[idx].get(
"content") != content:
448 session.messages[idx][
"content"] = content
453 session: ChatSession,
454 provider:
"ProviderPlugin",
455 presenter:
"Presenter | None" =
None,
457 capture: bool =
False,
459 """Migrate a retired session model id to its replacement on load (plan 135 §4.6).
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.
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.
473 func =
"migrate_session_model"
474 result = provider.migrate_model_id(session.model)
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)
482 if presenter
is not None:
483 presenter.show_info(notice)
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]",
494 """Choose the new session's lane and model through the plan-217 picker.
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.
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.
508 func =
"_pick_lane_and_model"
511 selection = run_lane_picker(
512 presenter, prompt_driver=prompt_driver, on_interrupt=on_interrupt
514 if selection
is None:
515 _dbg(
"OC_CHAT.MAIN", func,
"picker abandoned - no new session", 2)
518 if lane_switch
is not None:
520 lane_switch(selection)
521 except Exception
as exc:
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}")
529 elif model_manager
is not None and hasattr(model_manager,
"ensure"):
533 model_manager.ensure(selection.model_id)
535 detail = vendor_detail(selection.model_id)
537 f
"Lane: {selection.lane_name} model: {selection.model_id}"
538 + (f
" ({detail})" if detail
else "")
540 _dbg(
"OC_CHAT.MAIN", func,
541 f
"new session lane={selection.lane_name} model={selection.model_id}", 2)
542 return selection.model_id
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,
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.
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.
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.
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.
577 func =
"_create_new_session"
580 if on_interrupt
is None:
584 selected_model = model
585 _dbg(
"OC_CHAT.MAIN", func, f
"model supplied by caller: {model!r}", 3)
588 presenter, prompt_driver, model_manager, lane_switch, on_interrupt
590 if selected_model
is None:
593 def _read_name() -> str:
596 "Enter a name for the new session (or press Enter for auto-generated): ",
602 presenter.show_info(f
"Using auto-generated name: {name}")
608 active_profile, runtime_settings, runtime_mode, resolve_system_prompt,
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)
617 messages=[{
"role":
"system",
"content": system_text}],
618 model=selected_model,
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()})")
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*.
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.
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`.
647 def _fallback() -> str:
648 if prompt_driver
is not None:
649 raw = prompt_driver.read_user_input(
None, prompt_text=
"")
651 raise KeyboardInterrupt
655 key = read_single_key(prompt_text, valid=valid, fallback=_fallback)
656 return key
or default
660 """Read one line at a pre-session prompt; a cancel raises, never defaults.
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.
668 if prompt_driver
is None:
669 return input(prompt_text)
670 raw = prompt_driver.read_user_input(
None, prompt_text=prompt_text)
672 raise KeyboardInterrupt
677 presenter:
"Presenter", prompt_driver:
"PromptDriver | None"
679 """Answer a pre-session Ctrl+C with ``Quit OC Chat? (y/N)`` (plan 220 P3).
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.
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.
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.
695 func =
"confirm_quit_or_continue"
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.")
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!")
706 _dbg(
"OC_CHAT.MAIN", func,
"quit declined - re-showing the interrupted prompt", 3)
710 presenter:
"Presenter", prompt_driver:
"PromptDriver | None"
711) ->
"Callable[[], None]":
712 """Bind :func:`confirm_quit_or_continue` to this menu's presenter/driver."""
714 def _handler() -> None:
720def _interruptible(read:
"Callable[[], str]", on_interrupt:
"Callable[[], None]") -> str:
721 """Run *read*; a Ctrl+C is answered by *on_interrupt*, then re-asked.
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.
730 except KeyboardInterrupt:
734def _show_block(presenter:
"Presenter", lines:
"list[str]") ->
None:
735 """Emit *lines* as ONE section — single-spaced, one blank line above it.
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.
743 block = section_text(lines)
745 presenter.show_info(block)
755 """Return the catalogue-backed lane rows among *providers*, in menu order.
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.
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.
770 for provider
in providers:
771 lane = getattr(provider,
"lane",
None)
774 if not all(hasattr(lane, f)
for f
in (
"display_label",
"free",
"verification")):
777 return sorted(rows, key=
lambda lane: (
not lane.free, lane.name))
781 """Whether the opening menu should offer the "Add provider" entry (plan 216).
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.
793 return bool(
_lane_rows([registry.get(name)
for name
in registry.names()]))
797 """Startup hook (plan 216): sidecar keys into the environment, overlay lanes in.
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:
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
808 Silent on the common path, which is what keeps the menu golden intact.
810 func =
"_load_provider_overlays"
816 registered = register_overlay_lanes()
817 except OverlayError
as exc:
819 presenter.show_error(f
"[lane overlay] {exc}")
822 _dbg(
"OC_CHAT.MAIN", func,
823 f
"overlay contributed {len(registered)} lane(s)", 2)
827 """Render the opening menu's provider-lane panel as a list of lines.
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
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).
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.
843 func =
"_render_provider_lanes"
846 environ = os.environ
if env
is None else env
847 providers = [registry.get(name)
for name
in registry.names()]
853 lines = [
"",
" Provider lanes - select one with OC_CHAT_PROVIDER=<name>:"]
855 badge =
"FREE" if lane.free
else "PAID"
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})"
861 f
" {lane.name:<{_LANE_NAME_W}}{lane.display_label:<{_LANE_LABEL_W}}"
862 f
"{badge:<6}verified: {lane.verification:<7}{status}"
865 _dbg(
"OC_CHAT.MAIN", func, f
"rendered {len(lanes)} provider lane(s)", 3)
870 """Return *sessions* sorted by *mode*.
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.
877 return sorted(sessions, key=
lambda s: s[
"name"].lower())
878 return sorted(sessions, key=
lambda s: os.path.getmtime(s[
"filepath"]), reverse=
True)
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.
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).
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.
901 if on_interrupt
is None:
907 sort_label =
"name (A–Z)" if sort_mode ==
"name" else "date (newest first)"
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",
915 for i, s
in enumerate(ordered, 1):
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
921 if len(model_label) > 34:
922 model_label = model_label[:33] +
"…"
923 vol = fmt_tokens_or_size(stats, size)
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']}"
932 prompt =
"Enter session # or name · 'n'=sort by name · 'd'=by date · Enter=back: "
934 def _read_choice() -> str:
944 presenter.show_info(
"Returning to session selection menu...")
956 if raw.isdigit()
and len(raw) <= 3:
958 if 1 <= idx <= len(ordered):
960 presenter.show_info(f
" No session at index {idx} (range 1–{len(ordered)}).")
963 if not session_exists(raw):
964 presenter.show_info(f
"Session '{raw}' not found. (Enter to go back.)")
966 match = next((s
for s
in ordered
if s[
"name"] == raw),
None)
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.
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.
985 The picker itself mutates nothing (plan 217 P4); everything that does
986 happens inside *lane_switch*, which is only ever reached with a
989 func =
"_run_lane_picker_entry"
992 selection = run_lane_picker(
993 presenter, prompt_driver=prompt_driver, on_interrupt=on_interrupt
995 if selection
is None or lane_switch
is None:
998 rebuilt = lane_switch(selection)
999 except Exception
as exc:
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}")
1006 presenter.show_info(
1007 f
" Lane set to {selection.lane_name} (model: {selection.model_id})."
1009 _dbg(
"OC_CHAT.MAIN", func,
1010 f
"switched to {selection.lane_name}/{selection.model_id}", 2)
1015 presenter:
"Presenter",
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,
1024 """Present the startup session-selection menu at OC Chat launch.
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).
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*).
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.
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.
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.
1052 Returns a :class:`ChatSession` ready for the REPL — either loaded
1053 from disk (with full message history) or freshly created.
1055 func =
"startup_session_prompt"
1058 sessions = list_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.")
1078 presenter, tool_schemas, prompt_driver=prompt_driver,
1079 model_manager=model_manager, client=client,
1080 lane_switch=lane_switch, on_interrupt=on_interrupt,
1082 if created
is not None:
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!")
1092 most_recent = max(sessions, key=
lambda s: os.path.getmtime(s[
"filepath"]))
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"),
1109 f
" OC Chat — v{version}",
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}, "
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",
1125 menu.append(
" [5] Add provider (onboard a new lane)")
1129 pick_lane = add_provider
and lane_switch
is not None
1131 menu.append(
" [6] Choose lane and model (switch provider)")
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))
1137 def _read_choice() -> str:
1144 f
" Choice (1-{last_entry}) [1]: ",
1155 presenter.show_info(
"Exiting.")
1160 presenter, tool_schemas, prompt_driver=prompt_driver,
1161 model_manager=model_manager, client=client,
1162 lane_switch=lane_switch, on_interrupt=on_interrupt,
1164 if created
is not None:
1174 sessions, presenter, model_manager, tool_schemas, prompt_driver,
1177 if selected
is not None:
1182 presenter.show_info(
"Goodbye!")
1185 elif choice ==
"5" and add_provider:
1190 added = run_add_provider_wizard(presenter, prompt_driver=prompt_driver)
1191 if added
is not None:
1195 elif choice ==
"6" and pick_lane:
1199 presenter, prompt_driver, lane_switch, on_interrupt
1201 if rebuilt
is not None:
1202 model_manager, client = rebuilt
1205 presenter.show_info(f
"Invalid choice. Please enter 1-{last_entry}.")
str _read_line(prompt_driver, str prompt_text)
list[str] _render_provider_lanes("dict | None" env=None)
"str | None" migrate_session_model(ChatSession session, "ProviderPlugin" provider, "Presenter | None" presenter=None, *, bool capture=False)
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)
None _load_provider_overlays("Presenter" presenter)
str _interruptible("Callable[[], str]" read, "Callable[[], None]" on_interrupt)
None _show_history_snippet(ChatSession session, "Presenter" presenter, int n=3)
ChatSession _load_session_by_meta(dict meta, "Presenter" presenter, list|None tool_schemas=None)
"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)
str|None _render_environment_block(ChatSession session, dict profile)
"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)
"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)
bool _add_provider_available()
None _inject_variable_context(ChatSession session)
list _lane_rows("list[ProviderPlugin]" providers)
"Callable[[], None]" _quit_confirmer("Presenter" presenter, "PromptDriver | None" prompt_driver)
None _show_block("Presenter" presenter, "list[str]" lines)
None refresh_variable_context(ChatSession session)
str _menu_key(prompt_driver, str prompt_text, *, str valid, str default)
list[dict] _sort_sessions(list[dict] sessions, str mode)
str|None _read_workspace_file(str filepath)
None confirm_quit_or_continue("Presenter" presenter, "PromptDriver | None" prompt_driver)
"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)
str|None _compose_variable_context(ChatSession session)