oc_chat
Loading...
Searching...
No Matches
lane_picker.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/lane_picker.py
4
5"""
6The drill-down picker — lane, class, family, model, and back out of any of them.
7
8Purpose
9-------
10Plan 217 T2/T4. :class:`Navigator` walks the tree
11:mod:`oc_chat.core.lane_tree` describes — lane, free/paid, family, model,
12confirm — and :func:`run_lane_picker` drives it against a presenter and a
13prompt driver.
14
15The operator's requirement is reversibility, and it is met STRUCTURALLY
16rather than carefully (P4). Every state is a frozen value, every
17transition returns a NEW value, and the navigator keeps the values it
18came from on a stack. "Back" is therefore not a re-derivation that might
19disagree with what was on screen: it is the identical earlier object,
20restored. Nothing outside the navigator is touched at any point — the
21picker only ever RETURNS a :class:`LaneSelection`, and applying one is
22:mod:`oc_chat.core.lane_switch`'s job. An abandoned traversal is a no-op
23because there was never anything to undo.
24
25Responsibilities
26----------------
27- :class:`PickerState` / :class:`Screen` / :class:`LaneSelection` — the values.
28- :class:`Navigator` — choose / act / back / abandon over a state stack.
29- :func:`render_screen` — ASCII rendering of one screen.
30- :func:`run_lane_picker` — the interactive driver.
31- :func:`lane_options_from_registry` / :func:`live_cache_from_overlay` —
32 join the live registry with the cache the 215/216 machinery wrote.
33
34Diagnostics
35-----------
36Domain: OC_CHAT.MAIN
37Levels:
38 L2 — lifecycle events (picker entered, selection made, abandoned).
39 L3 — semantic details (level transitions, action outcomes).
40 L4 — deep tracing.
41
42Contracts
43---------
44- **Nothing mutates until the final confirmation.** The confirm level is
45 never collapsed away, so a complete selection is always an explicit
46 answer. Even then this module only returns a value.
47- **Level one and the confirm level never collapse; the three middle
48 levels do.** Level one IS the picker's purpose, so a single-lane
49 install still sees which lane it has; confirm is P4's point of no
50 return. A middle level with exactly one selectable answer answers
51 itself, and ``back`` steps over the whole collapsed run as one unit,
52 because a level the operator never saw is not a level they can return
53 to.
54- **Refresh is in-memory and the only network call.** It is reached only
55 by the explicit action, and the fetched list lives in the picker state
56 — it is NOT written back to the overlay. Writing it would be a disk
57 mutation inside a traversal the operator may still abandon, which is
58 exactly what P4 forbids. The persisted cache stays the wizard's to
59 write.
60- **A cache that does not exist is reported, never silently filled.** The
61 "show all" escape is offered as unselectable with its reason when the
62 lane has no cached list, alongside the refresh that would create one.
63- The single-letter answers ``b`` / ``m`` / ``a`` / ``r`` are reserved at
64 every prompt, so a lane literally named ``b`` is unreachable through the
65 picker (it stays selectable by ``/lane b``). Same trade the plan-216
66 wizard documents for ``q``.
67- ASCII only: every rendered line survives a stock cp1252 console (F4).
68- **A Ctrl+C is not a "back" (plan 220 P3).** With *on_interrupt* wired —
69 the pre-session callers only — an interrupt is handed to that callback
70 and, if it returns, the same screen is re-shown unchanged. Without it
71 the in-session ``/lane`` behaviour is byte-identical to pre-220: a
72 cancel abandons. One reader, two honest meanings, chosen by the caller
73 that knows whether a session exists yet.
74- **One presenter call per screen (plan 220 P2).** Screens are emitted as
75 a single block, because every presenter opens ``show_info`` with a
76 blank line and a screen emitted row by row is a double-spaced screen.
77
78Dependencies
79------------
80- oc_diagnostics (structured debug logging via _dbg)
81- oc_chat.core.lane_tree (the pure tree model this walks)
82- oc_chat.core.lane_overlay (lazy; read-only: the cached live model lists)
83- oc_chat.core.defaults (menu column widths from the shipped defaults file)
84- oc_chat.core.client_interface (lazy; ClientInterface for the live model list fetch)
85- oc_chat.core.provider_plugin (lazy; provider registry for the lane rows and the live fetch)
86"""
87from __future__ import annotations
88
89from dataclasses import dataclass, replace
90from pathlib import Path
91from typing import Any, Callable, Mapping, Sequence
92
93from oc_diagnostics import _dbg
94
95from oc_chat.core import lane_tree
96from oc_chat.core.defaults import default
97from oc_chat.core.lane_tree import Choice, LaneOption
98
99_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
100
101
102#: The five levels, in walk order.
103LEVEL_LANE = "lane"
104LEVEL_CLASS = "class"
105LEVEL_FAMILY = "family"
106LEVEL_MODEL = "model"
107LEVEL_CONFIRM = "confirm"
108
109LEVEL_ORDER: tuple[str, ...] = (
110 LEVEL_LANE, LEVEL_CLASS, LEVEL_FAMILY, LEVEL_MODEL, LEVEL_CONFIRM,
111)
112
113#: Only the three middle levels answer themselves — see the module contract.
114COLLAPSIBLE_LEVELS: frozenset[str] = frozenset({LEVEL_CLASS, LEVEL_FAMILY, LEVEL_MODEL})
115
116#: The confirm level's single answer key.
117CONFIRM_KEY = "yes"
118
119#: The two model-pool actions.
120ACTION_SHOW_ALL = "show_all"
121ACTION_REFRESH = "refresh"
122
123#: Reserved single-letter answers, at every prompt.
124BACK_ANSWER = "b"
125ABANDON_ANSWERS: frozenset[str] = frozenset({"m", "q"})
126SHOW_ALL_ANSWER = "a"
127REFRESH_ANSWER = "r"
128
129#: Menu column widths — values in the shipped defaults file (CONFIG-01).
130_NAME_W = default("lane_picker_name_width")
131_LABEL_W = default("lane_picker_label_width")
132
133
134@dataclass(frozen=True)
136 """A complete, confirmed answer: which lane, which model."""
137
138 lane_name: str
139 model_id: str
140
141
142@dataclass(frozen=True)
143class Answer:
144 """One answered level. ``auto`` marks a level the collapse rule filled."""
145
146 level: str
147 key: str
148 auto: bool = False
149
150
151@dataclass(frozen=True)
153 """The whole traversal as one immutable value.
154
155 ``live`` holds refreshed model lists as a tuple of pairs rather than a
156 dict so the state stays hashable and obviously copy-free; a traversal
157 refreshes at most a handful of lanes.
158 """
159
160 lanes: tuple[LaneOption, ...]
161 answers: tuple[Answer, ...] = ()
162 show_all: bool = False
163 live: tuple[tuple[str, tuple[str, ...]], ...] = ()
164
165 def answer_for(self, level: str) -> str:
166 """The key answered at *level*, or ``""``."""
167 for answer in self.answers:
168 if answer.level == level:
169 return answer.key
170 return ""
171
172 def live_models(self, lane_name: str) -> tuple[str, ...]:
173 """The list a refresh fetched for *lane_name* this traversal."""
174 for name, models in self.live:
175 if name == lane_name:
176 return models
177 return ()
178
179 def lane_option(self) -> LaneOption | None:
180 """The chosen lane's row, or ``None`` before level one is answered."""
181 name = self.answer_for(LEVEL_LANE)
182 if not name:
183 return None
184 return next((lane for lane in self.lanes if lane.name == name), None)
185
186
187@dataclass(frozen=True)
188class Screen:
189 """One rendered level: what to show and what may be answered."""
190
191 level: str
192 title: str
193 choices: tuple[Choice, ...]
194 actions: tuple[Choice, ...] = ()
195 highlight: str = ""
196 can_back: bool = True
197 can_abandon: bool = True
198
199
200# ── Pure tree walking ───────────────────────────────────────────────────────
201
202
203def current_level(state: PickerState) -> str | None:
204 """The level awaiting an answer, or ``None`` when the walk is complete."""
205 for level in LEVEL_ORDER:
206 if not state.answer_for(level):
207 return level
208 return None
209
210
211def full_pool(state: PickerState, option: LaneOption) -> tuple[str, ...]:
212 """The lane's live-discovered list: this traversal's refresh, else the cache."""
213 return state.live_models(option.name) or option.cached_models
214
215
216def _base_pool(state: PickerState, option: LaneOption) -> tuple[str, ...]:
217 """Curated by default; the full live list once "show all" is engaged (P2)."""
218 if state.show_all:
219 return full_pool(state, option)
220 return option.models
221
222
223def pool_for(state: PickerState, level: str) -> tuple[str, ...]:
224 """The model ids still in play when *level* is being asked."""
225 option = state.lane_option()
226 if option is None:
227 return ()
228 ids = _base_pool(state, option)
229 if level in (LEVEL_LANE, LEVEL_CLASS):
230 return ids
231 chosen_class = state.answer_for(LEVEL_CLASS)
232 if chosen_class:
233 ids = lane_tree.models_in_class(
234 ids, lane_free=option.free, model_class=chosen_class
235 )
236 if level == LEVEL_FAMILY:
237 return ids
238 chosen_family = state.answer_for(LEVEL_FAMILY)
239 if chosen_family:
240 ids = lane_tree.models_in_family(ids, chosen_family)
241 return ids
242
243
244def _lane_choices(state: PickerState) -> tuple[Choice, ...]:
245 out: list[Choice] = []
246 for option in state.lanes:
247 detail = (
248 f"{option.badge:<5} "
249 f"verified: {(option.verification or '-'):<7}"
250 f"{len(option.models)} curated"
251 )
252 reason = (
253 ""
254 if option.selectable
255 else (
256 f"no key ({option.api_key_env_var}) - set it, or onboard the lane "
257 f"with [5] Add provider"
258 )
259 )
260 out.append(Choice(
261 key=option.name,
262 label=option.display_label if option.display_label != option.name else "",
263 detail=detail,
264 selectable=option.selectable,
265 reason=reason,
266 ))
267 return tuple(out)
268
269
270def _class_choices(state: PickerState) -> tuple[Choice, ...]:
271 option = state.lane_option()
272 if option is None:
273 return ()
274 ids = pool_for(state, LEVEL_CLASS)
275 out: list[Choice] = []
276 for name in lane_tree.model_classes(ids, lane_free=option.free):
277 members = lane_tree.models_in_class(
278 ids, lane_free=option.free, model_class=name
279 )
280 out.append(Choice(
281 key=name,
282 label=lane_tree.CLASS_LABELS.get(name, name),
283 detail=f"{len(members)} model(s)",
284 ))
285 return tuple(out)
286
287
288def _family_choices(state: PickerState) -> tuple[Choice, ...]:
289 ids = pool_for(state, LEVEL_FAMILY)
290 return tuple(
291 Choice(key=family, label="", detail=f"{len(members)} model(s)")
292 for family, members in lane_tree.group_by_family(ids)
293 )
294
295
296def _model_choices(state: PickerState) -> tuple[Choice, ...]:
297 # Plan 220 P4: a routed lane's id names its VENDOR, and on a lane like
298 # OpenRouter that is the only thing on the row that says who actually
299 # built the model. An id without the convention gets no vendor line
300 # rather than a guessed one (lane_tree.vendor_detail returns "").
301 return tuple(
302 Choice(key=model_id, label="", detail=lane_tree.vendor_detail(model_id))
303 for model_id in pool_for(state, LEVEL_MODEL)
304 )
305
306
307def _confirm_choices(state: PickerState) -> tuple[Choice, ...]:
308 lane = state.answer_for(LEVEL_LANE)
309 model = state.answer_for(LEVEL_MODEL)
310 # Plan 220 P4: the point of no return names the lane AND the vendor, so
311 # the last thing on screen before the switch is the whole attribution.
312 return (Choice(
313 key=CONFIRM_KEY,
314 label=f"switch to {lane} / {model}",
315 detail=lane_tree.vendor_detail(model),
316 ),)
317
318
319_CHOICE_BUILDERS: Mapping[str, Callable[[PickerState], tuple[Choice, ...]]] = {
320 LEVEL_LANE: _lane_choices,
321 LEVEL_CLASS: _class_choices,
322 LEVEL_FAMILY: _family_choices,
323 LEVEL_MODEL: _model_choices,
324 LEVEL_CONFIRM: _confirm_choices,
325}
326
327
328def choices_for(state: PickerState) -> tuple[Choice, ...]:
329 """The choices at the level awaiting an answer."""
330 level = current_level(state)
331 if level is None:
332 return ()
333 return _CHOICE_BUILDERS[level](state)
334
335
336def collapse(state: PickerState) -> PickerState:
337 """Answer every collapsible level that has exactly one selectable answer."""
338 func = "collapse"
339 while True:
340 level = current_level(state)
341 if level is None or level not in COLLAPSIBLE_LEVELS:
342 return state
343 only = lane_tree.collapse_choices(_CHOICE_BUILDERS[level](state))
344 if only is None:
345 return state
346 state = replace(
347 state, answers=state.answers + (Answer(level, only.key, auto=True),)
348 )
349 _dbg("OC_CHAT.MAIN", func, f"level {level!r} collapsed to {only.key!r}", 4)
350
351
352def advance(state: PickerState, key: str) -> PickerState | None:
353 """Answer the current level with *key*, or ``None`` when it is not an answer."""
354 func = "advance"
355 level = current_level(state)
356 if level is None:
357 return None
358 match = next(
359 (c for c in _CHOICE_BUILDERS[level](state) if c.key == key and c.selectable),
360 None,
361 )
362 if match is None:
363 return None
364 _dbg("OC_CHAT.MAIN", func, f"level {level!r} answered {key!r}", 3)
365 return collapse(
366 replace(state, answers=state.answers + (Answer(level, key),))
367 )
368
369
370def _drop_levels(state: PickerState, levels: frozenset[str]) -> PickerState:
371 """Forget the answers at *levels* (used when the model pool changes)."""
372 return replace(
373 state, answers=tuple(a for a in state.answers if a.level not in levels)
374 )
375
376
377#: The answers a pool change invalidates. The lane and the class survive it
378#: where they still make sense — an operator who asked for free models still
379#: wants free models after opening the full list.
380_POOL_DEPENDENT: frozenset[str] = frozenset({LEVEL_FAMILY, LEVEL_MODEL, LEVEL_CONFIRM})
381
382
383def apply_show_all(state: PickerState) -> PickerState | None:
384 """Open the full live list, keeping the lane (and the class where it fits).
385
386 ``None`` when there is nothing to open — the lane has no cached list and
387 none has been fetched this traversal (P2: an empty cache is not an
388 implicit licence to reach the network).
389 """
390 func = "apply_show_all"
391 option = state.lane_option()
392 if option is None or not full_pool(state, option):
393 return None
394 opened = replace(_drop_levels(state, _POOL_DEPENDENT), show_all=True)
395 # A class answer that survives into an empty pool would strand the walk;
396 # drop it and let the class level be asked again over the new list.
397 if state.answer_for(LEVEL_CLASS) and not pool_for(opened, LEVEL_FAMILY):
398 opened = _drop_levels(opened, frozenset({LEVEL_CLASS}))
399 _dbg("OC_CHAT.MAIN", func,
400 f"lane={option.name} opened {len(full_pool(state, option))} live id(s)", 3)
401 return collapse(opened)
402
403
405 state: PickerState,
406 fetcher: Callable[[LaneOption], Sequence[str]],
407) -> PickerState:
408 """Fetch the lane's live list into the STATE — never onto disk.
409
410 Raises whatever *fetcher* raises; the navigator turns that into a
411 reported failure with the state untouched.
412 """
413 func = "apply_refresh"
414 option = state.lane_option()
415 if option is None:
416 raise ValueError("no lane chosen yet")
417 fetched = tuple(str(model_id) for model_id in fetcher(option))
418 live = tuple(pair for pair in state.live if pair[0] != option.name)
419 _dbg("OC_CHAT.MAIN", func,
420 f"lane={option.name} refreshed to {len(fetched)} id(s)", 2)
421 return replace(state, live=live + ((option.name, fetched),))
422
423
424# ── The navigator ───────────────────────────────────────────────────────────
425
426
428 """Walk the tree over a stack of states, so ``back`` restores exactly.
429
430 Not thread-safe and not meant to be: one navigator per picker run, on
431 the main thread, between turns.
432 """
433
435 self,
436 lanes: Sequence[LaneOption],
437 *,
438 fetcher: Callable[[LaneOption], Sequence[str]] | None = None,
439 ) -> None:
440 func = "Navigator.__init__"
441 self._fetcher = fetcher
442 self._stack: list[tuple[PickerState, str]] = [
443 (collapse(PickerState(lanes=tuple(lanes))), "")
444 ]
445 self._abandoned = False
446 self._undone = ""
447 self.last_error = ""
448 _dbg("OC_CHAT.MAIN", func, f"picker opened over {len(lanes)} lane(s)", 2)
449
450 # ── Reading ─────────────────────────────────────────────────────────────
451
452 @property
453 def state(self) -> PickerState:
454 """The live state. Valid even after abandoning (it is a value)."""
455 return self._stack[-1][0]
456
457 @property
458 def abandoned(self) -> bool:
459 """Whether the traversal was given up, by ``back`` or by ``abandon``."""
460 return self._abandoned
461
462 def pending_model(self) -> str:
463 """The model the walk has settled on, or ``""``."""
464 return self.state.answer_for(LEVEL_MODEL)
465
466 def selection(self) -> LaneSelection | None:
467 """The confirmed selection, or ``None`` while incomplete or abandoned."""
468 if self._abandoned or current_level(self.state) is not None:
469 return None
470 return LaneSelection(
471 lane_name=self.state.answer_for(LEVEL_LANE),
472 model_id=self.state.answer_for(LEVEL_MODEL),
473 )
474
475 def screen(self) -> Screen | None:
476 """The screen to render, or ``None`` when abandoned or complete."""
477 if self._abandoned:
478 return None
479 state = self.state
480 level = current_level(state)
481 if level is None:
482 return None
483 return Screen(
484 level=level,
485 title=_title_for(state, level),
486 choices=_CHOICE_BUILDERS[level](state),
487 actions=self._actions(state, level),
488 highlight=self._undone,
489 )
490
491 def _actions(self, state: PickerState, level: str) -> tuple[Choice, ...]:
492 """The pool actions, offered on every screen below the lane level.
493
494 Offering them only at the model level would strand the operator
495 whenever that level collapsed away, which is precisely the
496 single-model lane where "show me everything" is most wanted.
497 """
498 option = state.lane_option()
499 if option is None or level == LEVEL_LANE:
500 return ()
501 live = full_pool(state, option)
502 if state.show_all:
503 show_all = Choice(
504 key=ACTION_SHOW_ALL,
505 label=f"showing all {len(live)} live model(s)",
506 selectable=False,
507 reason="already showing the full list",
508 )
509 elif live:
510 stamp = option.models_fetched_at or "this session"
511 show_all = Choice(
512 key=ACTION_SHOW_ALL,
513 label=f"show all {len(live)} (cached {stamp})",
514 )
515 else:
516 show_all = Choice(
517 key=ACTION_SHOW_ALL,
518 label="show all",
519 selectable=False,
520 reason=(
521 f"no cached model list for {option.name}; "
522 f"'{REFRESH_ANSWER}' fetches one"
523 ),
524 )
525 if self._fetcher is None:
526 refresh = Choice(
527 key=ACTION_REFRESH,
528 label="refresh the live list",
529 selectable=False,
530 reason="no live fetcher is wired into this picker",
531 )
532 else:
533 refresh = Choice(
534 key=ACTION_REFRESH, label="refresh the live list (one network call)"
535 )
536 return (show_all, refresh)
537
538 # ── Transitions ─────────────────────────────────────────────────────────
539
540 def choose(self, key: str) -> bool:
541 """Answer the current level. ``False`` leaves the state untouched."""
542 if self._abandoned:
543 return False
544 nxt = advance(self.state, key)
545 if nxt is None:
546 return False
547 self._stack.append((nxt, key))
548 self._undone = ""
549 self.last_error = ""
550 return True
551
552 def act(self, action: str) -> bool:
553 """Apply a pool action. ``False`` leaves the state untouched."""
554 func = "Navigator.act"
555 if self._abandoned:
556 return False
557 offered = next(
558 (a for a in self._actions(self.state, current_level(self.state) or "")
559 if a.key == action),
560 None,
561 )
562 if offered is None or not offered.selectable:
563 self.last_error = offered.reason if offered is not None else (
564 f"unknown action {action!r}"
565 )
566 return False
567 if action == ACTION_SHOW_ALL:
568 nxt = apply_show_all(self.state)
569 if nxt is None:
570 self.last_error = "there is no live list to open"
571 return False
572 else:
573 try:
574 nxt = apply_refresh(self.state, self._fetcher) # type: ignore[arg-type]
575 except Exception as exc: # noqa: BLE001 — any client failure is reported
576 # EXTERNAL_ERROR (S6 L2): the lane refused; the traversal is intact.
577 _dbg("OC_CHAT.MAIN", func,
578 f"EXTERNAL_ERROR: refresh failed: {type(exc).__name__}", 2,
579 override=True)
580 self.last_error = f"could not refresh: {type(exc).__name__}: {exc}"
581 return False
582 self._stack.append((nxt, action))
583 self._undone = ""
584 self.last_error = ""
585 return True
586
587 def back(self) -> bool:
588 """Step back one screen. ``False`` means the walk was abandoned.
589
590 Stepping back off the first level IS abandoning — P4 asks for back
591 at every level, and there is nothing above level one.
592 """
593 func = "Navigator.back"
594 if self._abandoned:
595 return False
596 self.last_error = ""
597 if len(self._stack) == 1:
598 self._abandoned = True
599 _dbg("OC_CHAT.MAIN", func, "backed out of the first level - abandoned", 2)
600 return False
601 _, key = self._stack.pop()
602 self._undone = key
603 return True
604
605 def abandon(self) -> None:
606 """Give up the whole traversal. Nothing was ever mutated."""
607 func = "Navigator.abandon"
608 self._abandoned = True
609 _dbg("OC_CHAT.MAIN", func, "traversal abandoned", 2)
610
611
612def _title_for(state: PickerState, level: str) -> str:
613 """One ASCII line naming where in the tree this screen sits.
614
615 Plan 220 P4: every screen below level one lists models from ONE lane,
616 so the header names it — ``lane openrouter (free) / z-ai: model``. A
617 list that mixes lanes is level one, where the lane is on every row.
618 """
619 lane = state.answer_for(LEVEL_LANE)
620 chosen_class = state.answer_for(LEVEL_CLASS)
621 family = state.answer_for(LEVEL_FAMILY)
622 scope = f"lane {lane}" if lane else lane
623 if chosen_class and chosen_class != lane_tree.CLASS_UNKNOWN:
624 scope = f"{scope} ({chosen_class})"
625 if family and family != lane_tree.NO_FAMILY:
626 scope = f"{scope} / {family}"
627 if level == LEVEL_LANE:
628 return "choose a lane"
629 if level == LEVEL_CLASS:
630 return f"{scope}: free or paid models?"
631 if level == LEVEL_FAMILY:
632 return f"{scope}: model family"
633 if level == LEVEL_MODEL:
634 return f"{scope}: model"
635 return f"{scope}: confirm"
636
637
638# ── Rendering ───────────────────────────────────────────────────────────────
639
640
641def _cell(text: str, width: int) -> str:
642 """*text* padded to *width* — or given ONE separating space if it overflows.
643
644 Plan 220: a routed model id is routinely longer than its column
645 (``switch to openrouter / z-ai/glm-4.6``), and the bare ``:<`` form
646 then butts the next column straight onto it —
647 ``...glm-4.6vendor: z-ai``. Truncating would hide the id, so the
648 column simply gives way: one space, and the row stays readable.
649 """
650 if len(text) < width:
651 return f"{text:<{width}}"
652 return text + " "
653
654
655def render_screen(screen: Screen) -> list[str]:
656 """Render *screen* as ASCII lines for a presenter to show."""
657 lines = ["", f" Lane picker - {screen.title}", " " + "-" * 60]
658 for index, choice in enumerate(screen.choices, 1):
659 marker = ">" if choice.key == screen.highlight else " "
660 body = _cell(choice.key, _NAME_W) + _cell(choice.label, _LABEL_W) + choice.detail
661 lines.append(f" {marker} {index:>2}. {body}".rstrip())
662 if not choice.selectable and choice.reason:
663 lines.append(f" {choice.reason}")
664 for action in screen.actions:
665 letter = SHOW_ALL_ANSWER if action.key == ACTION_SHOW_ALL else REFRESH_ANSWER
666 if action.selectable:
667 lines.append(f" [{letter}] {action.label}")
668 else:
669 lines.append(f" [{letter}] {action.label} - {action.reason}")
670 hint = f" Enter number - '{BACK_ANSWER}' back - 'm' main menu"
671 if screen.highlight:
672 hint += f" - Enter repeats {screen.highlight!r}"
673 lines.append(hint)
674 return lines
675
676
677def prompt_for(screen: Screen) -> str:
678 """The one-line prompt shown under *screen*."""
679 return f" {screen.level.capitalize()} choice: "
680
681
682# ── Registry / overlay joins ────────────────────────────────────────────────
683
684
686 path: str | Path | None = None,
687) -> tuple[dict[str, tuple[str, ...]], dict[str, str | None]]:
688 """The cached live lists the 215/216 machinery wrote, by lane name (P2).
689
690 Degrades to empty on a missing OR malformed overlay. The startup hook
691 ``_load_provider_overlays`` already reports a malformed overlay loudly
692 and is the right place for that; a second fatal reporter here would
693 turn a bad row into a picker that cannot open.
694 """
695 func = "live_cache_from_overlay"
696 from oc_chat.core.lane_overlay import DEFAULT_OVERLAY_PATH, OverlayError, load_overlay
697
698 target = Path(path) if path is not None else DEFAULT_OVERLAY_PATH
699 try:
700 rows = load_overlay(target)
701 except (OverlayError, OSError) as exc:
702 # VALIDATION_ERROR (S6 L3) — operator-authored data; the picker degrades
703 # to "no cache" and the startup hook surfaces the row itself.
704 _dbg("OC_CHAT.MAIN", func,
705 f"VALIDATION_ERROR: {type(exc).__name__}: {exc}", 3)
706 return {}, {}
707 cached = {row.name: tuple(row.cached_models) for row in rows}
708 stamps = {row.name: row.models_fetched_at for row in rows}
709 _dbg("OC_CHAT.MAIN", func, f"cache for {len(cached)} overlay lane(s)", 3)
710 return cached, stamps
711
712
714 *,
715 env: Mapping[str, str] | None = None,
716 overlay_path: str | Path | None = None,
717) -> tuple[LaneOption, ...]:
718 """Level-one rows for every registered provider, cache joined in."""
719 from oc_chat.core.provider_plugin import registry
720
721 cached, stamps = live_cache_from_overlay(overlay_path)
722 providers = [registry.get(name) for name in registry.names()]
723 return lane_tree.lane_options(
724 providers, env=env, cached_models=cached, fetched_at=stamps
725 )
726
727
728def default_fetcher(option: LaneOption) -> tuple[str, ...]:
729 """Ask the lane's own API for its live model list. THE network call.
730
731 Goes through the provider's ``fetch_live_models``, which is the same
732 call :meth:`ModelManager.fetch_live` makes, so a lane that works in a
733 session works here. Never called by the test suite.
734 """
735 func = "default_fetcher"
736 from oc_chat.core.client_interface import ClientInterface
737 from oc_chat.core.provider_plugin import registry
738
739 provider = registry.get(option.name)
740 client = ClientInterface(provider)
741 models = tuple(str(model_id) for model_id in provider.fetch_live_models(client))
742 _dbg("OC_CHAT.MAIN", func, f"lane={option.name} live list = {len(models)}", 2)
743 return models
744
745
746# ── The interactive driver ──────────────────────────────────────────────────
747
748
749def _read(driver: Any, prompt_text: str, *, interruptible: bool = False) -> str | None:
750 """Read one answer. ``None`` on cancel, EOF, or a non-interactive stdin.
751
752 With *interruptible* set (plan 220 P3 — the picker reached from the
753 opening menu, where no session exists yet) a Ctrl+C is RAISED instead
754 of folded into the abandon path, so the caller can answer it with the
755 quit confirmation and then re-show this very screen. A driver reports a
756 cancelled menu prompt by RETURNING ``None`` rather than raising, so
757 that is the interrupt too: coalescing it with "no answer" is exactly
758 the swallow TUI-02 finding 3 describes.
759
760 Without it the pre-220 behaviour is byte-identical, which is what keeps
761 the in-session ``/lane`` semantics ("cancel abandons, nothing else")
762 untouched.
763 """
764 try:
765 if driver is not None:
766 raw = driver.read_user_input(None, prompt_text=prompt_text)
767 else:
768 raw = input(prompt_text)
769 except KeyboardInterrupt:
770 if interruptible:
771 raise
772 return None
773 except (EOFError, OSError):
774 return None
775 if raw is None:
776 if interruptible:
777 raise KeyboardInterrupt
778 return None
779 return raw.strip()
780
781
782def _resolve_answer(screen: Screen, raw: str) -> str:
783 """Map a typed answer to a choice key, or ``""`` when it matches none.
784
785 Three forms, in order: the displayed index, the key typed in full, and
786 an UNAMBIGUOUS case-insensitive prefix of one. The prefix form is what
787 makes ``y`` answer the confirm screen — the obvious keystroke there —
788 and it lets a long model id be picked by its distinctive head. It
789 resolves only when exactly one selectable key matches, so it can never
790 silently pick the wrong model.
791 """
792 if not raw:
793 highlighted = next(
794 (c for c in screen.choices if c.key == screen.highlight and c.selectable),
795 None,
796 )
797 if highlighted is not None:
798 return highlighted.key
799 first = next((c for c in screen.choices if c.selectable), None)
800 return first.key if first is not None else ""
801 if raw.isdigit():
802 index = int(raw)
803 if 1 <= index <= len(screen.choices):
804 return screen.choices[index - 1].key
805 return ""
806 exact = next((c.key for c in screen.choices if c.key == raw), "")
807 if exact:
808 return exact
809 lowered = raw.lower()
810 prefixed = [
811 c.key for c in screen.choices
812 if c.selectable and c.key.lower().startswith(lowered)
813 ]
814 return prefixed[0] if len(prefixed) == 1 else ""
815
816
818 presenter: Any,
819 *,
820 prompt_driver: Any = None,
821 lanes: Sequence[LaneOption] | None = None,
822 env: Mapping[str, str] | None = None,
823 fetcher: Callable[[LaneOption], Sequence[str]] | None = None,
824 overlay_path: str | Path | None = None,
825 on_interrupt: Callable[[], None] | None = None,
826) -> LaneSelection | None:
827 """Drive the picker to a confirmed selection, or ``None``.
828
829 ``None`` means "nothing was chosen" in every case — stepped back off
830 the first level, escaped to the main menu, or a closed stdin. In none
831 of them has anything been mutated: applying a selection is
832 :func:`oc_chat.core.lane_switch.switch_lane`'s job, and it is only
833 ever reached with a returned value.
834
835 *on_interrupt* (plan 220 P3) is supplied ONLY by the pre-session
836 callers — the opening menu and its new-session path — where a Ctrl+C
837 must interrupt and confirm rather than quietly abandon. It is called
838 with the traversal untouched and either raises (the operator confirmed
839 the quit) or returns, in which case the SAME screen is re-shown:
840 "back" already means back, so an interrupt must not silently mean it
841 too. Omitted, the picker keeps its pre-220 in-session behaviour
842 exactly.
843 """
844 func = "run_lane_picker"
845 if lanes is None:
846 lanes = lane_options_from_registry(env=env, overlay_path=overlay_path)
847 if fetcher is None:
848 fetcher = default_fetcher
849 if not lanes:
850 presenter.show_info(" No provider lanes are registered.")
851 return None
852
853 navigator = Navigator(lanes, fetcher=fetcher)
854 while True:
855 screen = navigator.screen()
856 if screen is None:
857 break
858 # Plan 220 P2: ONE call per screen. Emitted line by line, every
859 # presenter's leading newline turns a ten-row screen into a
860 # twenty-line one — the same double spacing TUI-02 filed against
861 # the menu, and the picker is now the new-session path.
862 block = lane_tree.section_text(render_screen(screen))
863 if block:
864 presenter.show_info(block)
865 try:
866 raw = _read(
867 prompt_driver, prompt_for(screen),
868 interruptible=on_interrupt is not None,
869 )
870 except KeyboardInterrupt:
871 # Raises when the operator confirms the quit; returns when they
872 # decline, and the loop then re-renders THIS screen with the
873 # traversal exactly as it was.
874 on_interrupt() # type: ignore[misc] — only reachable when set
875 continue
876 if raw is None:
877 navigator.abandon()
878 break
879 answer = raw.lower()
880 if answer in ABANDON_ANSWERS:
881 navigator.abandon()
882 break
883 if answer == BACK_ANSWER:
884 if not navigator.back():
885 break
886 continue
887 if answer in (SHOW_ALL_ANSWER, REFRESH_ANSWER):
888 action = ACTION_SHOW_ALL if answer == SHOW_ALL_ANSWER else ACTION_REFRESH
889 if not navigator.act(action):
890 presenter.show_info(f" {navigator.last_error}")
891 continue
892 key = _resolve_answer(screen, raw)
893 if not key or not navigator.choose(key):
894 refused = next((c for c in screen.choices if c.key == key), None)
895 if refused is not None and refused.reason:
896 presenter.show_info(f" {refused.reason}")
897 else:
898 presenter.show_info(f" Not a choice here: {raw!r}")
899
900 selection = navigator.selection()
901 _dbg("OC_CHAT.MAIN", func,
902 f"picker closed: {selection if selection else 'no selection'}", 2)
903 return selection
904
905
906__all__ = [
907 "ABANDON_ANSWERS",
908 "ACTION_REFRESH",
909 "ACTION_SHOW_ALL",
910 "Answer",
911 "BACK_ANSWER",
912 "COLLAPSIBLE_LEVELS",
913 "CONFIRM_KEY",
914 "LEVEL_CLASS",
915 "LEVEL_CONFIRM",
916 "LEVEL_FAMILY",
917 "LEVEL_LANE",
918 "LEVEL_MODEL",
919 "LEVEL_ORDER",
920 "LaneSelection",
921 "Navigator",
922 "PickerState",
923 "REFRESH_ANSWER",
924 "SHOW_ALL_ANSWER",
925 "Screen",
926 "advance",
927 "apply_refresh",
928 "apply_show_all",
929 "choices_for",
930 "collapse",
931 "current_level",
932 "default_fetcher",
933 "full_pool",
934 "lane_options_from_registry",
935 "live_cache_from_overlay",
936 "pool_for",
937 "prompt_for",
938 "render_screen",
939 "run_lane_picker",
940]
LaneSelection|None selection(self)
None __init__(self, Sequence[LaneOption] lanes, *, Callable[[LaneOption], Sequence[str]]|None fetcher=None)
tuple[Choice,...] _actions(self, PickerState state, str level)
LaneOption|None lane_option(self)
tuple[str,...] live_models(self, str lane_name)
tuple[Choice,...] _family_choices(PickerState state)
PickerState _drop_levels(PickerState state, frozenset[str] levels)
PickerState|None advance(PickerState state, str key)
str _cell(str text, int width)
tuple[str,...] pool_for(PickerState state, str level)
tuple[LaneOption,...] lane_options_from_registry(*, Mapping[str, str]|None env=None, str|Path|None overlay_path=None)
tuple[Choice,...] _confirm_choices(PickerState state)
tuple[dict[str, tuple[str,...]], dict[str, str|None]] live_cache_from_overlay(str|Path|None path=None)
str prompt_for(Screen screen)
str _resolve_answer(Screen screen, str raw)
PickerState collapse(PickerState state)
list[str] render_screen(Screen screen)
str _title_for(PickerState state, str level)
tuple[str,...] default_fetcher(LaneOption option)
tuple[str,...] full_pool(PickerState state, LaneOption option)
LaneSelection|None run_lane_picker(Any presenter, *, Any prompt_driver=None, Sequence[LaneOption]|None lanes=None, Mapping[str, str]|None env=None, Callable[[LaneOption], Sequence[str]]|None fetcher=None, str|Path|None overlay_path=None, Callable[[], None]|None on_interrupt=None)
str|None current_level(PickerState state)
tuple[Choice,...] _model_choices(PickerState state)
tuple[str,...] _base_pool(PickerState state, LaneOption option)
tuple[Choice,...] _lane_choices(PickerState state)
str|None _read(Any driver, str prompt_text, *, bool interruptible=False)
PickerState|None apply_show_all(PickerState state)
PickerState apply_refresh(PickerState state, Callable[[LaneOption], Sequence[str]] fetcher)
tuple[Choice,...] _class_choices(PickerState state)
tuple[Choice,...] choices_for(PickerState state)