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

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

2 

3from __future__ import annotations 

4 

5import logging 

6import re 

7from collections.abc import Callable 

8from dataclasses import dataclass 

9from typing import TYPE_CHECKING, ClassVar 

10 

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) 

27 

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 

57 

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 

62 

63log = logging.getLogger(__name__) 

64 

65 

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

75 

76 

77@dataclass(frozen=True) 

78class _PaneGroup: 

79 """One settings tab: pane id, group label, ordered settings.""" 

80 

81 pane_id: str 

82 group_name: str 

83 items: list[tuple[str, SettingDef]] 

84 

85 

86class _LazyGroupBody(VerticalScroll, can_focus=False): 

87 """Pane-body that mounts rows on first activation; scrolls when taller than viewport.""" 

88 

89 def __init__(self, *, id: str | None = None) -> None: 

90 super().__init__(id=id) 

91 self._populated = False 

92 

93 @property 

94 def populated(self) -> bool: 

95 return self._populated 

96 

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) 

105 

106 

107class SettingsScreen(Screen[None]): 

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

109 

110 app: LilbeeApp # type: ignore[assignment] 

111 

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 ) 

122 

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 ] 

143 

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 

153 

154 def compose(self) -> ComposeResult: 

155 from textual.widgets import Footer 

156 

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 

161 

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

174 

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 

195 

196 def on_mount(self) -> None: 

197 """Defer first-pane content mount until after the screen has painted. 

198 

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) 

207 

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) 

215 

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) 

220 

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

232 

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 

246 

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 ) 

271 

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 ) 

282 

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) 

298 

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) 

314 

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

325 

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) 

340 

341 def _persist_value(self, key: str, defn: SettingDef, raw: str, *, quiet: bool = False) -> None: 

342 """Parse, apply, and persist a setting value. 

343 

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

358 

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) 

368 

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 

378 

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

404 

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

423 

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) 

431 

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) 

440 

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) 

449 

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) 

461 

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. 

465 

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 

473 

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) 

478 

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 

483 

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 ) 

505 

506 def _on_model_picker_dismissed(self, key: str, ref: str | None) -> None: 

507 """Persist the picker selection and refresh the button label. 

508 

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) 

525 

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 

530 

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) 

535 

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 

541 

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 ) 

546 

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 

553 

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) 

563 

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 

567 

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 ) 

575 

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) 

588 

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 

607 

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 

622 

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) 

635 

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) 

644 

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

649 

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) 

659 

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 

671 

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) 

681 

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) 

690 

691 def action_go_back(self) -> None: 

692 self.app.switch_view("Chat") 

693 

694 def _active_pane_body(self) -> _LazyGroupBody | None: 

695 """Resolve the currently-active settings tab body (a VerticalScroll). 

696 

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 

712 

713 def action_scroll_down(self) -> None: 

714 if (body := self._active_pane_body()) is not None: 

715 body.scroll_down() 

716 

717 def action_scroll_up(self) -> None: 

718 if (body := self._active_pane_body()) is not None: 

719 body.scroll_up() 

720 

721 def action_scroll_home(self) -> None: 

722 if (body := self._active_pane_body()) is not None: 

723 body.scroll_home() 

724 

725 def action_scroll_end(self) -> None: 

726 if (body := self._active_pane_body()) is not None: 

727 body.scroll_end() 

728 

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) 

732 

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) 

736 

737 def action_cycle_pane(self, delta: int) -> None: 

738 """Step the active settings tab by *delta*, wrapping around the strip. 

739 

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 

757 

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) 

787 

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