Coverage for src / lilbee / cli / tui / screens / settings.py: 100%
478 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
1"""Settings screen. Grouped, type-aware configuration editor."""
3from __future__ import annotations
5import logging
6import re
7from collections.abc import Callable
8from dataclasses import dataclass
9from typing import TYPE_CHECKING, 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)
28from lilbee.app.services import get_services
29from lilbee.cli.settings_map import SETTINGS_MAP, SettingDef, get_default
30from lilbee.cli.tui import messages as msg
31from lilbee.cli.tui.screens.settings_widgets import (
32 API_KEYS_GROUP,
33 API_KEYS_WARNING_CLASS,
34 EDITOR_ID_PREFIX,
35 LIST_ERROR_ID_PREFIX,
36 LIST_ERROR_VISIBLE_CLASS,
37 LIST_RESTORE_PREFIX,
38 MODEL_PICKER_BUTTON_PREFIX,
39 RESET_BUTTON_ID_PREFIX,
40 RESET_BUTTON_LABEL,
41 ROW_ID_PREFIX,
42 config_toml_path,
43 group_settings,
44 help_content,
45 make_editor,
46 model_field_to_picker_scope,
47 model_picker_label,
48 picker_scope_to_task,
49 set_widget_value,
50 stringify_default,
51 title_content,
52)
53from lilbee.cli.tui.widgets.list_text_area import ListTextArea
54from lilbee.core import settings
55from lilbee.core.config import DEFAULT_CRAWL_EXCLUDE_PATTERNS, cfg
56from lilbee.providers.worker.transport import WorkerRole
58if TYPE_CHECKING:
59 from lilbee.cli.tui.app import LilbeeApp
60 from lilbee.cli.tui.screens.model_picker import PickerScope
61 from lilbee.cli.tui.widgets.model_bar import ModelOption
63log = logging.getLogger(__name__)
66_MODEL_KEY_TO_WORKER_ROLE: dict[str, WorkerRole] = {
67 "chat_model": WorkerRole.CHAT,
68 "embedding_model": WorkerRole.EMBED,
69 "reranker_model": WorkerRole.RERANK,
70 "vision_model": WorkerRole.VISION,
71}
72"""Picker key -> worker pool role. Lets the Settings picker respawn the right
73worker after a swap so the new ref actually takes effect on the next call.
74"""
77@dataclass(frozen=True)
78class _PaneGroup:
79 """One settings tab: pane id, group label, ordered settings."""
81 pane_id: str
82 group_name: str
83 items: list[tuple[str, SettingDef]]
86class _LazyGroupBody(VerticalScroll, can_focus=False):
87 """Pane-body that mounts rows on first activation; scrolls when taller than viewport."""
89 def __init__(self, *, id: str | None = None) -> None:
90 super().__init__(id=id)
91 self._populated = False
93 @property
94 def populated(self) -> bool:
95 return self._populated
97 def populate(self, build: Callable[[], list[Widget]]) -> None:
98 """Build and mount this pane's row widgets exactly once."""
99 if self._populated:
100 return
101 self._populated = True
102 widgets = build()
103 if widgets:
104 self.mount_all(widgets)
107class SettingsScreen(Screen[None]):
108 """Interactive settings viewer with grouped, type-aware editors."""
110 app: LilbeeApp # type: ignore[assignment]
112 CSS_PATH = "settings.tcss"
113 # Target the TabbedContent's inner Tabs strip rather than the outer
114 # #settings-scroll Container -- Container can't accept focus, so on
115 # mount focus would otherwise stay at None and downstream Tab-cycling
116 # has nowhere to start. The Tabs widget is the canonical entry point.
117 AUTO_FOCUS = "#settings-tabs Tabs"
118 HELP = (
119 "Browse and edit configuration.\n\n"
120 "Use / to search, Enter to confirm, Escape to return to the list."
121 )
123 BINDINGS: ClassVar[list[BindingType]] = [
124 Binding("q", "go_back", "Back", show=True),
125 Binding("escape", "go_back", "Back", show=False),
126 # Tab cycles editors inside the active pane and rolls over to the
127 # next group tab when you Tab past the last editor (and the
128 # previous group tab on shift+Tab past the first editor). Use
129 # > / < to jump straight to the next / previous group tab.
130 Binding("tab", "next_field_or_pane", "Next field", show=True),
131 Binding("shift+tab", "prev_field_or_pane", "Prev field", show=True),
132 # Direct tab cycling, mirrored from CatalogScreen. priority=True
133 # so the bindings win when an editor input has focus.
134 Binding("greater_than_sign", "cycle_pane(1)", "Next tab", show=True, priority=True),
135 Binding("less_than_sign", "cycle_pane(-1)", "Prev tab", show=True, priority=True),
136 Binding("ctrl+r", "reset_focused", "Reset field", show=False),
137 Binding("ctrl+shift+r", "reset_all", "Reset all", show=True),
138 Binding("j", "scroll_down", "Down", show=False),
139 Binding("k", "scroll_up", "Up", show=False),
140 Binding("g", "scroll_home", "Top", show=False),
141 Binding("G", "scroll_end", "End", show=False),
142 ]
144 def __init__(self) -> None:
145 super().__init__()
146 # Group definitions for lazy-mount on tab activation. Indexed
147 # by pane id so the activated-pane handler can look up its
148 # bundle in O(1). ``_eagerly_populate`` is the pane id whose
149 # body gets populated in on_mount (the active-by-default first
150 # pane); the rest fill in on first activation.
151 self._pane_groups: dict[str, _PaneGroup] = {}
152 self._eagerly_populate: str | None = None
154 def compose(self) -> ComposeResult:
155 from textual.widgets import Footer
157 from lilbee.cli.tui.widgets.bottom_bars import BottomBars
158 from lilbee.cli.tui.widgets.status_bar import ViewTabs
159 from lilbee.cli.tui.widgets.task_bar import TaskBar
160 from lilbee.cli.tui.widgets.top_bars import TopBars
162 with TopBars():
163 yield ViewTabs()
164 # Container (not VerticalScroll) here -- each tab body is itself a
165 # VerticalScroll, and stacking two scrollables on the same column
166 # tears the layout when the inner one wheels past its top edge
167 # (bb-...-wiki-tear). Only the inner pane scrolls; the outer just
168 # reserves the flex row.
169 with Container(id="settings-scroll"), TabbedContent(id="settings-tabs"):
170 yield from self._compose_group_tabs()
171 with BottomBars():
172 yield TaskBar()
173 yield Footer()
175 def _compose_group_tabs(self) -> ComposeResult:
176 """Yield one TabPane per setting group; bodies populate on activation."""
177 first = True
178 for group_name, items in group_settings().items():
179 pane_id = f"settings-tab-{group_name.lower().replace('-', '_')}"
180 self._pane_groups[pane_id] = _PaneGroup(
181 pane_id=pane_id, group_name=group_name, items=items
182 )
183 yield TabPane(
184 group_name,
185 _LazyGroupBody(id=f"{pane_id}-body"),
186 id=pane_id,
187 )
188 # The first pane is the one TabbedContent activates by
189 # default; populate it eagerly so a user landing on
190 # Settings sees content on first paint instead of an empty
191 # active pane that fills in one frame later.
192 if first:
193 first = False
194 self._eagerly_populate = pane_id
196 def on_mount(self) -> None:
197 """Defer first-pane content mount until after the screen has painted.
199 ``_populate_pane`` calls ``mount_all`` for ~25 editor widgets which
200 triggers a full Textual layout pass; running it inside ``on_mount``
201 adds that pass to the screen-switch latency budget. ``call_after_refresh``
202 moves it to the next event-loop tick so the user sees the empty pane
203 skeleton immediately and the rows hydrate one frame later.
204 """
205 if self._eagerly_populate is not None:
206 self.call_after_refresh(self._populate_pane, self._eagerly_populate)
208 @on(TabbedContent.TabActivated)
209 def _on_tab_activated(self, event: TabbedContent.TabActivated) -> None:
210 """Populate the activated pane's body on first activation."""
211 pane = event.pane
212 if pane is None or pane.id is None:
213 return
214 self._populate_pane(pane.id)
216 def populate_all_panes(self) -> None:
217 """Force every tab body to populate now (test/agent helper)."""
218 for pane_id in self._pane_groups:
219 self._populate_pane(pane_id)
221 def _populate_pane(self, pane_id: str) -> None:
222 """Populate a pane's body if known and the body widget is mounted."""
223 group = self._pane_groups.get(pane_id)
224 if group is None:
225 return
226 try:
227 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody)
228 except Exception:
229 log.debug("pane body %s not yet mounted", pane_id, exc_info=True)
230 return
231 body.populate(lambda: self._build_pane_widgets(group))
233 def _build_pane_widgets(self, group: _PaneGroup) -> list[Widget]:
234 """Return the body widgets for one settings tab."""
235 widgets: list[Widget] = []
236 if group.group_name == API_KEYS_GROUP:
237 widgets.append(
238 Static(
239 msg.SETTINGS_API_KEYS_WARNING.format(path=config_toml_path()),
240 classes=API_KEYS_WARNING_CLASS,
241 )
242 )
243 for key, defn in group.items:
244 widgets.append(self._build_setting_row(key, defn))
245 return widgets
247 def _build_setting_row(self, key: str, defn: SettingDef) -> VerticalGroup:
248 """Construct one setting row with its title, help, editor, and reset."""
249 title = Static(title_content(key, defn), classes="setting-title")
250 help_widget = Static(help_content(key, defn), classes="setting-help")
251 children: list[Widget] = [title, help_widget]
252 if key in model_field_to_picker_scope():
253 children.append(self._build_model_picker_row(key))
254 elif defn.writable:
255 editor_row = Horizontal(
256 make_editor(key, defn),
257 Button(
258 RESET_BUTTON_LABEL,
259 id=f"{RESET_BUTTON_ID_PREFIX}{key}",
260 classes="setting-reset-button",
261 tooltip=msg.SETTINGS_RESET_TO_DEFAULT_TOOLTIP,
262 ),
263 classes="setting-editor-row",
264 )
265 children.append(editor_row)
266 return VerticalGroup(
267 *children,
268 classes="setting-row",
269 id=f"{ROW_ID_PREFIX}{key}",
270 )
272 def _build_model_picker_row(self, key: str) -> Horizontal:
273 """A button-style row that opens the same ModelPickerModal as the chat bar."""
274 return Horizontal(
275 Button(
276 model_picker_label(key),
277 id=f"{MODEL_PICKER_BUTTON_PREFIX}{key}",
278 classes="setting-model-picker-button",
279 ),
280 classes="setting-editor-row",
281 )
283 @on(Input.Submitted, ".setting-editor")
284 @on(Input.Blurred, ".setting-editor")
285 def _on_input_save(self, event: Input.Submitted | Input.Blurred) -> None:
286 """Save string/number input on submit or blur."""
287 name = event.input.name
288 if name is None:
289 return
290 defn = SETTINGS_MAP.get(name)
291 if defn is None:
292 return
293 raw = event.value.strip()
294 current = str(getattr(cfg, name, ""))
295 if raw == current:
296 return
297 self._persist_value(name, defn, raw)
299 @on(ListTextArea.Blurred, ".setting-multiline-editor")
300 def _on_multiline_save(self, event: ListTextArea.Blurred) -> None:
301 """Save multi-line string settings (system prompts) on blur."""
302 ta = event.control
303 name = ta.name
304 if name is None:
305 return
306 defn = SETTINGS_MAP.get(name)
307 if defn is None:
308 return
309 raw = ta.text
310 current = str(getattr(cfg, name, ""))
311 if raw == current:
312 return
313 self._persist_value(name, defn, raw)
315 @on(Checkbox.Changed, ".setting-editor")
316 def _on_checkbox_save(self, event: Checkbox.Changed) -> None:
317 """Save boolean on toggle."""
318 name = event.checkbox.name
319 if name is None:
320 return
321 defn = SETTINGS_MAP.get(name)
322 if defn is None:
323 return
324 self._persist_value(name, defn, str(event.checkbox.value))
326 @on(Select.Changed, ".setting-editor")
327 def _on_select_save(self, event: Select.Changed) -> None:
328 """Save select choice on change."""
329 name = event.select.name
330 if name is None:
331 return
332 defn = SETTINGS_MAP.get(name)
333 if defn is None:
334 return
335 value = str(event.value) if event.value != Select.BLANK else ""
336 current = str(getattr(cfg, name, ""))
337 if value == current:
338 return
339 self._persist_value(name, defn, value)
341 def _persist_value(self, key: str, defn: SettingDef, raw: str, *, quiet: bool = False) -> None:
342 """Parse, apply, and persist a setting value.
344 No success toast: the editor already shows the new value and the
345 write is silently persisted. Tab-cycling between sub-tabs blurs
346 the focused input, which fires Input.Blurred -> _on_input_save
347 en masse; one toast per blur is just noise. Errors still toast
348 so the user sees why a value didn't take.
349 """
350 try:
351 parsed = self._parse_value(defn, raw)
352 # set_setting handles theme live-apply, signal publish, etc.
353 self.app.set_setting(key, parsed)
354 self._refresh_help(key, defn)
355 _ = quiet # accepted for API compatibility; success path is now always silent
356 except (ValueError, TypeError) as exc:
357 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error")
359 def _parse_value(self, defn: SettingDef, raw: str) -> object:
360 """Convert a raw string to the setting's target type."""
361 if defn.nullable and raw.lower() in ("none", "null", ""):
362 return None
363 if defn.type is bool:
364 return raw.lower() in ("true", "1", "yes", "on")
365 if defn.type is list:
366 return [line.strip() for line in raw.split("\n") if line.strip()]
367 return defn.type(raw)
369 @staticmethod
370 def _validate_regex_list(lines: list[str]) -> tuple[int, str] | None:
371 """Return the 1-indexed line number and error for the first bad regex, or None."""
372 for i, line in enumerate(lines, 1):
373 try:
374 re.compile(line)
375 except re.error as exc:
376 return (i, str(exc))
377 return None
379 @on(ListTextArea.Blurred, ".setting-list-editor")
380 def _on_list_blur_save(self, event: ListTextArea.Blurred) -> None:
381 """Validate and save list values when a ListTextArea loses focus."""
382 ta = event.control
383 key = ta.name
384 if key is None:
385 return
386 defn = SETTINGS_MAP.get(key)
387 if defn is None:
388 return
389 raw = ta.text
390 parsed = self._parse_value(defn, raw)
391 assert isinstance(parsed, list) # noqa: S101 -- mypy narrowing, defn.type is list above
392 err = self._validate_regex_list(parsed)
393 error_widget = self.query_one(f"#{LIST_ERROR_ID_PREFIX}{key}", Static)
394 if err is not None:
395 line_no, err_text = err
396 error_widget.update(
397 msg.SETTINGS_LIST_EDITOR_INVALID_REGEX.format(n=line_no, error=err_text)
398 )
399 error_widget.add_class(LIST_ERROR_VISIBLE_CLASS)
400 return
401 error_widget.remove_class(LIST_ERROR_VISIBLE_CLASS)
402 self._persist_value(key, defn, raw)
403 self._refresh_list_title(key, len(parsed))
405 @on(Button.Pressed, ".setting-list-restore")
406 def _on_list_restore(self, event: Button.Pressed) -> None:
407 """Restore defaults for a LIST_COLLAPSED setting."""
408 btn_id = event.button.id
409 if btn_id is None or not btn_id.startswith(LIST_RESTORE_PREFIX):
410 return
411 key = btn_id.removeprefix(LIST_RESTORE_PREFIX)
412 defn = SETTINGS_MAP.get(key)
413 if defn is None:
414 return
415 defaults = list(DEFAULT_CRAWL_EXCLUDE_PATTERNS)
416 text = "\n".join(defaults)
417 ta = self.query_one(f"#ed-{key}", ListTextArea)
418 ta.load_text(text)
419 self._persist_value(key, defn, text)
420 error_widget = self.query_one(f"#{LIST_ERROR_ID_PREFIX}{key}", Static)
421 error_widget.remove_class(LIST_ERROR_VISIBLE_CLASS)
422 self._refresh_list_title(key, len(defaults))
424 def _refresh_list_title(self, key: str, count: int) -> None:
425 """Update the Collapsible title to reflect the current line count."""
426 try:
427 collapsible = self.query_one(f"#collapsible-{key}", Collapsible)
428 collapsible.title = msg.SETTINGS_LIST_EDITOR_TITLE.format(key=key, count=count)
429 except Exception:
430 log.debug("Failed to refresh collapsible title for %s", key, exc_info=True)
432 def _refresh_help(self, key: str, defn: SettingDef) -> None:
433 """Update the help text after a value change."""
434 try:
435 row = self.query_one(f"#{ROW_ID_PREFIX}{key}", VerticalGroup)
436 help_widget = row.query_one(".setting-help", Static)
437 help_widget.update(help_content(key, defn))
438 except Exception:
439 log.debug("Failed to refresh help for %s", key, exc_info=True)
441 @on(Button.Pressed, ".setting-reset-button")
442 def _on_reset_pressed(self, event: Button.Pressed) -> None:
443 """Handle the small reset button embedded in each writable row."""
444 button_id = event.button.id
445 if button_id is None or not button_id.startswith(RESET_BUTTON_ID_PREFIX):
446 return
447 key = button_id[len(RESET_BUTTON_ID_PREFIX) :]
448 self._reset_to_default(key)
450 @on(Button.Pressed, ".setting-model-picker-button")
451 def _on_model_picker_pressed(self, event: Button.Pressed) -> None:
452 """Open ModelPickerModal for the model field this button represents."""
453 button_id = event.button.id
454 if button_id is None or not button_id.startswith(MODEL_PICKER_BUTTON_PREFIX):
455 return
456 key = button_id[len(MODEL_PICKER_BUTTON_PREFIX) :]
457 scope = model_field_to_picker_scope().get(key)
458 if scope is None:
459 return
460 self._discover_then_open_picker(key, scope)
462 @work(thread=True, exit_on_error=False)
463 def _discover_then_open_picker(self, key: str, scope: PickerScope) -> None:
464 """Discover installed models off the UI thread, then push the picker.
466 ``classify_installed_models_full`` probes the native registry,
467 Ollama (HTTP), and litellm provider lists. Running it on the
468 event loop blocks paint for hundreds of ms; the chat-bar uses
469 the same worker pattern.
470 """
471 from lilbee.cli.tui.thread_safe import call_from_thread
472 from lilbee.cli.tui.widgets.model_bar import classify_installed_models_full
474 task = picker_scope_to_task(scope)
475 buckets = classify_installed_models_full()
476 options = list(buckets.get(task, []))
477 call_from_thread(self, self._push_model_picker, key, scope, options)
479 def _push_model_picker(self, key: str, scope: PickerScope, options: list[ModelOption]) -> None:
480 """Push ModelPickerModal once the worker has resolved options."""
481 from lilbee.cli.tui.screens.model_picker import ModelPickerModal
482 from lilbee.cli.tui.widgets.model_bar import ModelOption
484 # Bail out if the user navigated away from Settings while the
485 # discovery worker was still running; otherwise we'd push the
486 # modal onto whatever screen is now on top.
487 if not self.is_mounted:
488 return
489 if not options:
490 options = [ModelOption(label=msg.MODEL_VALUE_NONE, ref="")]
491 # Nullable model fields (vision_model, reranker_model) need an
492 # explicit "disable this model" pick. The picker's empty-input
493 # cancel returns None; this row returns "" so the dismiss
494 # handler can distinguish "cancel" from "set to none".
495 defn = SETTINGS_MAP.get(key)
496 if defn is not None and defn.nullable:
497 options = [
498 ModelOption(label=msg.MODEL_PICKER_DISABLE_LABEL, ref=""),
499 *options,
500 ]
501 self.app.push_screen(
502 ModelPickerModal(scope=scope, options=options),
503 lambda ref: self._on_model_picker_dismissed(key, ref),
504 )
506 def _on_model_picker_dismissed(self, key: str, ref: str | None) -> None:
507 """Persist the picker selection and refresh the button label.
509 ``ref is None`` means the user cancelled (Esc); leave the field
510 alone. ``ref == ""`` for a nullable field means the user picked
511 the explicit "disabled" row; clear the field. Any other value is
512 a real model ref. Embedding-model swaps against a populated store
513 route through a confirm modal first so the user is not surprised
514 by the rebuild requirement.
515 """
516 if ref is None:
517 return
518 defn = SETTINGS_MAP.get(key)
519 if not ref and (defn is None or not defn.nullable):
520 return
521 if key == "embedding_model" and ref:
522 self._maybe_confirm_embedding_swap(key, ref)
523 return
524 self._apply_picker_choice(key, ref, True)
526 @work(thread=True, name="settings_has_chunks_check", exit_on_error=False)
527 def _maybe_confirm_embedding_swap(self, key: str, ref: str) -> None:
528 """Run ``store.has_chunks`` off the UI thread; confirm-modal if non-empty."""
529 from lilbee.cli.tui.thread_safe import call_from_thread
531 if get_services().store.has_chunks():
532 call_from_thread(self, self._push_embed_swap_confirm, key, ref)
533 else:
534 call_from_thread(self, self._apply_picker_choice, key, ref, True)
536 def _push_embed_swap_confirm(self, key: str, ref: str) -> None:
537 """Push the embed-swap confirm dialog if the screen is still mounted."""
538 if not self.is_mounted:
539 return
540 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
542 self.app.push_screen(
543 ConfirmDialog(msg.EMBED_SWAP_CONFIRM_TITLE, msg.EMBED_SWAP_CONFIRM_MESSAGE),
544 lambda confirmed: self._apply_picker_choice(key, ref, confirmed),
545 )
547 def _apply_picker_choice(self, key: str, ref: str, confirmed: bool | None) -> None:
548 """Commit the picker choice or notify cancel; ``confirmed`` mirrors ConfirmDialog."""
549 if not confirmed:
550 self.app.notify(msg.EMBED_SWAP_CANCELLED)
551 return
552 from lilbee.cli.tui.app import apply_active_model
554 apply_active_model(self.app, key, ref)
555 role = _MODEL_KEY_TO_WORKER_ROLE.get(key)
556 if role is not None:
557 get_services().reload_role(role)
558 try:
559 button = self.query_one(f"#{MODEL_PICKER_BUTTON_PREFIX}{key}", Button)
560 button.label = model_picker_label(key)
561 except Exception:
562 log.debug("Failed to refresh model picker label for %s", key, exc_info=True)
564 def action_reset_all(self) -> None:
565 """Bound to Ctrl+Shift+R; opens the destructive-confirm dialog."""
566 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
568 self.app.push_screen(
569 ConfirmDialog(
570 title=msg.SETTINGS_RESET_ALL_CONFIRM_TITLE,
571 message=msg.SETTINGS_RESET_ALL_CONFIRM_MESSAGE,
572 ),
573 self._on_reset_all_confirmed,
574 )
576 def _on_reset_all_confirmed(self, confirmed: bool | None) -> None:
577 """Reset every writable setting to its cfg default atomically."""
578 if not confirmed:
579 return
580 writable = [(key, defn) for key, defn in SETTINGS_MAP.items() if defn.writable]
581 snapshot = {key: getattr(cfg, key) for key, _ in writable}
582 updates, signal_payload, skipped = self._apply_batch_defaults(writable)
583 if updates and not self._persist_batch(writable, snapshot, updates):
584 return
585 self._refresh_batch(writable, skipped)
586 self._publish_batch_signals(signal_payload)
587 self._notify_batch_result(skipped)
589 def _apply_batch_defaults(
590 self, writable: list[tuple[str, SettingDef]]
591 ) -> tuple[dict[str, str], list[tuple[str, object]], list[str]]:
592 """Mutate cfg in-memory for every writable key; track updates + skips."""
593 updates: dict[str, str] = {}
594 signal_payload: list[tuple[str, object]] = []
595 skipped: list[str] = []
596 for key, _defn in writable:
597 default = get_default(key)
598 try:
599 setattr(cfg, key, default)
600 except (ValueError, TypeError) as exc:
601 log.warning("Default for %s rejected by cfg (%s); skipping", key, exc)
602 skipped.append(key)
603 continue
604 updates[key] = stringify_default(default)
605 signal_payload.append((key, default))
606 return updates, signal_payload, skipped
608 def _persist_batch(
609 self,
610 writable: list[tuple[str, SettingDef]],
611 snapshot: dict[str, object],
612 updates: dict[str, str],
613 ) -> bool:
614 """Persist the batch; roll back cfg + UI on disk error. Returns True on success."""
615 try:
616 settings.update_values(cfg.data_root, updates)
617 except OSError as exc:
618 self._rollback_batch(writable, snapshot)
619 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error")
620 return False
621 return True
623 def _rollback_batch(
624 self, writable: list[tuple[str, SettingDef]], snapshot: dict[str, object]
625 ) -> None:
626 """Restore cfg and editor widgets from snapshot after a failed persist."""
627 for key, prev in snapshot.items():
628 try:
629 setattr(cfg, key, prev)
630 except (ValueError, TypeError):
631 log.exception("Failed to roll back cfg.%s", key)
632 for key, defn in writable:
633 self._refresh_editor(key, defn, snapshot[key])
634 self._refresh_help(key, defn)
636 def _refresh_batch(self, writable: list[tuple[str, SettingDef]], skipped: list[str]) -> None:
637 """Refresh editor + help for each successfully-reset writable key."""
638 for key, defn in writable:
639 if key in skipped:
640 continue
641 default = get_default(key)
642 self._refresh_editor(key, defn, default)
643 self._refresh_help(key, defn)
645 def _publish_batch_signals(self, signal_payload: list[tuple[str, object]]) -> None:
646 """Fan out settings_changed signals for every successfully-reset key."""
647 for pub_key, pub_parsed in signal_payload:
648 self.app.settings_changed_signal.publish((pub_key, pub_parsed))
650 def _notify_batch_result(self, skipped: list[str]) -> None:
651 """Surface a single summary toast; warning severity when any key skipped."""
652 if skipped:
653 self.notify(
654 msg.SETTINGS_RESET_ALL_PARTIAL.format(skipped=", ".join(skipped)),
655 severity="warning",
656 )
657 else:
658 self.notify(msg.SETTINGS_RESET_ALL_SUCCESS)
660 def action_reset_focused(self) -> None:
661 """Reset the currently-focused setting row to its cfg default."""
662 focused = self.focused
663 if focused is None:
664 return
665 for ancestor in focused.ancestors_with_self:
666 ancestor_id = getattr(ancestor, "id", None)
667 if ancestor_id and ancestor_id.startswith(ROW_ID_PREFIX):
668 key = ancestor_id[len(ROW_ID_PREFIX) :]
669 self._reset_to_default(key)
670 return
672 def _reset_to_default(self, key: str) -> None:
673 """Restore a single setting to its cfg default."""
674 defn = SETTINGS_MAP.get(key)
675 if defn is None or not defn.writable:
676 return
677 default = get_default(key)
678 stringified = stringify_default(default)
679 self._persist_value(key, defn, stringified)
680 self._refresh_editor(key, defn, default)
682 def _refresh_editor(self, key: str, defn: SettingDef, value: object) -> None:
683 """Update the editor widget to reflect a new value (e.g. after reset)."""
684 try:
685 widget = self.query_one(f"#{EDITOR_ID_PREFIX}{key}")
686 except Exception:
687 log.debug("Failed to refresh editor for %s", key, exc_info=True)
688 return
689 set_widget_value(widget, value)
691 def action_go_back(self) -> None:
692 self.app.switch_view("Chat")
694 def _active_pane_body(self) -> _LazyGroupBody | None:
695 """Resolve the currently-active settings tab body (a VerticalScroll).
697 j/k/g/G key actions scroll this body directly because the outer
698 ``#settings-scroll`` is a Container, not a scroller -- one column
699 of scrolling per screen, the active tab's pane.
700 """
701 try:
702 tabs = self.query_one("#settings-tabs", TabbedContent)
703 except Exception:
704 return None
705 active = tabs.active
706 if not active:
707 return None
708 try:
709 return self.query_one(f"#{active}-body", _LazyGroupBody)
710 except Exception:
711 return None
713 def action_scroll_down(self) -> None:
714 if (body := self._active_pane_body()) is not None:
715 body.scroll_down()
717 def action_scroll_up(self) -> None:
718 if (body := self._active_pane_body()) is not None:
719 body.scroll_up()
721 def action_scroll_home(self) -> None:
722 if (body := self._active_pane_body()) is not None:
723 body.scroll_home()
725 def action_scroll_end(self) -> None:
726 if (body := self._active_pane_body()) is not None:
727 body.scroll_end()
729 def action_next_field_or_pane(self) -> None:
730 """Tab inside a pane; on overflow advance to the next group tab."""
731 self._move_focus_within_pane(direction=1)
733 def action_prev_field_or_pane(self) -> None:
734 """Shift+Tab inside a pane; on underflow retreat to the previous group tab."""
735 self._move_focus_within_pane(direction=-1)
737 def action_cycle_pane(self, delta: int) -> None:
738 """Step the active settings tab by *delta*, wrapping around the strip.
740 Shortcut for users who don't want to Tab through every field to
741 reach the next group. Mirrors CatalogScreen.action_cycle_tab.
742 """
743 try:
744 tabs = self.query_one("#settings-tabs", TabbedContent)
745 except Exception:
746 return
747 pane_ids = list(self._pane_groups)
748 if not pane_ids:
749 return
750 try:
751 current = pane_ids.index(tabs.active)
752 except ValueError:
753 current = 0
754 next_id = pane_ids[(current + delta) % len(pane_ids)]
755 if tabs.active != next_id:
756 tabs.active = next_id
758 def _move_focus_within_pane(self, *, direction: int) -> None:
759 focused = self.app.focused
760 tabs = self.query_one("#settings-tabs", TabbedContent)
761 active_pane_id = tabs.active
762 try:
763 body = self.query_one(f"#{active_pane_id}-body", _LazyGroupBody)
764 except Exception:
765 self.app.action_focus_next() if direction == 1 else self.app.action_focus_previous()
766 return
767 focusables = [w for w in body.query("*") if w.focusable]
768 if not focusables or focused is None or focused not in focusables:
769 self.app.action_focus_next() if direction == 1 else self.app.action_focus_previous()
770 return
771 index = focusables.index(focused)
772 next_index = index + direction
773 if 0 <= next_index < len(focusables):
774 focusables[next_index].focus()
775 return
776 # At the boundary: advance to the next/previous pane.
777 pane_ids = list(self._pane_groups.keys())
778 if active_pane_id not in pane_ids:
779 return
780 target_index = (pane_ids.index(active_pane_id) + direction) % len(pane_ids)
781 target_pane = pane_ids[target_index]
782 tabs.active = target_pane
783 self._populate_pane(target_pane)
784 # Park focus on the first/last field of the new pane so the next
785 # Tab keeps moving in the same direction.
786 self.call_after_refresh(self._focus_pane_edge, target_pane, direction)
788 def _focus_pane_edge(self, pane_id: str, direction: int) -> None:
789 try:
790 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody)
791 except Exception:
792 return
793 focusables = [w for w in body.query("*") if w.focusable]
794 if not focusables:
795 return
796 focusables[0 if direction == 1 else -1].focus()