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

1"""Settings screen. Grouped, type-aware configuration editor.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import os 

7import re 

8from collections import defaultdict 

9from typing import Any, ClassVar 

10 

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 

19 

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 

27 

28_ROW_ID_PREFIX = "row-" 

29_EDITOR_ID_PREFIX = "ed-" 

30_RESET_BUTTON_ID_PREFIX = "reset-" 

31_RESET_BUTTON_LABEL = "↺" 

32 

33log = logging.getLogger(__name__) 

34 

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} 

42 

43 

44_DEFAULTS_REMAP: dict[str, str] = {"top_k_sampling": "top_k"} 

45 

46_LIST_RESTORE_PREFIX = "list-restore-" 

47_LIST_ERROR_ID_PREFIX = "err-" 

48_LIST_ERROR_VISIBLE_CLASS = "-visible" 

49 

50_API_KEYS_GROUP = "API-Keys" 

51_API_KEYS_WARNING_CLASS = "api-keys-warning" 

52_CONFIG_TOML_FILENAME = "config.toml" 

53 

54 

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) 

58 

59 

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" 

75 

76 

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 

81 

82 

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) 

90 

91 

92def _env_var_name(key: str) -> str: 

93 """Return the LILBEE_* env var name for a config key.""" 

94 return f"LILBEE_{key.upper()}" 

95 

96 

97def _env_pill(key: str) -> Content | None: 

98 """Return a warning pill showing the literal env var when it's set. 

99 

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") 

108 

109 

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("") 

115 

116 

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) 

125 

126 

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) 

134 

135 

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) 

142 

143 

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) 

154 

155 

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 ) 

181 

182 

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}") 

195 

196 

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 ) 

203 

204 

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 ) 

211 

212 

213class SettingsScreen(Screen[None]): 

214 """Interactive settings viewer with grouped, type-aware editors.""" 

215 

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 ) 

222 

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 ] 

235 

236 def compose(self) -> ComposeResult: 

237 from textual.widgets import Footer 

238 

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 

243 

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() 

261 

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) 

274 

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 ) 

293 

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() 

298 

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 

311 

312 def _filter_index(self) -> list[tuple[Any, list[Any]]]: 

313 """Lazy cache of (group, rows) for the filter handler. 

314 

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 

325 

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) 

341 

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)) 

352 

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) 

367 

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 

379 

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") 

384 

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) 

394 

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) 

403 

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 

413 

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)) 

439 

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)) 

458 

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) 

466 

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) 

475 

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) 

484 

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 

489 

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 ) 

497 

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) 

510 

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 

529 

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 

544 

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) 

557 

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) 

566 

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 

570 

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)) 

575 

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) 

585 

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 

597 

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) 

607 

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)) 

629 

630 def action_focus_search(self) -> None: 

631 """Focus the search input -- bound to / key.""" 

632 self.query_one("#settings-search", Input).focus() 

633 

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 

640 

641 if isinstance(self.app, LilbeeApp): # test apps aren't LilbeeApp 

642 self.app.switch_view("Chat") 

643 else: 

644 self.app.pop_screen() 

645 

646 def action_scroll_down(self) -> None: 

647 self.query_one("#settings-scroll", VerticalScroll).scroll_down() 

648 

649 def action_scroll_up(self) -> None: 

650 self.query_one("#settings-scroll", VerticalScroll).scroll_up() 

651 

652 def action_scroll_home(self) -> None: 

653 self.query_one("#settings-scroll", VerticalScroll).scroll_home() 

654 

655 def action_scroll_end(self) -> None: 

656 self.query_one("#settings-scroll", VerticalScroll).scroll_end()