Coverage for src/lilbee/cli/tui/screens/settings.py: 100%

573 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

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

2 

3from __future__ import annotations 

4 

5import asyncio 

6import logging 

7from collections.abc import Callable, Iterable, Mapping 

8from dataclasses import dataclass 

9from typing import TYPE_CHECKING, Any, 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 TextArea, 

27) 

28 

29from lilbee.app.settings import ( 

30 OCR_SETTING_KEYS, 

31 chunk_limit_loosened, 

32 list_setting_value, 

33 reindex_values, 

34 reindex_values_differ, 

35 setting_sources, 

36) 

37from lilbee.app.settings_map import SETTINGS_MAP, SettingDef, SettingGroup 

38from lilbee.app.status import held_out_sources 

39from lilbee.cli.tui import messages as msg 

40from lilbee.cli.tui.browse_bindings import BROWSE_LIST_BINDINGS, browse_back_bindings 

41from lilbee.cli.tui.screens.profile_tab import ProfileSnapshot, ProfileTab, load_snapshot 

42from lilbee.cli.tui.screens.settings_widgets import ( 

43 ADVANCED_COLLAPSIBLE_CLASS, 

44 ADVANCED_COLLAPSIBLE_ID_PREFIX, 

45 API_KEYS_GROUP, 

46 API_KEYS_WARNING_CLASS, 

47 EDITOR_ID_PREFIX, 

48 EDITOR_KINDS, 

49 GATED_ROWS, 

50 LIST_ERROR_ID_PREFIX, 

51 LIST_ERROR_VISIBLE_CLASS, 

52 LIST_RESTORE_PREFIX, 

53 MODEL_PICKER_BUTTON_PREFIX, 

54 RESET_BUTTON_ID_PREFIX, 

55 RESET_BUTTON_LABEL, 

56 ROW_ID_PREFIX, 

57 SettingEditor, 

58 SettingRow, 

59 active_profile_name, 

60 advanced_title, 

61 config_toml_path, 

62 displayed_text, 

63 editor_choices, 

64 effective_value, 

65 group_settings, 

66 help_content, 

67 list_editor_text, 

68 make_editor, 

69 model_field_to_picker_scope, 

70 model_picker_label, 

71 picker_scope_to_task, 

72 row_visible, 

73 select_shown_value, 

74 set_widget_value, 

75 title_content, 

76) 

77from lilbee.cli.tui.widgets.list_text_area import ListTextArea 

78from lilbee.cli.tui.widgets.model_pick import STORE_ERRORS, apply_model_pick 

79from lilbee.cli.tui.widgets.profile_line import ProfileLine, ProfileLinePill 

80from lilbee.core.config import cfg 

81from lilbee.core.config.enums import SettingSource 

82 

83if TYPE_CHECKING: 

84 from lilbee.cli.tui.app import LilbeeApp 

85 from lilbee.cli.tui.screens.model_picker import PickerScope 

86 from lilbee.cli.tui.widgets.model_bar import ModelOption 

87 

88log = logging.getLogger(__name__) 

89 

90PROFILE_PANE_ID = "settings-tab-profile" 

91 

92 

93@dataclass(frozen=True) 

94class _PaneGroup: 

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

96 

97 pane_id: str 

98 group_name: SettingGroup 

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

100 

101 

102class _LazyGroupBody(VerticalScroll, can_focus=False): 

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

104 

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

106 super().__init__(id=id) 

107 self._populated = False 

108 

109 @property 

110 def populated(self) -> bool: 

111 return self._populated 

112 

113 def populate(self, build: Callable[[], list[Widget]]) -> None: 

114 """Build and mount this pane's row widgets exactly once.""" 

115 if self._populated: 

116 return 

117 self._populated = True 

118 widgets = build() 

119 if widgets: 

120 self.mount_all(widgets) 

121 

122 

123class SettingsScreen(Screen[None]): 

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

125 

126 app: LilbeeApp # type: ignore[assignment] 

127 

128 CSS_PATH = "settings.tcss" 

129 # Target the TabbedContent's inner Tabs strip rather than the outer 

130 # #settings-scroll Container -- Container can't accept focus, so on 

131 # mount focus would otherwise stay at None and downstream Tab-cycling 

132 # has nowhere to start. The Tabs widget is the canonical entry point. 

133 AUTO_FOCUS = "#settings-tabs Tabs" 

134 HELP = ( 

135 "Browse and edit configuration.\n\n" 

136 "Tab / Shift+Tab move between fields, > and < jump between groups, " 

137 "j / k scroll and g / G jump to the top / bottom, " 

138 "Ctrl+R resets the focused field and Ctrl+Shift+R resets every " 

139 "setting, and q or Escape goes back. In a value field the keys type, " 

140 "so press Escape to leave the field first; then > and < work again." 

141 ) 

142 

143 # < and > are one action in two directions, so a single "Tabs" label still 

144 # says what both keys do. Keys that do different things get their own cell 

145 # or move to the help panel. 

146 _TAB_GROUP = Binding.Group("Tabs", compact=True) 

147 

148 BINDINGS: ClassVar[list[BindingType]] = [ 

149 # In a value field Escape leaves the field; check_action keeps Back off then, so the 

150 # second Escape, from the row, goes back. Priority, so the app's priority Escape 

151 # does not take the footer cell; that one skips when no help panel is open. 

152 Binding("escape", "leave_field", msg.SETTINGS_LEAVE_FIELD, show=True, priority=True), 

153 *browse_back_bindings(), 

154 # Tab cycles editors inside the active pane and rolls over to the 

155 # next group tab when you Tab past the last editor (and the 

156 # previous group tab on shift+Tab past the first editor). Use 

157 # > / < to jump straight to the next / previous group tab. 

158 Binding("tab", "next_field_or_pane", "Next field", show=False), 

159 Binding("shift+tab", "prev_field_or_pane", "Prev field", show=False), 

160 # Direct tab cycling, mirrored from CatalogScreen. priority=True so the keys win 

161 # over the screen's other bindings; a focused text field still keeps its characters. 

162 Binding( 

163 "less_than_sign", 

164 "cycle_pane(-1)", 

165 "Prev tab", 

166 show=True, 

167 priority=True, 

168 group=_TAB_GROUP, 

169 ), 

170 Binding( 

171 "greater_than_sign", 

172 "cycle_pane(1)", 

173 "Next tab", 

174 show=True, 

175 priority=True, 

176 group=_TAB_GROUP, 

177 ), 

178 # Resetting the focused setting is what this screen is for, so it keeps 

179 # a cell. Reset-all is a rarer, wider-reaching action and lives in help; 

180 # the two are different actions, so they must not share one label. 

181 Binding("ctrl+r", "reset_focused", "Reset", show=True), 

182 Binding("ctrl+shift+r", "reset_all", "Reset all", show=False), 

183 *BROWSE_LIST_BINDINGS, 

184 ] 

185 

186 def __init__(self) -> None: 

187 super().__init__() 

188 # Group definitions by pane id; a pane fills on first activation. 

189 self._pane_groups: dict[str, _PaneGroup] = {} 

190 self._pane_ids: list[str] = [PROFILE_PANE_ID] 

191 self._eagerly_populate = PROFILE_PANE_ID 

192 # What each editor showed when last built or refreshed; a save acts on a change from it. 

193 self._mount_display: dict[str, str] = {} 

194 self._stale_source_keys: set[str] = set() 

195 # The index-changing values in force when the user came in; None while away. 

196 self._index_values: dict[str, Any] | None = None 

197 

198 def compose(self) -> ComposeResult: 

199 from lilbee.cli.tui.widgets.bottom_bars import BottomBars 

200 from lilbee.cli.tui.widgets.stable_footer import StableFooter 

201 from lilbee.cli.tui.widgets.status_bar import ViewTabs 

202 from lilbee.cli.tui.widgets.task_bar import TaskBar 

203 from lilbee.cli.tui.widgets.top_bars import TopBars 

204 

205 with TopBars(): 

206 yield ViewTabs() 

207 yield ProfileLine(id="profile-line") 

208 # A Container, not a VerticalScroll: each tab body scrolls, and nested scrollables tear. 

209 with Container(id="settings-scroll"), TabbedContent(id="settings-tabs"): 

210 yield TabPane( 

211 msg.PROFILE_TAB_LABEL, 

212 _LazyGroupBody(id=f"{PROFILE_PANE_ID}-body"), 

213 id=PROFILE_PANE_ID, 

214 ) 

215 yield from self._compose_group_tabs() 

216 with BottomBars(): 

217 yield TaskBar() 

218 yield StableFooter() 

219 

220 def _compose_group_tabs(self) -> ComposeResult: 

221 """Yield one TabPane per setting group; bodies populate on activation.""" 

222 for group_name, items in group_settings().items(): 

223 pane_id = f"settings-tab-{group_name.lower().replace('-', '_')}" 

224 self._pane_groups[pane_id] = _PaneGroup( 

225 pane_id=pane_id, group_name=group_name, items=items 

226 ) 

227 self._pane_ids.append(pane_id) 

228 yield TabPane( 

229 group_name, 

230 _LazyGroupBody(id=f"{pane_id}-body"), 

231 id=pane_id, 

232 ) 

233 

234 def on_mount(self) -> None: 

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

236 

237 ``_populate_pane`` calls ``mount_all`` for ~25 editor widgets which 

238 triggers a full Textual layout pass; running it inside ``on_mount`` 

239 adds that pass to the screen-switch latency budget. ``call_after_refresh`` 

240 moves it to the next event-loop tick so the user sees the empty pane 

241 skeleton immediately and the rows hydrate one frame later. 

242 """ 

243 self.app.settings_changed_signal.subscribe(self, self._on_setting_changed) 

244 # The first pane is the one TabbedContent activates by default; populate 

245 # it eagerly so Settings shows content on first paint. 

246 self.call_after_refresh(self._populate_pane, self._eagerly_populate) 

247 

248 def on_screen_resume(self) -> None: 

249 """Take setting changes and rescan the profiles: another shell may have changed both. 

250 

251 The first resume only opens the screen: the Profile tab loads its own state on mount. 

252 """ 

253 file_changed = self.app.adopt_external_settings() 

254 if file_changed or self._pane_is_populated(PROFILE_PANE_ID): 

255 self.reload_profile() 

256 # A dialog closing resumes the screen too; only a visit takes the values. 

257 if self._index_values is None: 

258 self.take_index_values() 

259 

260 def on_screen_suspend(self) -> None: 

261 """Offer one rebuild for the edits of this visit once another view replaces the screen. 

262 

263 A dialog on top suspends the screen too, but leaves it on the stack. 

264 """ 

265 if self not in self.app.screen_stack and (message := self.leave()): 

266 self.app.offer_rebuild(message) 

267 

268 def take_index_values(self, keys: Iterable[str] | None = None) -> None: 

269 """Count the values in force of *keys*, or of all when None, as the ones the index holds. 

270 

271 Leaving asks about the values changed after this. Only a visit in progress moves for *keys*. 

272 """ 

273 values = reindex_values() 

274 if keys is None: 

275 self._index_values = values 

276 elif self._index_values is not None: 

277 self._index_values.update({key: values[key] for key in keys if key in values}) 

278 

279 def leave(self) -> str | None: 

280 """End the visit; the rebuild question its edits leave, or None when they leave none. 

281 

282 A rebuild is owed when the index was built with other values, or when a looser 

283 chunk limit now admits files it held out: a rebuild retries those files. 

284 """ 

285 before, self._index_values = self._index_values, None 

286 if before is None: 

287 return None 

288 retry = chunk_limit_loosened(before) and held_out_sources()[1] > 0 

289 return msg.settings_rebuild_text(reindex_values_differ(before), retry) 

290 

291 def _on_setting_changed(self, change: tuple[str, object]) -> None: 

292 """Queue the changed key's source pill and editor, and the profile state, for a refresh.""" 

293 key, _value = change 

294 if not self._stale_source_keys: 

295 self.call_after_refresh(self._refresh_changed_rows) 

296 self._stale_source_keys.add(key) 

297 

298 def _refresh_changed_rows(self) -> None: 

299 """Repaint every queued row's title and editor from one read of the setting sources.""" 

300 keys, self._stale_source_keys = self._stale_source_keys, set() 

301 sources = setting_sources() 

302 profile_name = active_profile_name() 

303 rows = {row.id: row for row in self.query(".setting-row")} 

304 sections: dict[str, Collapsible] = {} 

305 for key in keys: 

306 row = rows.get(f"{ROW_ID_PREFIX}{key}") 

307 if row is None: 

308 continue 

309 for ancestor in row.ancestors: 

310 if isinstance(ancestor, Collapsible) and ancestor.has_class( 

311 ADVANCED_COLLAPSIBLE_CLASS 

312 ): 

313 sections[ancestor.id or ""] = ancestor 

314 for title in row.query_children(".setting-title").results(Static): 

315 title.update(title_content(key, SETTINGS_MAP[key], sources[key], profile_name)) 

316 self._sync_editor(row, key) 

317 for section in sections.values(): 

318 pane_id = (section.id or "").removeprefix(ADVANCED_COLLAPSIBLE_ID_PREFIX) 

319 advanced = [k for k, defn in self._pane_groups[pane_id].items if defn.advanced] 

320 section.title = advanced_title(advanced, sources) 

321 if self._is_visible(): 

322 self.reload_profile() 

323 

324 def _is_visible(self) -> bool: 

325 """Whether the user sees this screen: it is on top, or the top screen is see-through.""" 

326 top = self.app.screen 

327 return top is self or top.styles.background.a < 1 

328 

329 def _sync_editor(self, row: Widget, key: str) -> None: 

330 """Show cfg's value in the editor in *row*, unless the user is typing in it.""" 

331 editor = row.query(f"#{EDITOR_ID_PREFIX}{key}") 

332 if editor and not editor.first().has_focus_within: 

333 self._show_editor_value(key, editor.first(), getattr(cfg, key)) 

334 

335 @work(exclusive=True, group="profile-load", exit_on_error=False) 

336 async def reload_profile(self) -> None: 

337 """Load the profile state off the loop, then show it on the line and the Profile tab. 

338 

339 Only the read runs on a thread. A newer load cancels this task at the await, 

340 and a cancelled thread worker would still deliver its old snapshot. 

341 """ 

342 try: 

343 snapshot = await asyncio.to_thread(load_snapshot) 

344 except OSError as exc: 

345 self._show_profile_failure(str(exc)) 

346 return 

347 self._show_profile(snapshot) 

348 

349 def _mounted_profile_tabs(self) -> list[ProfileTab]: 

350 """The tabs that can take a snapshot: filling one before it mounts raises.""" 

351 return [tab for tab in self.query(ProfileTab) if tab.is_mounted] 

352 

353 def _show_profile_failure(self, reason: str) -> None: 

354 for tab in self._mounted_profile_tabs(): 

355 tab.show_failure(reason) 

356 

357 def _show_profile(self, snapshot: ProfileSnapshot) -> None: 

358 self.query_one(ProfileLine).show(snapshot) 

359 for tab in self._mounted_profile_tabs(): 

360 tab.show(snapshot) 

361 

362 @on(ProfileTab.Stale) 

363 def _on_profile_stale(self) -> None: 

364 self.reload_profile() 

365 

366 @on(ProfileLinePill.Jump) 

367 def _on_profile_jump(self) -> None: 

368 self.show_profile_tab() 

369 

370 def show_profile_tab(self) -> None: 

371 """Activate the Profile tab and put focus on its dropdown.""" 

372 self.query_one("#settings-tabs", TabbedContent).active = PROFILE_PANE_ID 

373 self._populate_pane(PROFILE_PANE_ID) 

374 self.call_after_refresh(self._focus_profile_select) 

375 

376 def _focus_profile_select(self) -> None: 

377 for tab in self.query(ProfileTab): 

378 tab.focus_select() 

379 

380 @on(TabbedContent.TabActivated) 

381 def _on_tab_activated(self, event: TabbedContent.TabActivated) -> None: 

382 """Populate the activated pane's body on first activation, and take outside changes. 

383 

384 A config.toml change another process wrote reloads the profile line on any tab. 

385 """ 

386 pane = event.pane 

387 if pane is None or pane.id is None: 

388 return 

389 file_changed = self.app.adopt_external_settings() 

390 revisit = pane.id == PROFILE_PANE_ID and self._pane_is_populated(pane.id) 

391 self._populate_pane(pane.id) 

392 if revisit or file_changed: 

393 self.reload_profile() 

394 

395 def _pane_is_populated(self, pane_id: str) -> bool: 

396 """Whether *pane_id*'s body already built its rows; a new body loads its own state.""" 

397 return any( 

398 body.populated for body in self.query(f"#{pane_id}-body").results(_LazyGroupBody) 

399 ) 

400 

401 def populate_all_panes(self) -> None: 

402 """Force every tab body to populate now (test/agent helper).""" 

403 for pane_id in self._pane_ids: 

404 self._populate_pane(pane_id) 

405 

406 def _populate_pane(self, pane_id: str) -> None: 

407 """Populate a pane's body if known and the body widget is mounted.""" 

408 build = self._pane_builder(pane_id) 

409 if build is None: 

410 return 

411 try: 

412 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody) 

413 except Exception: 

414 log.debug("pane body %s not yet mounted", pane_id, exc_info=True) 

415 return 

416 body.populate(build) 

417 

418 def _pane_builder(self, pane_id: str) -> Callable[[], list[Widget]] | None: 

419 """The function that builds *pane_id*'s body widgets, or None for an unknown pane.""" 

420 if pane_id == PROFILE_PANE_ID: 

421 return lambda: [ProfileTab()] 

422 group = self._pane_groups.get(pane_id) 

423 return None if group is None else lambda: self._build_pane_widgets(group) 

424 

425 def _build_pane_widgets(self, group: _PaneGroup) -> list[Widget]: 

426 """Return the body widgets for one settings tab. 

427 

428 Advanced settings are folded into one collapsed section at the 

429 bottom, after every regular row. 

430 """ 

431 widgets: list[Widget] = [] 

432 if group.group_name == API_KEYS_GROUP: 

433 widgets.append( 

434 Static( 

435 msg.SETTINGS_API_KEYS_WARNING.format(path=config_toml_path()), 

436 classes=API_KEYS_WARNING_CLASS, 

437 ) 

438 ) 

439 sources = setting_sources() 

440 profile_name = active_profile_name() 

441 basic = [(key, defn) for key, defn in group.items if not defn.advanced] 

442 advanced = [(key, defn) for key, defn in group.items if defn.advanced] 

443 for key, defn in basic: 

444 widgets.append(self._build_setting_row(key, defn, sources[key], profile_name)) 

445 if advanced: 

446 rows = [ 

447 self._build_setting_row(key, defn, sources[key], profile_name) 

448 for key, defn in advanced 

449 ] 

450 widgets.append( 

451 self._build_advanced_section(group.pane_id, [k for k, _ in advanced], rows, sources) 

452 ) 

453 return widgets 

454 

455 def _build_advanced_section( 

456 self, 

457 pane_id: str, 

458 keys: list[str], 

459 rows: list[VerticalGroup], 

460 sources: Mapping[str, SettingSource], 

461 ) -> Collapsible: 

462 """One collapsed section holding every advanced row for a tab.""" 

463 title = advanced_title(keys, sources) 

464 return Collapsible( 

465 *rows, 

466 title=title, 

467 collapsed=True, 

468 id=f"{ADVANCED_COLLAPSIBLE_ID_PREFIX}{pane_id}", 

469 classes=ADVANCED_COLLAPSIBLE_CLASS, 

470 ) 

471 

472 def _build_setting_row( 

473 self, key: str, defn: SettingDef, source: SettingSource, profile_name: str | None 

474 ) -> VerticalGroup: 

475 """Construct one setting row with its title, help, editor, and reset.""" 

476 title = Static(title_content(key, defn, source, profile_name), classes="setting-title") 

477 help_widget = Static(help_content(key, defn), classes="setting-help") 

478 children: list[Widget] = [title, help_widget] 

479 if key in model_field_to_picker_scope(): 

480 children.append(self._build_model_picker_row(key)) 

481 elif defn.writable: 

482 editor = make_editor(key, defn) 

483 self._mount_display[key] = self._mount_baseline(key, defn, editor) 

484 editor_row = Horizontal( 

485 editor, 

486 Button( 

487 RESET_BUTTON_LABEL, 

488 id=f"{RESET_BUTTON_ID_PREFIX}{key}", 

489 classes="setting-reset-button", 

490 tooltip=msg.SETTINGS_RESET_TO_DEFAULT_TOOLTIP, 

491 ), 

492 classes="setting-editor-row", 

493 ) 

494 children.append(editor_row) 

495 row = SettingRow( 

496 *children, 

497 classes="setting-row", 

498 id=f"{ROW_ID_PREFIX}{key}", 

499 ) 

500 row.display = row_visible(key) 

501 return row 

502 

503 @staticmethod 

504 def _mount_baseline(key: str, defn: SettingDef, editor: Collapsible | SettingEditor) -> str: 

505 """The text a freshly built editor will show once mounted.""" 

506 # A list setting's editor is a Collapsible that holds the text area. 

507 if isinstance(editor, Collapsible): 

508 return list_editor_text(key) 

509 if editor_choices(defn): 

510 return select_shown_value(defn, effective_value(key)) 

511 return displayed_text(editor) 

512 

513 def _build_model_picker_row(self, key: str) -> Horizontal: 

514 """A button-style row that opens the same ModelPickerModal as the chat bar.""" 

515 return Horizontal( 

516 Button( 

517 model_picker_label(key), 

518 id=f"{MODEL_PICKER_BUTTON_PREFIX}{key}", 

519 classes="setting-model-picker-button", 

520 ), 

521 classes="setting-editor-row", 

522 ) 

523 

524 @on(Input.Submitted, ".setting-editor") 

525 @on(Input.Blurred, ".setting-editor") 

526 def _on_input_save(self, event: Input.Submitted | Input.Blurred) -> None: 

527 """Save string/number input on submit or blur, but only if it changed.""" 

528 name = event.input.name 

529 if name is None: 

530 return 

531 defn = SETTINGS_MAP.get(name) 

532 if defn is None: 

533 return 

534 raw = event.value.strip() 

535 if self._mount_display.get(name) == raw: 

536 return 

537 if self._persist_value(name, defn, raw): 

538 self._mount_display[name] = raw 

539 

540 @on(ListTextArea.Blurred, ".setting-multiline-editor") 

541 def _on_multiline_save(self, event: ListTextArea.Blurred) -> None: 

542 """Save multi-line string settings (system prompts) on blur, but only if changed.""" 

543 ta = event.control 

544 name = ta.name 

545 if name is None: 

546 return 

547 defn = SETTINGS_MAP.get(name) 

548 if defn is None: 

549 return 

550 raw = ta.text 

551 if self._mount_display.get(name) == raw: 

552 return 

553 if self._persist_value(name, defn, raw): 

554 self._mount_display[name] = raw 

555 

556 @on(Checkbox.Changed, ".setting-editor") 

557 def _on_checkbox_save(self, event: Checkbox.Changed) -> None: 

558 """Save boolean on toggle, but only if it still differs from the last saved value.""" 

559 name = event.checkbox.name 

560 if name is None: 

561 return 

562 defn = SETTINGS_MAP.get(name) 

563 if defn is None: 

564 return 

565 value = str(event.checkbox.value) 

566 if self._mount_display.get(name) == value: 

567 return 

568 if self._persist_value(name, defn, value): 

569 self._mount_display[name] = value 

570 

571 @on(Select.Changed, ".setting-editor") 

572 def _on_select_save(self, event: Select.Changed) -> None: 

573 """Save select choice on change, but only if it differs from the mount baseline.""" 

574 name = event.select.name 

575 if name is None: 

576 return 

577 defn = SETTINGS_MAP.get(name) 

578 if defn is None: 

579 return 

580 value = str(event.value) if event.value is not Select.NULL else "" 

581 if self._mount_display.get(name) == value: 

582 return 

583 if self._persist_value(name, defn, value): 

584 self._mount_display[name] = value 

585 

586 def _persist_value(self, key: str, defn: SettingDef, raw: str) -> bool: 

587 """Parse, apply, and persist a setting value. Returns whether it succeeded. 

588 

589 Success is silent; a parse or apply error toasts and returns False. 

590 """ 

591 try: 

592 parsed = self._parse_value(defn, raw) 

593 if not self.app.set_setting(key, parsed, ask_rebuild=False): 

594 return False 

595 self._refresh_help(key, defn) 

596 return True 

597 except (ValueError, TypeError) as exc: 

598 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error") 

599 return False 

600 

601 def _parse_value(self, defn: SettingDef, raw: str) -> object: 

602 """Convert a raw string to the setting's target type.""" 

603 if defn.nullable and raw.lower() in ("none", "null", ""): 

604 return None 

605 if defn.type is bool: 

606 # The text goes to the shared write, which refuses what it cannot read. 

607 return raw 

608 if defn.type is list: 

609 return list_setting_value(raw) 

610 return defn.type(raw) 

611 

612 @staticmethod 

613 def _validate_regex_list(lines: list[str]) -> tuple[int, str] | None: 

614 """Return the 1-indexed line number and reason for the first pattern the crawler refuses.""" 

615 from lilbee.core.crawl_patterns import refused_exclude_patterns 

616 

617 refused = refused_exclude_patterns(lines) 

618 return (refused[0][0] + 1, refused[0][1]) if refused else None 

619 

620 @on(ListTextArea.Blurred, ".setting-list-editor") 

621 def _on_list_blur_save(self, event: ListTextArea.Blurred) -> None: 

622 """Validate and save list values when a ListTextArea loses focus, but only if changed.""" 

623 ta = event.control 

624 key = ta.name 

625 if key is None: 

626 return 

627 defn = SETTINGS_MAP.get(key) 

628 if defn is None: 

629 return 

630 raw = ta.text 

631 if self._mount_display.get(key) == raw: 

632 return 

633 parsed = self._parse_value(defn, raw) 

634 assert isinstance(parsed, list) # noqa: S101 -- mypy narrowing, defn.type is list above 

635 err = self._validate_regex_list(parsed) if defn.validate_regex else None 

636 error_widget = self.query_one(f"#{LIST_ERROR_ID_PREFIX}{key}", Static) 

637 if err is not None: 

638 line_no, err_text = err 

639 error_widget.update( 

640 msg.SETTINGS_LIST_EDITOR_INVALID_REGEX.format(n=line_no, error=err_text) 

641 ) 

642 error_widget.add_class(LIST_ERROR_VISIBLE_CLASS) 

643 return 

644 error_widget.remove_class(LIST_ERROR_VISIBLE_CLASS) 

645 if self._persist_value(key, defn, raw): 

646 self._mount_display[key] = raw 

647 self._refresh_list_title(key, len(parsed)) 

648 

649 @on(Button.Pressed, ".setting-list-restore") 

650 def _on_list_restore(self, event: Button.Pressed) -> None: 

651 """Reset a list setting.""" 

652 btn_id = event.button.id 

653 if btn_id is None or not btn_id.startswith(LIST_RESTORE_PREFIX): 

654 return 

655 key = btn_id.removeprefix(LIST_RESTORE_PREFIX) 

656 if SETTINGS_MAP.get(key) is None or not self._reset_keys([key]): 

657 return 

658 error_widget = self.query_one(f"#{LIST_ERROR_ID_PREFIX}{key}", Static) 

659 error_widget.remove_class(LIST_ERROR_VISIBLE_CLASS) 

660 self._refresh_list_title(key, len(getattr(cfg, key))) 

661 

662 def _refresh_list_title(self, key: str, count: int) -> None: 

663 """Update the Collapsible title to reflect the current line count.""" 

664 try: 

665 collapsible = self.query_one(f"#collapsible-{key}", Collapsible) 

666 collapsible.title = msg.SETTINGS_LIST_EDITOR_TITLE.format(key=key, count=count) 

667 except Exception: 

668 log.debug("Failed to refresh collapsible title for %s", key, exc_info=True) 

669 

670 def _refresh_help(self, key: str, defn: SettingDef) -> None: 

671 """Update the help text after a value change; an OCR key refreshes every OCR row.""" 

672 if key not in OCR_SETTING_KEYS: 

673 self._update_help_row(key, defn) 

674 return 

675 for ocr_key in OCR_SETTING_KEYS: 

676 self._update_help_row(ocr_key, SETTINGS_MAP[ocr_key]) 

677 for gated_key in GATED_ROWS: 

678 self._update_row_visibility(gated_key) 

679 

680 def _update_row_visibility(self, key: str) -> None: 

681 """Show or hide a gated row; an unpopulated pane applies the gate when it builds.""" 

682 try: 

683 self.query_one(f"#{ROW_ID_PREFIX}{key}", VerticalGroup).display = row_visible(key) 

684 except Exception: 

685 log.debug("No row to show or hide for %s", key, exc_info=True) 

686 

687 def _update_help_row(self, key: str, defn: SettingDef) -> None: 

688 """Re-render one row's help text.""" 

689 try: 

690 row = self.query_one(f"#{ROW_ID_PREFIX}{key}", VerticalGroup) 

691 help_widget = row.query_one(".setting-help", Static) 

692 help_widget.update(help_content(key, defn)) 

693 except Exception: 

694 log.debug("Failed to refresh help for %s", key, exc_info=True) 

695 

696 @on(Button.Pressed, ".setting-reset-button") 

697 def _on_reset_pressed(self, event: Button.Pressed) -> None: 

698 """Handle the small reset button embedded in each writable row.""" 

699 button_id = event.button.id 

700 if button_id is None or not button_id.startswith(RESET_BUTTON_ID_PREFIX): 

701 return 

702 key = button_id[len(RESET_BUTTON_ID_PREFIX) :] 

703 self._reset_to_default(key) 

704 

705 @on(Button.Pressed, ".setting-model-picker-button") 

706 def _on_model_picker_pressed(self, event: Button.Pressed) -> None: 

707 """Open ModelPickerModal for the model field this button represents.""" 

708 button_id = event.button.id 

709 if button_id is None or not button_id.startswith(MODEL_PICKER_BUTTON_PREFIX): 

710 return 

711 key = button_id[len(MODEL_PICKER_BUTTON_PREFIX) :] 

712 scope = model_field_to_picker_scope().get(key) 

713 if scope is None: 

714 return 

715 self._discover_then_open_picker(key, scope) 

716 

717 @work(thread=True, exit_on_error=False) 

718 def _discover_then_open_picker(self, key: str, scope: PickerScope) -> None: 

719 """Discover installed models off the UI thread, then push the picker. 

720 

721 ``classify_installed_models_full`` probes the native registry, 

722 Ollama (HTTP), and SDK provider lists. Running it on the 

723 event loop blocks paint for hundreds of ms; the chat-bar uses 

724 the same worker pattern. 

725 """ 

726 from lilbee.cli.tui.thread_safe import call_from_thread 

727 from lilbee.cli.tui.widgets.model_bar import classify_installed_models_full 

728 

729 task = picker_scope_to_task(scope) 

730 buckets = classify_installed_models_full() 

731 options = list(buckets.get(task, [])) 

732 call_from_thread(self, self._push_model_picker, key, scope, options) 

733 

734 def _push_model_picker(self, key: str, scope: PickerScope, options: list[ModelOption]) -> None: 

735 """Push ModelPickerModal once the worker has resolved options.""" 

736 from lilbee.cli.tui.screens.model_picker import ModelPickerModal 

737 from lilbee.cli.tui.widgets.model_bar import ModelOption 

738 

739 # Bail out if the user navigated away from Settings while the 

740 # discovery worker was still running; otherwise we'd push the 

741 # modal onto whatever screen is now on top. 

742 if not self.is_mounted: 

743 return 

744 if not options: 

745 options = [ModelOption(label=msg.MODEL_VALUE_NONE, ref="")] 

746 # Nullable model fields (vision_model, reranker_model) need an 

747 # explicit "disable this model" pick. The picker's empty-input 

748 # cancel returns None; this row returns "" so the dismiss 

749 # handler can distinguish "cancel" from "set to none". 

750 defn = SETTINGS_MAP.get(key) 

751 if defn is not None and defn.nullable: 

752 options = [ 

753 ModelOption(label=msg.MODEL_PICKER_DISABLE_LABEL, ref=""), 

754 *options, 

755 ] 

756 self.app.push_screen( 

757 ModelPickerModal(scope=scope, options=options), 

758 lambda ref: self._on_model_picker_dismissed(key, ref), 

759 ) 

760 

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

762 """Persist the picker selection, refresh the button, and reload the role's server.""" 

763 # apply_model_pick owns the reload (off the event loop in a worker); the 

764 # on_done callback only repaints the button once the swap is applied. 

765 apply_model_pick(self, key=key, ref=ref, on_done=lambda: self._after_model_pick(key)) 

766 

767 def _after_model_pick(self, key: str) -> None: 

768 """Refresh the picker button after a swap. 

769 

770 The role reload is owned by ``apply_model_pick`` (it runs off the event 

771 loop in a worker), so this on_done only repaints the label. Reloading 

772 here too would double the fleet restart AND block the UI on the main 

773 thread, which is the freeze this path is meant to avoid. 

774 """ 

775 self._refresh_picker_button(key) 

776 self._refresh_help(key, SETTINGS_MAP[key]) 

777 

778 def _refresh_picker_button(self, key: str) -> None: 

779 try: 

780 button = self.query_one(f"#{MODEL_PICKER_BUTTON_PREFIX}{key}", Button) 

781 button.label = model_picker_label(key) 

782 except Exception: 

783 log.debug("Failed to refresh model picker label for %s", key, exc_info=True) 

784 

785 def action_reset_all(self) -> None: 

786 """Bound to Ctrl+Shift+R; opens the destructive-confirm dialog.""" 

787 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog 

788 

789 self.app.push_screen( 

790 ConfirmDialog( 

791 title=msg.SETTINGS_RESET_ALL_CONFIRM_TITLE, 

792 message=msg.SETTINGS_RESET_ALL_CONFIRM_MESSAGE, 

793 ), 

794 self._on_reset_all_confirmed, 

795 ) 

796 

797 def _on_reset_all_confirmed(self, confirmed: bool | None) -> None: 

798 """Reset every writable setting in one batch.""" 

799 if not confirmed: 

800 return 

801 

802 writable = [key for key, defn in SETTINGS_MAP.items() if defn.writable] 

803 if self._reset_keys(writable, skip_unresettable=True): 

804 self.notify(msg.SETTINGS_RESET_ALL_SUCCESS) 

805 

806 def action_reset_focused(self) -> None: 

807 """Reset the setting whose row holds focus.""" 

808 focused = self.focused 

809 if focused is None: 

810 return 

811 for ancestor in focused.ancestors_with_self: 

812 ancestor_id = getattr(ancestor, "id", None) 

813 if ancestor_id and ancestor_id.startswith(ROW_ID_PREFIX): 

814 key = ancestor_id[len(ROW_ID_PREFIX) :] 

815 self._reset_to_default(key) 

816 return 

817 

818 def _reset_to_default(self, key: str) -> None: 

819 """Reset one writable setting.""" 

820 defn = SETTINGS_MAP.get(key) 

821 if defn is None or not defn.writable: 

822 return 

823 self._reset_keys([key]) 

824 

825 def _reset_keys(self, keys: list[str], *, skip_unresettable: bool = False) -> bool: 

826 """Reset *keys*, then show each one's resolved value. Returns whether it succeeded.""" 

827 try: 

828 reset = self.app.reset_and_publish(keys, skip_unresettable=skip_unresettable) 

829 except STORE_ERRORS as exc: 

830 self.notify(msg.SETTINGS_INVALID_VALUE.format(error=exc), severity="error") 

831 return False 

832 for key in reset: 

833 defn = SETTINGS_MAP[key] 

834 self._refresh_editor(key, defn, getattr(cfg, key)) 

835 self._refresh_help(key, defn) 

836 return True 

837 

838 def _refresh_editor(self, key: str, defn: SettingDef, value: object) -> None: 

839 """Update the editor widget to reflect a new value (e.g. after reset).""" 

840 try: 

841 widget = self.query_one(f"#{EDITOR_ID_PREFIX}{key}") 

842 except Exception: 

843 log.debug("Failed to refresh editor for %s", key, exc_info=True) 

844 return 

845 self._show_editor_value(key, widget, value) 

846 

847 def _show_editor_value(self, key: str, widget: Widget, value: object) -> None: 

848 """Put *value* in *key*'s editor *widget* and make it the save baseline.""" 

849 # prevent() only covers a Changed message this call posts, not one already queued. 

850 # Checkbox and Select save on Changed, so a sync must not post either one. 

851 with widget.prevent(Checkbox.Changed, Select.Changed): 

852 set_widget_value(widget, value) 

853 # A key absent from _mount_display has no baseline; the isinstance narrows for mypy. 

854 if key in self._mount_display and isinstance(widget, EDITOR_KINDS): 

855 self._mount_display[key] = displayed_text(widget) 

856 

857 def _focused_field_row(self) -> SettingRow | None: 

858 """The row of the value field that has focus, or None when focus is not in one.""" 

859 focused = self.focused 

860 if not isinstance(focused, Input | TextArea): 

861 return None 

862 return next((a for a in focused.ancestors if isinstance(a, SettingRow)), None) 

863 

864 def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: 

865 """In a value field only Leave field applies; the field keeps every character.""" 

866 in_field = self._focused_field_row() is not None 

867 if action == "leave_field": 

868 return in_field 

869 if action in ("go_back", "cycle_pane"): 

870 return not in_field 

871 return True 

872 

873 def action_leave_field(self) -> None: 

874 """Move focus from the value field to its row, where the tab and view keys work.""" 

875 if (row := self._focused_field_row()) is not None: 

876 row.focus() 

877 

878 def action_go_back(self) -> None: 

879 self.app.go_back() 

880 

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

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

883 

884 j/k/g/G key actions scroll this body directly because the outer 

885 ``#settings-scroll`` is a Container, not a scroller -- one column 

886 of scrolling per screen, the active tab's pane. 

887 """ 

888 try: 

889 tabs = self.query_one("#settings-tabs", TabbedContent) 

890 except Exception: 

891 return None 

892 active = tabs.active 

893 if not active: 

894 return None 

895 try: 

896 return self.query_one(f"#{active}-body", _LazyGroupBody) 

897 except Exception: 

898 return None 

899 

900 def action_cursor_down(self) -> None: 

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

902 body.scroll_down() 

903 

904 def action_cursor_up(self) -> None: 

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

906 body.scroll_up() 

907 

908 def action_jump_top(self) -> None: 

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

910 body.scroll_home() 

911 

912 def action_jump_bottom(self) -> None: 

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

914 body.scroll_end() 

915 

916 def action_next_field_or_pane(self) -> None: 

917 """Tab inside a pane; on overflow advance to the next group tab.""" 

918 self._move_focus_within_pane(direction=1) 

919 

920 def action_prev_field_or_pane(self) -> None: 

921 """Shift+Tab inside a pane; on underflow retreat to the previous group tab.""" 

922 self._move_focus_within_pane(direction=-1) 

923 

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

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

926 

927 Shortcut for users who don't want to Tab through every field to 

928 reach the next group. Mirrors CatalogScreen.action_cycle_tab. A 

929 focused Input/TextArea consumes printable keys before even priority 

930 bindings see them, so < and > type into a text field; Escape leaves it. 

931 """ 

932 try: 

933 tabs = self.query_one("#settings-tabs", TabbedContent) 

934 except Exception: 

935 return 

936 pane_ids = self._pane_ids 

937 try: 

938 current = pane_ids.index(tabs.active) 

939 except ValueError: 

940 current = 0 

941 next_id = pane_ids[(current + delta) % len(pane_ids)] 

942 if tabs.active != next_id: 

943 # TabbedContent activates the pane that holds the focused widget, so focus left in 

944 # the old pane would pull the tab straight back. 

945 self.query_one("#settings-tabs Tabs").focus() 

946 tabs.active = next_id 

947 

948 def _focus_adjacent(self, direction: int) -> None: 

949 """Move focus to the next/previous widget app-wide (direction 1 / -1).""" 

950 if direction == 1: 

951 self.app.action_focus_next() 

952 else: 

953 self.app.action_focus_previous() 

954 

955 def _move_focus_within_pane(self, *, direction: int) -> None: 

956 focused = self.app.focused 

957 tabs = self.query_one("#settings-tabs", TabbedContent) 

958 active_pane_id = tabs.active 

959 try: 

960 body = self.query_one(f"#{active_pane_id}-body", _LazyGroupBody) 

961 except Exception: 

962 self._focus_adjacent(direction) 

963 return 

964 focusables = self._focus_stops(body) 

965 if not focusables or focused is None or focused not in focusables: 

966 self._focus_adjacent(direction) 

967 return 

968 index = focusables.index(focused) 

969 next_index = index + direction 

970 if 0 <= next_index < len(focusables): 

971 focusables[next_index].focus() 

972 return 

973 # At the boundary: advance to the next/previous pane. 

974 pane_ids = self._pane_ids 

975 if active_pane_id not in pane_ids: 

976 return 

977 target_index = (pane_ids.index(active_pane_id) + direction) % len(pane_ids) 

978 target_pane = pane_ids[target_index] 

979 tabs.active = target_pane 

980 self._populate_pane(target_pane) 

981 # Park focus on the first/last field of the new pane so the next 

982 # Tab keeps moving in the same direction. 

983 self.call_after_refresh(self._focus_pane_edge, target_pane, direction) 

984 

985 def _focus_pane_edge(self, pane_id: str, direction: int) -> None: 

986 try: 

987 body = self.query_one(f"#{pane_id}-body", _LazyGroupBody) 

988 except Exception: 

989 return 

990 focusables = self._focus_stops(body) 

991 if not focusables: 

992 return 

993 focusables[0 if direction == 1 else -1].focus() 

994 

995 def _focus_stops(self, body: _LazyGroupBody) -> list[Widget]: 

996 """The widgets in *body* that Tab can reach, without the hidden ones.""" 

997 return [ 

998 w for w in self.focus_chain if body in w.ancestors and not isinstance(w, SettingRow) 

999 ]