Coverage for src/lilbee/cli/tui/screens/settings.py: 100%
573 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Settings screen. Grouped, type-aware configuration editor."""
3from __future__ import annotations
5import asyncio
6import logging
7from collections.abc import Callable, Iterable, Mapping
8from dataclasses import dataclass
9from typing import TYPE_CHECKING, Any, ClassVar
11from textual import on, work
12from textual.app import ComposeResult
13from textual.binding import Binding, BindingType
14from textual.containers import Container, Horizontal, VerticalGroup, VerticalScroll
15from textual.screen import Screen
16from textual.widget import Widget
17from textual.widgets import (
18 Button,
19 Checkbox,
20 Collapsible,
21 Input,
22 Select,
23 Static,
24 TabbedContent,
25 TabPane,
26 TextArea,
27)
29from lilbee.app.settings import (
30 OCR_SETTING_KEYS,
31 chunk_limit_loosened,
32 list_setting_value,
33 reindex_values,
34 reindex_values_differ,
35 setting_sources,
36)
37from lilbee.app.settings_map import SETTINGS_MAP, SettingDef, SettingGroup
38from lilbee.app.status import held_out_sources
39from lilbee.cli.tui import messages as msg
40from lilbee.cli.tui.browse_bindings import BROWSE_LIST_BINDINGS, browse_back_bindings
41from lilbee.cli.tui.screens.profile_tab import ProfileSnapshot, ProfileTab, load_snapshot
42from lilbee.cli.tui.screens.settings_widgets import (
43 ADVANCED_COLLAPSIBLE_CLASS,
44 ADVANCED_COLLAPSIBLE_ID_PREFIX,
45 API_KEYS_GROUP,
46 API_KEYS_WARNING_CLASS,
47 EDITOR_ID_PREFIX,
48 EDITOR_KINDS,
49 GATED_ROWS,
50 LIST_ERROR_ID_PREFIX,
51 LIST_ERROR_VISIBLE_CLASS,
52 LIST_RESTORE_PREFIX,
53 MODEL_PICKER_BUTTON_PREFIX,
54 RESET_BUTTON_ID_PREFIX,
55 RESET_BUTTON_LABEL,
56 ROW_ID_PREFIX,
57 SettingEditor,
58 SettingRow,
59 active_profile_name,
60 advanced_title,
61 config_toml_path,
62 displayed_text,
63 editor_choices,
64 effective_value,
65 group_settings,
66 help_content,
67 list_editor_text,
68 make_editor,
69 model_field_to_picker_scope,
70 model_picker_label,
71 picker_scope_to_task,
72 row_visible,
73 select_shown_value,
74 set_widget_value,
75 title_content,
76)
77from lilbee.cli.tui.widgets.list_text_area import ListTextArea
78from lilbee.cli.tui.widgets.model_pick import STORE_ERRORS, apply_model_pick
79from lilbee.cli.tui.widgets.profile_line import ProfileLine, ProfileLinePill
80from lilbee.core.config import cfg
81from lilbee.core.config.enums import SettingSource
83if TYPE_CHECKING:
84 from lilbee.cli.tui.app import LilbeeApp
85 from lilbee.cli.tui.screens.model_picker import PickerScope
86 from lilbee.cli.tui.widgets.model_bar import ModelOption
88log = logging.getLogger(__name__)
90PROFILE_PANE_ID = "settings-tab-profile"
93@dataclass(frozen=True)
94class _PaneGroup:
95 """One settings tab: pane id, group label, ordered settings."""
97 pane_id: str
98 group_name: SettingGroup
99 items: list[tuple[str, SettingDef]]
102class _LazyGroupBody(VerticalScroll, can_focus=False):
103 """Pane-body that mounts rows on first activation; scrolls when taller than viewport."""
105 def __init__(self, *, id: str | None = None) -> None:
106 super().__init__(id=id)
107 self._populated = False
109 @property
110 def populated(self) -> bool:
111 return self._populated
113 def populate(self, build: Callable[[], list[Widget]]) -> None:
114 """Build and mount this pane's row widgets exactly once."""
115 if self._populated:
116 return
117 self._populated = True
118 widgets = build()
119 if widgets:
120 self.mount_all(widgets)
123class SettingsScreen(Screen[None]):
124 """Interactive settings viewer with grouped, type-aware editors."""
126 app: LilbeeApp # type: ignore[assignment]
128 CSS_PATH = "settings.tcss"
129 # Target the TabbedContent's inner Tabs strip rather than the outer
130 # #settings-scroll Container -- Container can't accept focus, so on
131 # mount focus would otherwise stay at None and downstream Tab-cycling
132 # has nowhere to start. The Tabs widget is the canonical entry point.
133 AUTO_FOCUS = "#settings-tabs Tabs"
134 HELP = (
135 "Browse and edit configuration.\n\n"
136 "Tab / Shift+Tab move between fields, > and < jump between groups, "
137 "j / k scroll and g / G jump to the top / bottom, "
138 "Ctrl+R resets the focused field and Ctrl+Shift+R resets every "
139 "setting, and q or Escape goes back. In a value field the keys type, "
140 "so press Escape to leave the field first; then > and < work again."
141 )
143 # < and > are one action in two directions, so a single "Tabs" label still
144 # says what both keys do. Keys that do different things get their own cell
145 # or move to the help panel.
146 _TAB_GROUP = Binding.Group("Tabs", compact=True)
148 BINDINGS: ClassVar[list[BindingType]] = [
149 # In a value field Escape leaves the field; check_action keeps Back off then, so the
150 # second Escape, from the row, goes back. Priority, so the app's priority Escape
151 # does not take the footer cell; that one skips when no help panel is open.
152 Binding("escape", "leave_field", msg.SETTINGS_LEAVE_FIELD, show=True, priority=True),
153 *browse_back_bindings(),
154 # Tab cycles editors inside the active pane and rolls over to the
155 # next group tab when you Tab past the last editor (and the
156 # previous group tab on shift+Tab past the first editor). Use
157 # > / < to jump straight to the next / previous group tab.
158 Binding("tab", "next_field_or_pane", "Next field", show=False),
159 Binding("shift+tab", "prev_field_or_pane", "Prev field", show=False),
160 # Direct tab cycling, mirrored from CatalogScreen. priority=True so the keys win
161 # over the screen's other bindings; a focused text field still keeps its characters.
162 Binding(
163 "less_than_sign",
164 "cycle_pane(-1)",
165 "Prev tab",
166 show=True,
167 priority=True,
168 group=_TAB_GROUP,
169 ),
170 Binding(
171 "greater_than_sign",
172 "cycle_pane(1)",
173 "Next tab",
174 show=True,
175 priority=True,
176 group=_TAB_GROUP,
177 ),
178 # Resetting the focused setting is what this screen is for, so it keeps
179 # a cell. Reset-all is a rarer, wider-reaching action and lives in help;
180 # the two are different actions, so they must not share one label.
181 Binding("ctrl+r", "reset_focused", "Reset", show=True),
182 Binding("ctrl+shift+r", "reset_all", "Reset all", show=False),
183 *BROWSE_LIST_BINDINGS,
184 ]
186 def __init__(self) -> None:
187 super().__init__()
188 # Group definitions by pane id; a pane fills on first activation.
189 self._pane_groups: dict[str, _PaneGroup] = {}
190 self._pane_ids: list[str] = [PROFILE_PANE_ID]
191 self._eagerly_populate = PROFILE_PANE_ID
192 # What each editor showed when last built or refreshed; a save acts on a change from it.
193 self._mount_display: dict[str, str] = {}
194 self._stale_source_keys: set[str] = set()
195 # The index-changing values in force when the user came in; None while away.
196 self._index_values: dict[str, Any] | None = None
198 def compose(self) -> ComposeResult:
199 from lilbee.cli.tui.widgets.bottom_bars import BottomBars
200 from lilbee.cli.tui.widgets.stable_footer import StableFooter
201 from lilbee.cli.tui.widgets.status_bar import ViewTabs
202 from lilbee.cli.tui.widgets.task_bar import TaskBar
203 from lilbee.cli.tui.widgets.top_bars import TopBars
205 with TopBars():
206 yield ViewTabs()
207 yield ProfileLine(id="profile-line")
208 # A Container, not a VerticalScroll: each tab body scrolls, and nested scrollables tear.
209 with Container(id="settings-scroll"), TabbedContent(id="settings-tabs"):
210 yield TabPane(
211 msg.PROFILE_TAB_LABEL,
212 _LazyGroupBody(id=f"{PROFILE_PANE_ID}-body"),
213 id=PROFILE_PANE_ID,
214 )
215 yield from self._compose_group_tabs()
216 with BottomBars():
217 yield TaskBar()
218 yield StableFooter()
220 def _compose_group_tabs(self) -> ComposeResult:
221 """Yield one TabPane per setting group; bodies populate on activation."""
222 for group_name, items in group_settings().items():
223 pane_id = f"settings-tab-{group_name.lower().replace('-', '_')}"
224 self._pane_groups[pane_id] = _PaneGroup(
225 pane_id=pane_id, group_name=group_name, items=items
226 )
227 self._pane_ids.append(pane_id)
228 yield TabPane(
229 group_name,
230 _LazyGroupBody(id=f"{pane_id}-body"),
231 id=pane_id,
232 )
234 def on_mount(self) -> None:
235 """Defer first-pane content mount until after the screen has painted.
237 ``_populate_pane`` calls ``mount_all`` for ~25 editor widgets which
238 triggers a full Textual layout pass; running it inside ``on_mount``
239 adds that pass to the screen-switch latency budget. ``call_after_refresh``
240 moves it to the next event-loop tick so the user sees the empty pane
241 skeleton immediately and the rows hydrate one frame later.
242 """
243 self.app.settings_changed_signal.subscribe(self, self._on_setting_changed)
244 # The first pane is the one TabbedContent activates by default; populate
245 # it eagerly so Settings shows content on first paint.
246 self.call_after_refresh(self._populate_pane, self._eagerly_populate)
248 def on_screen_resume(self) -> None:
249 """Take setting changes and rescan the profiles: another shell may have changed both.
251 The first resume only opens the screen: the Profile tab loads its own state on mount.
252 """
253 file_changed = self.app.adopt_external_settings()
254 if file_changed or self._pane_is_populated(PROFILE_PANE_ID):
255 self.reload_profile()
256 # A dialog closing resumes the screen too; only a visit takes the values.
257 if self._index_values is None:
258 self.take_index_values()
260 def on_screen_suspend(self) -> None:
261 """Offer one rebuild for the edits of this visit once another view replaces the screen.
263 A dialog on top suspends the screen too, but leaves it on the stack.
264 """
265 if self not in self.app.screen_stack and (message := self.leave()):
266 self.app.offer_rebuild(message)
268 def take_index_values(self, keys: Iterable[str] | None = None) -> None:
269 """Count the values in force of *keys*, or of all when None, as the ones the index holds.
271 Leaving asks about the values changed after this. Only a visit in progress moves for *keys*.
272 """
273 values = reindex_values()
274 if keys is None:
275 self._index_values = values
276 elif self._index_values is not None:
277 self._index_values.update({key: values[key] for key in keys if key in values})
279 def leave(self) -> str | None:
280 """End the visit; the rebuild question its edits leave, or None when they leave none.
282 A rebuild is owed when the index was built with other values, or when a looser
283 chunk limit now admits files it held out: a rebuild retries those files.
284 """
285 before, self._index_values = self._index_values, None
286 if before is None:
287 return None
288 retry = chunk_limit_loosened(before) and held_out_sources()[1] > 0
289 return msg.settings_rebuild_text(reindex_values_differ(before), retry)
291 def _on_setting_changed(self, change: tuple[str, object]) -> None:
292 """Queue the changed key's source pill and editor, and the profile state, for a refresh."""
293 key, _value = change
294 if not self._stale_source_keys:
295 self.call_after_refresh(self._refresh_changed_rows)
296 self._stale_source_keys.add(key)
298 def _refresh_changed_rows(self) -> None:
299 """Repaint every queued row's title and editor from one read of the setting sources."""
300 keys, self._stale_source_keys = self._stale_source_keys, set()
301 sources = setting_sources()
302 profile_name = active_profile_name()
303 rows = {row.id: row for row in self.query(".setting-row")}
304 sections: dict[str, Collapsible] = {}
305 for key in keys:
306 row = rows.get(f"{ROW_ID_PREFIX}{key}")
307 if row is None:
308 continue
309 for ancestor in row.ancestors:
310 if isinstance(ancestor, Collapsible) and ancestor.has_class(
311 ADVANCED_COLLAPSIBLE_CLASS
312 ):
313 sections[ancestor.id or ""] = ancestor
314 for title in row.query_children(".setting-title").results(Static):
315 title.update(title_content(key, SETTINGS_MAP[key], sources[key], profile_name))
316 self._sync_editor(row, key)
317 for section in sections.values():
318 pane_id = (section.id or "").removeprefix(ADVANCED_COLLAPSIBLE_ID_PREFIX)
319 advanced = [k for k, defn in self._pane_groups[pane_id].items if defn.advanced]
320 section.title = advanced_title(advanced, sources)
321 if self._is_visible():
322 self.reload_profile()
324 def _is_visible(self) -> bool:
325 """Whether the user sees this screen: it is on top, or the top screen is see-through."""
326 top = self.app.screen
327 return top is self or top.styles.background.a < 1
329 def _sync_editor(self, row: Widget, key: str) -> None:
330 """Show cfg's value in the editor in *row*, unless the user is typing in it."""
331 editor = row.query(f"#{EDITOR_ID_PREFIX}{key}")
332 if editor and not editor.first().has_focus_within:
333 self._show_editor_value(key, editor.first(), getattr(cfg, key))
335 @work(exclusive=True, group="profile-load", exit_on_error=False)
336 async def reload_profile(self) -> None:
337 """Load the profile state off the loop, then show it on the line and the Profile tab.
339 Only the read runs on a thread. A newer load cancels this task at the await,
340 and a cancelled thread worker would still deliver its old snapshot.
341 """
342 try:
343 snapshot = await asyncio.to_thread(load_snapshot)
344 except OSError as exc:
345 self._show_profile_failure(str(exc))
346 return
347 self._show_profile(snapshot)
349 def _mounted_profile_tabs(self) -> list[ProfileTab]:
350 """The tabs that can take a snapshot: filling one before it mounts raises."""
351 return [tab for tab in self.query(ProfileTab) if tab.is_mounted]
353 def _show_profile_failure(self, reason: str) -> None:
354 for tab in self._mounted_profile_tabs():
355 tab.show_failure(reason)
357 def _show_profile(self, snapshot: ProfileSnapshot) -> None:
358 self.query_one(ProfileLine).show(snapshot)
359 for tab in self._mounted_profile_tabs():
360 tab.show(snapshot)
362 @on(ProfileTab.Stale)
363 def _on_profile_stale(self) -> None:
364 self.reload_profile()
366 @on(ProfileLinePill.Jump)
367 def _on_profile_jump(self) -> None:
368 self.show_profile_tab()
370 def show_profile_tab(self) -> None:
371 """Activate the Profile tab and put focus on its dropdown."""
372 self.query_one("#settings-tabs", TabbedContent).active = PROFILE_PANE_ID
373 self._populate_pane(PROFILE_PANE_ID)
374 self.call_after_refresh(self._focus_profile_select)
376 def _focus_profile_select(self) -> None:
377 for tab in self.query(ProfileTab):
378 tab.focus_select()
380 @on(TabbedContent.TabActivated)
381 def _on_tab_activated(self, event: TabbedContent.TabActivated) -> None:
382 """Populate the activated pane's body on first activation, and take outside changes.
384 A config.toml change another process wrote reloads the profile line on any tab.
385 """
386 pane = event.pane
387 if pane is None or pane.id is None:
388 return
389 file_changed = self.app.adopt_external_settings()
390 revisit = pane.id == PROFILE_PANE_ID and self._pane_is_populated(pane.id)
391 self._populate_pane(pane.id)
392 if revisit or file_changed:
393 self.reload_profile()
395 def _pane_is_populated(self, pane_id: str) -> bool:
396 """Whether *pane_id*'s body already built its rows; a new body loads its own state."""
397 return any(
398 body.populated for body in self.query(f"#{pane_id}-body").results(_LazyGroupBody)
399 )
401 def populate_all_panes(self) -> None:
402 """Force every tab body to populate now (test/agent helper)."""
403 for pane_id in self._pane_ids:
404 self._populate_pane(pane_id)
406 def _populate_pane(self, pane_id: str) -> None:
407 """Populate a pane's body if known and the body widget is mounted."""
408 build = self._pane_builder(pane_id)
409 if build is None:
410 return
411 try:
412 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody)
413 except Exception:
414 log.debug("pane body %s not yet mounted", pane_id, exc_info=True)
415 return
416 body.populate(build)
418 def _pane_builder(self, pane_id: str) -> Callable[[], list[Widget]] | None:
419 """The function that builds *pane_id*'s body widgets, or None for an unknown pane."""
420 if pane_id == PROFILE_PANE_ID:
421 return lambda: [ProfileTab()]
422 group = self._pane_groups.get(pane_id)
423 return None if group is None else lambda: self._build_pane_widgets(group)
425 def _build_pane_widgets(self, group: _PaneGroup) -> list[Widget]:
426 """Return the body widgets for one settings tab.
428 Advanced settings are folded into one collapsed section at the
429 bottom, after every regular row.
430 """
431 widgets: list[Widget] = []
432 if group.group_name == API_KEYS_GROUP:
433 widgets.append(
434 Static(
435 msg.SETTINGS_API_KEYS_WARNING.format(path=config_toml_path()),
436 classes=API_KEYS_WARNING_CLASS,
437 )
438 )
439 sources = setting_sources()
440 profile_name = active_profile_name()
441 basic = [(key, defn) for key, defn in group.items if not defn.advanced]
442 advanced = [(key, defn) for key, defn in group.items if defn.advanced]
443 for key, defn in basic:
444 widgets.append(self._build_setting_row(key, defn, sources[key], profile_name))
445 if advanced:
446 rows = [
447 self._build_setting_row(key, defn, sources[key], profile_name)
448 for key, defn in advanced
449 ]
450 widgets.append(
451 self._build_advanced_section(group.pane_id, [k for k, _ in advanced], rows, sources)
452 )
453 return widgets
455 def _build_advanced_section(
456 self,
457 pane_id: str,
458 keys: list[str],
459 rows: list[VerticalGroup],
460 sources: Mapping[str, SettingSource],
461 ) -> Collapsible:
462 """One collapsed section holding every advanced row for a tab."""
463 title = advanced_title(keys, sources)
464 return Collapsible(
465 *rows,
466 title=title,
467 collapsed=True,
468 id=f"{ADVANCED_COLLAPSIBLE_ID_PREFIX}{pane_id}",
469 classes=ADVANCED_COLLAPSIBLE_CLASS,
470 )
472 def _build_setting_row(
473 self, key: str, defn: SettingDef, source: SettingSource, profile_name: str | None
474 ) -> VerticalGroup:
475 """Construct one setting row with its title, help, editor, and reset."""
476 title = Static(title_content(key, defn, source, profile_name), classes="setting-title")
477 help_widget = Static(help_content(key, defn), classes="setting-help")
478 children: list[Widget] = [title, help_widget]
479 if key in model_field_to_picker_scope():
480 children.append(self._build_model_picker_row(key))
481 elif defn.writable:
482 editor = make_editor(key, defn)
483 self._mount_display[key] = self._mount_baseline(key, defn, editor)
484 editor_row = Horizontal(
485 editor,
486 Button(
487 RESET_BUTTON_LABEL,
488 id=f"{RESET_BUTTON_ID_PREFIX}{key}",
489 classes="setting-reset-button",
490 tooltip=msg.SETTINGS_RESET_TO_DEFAULT_TOOLTIP,
491 ),
492 classes="setting-editor-row",
493 )
494 children.append(editor_row)
495 row = SettingRow(
496 *children,
497 classes="setting-row",
498 id=f"{ROW_ID_PREFIX}{key}",
499 )
500 row.display = row_visible(key)
501 return row
503 @staticmethod
504 def _mount_baseline(key: str, defn: SettingDef, editor: Collapsible | SettingEditor) -> str:
505 """The text a freshly built editor will show once mounted."""
506 # A list setting's editor is a Collapsible that holds the text area.
507 if isinstance(editor, Collapsible):
508 return list_editor_text(key)
509 if editor_choices(defn):
510 return select_shown_value(defn, effective_value(key))
511 return displayed_text(editor)
513 def _build_model_picker_row(self, key: str) -> Horizontal:
514 """A button-style row that opens the same ModelPickerModal as the chat bar."""
515 return Horizontal(
516 Button(
517 model_picker_label(key),
518 id=f"{MODEL_PICKER_BUTTON_PREFIX}{key}",
519 classes="setting-model-picker-button",
520 ),
521 classes="setting-editor-row",
522 )
524 @on(Input.Submitted, ".setting-editor")
525 @on(Input.Blurred, ".setting-editor")
526 def _on_input_save(self, event: Input.Submitted | Input.Blurred) -> None:
527 """Save string/number input on submit or blur, but only if it changed."""
528 name = event.input.name
529 if name is None:
530 return
531 defn = SETTINGS_MAP.get(name)
532 if defn is None:
533 return
534 raw = event.value.strip()
535 if self._mount_display.get(name) == raw:
536 return
537 if self._persist_value(name, defn, raw):
538 self._mount_display[name] = raw
540 @on(ListTextArea.Blurred, ".setting-multiline-editor")
541 def _on_multiline_save(self, event: ListTextArea.Blurred) -> None:
542 """Save multi-line string settings (system prompts) on blur, but only if changed."""
543 ta = event.control
544 name = ta.name
545 if name is None:
546 return
547 defn = SETTINGS_MAP.get(name)
548 if defn is None:
549 return
550 raw = ta.text
551 if self._mount_display.get(name) == raw:
552 return
553 if self._persist_value(name, defn, raw):
554 self._mount_display[name] = raw
556 @on(Checkbox.Changed, ".setting-editor")
557 def _on_checkbox_save(self, event: Checkbox.Changed) -> None:
558 """Save boolean on toggle, but only if it still differs from the last saved value."""
559 name = event.checkbox.name
560 if name is None:
561 return
562 defn = SETTINGS_MAP.get(name)
563 if defn is None:
564 return
565 value = str(event.checkbox.value)
566 if self._mount_display.get(name) == value:
567 return
568 if self._persist_value(name, defn, value):
569 self._mount_display[name] = value
571 @on(Select.Changed, ".setting-editor")
572 def _on_select_save(self, event: Select.Changed) -> None:
573 """Save select choice on change, but only if it differs from the mount baseline."""
574 name = event.select.name
575 if name is None:
576 return
577 defn = SETTINGS_MAP.get(name)
578 if defn is None:
579 return
580 value = str(event.value) if event.value is not Select.NULL else ""
581 if self._mount_display.get(name) == value:
582 return
583 if self._persist_value(name, defn, value):
584 self._mount_display[name] = value
586 def _persist_value(self, key: str, defn: SettingDef, raw: str) -> bool:
587 """Parse, apply, and persist a setting value. Returns whether it succeeded.
589 Success is silent; a parse or apply error toasts and returns False.
590 """
591 try:
592 parsed = self._parse_value(defn, raw)
593 if not self.app.set_setting(key, parsed, ask_rebuild=False):
594 return False
595 self._refresh_help(key, defn)
596 return True
597 except (ValueError, TypeError) as exc:
598 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error")
599 return False
601 def _parse_value(self, defn: SettingDef, raw: str) -> object:
602 """Convert a raw string to the setting's target type."""
603 if defn.nullable and raw.lower() in ("none", "null", ""):
604 return None
605 if defn.type is bool:
606 # The text goes to the shared write, which refuses what it cannot read.
607 return raw
608 if defn.type is list:
609 return list_setting_value(raw)
610 return defn.type(raw)
612 @staticmethod
613 def _validate_regex_list(lines: list[str]) -> tuple[int, str] | None:
614 """Return the 1-indexed line number and reason for the first pattern the crawler refuses."""
615 from lilbee.core.crawl_patterns import refused_exclude_patterns
617 refused = refused_exclude_patterns(lines)
618 return (refused[0][0] + 1, refused[0][1]) if refused else None
620 @on(ListTextArea.Blurred, ".setting-list-editor")
621 def _on_list_blur_save(self, event: ListTextArea.Blurred) -> None:
622 """Validate and save list values when a ListTextArea loses focus, but only if changed."""
623 ta = event.control
624 key = ta.name
625 if key is None:
626 return
627 defn = SETTINGS_MAP.get(key)
628 if defn is None:
629 return
630 raw = ta.text
631 if self._mount_display.get(key) == raw:
632 return
633 parsed = self._parse_value(defn, raw)
634 assert isinstance(parsed, list) # noqa: S101 -- mypy narrowing, defn.type is list above
635 err = self._validate_regex_list(parsed) if defn.validate_regex else None
636 error_widget = self.query_one(f"#{LIST_ERROR_ID_PREFIX}{key}", Static)
637 if err is not None:
638 line_no, err_text = err
639 error_widget.update(
640 msg.SETTINGS_LIST_EDITOR_INVALID_REGEX.format(n=line_no, error=err_text)
641 )
642 error_widget.add_class(LIST_ERROR_VISIBLE_CLASS)
643 return
644 error_widget.remove_class(LIST_ERROR_VISIBLE_CLASS)
645 if self._persist_value(key, defn, raw):
646 self._mount_display[key] = raw
647 self._refresh_list_title(key, len(parsed))
649 @on(Button.Pressed, ".setting-list-restore")
650 def _on_list_restore(self, event: Button.Pressed) -> None:
651 """Reset a list setting."""
652 btn_id = event.button.id
653 if btn_id is None or not btn_id.startswith(LIST_RESTORE_PREFIX):
654 return
655 key = btn_id.removeprefix(LIST_RESTORE_PREFIX)
656 if SETTINGS_MAP.get(key) is None or not self._reset_keys([key]):
657 return
658 error_widget = self.query_one(f"#{LIST_ERROR_ID_PREFIX}{key}", Static)
659 error_widget.remove_class(LIST_ERROR_VISIBLE_CLASS)
660 self._refresh_list_title(key, len(getattr(cfg, key)))
662 def _refresh_list_title(self, key: str, count: int) -> None:
663 """Update the Collapsible title to reflect the current line count."""
664 try:
665 collapsible = self.query_one(f"#collapsible-{key}", Collapsible)
666 collapsible.title = msg.SETTINGS_LIST_EDITOR_TITLE.format(key=key, count=count)
667 except Exception:
668 log.debug("Failed to refresh collapsible title for %s", key, exc_info=True)
670 def _refresh_help(self, key: str, defn: SettingDef) -> None:
671 """Update the help text after a value change; an OCR key refreshes every OCR row."""
672 if key not in OCR_SETTING_KEYS:
673 self._update_help_row(key, defn)
674 return
675 for ocr_key in OCR_SETTING_KEYS:
676 self._update_help_row(ocr_key, SETTINGS_MAP[ocr_key])
677 for gated_key in GATED_ROWS:
678 self._update_row_visibility(gated_key)
680 def _update_row_visibility(self, key: str) -> None:
681 """Show or hide a gated row; an unpopulated pane applies the gate when it builds."""
682 try:
683 self.query_one(f"#{ROW_ID_PREFIX}{key}", VerticalGroup).display = row_visible(key)
684 except Exception:
685 log.debug("No row to show or hide for %s", key, exc_info=True)
687 def _update_help_row(self, key: str, defn: SettingDef) -> None:
688 """Re-render one row's help text."""
689 try:
690 row = self.query_one(f"#{ROW_ID_PREFIX}{key}", VerticalGroup)
691 help_widget = row.query_one(".setting-help", Static)
692 help_widget.update(help_content(key, defn))
693 except Exception:
694 log.debug("Failed to refresh help for %s", key, exc_info=True)
696 @on(Button.Pressed, ".setting-reset-button")
697 def _on_reset_pressed(self, event: Button.Pressed) -> None:
698 """Handle the small reset button embedded in each writable row."""
699 button_id = event.button.id
700 if button_id is None or not button_id.startswith(RESET_BUTTON_ID_PREFIX):
701 return
702 key = button_id[len(RESET_BUTTON_ID_PREFIX) :]
703 self._reset_to_default(key)
705 @on(Button.Pressed, ".setting-model-picker-button")
706 def _on_model_picker_pressed(self, event: Button.Pressed) -> None:
707 """Open ModelPickerModal for the model field this button represents."""
708 button_id = event.button.id
709 if button_id is None or not button_id.startswith(MODEL_PICKER_BUTTON_PREFIX):
710 return
711 key = button_id[len(MODEL_PICKER_BUTTON_PREFIX) :]
712 scope = model_field_to_picker_scope().get(key)
713 if scope is None:
714 return
715 self._discover_then_open_picker(key, scope)
717 @work(thread=True, exit_on_error=False)
718 def _discover_then_open_picker(self, key: str, scope: PickerScope) -> None:
719 """Discover installed models off the UI thread, then push the picker.
721 ``classify_installed_models_full`` probes the native registry,
722 Ollama (HTTP), and SDK provider lists. Running it on the
723 event loop blocks paint for hundreds of ms; the chat-bar uses
724 the same worker pattern.
725 """
726 from lilbee.cli.tui.thread_safe import call_from_thread
727 from lilbee.cli.tui.widgets.model_bar import classify_installed_models_full
729 task = picker_scope_to_task(scope)
730 buckets = classify_installed_models_full()
731 options = list(buckets.get(task, []))
732 call_from_thread(self, self._push_model_picker, key, scope, options)
734 def _push_model_picker(self, key: str, scope: PickerScope, options: list[ModelOption]) -> None:
735 """Push ModelPickerModal once the worker has resolved options."""
736 from lilbee.cli.tui.screens.model_picker import ModelPickerModal
737 from lilbee.cli.tui.widgets.model_bar import ModelOption
739 # Bail out if the user navigated away from Settings while the
740 # discovery worker was still running; otherwise we'd push the
741 # modal onto whatever screen is now on top.
742 if not self.is_mounted:
743 return
744 if not options:
745 options = [ModelOption(label=msg.MODEL_VALUE_NONE, ref="")]
746 # Nullable model fields (vision_model, reranker_model) need an
747 # explicit "disable this model" pick. The picker's empty-input
748 # cancel returns None; this row returns "" so the dismiss
749 # handler can distinguish "cancel" from "set to none".
750 defn = SETTINGS_MAP.get(key)
751 if defn is not None and defn.nullable:
752 options = [
753 ModelOption(label=msg.MODEL_PICKER_DISABLE_LABEL, ref=""),
754 *options,
755 ]
756 self.app.push_screen(
757 ModelPickerModal(scope=scope, options=options),
758 lambda ref: self._on_model_picker_dismissed(key, ref),
759 )
761 def _on_model_picker_dismissed(self, key: str, ref: str | None) -> None:
762 """Persist the picker selection, refresh the button, and reload the role's server."""
763 # apply_model_pick owns the reload (off the event loop in a worker); the
764 # on_done callback only repaints the button once the swap is applied.
765 apply_model_pick(self, key=key, ref=ref, on_done=lambda: self._after_model_pick(key))
767 def _after_model_pick(self, key: str) -> None:
768 """Refresh the picker button after a swap.
770 The role reload is owned by ``apply_model_pick`` (it runs off the event
771 loop in a worker), so this on_done only repaints the label. Reloading
772 here too would double the fleet restart AND block the UI on the main
773 thread, which is the freeze this path is meant to avoid.
774 """
775 self._refresh_picker_button(key)
776 self._refresh_help(key, SETTINGS_MAP[key])
778 def _refresh_picker_button(self, key: str) -> None:
779 try:
780 button = self.query_one(f"#{MODEL_PICKER_BUTTON_PREFIX}{key}", Button)
781 button.label = model_picker_label(key)
782 except Exception:
783 log.debug("Failed to refresh model picker label for %s", key, exc_info=True)
785 def action_reset_all(self) -> None:
786 """Bound to Ctrl+Shift+R; opens the destructive-confirm dialog."""
787 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
789 self.app.push_screen(
790 ConfirmDialog(
791 title=msg.SETTINGS_RESET_ALL_CONFIRM_TITLE,
792 message=msg.SETTINGS_RESET_ALL_CONFIRM_MESSAGE,
793 ),
794 self._on_reset_all_confirmed,
795 )
797 def _on_reset_all_confirmed(self, confirmed: bool | None) -> None:
798 """Reset every writable setting in one batch."""
799 if not confirmed:
800 return
802 writable = [key for key, defn in SETTINGS_MAP.items() if defn.writable]
803 if self._reset_keys(writable, skip_unresettable=True):
804 self.notify(msg.SETTINGS_RESET_ALL_SUCCESS)
806 def action_reset_focused(self) -> None:
807 """Reset the setting whose row holds focus."""
808 focused = self.focused
809 if focused is None:
810 return
811 for ancestor in focused.ancestors_with_self:
812 ancestor_id = getattr(ancestor, "id", None)
813 if ancestor_id and ancestor_id.startswith(ROW_ID_PREFIX):
814 key = ancestor_id[len(ROW_ID_PREFIX) :]
815 self._reset_to_default(key)
816 return
818 def _reset_to_default(self, key: str) -> None:
819 """Reset one writable setting."""
820 defn = SETTINGS_MAP.get(key)
821 if defn is None or not defn.writable:
822 return
823 self._reset_keys([key])
825 def _reset_keys(self, keys: list[str], *, skip_unresettable: bool = False) -> bool:
826 """Reset *keys*, then show each one's resolved value. Returns whether it succeeded."""
827 try:
828 reset = self.app.reset_and_publish(keys, skip_unresettable=skip_unresettable)
829 except STORE_ERRORS as exc:
830 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error")
831 return False
832 for key in reset:
833 defn = SETTINGS_MAP[key]
834 self._refresh_editor(key, defn, getattr(cfg, key))
835 self._refresh_help(key, defn)
836 return True
838 def _refresh_editor(self, key: str, defn: SettingDef, value: object) -> None:
839 """Update the editor widget to reflect a new value (e.g. after reset)."""
840 try:
841 widget = self.query_one(f"#{EDITOR_ID_PREFIX}{key}")
842 except Exception:
843 log.debug("Failed to refresh editor for %s", key, exc_info=True)
844 return
845 self._show_editor_value(key, widget, value)
847 def _show_editor_value(self, key: str, widget: Widget, value: object) -> None:
848 """Put *value* in *key*'s editor *widget* and make it the save baseline."""
849 # prevent() only covers a Changed message this call posts, not one already queued.
850 # Checkbox and Select save on Changed, so a sync must not post either one.
851 with widget.prevent(Checkbox.Changed, Select.Changed):
852 set_widget_value(widget, value)
853 # A key absent from _mount_display has no baseline; the isinstance narrows for mypy.
854 if key in self._mount_display and isinstance(widget, EDITOR_KINDS):
855 self._mount_display[key] = displayed_text(widget)
857 def _focused_field_row(self) -> SettingRow | None:
858 """The row of the value field that has focus, or None when focus is not in one."""
859 focused = self.focused
860 if not isinstance(focused, Input | TextArea):
861 return None
862 return next((a for a in focused.ancestors if isinstance(a, SettingRow)), None)
864 def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
865 """In a value field only Leave field applies; the field keeps every character."""
866 in_field = self._focused_field_row() is not None
867 if action == "leave_field":
868 return in_field
869 if action in ("go_back", "cycle_pane"):
870 return not in_field
871 return True
873 def action_leave_field(self) -> None:
874 """Move focus from the value field to its row, where the tab and view keys work."""
875 if (row := self._focused_field_row()) is not None:
876 row.focus()
878 def action_go_back(self) -> None:
879 self.app.go_back()
881 def _active_pane_body(self) -> _LazyGroupBody | None:
882 """Resolve the currently-active settings tab body (a VerticalScroll).
884 j/k/g/G key actions scroll this body directly because the outer
885 ``#settings-scroll`` is a Container, not a scroller -- one column
886 of scrolling per screen, the active tab's pane.
887 """
888 try:
889 tabs = self.query_one("#settings-tabs", TabbedContent)
890 except Exception:
891 return None
892 active = tabs.active
893 if not active:
894 return None
895 try:
896 return self.query_one(f"#{active}-body", _LazyGroupBody)
897 except Exception:
898 return None
900 def action_cursor_down(self) -> None:
901 if (body := self._active_pane_body()) is not None:
902 body.scroll_down()
904 def action_cursor_up(self) -> None:
905 if (body := self._active_pane_body()) is not None:
906 body.scroll_up()
908 def action_jump_top(self) -> None:
909 if (body := self._active_pane_body()) is not None:
910 body.scroll_home()
912 def action_jump_bottom(self) -> None:
913 if (body := self._active_pane_body()) is not None:
914 body.scroll_end()
916 def action_next_field_or_pane(self) -> None:
917 """Tab inside a pane; on overflow advance to the next group tab."""
918 self._move_focus_within_pane(direction=1)
920 def action_prev_field_or_pane(self) -> None:
921 """Shift+Tab inside a pane; on underflow retreat to the previous group tab."""
922 self._move_focus_within_pane(direction=-1)
924 def action_cycle_pane(self, delta: int) -> None:
925 """Step the active settings tab by *delta*, wrapping around the strip.
927 Shortcut for users who don't want to Tab through every field to
928 reach the next group. Mirrors CatalogScreen.action_cycle_tab. A
929 focused Input/TextArea consumes printable keys before even priority
930 bindings see them, so < and > type into a text field; Escape leaves it.
931 """
932 try:
933 tabs = self.query_one("#settings-tabs", TabbedContent)
934 except Exception:
935 return
936 pane_ids = self._pane_ids
937 try:
938 current = pane_ids.index(tabs.active)
939 except ValueError:
940 current = 0
941 next_id = pane_ids[(current + delta) % len(pane_ids)]
942 if tabs.active != next_id:
943 # TabbedContent activates the pane that holds the focused widget, so focus left in
944 # the old pane would pull the tab straight back.
945 self.query_one("#settings-tabs Tabs").focus()
946 tabs.active = next_id
948 def _focus_adjacent(self, direction: int) -> None:
949 """Move focus to the next/previous widget app-wide (direction 1 / -1)."""
950 if direction == 1:
951 self.app.action_focus_next()
952 else:
953 self.app.action_focus_previous()
955 def _move_focus_within_pane(self, *, direction: int) -> None:
956 focused = self.app.focused
957 tabs = self.query_one("#settings-tabs", TabbedContent)
958 active_pane_id = tabs.active
959 try:
960 body = self.query_one(f"#{active_pane_id}-body", _LazyGroupBody)
961 except Exception:
962 self._focus_adjacent(direction)
963 return
964 focusables = self._focus_stops(body)
965 if not focusables or focused is None or focused not in focusables:
966 self._focus_adjacent(direction)
967 return
968 index = focusables.index(focused)
969 next_index = index + direction
970 if 0 <= next_index < len(focusables):
971 focusables[next_index].focus()
972 return
973 # At the boundary: advance to the next/previous pane.
974 pane_ids = self._pane_ids
975 if active_pane_id not in pane_ids:
976 return
977 target_index = (pane_ids.index(active_pane_id) + direction) % len(pane_ids)
978 target_pane = pane_ids[target_index]
979 tabs.active = target_pane
980 self._populate_pane(target_pane)
981 # Park focus on the first/last field of the new pane so the next
982 # Tab keeps moving in the same direction.
983 self.call_after_refresh(self._focus_pane_edge, target_pane, direction)
985 def _focus_pane_edge(self, pane_id: str, direction: int) -> None:
986 try:
987 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody)
988 except Exception:
989 return
990 focusables = self._focus_stops(body)
991 if not focusables:
992 return
993 focusables[0 if direction == 1 else -1].focus()
995 def _focus_stops(self, body: _LazyGroupBody) -> list[Widget]:
996 """The widgets in *body* that Tab can reach, without the hidden ones."""
997 return [
998 w for w in self.focus_chain if body in w.ancestors and not isinstance(w, SettingRow)
999 ]