Coverage for src / lilbee / cli / tui / screens / settings.py: 100%
424 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
1"""Settings screen. Grouped, type-aware configuration editor."""
3from __future__ import annotations
5import logging
6import os
7import re
8from collections import defaultdict
9from typing import Any, ClassVar
11from textual import on
12from textual.app import ComposeResult
13from textual.binding import Binding, BindingType
14from textual.containers import Horizontal, VerticalGroup, VerticalScroll
15from textual.content import Content
16from textual.screen import Screen
17from textual.widget import Widget
18from textual.widgets import Button, Checkbox, Collapsible, Input, Select, Static, TextArea
20from lilbee import settings
21from lilbee.cli.settings_map import SETTINGS_MAP, RenderStyle, SettingDef, get_default
22from lilbee.cli.tui import messages as msg
23from lilbee.cli.tui.pill import pill
24from lilbee.cli.tui.widgets.list_text_area import ListTextArea
25from lilbee.cli.tui.widgets.nav_aware_input import NavAwareInput
26from lilbee.config import DEFAULT_CRAWL_EXCLUDE_PATTERNS, cfg
28_ROW_ID_PREFIX = "row-"
29_EDITOR_ID_PREFIX = "ed-"
30_RESET_BUTTON_ID_PREFIX = "reset-"
31_RESET_BUTTON_LABEL = "↺"
33log = logging.getLogger(__name__)
35_TYPE_COLORS: dict[str, tuple[str, str]] = {
36 "str": ("$secondary", "$text"),
37 "int": ("$primary", "$text"),
38 "float": ("$primary", "$text"),
39 "bool": ("$success", "$text"),
40 "select": ("$warning", "$text"),
41}
44_DEFAULTS_REMAP: dict[str, str] = {"top_k_sampling": "top_k"}
46_LIST_RESTORE_PREFIX = "list-restore-"
47_LIST_ERROR_ID_PREFIX = "err-"
48_LIST_ERROR_VISIBLE_CLASS = "-visible"
50_API_KEYS_GROUP = "API-Keys"
51_API_KEYS_WARNING_CLASS = "api-keys-warning"
52_CONFIG_TOML_FILENAME = "config.toml"
55def _config_toml_path() -> str:
56 """Effective path to the config.toml lilbee reads and writes."""
57 return str(cfg.data_dir / _CONFIG_TOML_FILENAME)
60def _effective_value(key: str) -> str:
61 """Return the effective value for a setting, including model defaults."""
62 user_value = getattr(cfg, key, None)
63 if user_value is not None:
64 if isinstance(user_value, list):
65 return f"{len(user_value)} lines"
66 return str(user_value)
67 defaults = cfg.model_defaults
68 if defaults is None:
69 return "None"
70 defaults_key = _DEFAULTS_REMAP.get(key, key)
71 default_val = getattr(defaults, defaults_key, None)
72 if default_val is not None:
73 return f"{default_val} (model default)"
74 return "None"
77def _is_writable(key: str) -> bool:
78 """Check if a setting key is writable (derived from SETTINGS_MAP)."""
79 defn = SETTINGS_MAP.get(key)
80 return defn is not None and defn.writable
83def _type_pill(defn: SettingDef) -> Content:
84 """Create a colored pill badge for a setting's type."""
85 type_name = defn.type.__name__
86 if defn.choices:
87 type_name = "select"
88 bg, fg = _TYPE_COLORS.get(type_name, ("$surface", "$text"))
89 return pill(type_name, bg, fg)
92def _env_var_name(key: str) -> str:
93 """Return the LILBEE_* env var name for a config key."""
94 return f"LILBEE_{key.upper()}"
97def _env_pill(key: str) -> Content | None:
98 """Return a warning pill showing the literal env var when it's set.
100 The pill appears only when the user has exported the corresponding
101 env var, signalling that TUI edits won't persist because the env
102 wins on next launch.
103 """
104 env_name = _env_var_name(key)
105 if os.environ.get(env_name) is None:
106 return None
107 return pill(env_name, "$warning", "$text")
110def _help_content(key: str, defn: SettingDef) -> Content:
111 """Build help text; the editor widget already shows the current value."""
112 if defn.help_text:
113 return Content(defn.help_text)
114 return Content("")
117def _title_content(key: str, defn: SettingDef) -> Content:
118 """Assemble the setting-row title: key name, type pill, and env pill when set."""
119 parts: list[Content] = [Content(key + " "), _type_pill(defn)]
120 env_badge = _env_pill(key)
121 if env_badge is not None:
122 parts.append(Content(" "))
123 parts.append(env_badge)
124 return Content.assemble(*parts)
127def _stringify_default(default: object) -> str:
128 """Serialize a default for the TOML settings store."""
129 if default is None:
130 return ""
131 if isinstance(default, list):
132 return "\n".join(default)
133 return str(default)
136def _group_settings() -> dict[str, list[tuple[str, SettingDef]]]:
137 """Group settings by their group field, preserving insertion order."""
138 groups: dict[str, list[tuple[str, SettingDef]]] = defaultdict(list)
139 for key, defn in SETTINGS_MAP.items():
140 groups[defn.group].append((key, defn))
141 return dict(groups)
144def _make_editor(key: str, defn: SettingDef) -> Widget:
145 """Create the appropriate editor widget for a setting."""
146 if defn.render is RenderStyle.LIST_COLLAPSED:
147 return _make_list_editor(key)
148 value = _effective_value(key)
149 if defn.choices:
150 return _make_select(key, defn, value)
151 if defn.type is bool:
152 return _make_checkbox(key, value)
153 return _make_input(key, value)
156def _make_list_editor(key: str) -> Collapsible:
157 """Create a Collapsible with a line-numbered TextArea for list[str] settings."""
158 current = getattr(cfg, key, None) or []
159 title = msg.SETTINGS_LIST_EDITOR_TITLE.format(key=key, count=len(current))
160 editor = ListTextArea(
161 text="\n".join(current),
162 show_line_numbers=True,
163 name=key,
164 id=f"ed-{key}",
165 classes="setting-list-editor",
166 )
167 error = Static("", id=f"{_LIST_ERROR_ID_PREFIX}{key}", classes="setting-list-error")
168 reset = Button(
169 msg.SETTINGS_LIST_EDITOR_RESTORE_DEFAULTS,
170 id=f"{_LIST_RESTORE_PREFIX}{key}",
171 classes="setting-list-restore",
172 )
173 return Collapsible(
174 editor,
175 error,
176 reset,
177 title=title,
178 collapsed=True,
179 id=f"collapsible-{key}",
180 )
183def _make_select(key: str, defn: SettingDef, value: str) -> Select[str]:
184 """Create a Select widget for choice-based settings."""
185 choices = [(c, c) for c in (defn.choices or ())]
186 if value in {c[1] for c in choices}:
187 return Select(
188 choices,
189 value=value,
190 name=key,
191 classes="setting-editor",
192 id=f"{_EDITOR_ID_PREFIX}{key}",
193 )
194 return Select(choices, name=key, classes="setting-editor", id=f"{_EDITOR_ID_PREFIX}{key}")
197def _make_checkbox(key: str, value: str) -> Checkbox:
198 """Create a Checkbox widget for boolean settings."""
199 checked = value.lower() in ("true", "1", "yes", "on")
200 return Checkbox(
201 value=checked, name=key, classes="setting-editor", id=f"{_EDITOR_ID_PREFIX}{key}"
202 )
205def _make_input(key: str, value: str) -> NavAwareInput:
206 """Create an Input widget for string/number settings."""
207 display = "" if value == "None" else value.replace(" (model default)", "")
208 return NavAwareInput(
209 value=display, name=key, classes="setting-editor", id=f"{_EDITOR_ID_PREFIX}{key}"
210 )
213class SettingsScreen(Screen[None]):
214 """Interactive settings viewer with grouped, type-aware editors."""
216 CSS_PATH = "settings.tcss"
217 AUTO_FOCUS = "#settings-scroll"
218 HELP = (
219 "Browse and edit configuration.\n\n"
220 "Use / to search, Enter to confirm, Escape to return to the list."
221 )
223 BINDINGS: ClassVar[list[BindingType]] = [
224 Binding("q", "go_back", "Back", show=True),
225 Binding("escape", "go_back", "Back", show=False),
226 Binding("slash", "focus_search", "Search", show=True),
227 Binding("tab", "app.focus_next", "Next field", show=True),
228 Binding("shift+tab", "app.focus_previous", "Prev field", show=True),
229 Binding("ctrl+r", "reset_focused", "Reset", show=False),
230 Binding("j", "scroll_down", "Down", show=False),
231 Binding("k", "scroll_up", "Up", show=False),
232 Binding("g", "scroll_home", "Top", show=False),
233 Binding("G", "scroll_end", "End", show=False),
234 ]
236 def compose(self) -> ComposeResult:
237 from textual.widgets import Footer
239 from lilbee.cli.tui.widgets.bottom_bars import BottomBars
240 from lilbee.cli.tui.widgets.status_bar import ViewTabs
241 from lilbee.cli.tui.widgets.task_bar import TaskBar
242 from lilbee.cli.tui.widgets.top_bars import TopBars
244 with TopBars():
245 yield ViewTabs()
246 with Horizontal(id="settings-top-row"):
247 yield NavAwareInput(
248 placeholder="Filter settings...",
249 id="settings-search",
250 )
251 yield Button(
252 msg.SETTINGS_RESET_ALL_LABEL,
253 id="reset-all-defaults",
254 classes="reset-all-button",
255 )
256 with VerticalScroll(id="settings-scroll"):
257 yield from self._compose_groups()
258 with BottomBars():
259 yield TaskBar()
260 yield Footer()
262 def _compose_groups(self) -> ComposeResult:
263 """Yield grouped setting sections."""
264 for group_name, items in _group_settings().items():
265 with VerticalGroup(classes="setting-group", id=f"group-{group_name.lower()}"):
266 yield Static(group_name, classes="group-title")
267 if group_name == _API_KEYS_GROUP:
268 yield Static(
269 msg.SETTINGS_API_KEYS_WARNING.format(path=_config_toml_path()),
270 classes=_API_KEYS_WARNING_CLASS,
271 )
272 for key, defn in items:
273 yield from self._compose_setting(key, defn)
275 def _compose_setting(self, key: str, defn: SettingDef) -> ComposeResult:
276 """Yield widgets for a single setting row."""
277 with VerticalGroup(
278 classes="setting-row",
279 name=f"{defn.group.lower()} {key}",
280 id=f"{_ROW_ID_PREFIX}{key}",
281 ):
282 yield Static(_title_content(key, defn), classes="setting-title")
283 yield Static(_help_content(key, defn), classes="setting-help")
284 if defn.writable:
285 with Horizontal(classes="setting-editor-row"):
286 yield _make_editor(key, defn)
287 yield Button(
288 _RESET_BUTTON_LABEL,
289 id=f"{_RESET_BUTTON_ID_PREFIX}{key}",
290 classes="setting-reset-button",
291 tooltip=msg.SETTINGS_RESET_TO_DEFAULT_TOOLTIP,
292 )
294 @on(Input.Submitted, "#settings-search")
295 def _on_search_submitted(self) -> None:
296 """Blur the search input when Enter is pressed."""
297 self.query_one("#settings-scroll", VerticalScroll).focus()
299 @on(Input.Changed, "#settings-search")
300 def _filter_settings(self, event: Input.Changed) -> None:
301 """Filter visible settings based on search input."""
302 term = event.value.strip().lower()
303 for group, rows in self._filter_index():
304 visible_count = 0
305 for row in rows:
306 matches = not term or term in (row.name or "")
307 row.display = matches
308 if matches:
309 visible_count += 1
310 group.display = visible_count > 0
312 def _filter_index(self) -> list[tuple[Any, list[Any]]]:
313 """Lazy cache of (group, rows) for the filter handler.
315 One DOM walk at first keystroke, O(1) lookups after.
316 """
317 cached: list[tuple[Any, list[Any]]] | None = getattr(self, "_settings_filter_index", None)
318 if cached is not None:
319 return cached
320 index: list[tuple[Any, list[Any]]] = [
321 (group, list(group.query(".setting-row"))) for group in self.query(".setting-group")
322 ]
323 self._settings_filter_index = index
324 return index
326 @on(Input.Submitted, ".setting-editor")
327 @on(Input.Blurred, ".setting-editor")
328 def _on_input_save(self, event: Input.Submitted | Input.Blurred) -> None:
329 """Save string/number input on submit or blur."""
330 name = event.input.name
331 if name is None:
332 return
333 defn = SETTINGS_MAP.get(name)
334 if defn is None:
335 return
336 raw = event.value.strip()
337 current = str(getattr(cfg, name, ""))
338 if raw == current:
339 return
340 self._persist_value(name, defn, raw)
342 @on(Checkbox.Changed, ".setting-editor")
343 def _on_checkbox_save(self, event: Checkbox.Changed) -> None:
344 """Save boolean on toggle."""
345 name = event.checkbox.name
346 if name is None:
347 return
348 defn = SETTINGS_MAP.get(name)
349 if defn is None:
350 return
351 self._persist_value(name, defn, str(event.checkbox.value))
353 @on(Select.Changed, ".setting-editor")
354 def _on_select_save(self, event: Select.Changed) -> None:
355 """Save select choice on change."""
356 name = event.select.name
357 if name is None:
358 return
359 defn = SETTINGS_MAP.get(name)
360 if defn is None:
361 return
362 value = str(event.value) if event.value != Select.BLANK else ""
363 current = str(getattr(cfg, name, ""))
364 if value == current:
365 return
366 self._persist_value(name, defn, value)
368 def _persist_value(self, key: str, defn: SettingDef, raw: str, *, quiet: bool = False) -> None:
369 """Parse, apply, and persist a setting value."""
370 try:
371 parsed = self._parse_value(defn, raw)
372 setattr(cfg, key, parsed)
373 persisted = self._stringify_for_toml(parsed)
374 settings.set_value(cfg.data_root, key, persisted)
375 if not quiet:
376 self.notify(msg.CMD_SET_SUCCESS.format(key=key, value=parsed))
377 self._refresh_help(key, defn)
378 from lilbee.cli.tui.app import LilbeeApp
380 if isinstance(self.app, LilbeeApp): # test apps aren't LilbeeApp
381 self.app.settings_changed_signal.publish((key, parsed))
382 except (ValueError, TypeError) as exc:
383 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error")
385 def _parse_value(self, defn: SettingDef, raw: str) -> object:
386 """Convert a raw string to the setting's target type."""
387 if defn.nullable and raw.lower() in ("none", "null", ""):
388 return None
389 if defn.type is bool:
390 return raw.lower() in ("true", "1", "yes", "on")
391 if defn.type is list:
392 return [line.strip() for line in raw.split("\n") if line.strip()]
393 return defn.type(raw)
395 @staticmethod
396 def _stringify_for_toml(parsed: object) -> str:
397 """Serialize a parsed value for the TOML settings store."""
398 if parsed is None:
399 return ""
400 if isinstance(parsed, list):
401 return "\n".join(parsed)
402 return str(parsed)
404 @staticmethod
405 def _validate_regex_list(lines: list[str]) -> tuple[int, str] | None:
406 """Return the 1-indexed line number and error for the first bad regex, or None."""
407 for i, line in enumerate(lines, 1):
408 try:
409 re.compile(line)
410 except re.error as exc:
411 return (i, str(exc))
412 return None
414 @on(ListTextArea.Blurred, ".setting-list-editor")
415 def _on_list_blur_save(self, event: ListTextArea.Blurred) -> None:
416 """Validate and save list values when a ListTextArea loses focus."""
417 ta = event.control
418 key = ta.name
419 if key is None:
420 return
421 defn = SETTINGS_MAP.get(key)
422 if defn is None:
423 return
424 raw = ta.text
425 parsed = self._parse_value(defn, raw)
426 assert isinstance(parsed, list) # noqa: S101 -- mypy narrowing, defn.type is list above
427 err = self._validate_regex_list(parsed)
428 error_widget = self.query_one(f"#{_LIST_ERROR_ID_PREFIX}{key}", Static)
429 if err is not None:
430 line_no, err_text = err
431 error_widget.update(
432 msg.SETTINGS_LIST_EDITOR_INVALID_REGEX.format(n=line_no, error=err_text)
433 )
434 error_widget.add_class(_LIST_ERROR_VISIBLE_CLASS)
435 return
436 error_widget.remove_class(_LIST_ERROR_VISIBLE_CLASS)
437 self._persist_value(key, defn, raw)
438 self._refresh_list_title(key, len(parsed))
440 @on(Button.Pressed, ".setting-list-restore")
441 def _on_list_restore(self, event: Button.Pressed) -> None:
442 """Restore defaults for a LIST_COLLAPSED setting."""
443 btn_id = event.button.id
444 if btn_id is None or not btn_id.startswith(_LIST_RESTORE_PREFIX):
445 return
446 key = btn_id.removeprefix(_LIST_RESTORE_PREFIX)
447 defn = SETTINGS_MAP.get(key)
448 if defn is None:
449 return
450 defaults = list(DEFAULT_CRAWL_EXCLUDE_PATTERNS)
451 text = "\n".join(defaults)
452 ta = self.query_one(f"#ed-{key}", ListTextArea)
453 ta.load_text(text)
454 self._persist_value(key, defn, text)
455 error_widget = self.query_one(f"#{_LIST_ERROR_ID_PREFIX}{key}", Static)
456 error_widget.remove_class(_LIST_ERROR_VISIBLE_CLASS)
457 self._refresh_list_title(key, len(defaults))
459 def _refresh_list_title(self, key: str, count: int) -> None:
460 """Update the Collapsible title to reflect the current line count."""
461 try:
462 collapsible = self.query_one(f"#collapsible-{key}", Collapsible)
463 collapsible.title = msg.SETTINGS_LIST_EDITOR_TITLE.format(key=key, count=count)
464 except Exception:
465 log.debug("Failed to refresh collapsible title for %s", key, exc_info=True)
467 def _refresh_help(self, key: str, defn: SettingDef) -> None:
468 """Update the help text after a value change."""
469 try:
470 row = self.query_one(f"#{_ROW_ID_PREFIX}{key}", VerticalGroup)
471 help_widget = row.query_one(".setting-help", Static)
472 help_widget.update(_help_content(key, defn))
473 except Exception:
474 log.debug("Failed to refresh help for %s", key, exc_info=True)
476 @on(Button.Pressed, ".setting-reset-button")
477 def _on_reset_pressed(self, event: Button.Pressed) -> None:
478 """Handle the small reset button embedded in each writable row."""
479 button_id = event.button.id
480 if button_id is None or not button_id.startswith(_RESET_BUTTON_ID_PREFIX):
481 return
482 key = button_id[len(_RESET_BUTTON_ID_PREFIX) :]
483 self._reset_to_default(key)
485 @on(Button.Pressed, "#reset-all-defaults")
486 def _on_reset_all_pressed(self) -> None:
487 """Open a destructive-confirm dialog before resetting every writable field."""
488 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
490 self.app.push_screen(
491 ConfirmDialog(
492 title=msg.SETTINGS_RESET_ALL_CONFIRM_TITLE,
493 message=msg.SETTINGS_RESET_ALL_CONFIRM_MESSAGE,
494 ),
495 self._on_reset_all_confirmed,
496 )
498 def _on_reset_all_confirmed(self, confirmed: bool | None) -> None:
499 """Reset every writable setting to its cfg default atomically."""
500 if not confirmed:
501 return
502 writable = [(key, defn) for key, defn in SETTINGS_MAP.items() if defn.writable]
503 snapshot = {key: getattr(cfg, key) for key, _ in writable}
504 updates, signal_payload, skipped = self._apply_batch_defaults(writable)
505 if updates and not self._persist_batch(writable, snapshot, updates):
506 return
507 self._refresh_batch(writable, skipped)
508 self._publish_batch_signals(signal_payload)
509 self._notify_batch_result(skipped)
511 def _apply_batch_defaults(
512 self, writable: list[tuple[str, SettingDef]]
513 ) -> tuple[dict[str, str], list[tuple[str, object]], list[str]]:
514 """Mutate cfg in-memory for every writable key; track updates + skips."""
515 updates: dict[str, str] = {}
516 signal_payload: list[tuple[str, object]] = []
517 skipped: list[str] = []
518 for key, _defn in writable:
519 default = get_default(key)
520 try:
521 setattr(cfg, key, default)
522 except (ValueError, TypeError) as exc:
523 log.warning("Default for %s rejected by cfg (%s); skipping", key, exc)
524 skipped.append(key)
525 continue
526 updates[key] = _stringify_default(default)
527 signal_payload.append((key, default))
528 return updates, signal_payload, skipped
530 def _persist_batch(
531 self,
532 writable: list[tuple[str, SettingDef]],
533 snapshot: dict[str, object],
534 updates: dict[str, str],
535 ) -> bool:
536 """Persist the batch; roll back cfg + UI on disk error. Returns True on success."""
537 try:
538 settings.update_values(cfg.data_root, updates)
539 except OSError as exc:
540 self._rollback_batch(writable, snapshot)
541 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error")
542 return False
543 return True
545 def _rollback_batch(
546 self, writable: list[tuple[str, SettingDef]], snapshot: dict[str, object]
547 ) -> None:
548 """Restore cfg and editor widgets from snapshot after a failed persist."""
549 for key, prev in snapshot.items():
550 try:
551 setattr(cfg, key, prev)
552 except (ValueError, TypeError):
553 log.exception("Failed to roll back cfg.%s", key)
554 for key, defn in writable:
555 self._refresh_editor(key, defn, snapshot[key])
556 self._refresh_help(key, defn)
558 def _refresh_batch(self, writable: list[tuple[str, SettingDef]], skipped: list[str]) -> None:
559 """Refresh editor + help for each successfully-reset writable key."""
560 for key, defn in writable:
561 if key in skipped:
562 continue
563 default = get_default(key)
564 self._refresh_editor(key, defn, default)
565 self._refresh_help(key, defn)
567 def _publish_batch_signals(self, signal_payload: list[tuple[str, object]]) -> None:
568 """Fan out settings_changed signals for every successfully-reset key."""
569 from lilbee.cli.tui.app import LilbeeApp
571 if not isinstance(self.app, LilbeeApp): # test apps aren't LilbeeApp
572 return
573 for pub_key, pub_parsed in signal_payload:
574 self.app.settings_changed_signal.publish((pub_key, pub_parsed))
576 def _notify_batch_result(self, skipped: list[str]) -> None:
577 """Surface a single summary toast; warning severity when any key skipped."""
578 if skipped:
579 self.notify(
580 msg.SETTINGS_RESET_ALL_PARTIAL.format(skipped=", ".join(skipped)),
581 severity="warning",
582 )
583 else:
584 self.notify(msg.SETTINGS_RESET_ALL_SUCCESS)
586 def action_reset_focused(self) -> None:
587 """Reset the currently-focused setting row to its cfg default."""
588 focused = self.focused
589 if focused is None:
590 return
591 for ancestor in focused.ancestors_with_self:
592 ancestor_id = getattr(ancestor, "id", None)
593 if ancestor_id and ancestor_id.startswith(_ROW_ID_PREFIX):
594 key = ancestor_id[len(_ROW_ID_PREFIX) :]
595 self._reset_to_default(key)
596 return
598 def _reset_to_default(self, key: str) -> None:
599 """Restore a single setting to its cfg default."""
600 defn = SETTINGS_MAP.get(key)
601 if defn is None or not defn.writable:
602 return
603 default = get_default(key)
604 stringified = _stringify_default(default)
605 self._persist_value(key, defn, stringified)
606 self._refresh_editor(key, defn, default)
608 def _refresh_editor(self, key: str, defn: SettingDef, value: object) -> None:
609 """Update the editor widget to reflect a new value (e.g. after reset)."""
610 try:
611 widget = self.query_one(f"#{_EDITOR_ID_PREFIX}{key}")
612 except Exception:
613 log.debug("Failed to refresh editor for %s", key, exc_info=True)
614 return
615 if isinstance(widget, Input):
616 widget.value = "" if value is None else str(value)
617 elif isinstance(widget, Checkbox):
618 widget.value = bool(value)
619 elif isinstance(widget, Select):
620 if value is None:
621 widget.clear()
622 else:
623 widget.value = str(value)
624 elif isinstance(widget, TextArea): # future-proofing: list/multiline defaults
625 if isinstance(value, list):
626 widget.load_text("\n".join(value))
627 else:
628 widget.load_text("" if value is None else str(value))
630 def action_focus_search(self) -> None:
631 """Focus the search input -- bound to / key."""
632 self.query_one("#settings-search", Input).focus()
634 def action_go_back(self) -> None:
635 search = self.query_one("#settings-search", Input)
636 if self.focused is search: # Escape from filter → blur, don't leave
637 self.query_one("#settings-scroll", VerticalScroll).focus()
638 return
639 from lilbee.cli.tui.app import LilbeeApp
641 if isinstance(self.app, LilbeeApp): # test apps aren't LilbeeApp
642 self.app.switch_view("Chat")
643 else:
644 self.app.pop_screen()
646 def action_scroll_down(self) -> None:
647 self.query_one("#settings-scroll", VerticalScroll).scroll_down()
649 def action_scroll_up(self) -> None:
650 self.query_one("#settings-scroll", VerticalScroll).scroll_up()
652 def action_scroll_home(self) -> None:
653 self.query_one("#settings-scroll", VerticalScroll).scroll_home()
655 def action_scroll_end(self) -> None:
656 self.query_one("#settings-scroll", VerticalScroll).scroll_end()