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

202 statements  

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

1"""Editor-row builders and label helpers for the Settings screen.""" 

2 

3from __future__ import annotations 

4 

5from collections import defaultdict 

6from collections.abc import Callable, Iterable, Mapping 

7from typing import TYPE_CHECKING, Any 

8 

9from textual import events 

10from textual.containers import VerticalGroup 

11from textual.content import Content 

12from textual.widget import Widget 

13from textual.widgets import Button, Checkbox, Collapsible, Input, Select, Static, TextArea 

14 

15from lilbee.app.settings import OCR_SETTING_KEYS, scanned_pages_line 

16from lilbee.app.settings_map import SETTINGS_MAP, RenderStyle, SettingDef, SettingGroup 

17from lilbee.cli.tui import messages as msg 

18from lilbee.cli.tui.pill import pill 

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

20from lilbee.core.config import cfg 

21from lilbee.core.config.defaults import env_var_name 

22from lilbee.core.config.enums import SettingSource 

23from lilbee.core.config.resolve import read_profile_table 

24from lilbee.crawler import crawler_available 

25 

26if TYPE_CHECKING: 

27 from lilbee.catalog.types import ModelTask 

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

29 

30ROW_ID_PREFIX = "row-" 

31EDITOR_ID_PREFIX = "ed-" 

32RESET_BUTTON_ID_PREFIX = "reset-" 

33RESET_BUTTON_LABEL = "↺" 

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 

43LIST_RESTORE_PREFIX = "list-restore-" 

44LIST_ERROR_ID_PREFIX = "err-" 

45LIST_ERROR_VISIBLE_CLASS = "-visible" 

46ADVANCED_COLLAPSIBLE_ID_PREFIX = "settings-advanced-" 

47ADVANCED_COLLAPSIBLE_CLASS = "settings-advanced-collapsible" 

48 

49API_KEYS_GROUP = SettingGroup.API_KEYS 

50API_KEYS_WARNING_CLASS = "api-keys-warning" 

51CONFIG_TOML_FILENAME = "config.toml" 

52 

53 

54def model_field_to_picker_scope() -> dict[str, PickerScope]: 

55 """Single source of truth for the picker scope each model field uses.""" 

56 mapping: dict[str, PickerScope] = { 

57 "chat_model": "chat", 

58 "embedding_model": "embed", 

59 "vision_model": "vision", 

60 "reranker_model": "rerank", 

61 } 

62 return mapping 

63 

64 

65def picker_scope_to_task(scope: PickerScope) -> ModelTask: 

66 """Map a picker scope to the ``ModelTask`` bucket it discovers from.""" 

67 from lilbee.catalog.types import ModelTask as _ModelTask 

68 

69 return { 

70 "chat": _ModelTask.CHAT, 

71 "embed": _ModelTask.EMBEDDING, 

72 "vision": _ModelTask.VISION, 

73 "rerank": _ModelTask.RERANK, 

74 }[scope] 

75 

76 

77MODEL_PICKER_BUTTON_PREFIX = "model-pick-" 

78 

79 

80class SettingInput(Input): 

81 """An Input that selects its whole text when focus arrives by key or by click. 

82 

83 Textual selects on a key focus (``select_on_focus``), but the click that gave 

84 focus then moves the cursor and drops the selection. A click on a field that 

85 already has focus still places the cursor. 

86 """ 

87 

88 def __init__(self, *args: Any, **kwargs: Any) -> None: 

89 super().__init__(*args, select_on_focus=True, **kwargs) 

90 self._had_focus_at_click = False 

91 

92 def focus_on_click(self) -> bool: 

93 # The screen calls this before it moves focus, so focus here is the state before the click. 

94 self._had_focus_at_click = self.screen.focused is self 

95 return True 

96 

97 def on_mouse_down(self, event: events.MouseDown) -> None: 

98 if not self._had_focus_at_click: 

99 event.prevent_default() 

100 

101 

102class SettingRow(VerticalGroup, can_focus=True): 

103 """One setting's row. Escape in a value field parks focus here, outside any text field.""" 

104 

105 def focus_on_click(self) -> bool: 

106 return False 

107 

108 

109def set_widget_value(widget: Widget, value: object) -> None: 

110 """Push *value* into a settings-row editor widget.""" 

111 if isinstance(widget, Input): 

112 widget.value = "" if value is None else str(value) 

113 elif isinstance(widget, Checkbox): 

114 widget.value = bool(value) 

115 elif isinstance(widget, Select): 

116 if value is None: 

117 widget.clear() 

118 else: 

119 widget.value = str(value) 

120 elif isinstance(widget, TextArea): # future-proofing: list/multiline defaults 

121 if isinstance(value, list): 

122 widget.load_text("\n".join(str(item) for item in value)) 

123 else: 

124 widget.load_text("" if value is None else str(value)) 

125 

126 

127SettingEditor = Input | TextArea | Checkbox | Select[str] 

128"""Every widget kind that edits one setting's value.""" 

129EDITOR_KINDS = (Input, TextArea, Checkbox, Select) 

130 

131 

132def displayed_text(widget: SettingEditor) -> str: 

133 """The raw text an editor shows: a Select's blank is "", a Checkbox is "True" or "False".""" 

134 if isinstance(widget, Input): 

135 return widget.value 

136 if isinstance(widget, TextArea): 

137 return widget.text 

138 if isinstance(widget, Checkbox): 

139 return str(widget.value) 

140 return "" if widget.value == Select.BLANK else str(widget.value) 

141 

142 

143def list_editor_text(key: str, value: list[object] | None = None) -> str: 

144 """The newline-joined text a list setting's editor shows for *value*. 

145 

146 *value* defaults to the current cfg value; a caller that already read it 

147 (to also compute a count, say) passes it through instead of re-reading cfg. 

148 """ 

149 items = (getattr(cfg, key, None) or []) if value is None else value 

150 return "\n".join(str(item) for item in items) 

151 

152 

153def editor_choices(defn: SettingDef) -> tuple[str, ...]: 

154 """The values a Select offers for *defn*; empty when the setting is not a Select. 

155 

156 A bool that can also be unset has three states, auto (blank), "True" and "False". 

157 A Checkbox shows only two, so it would draw auto the same as off. 

158 """ 

159 if defn.choices: 

160 return defn.choices 

161 if defn.type is bool and defn.nullable: 

162 return ("True", "False") 

163 return () 

164 

165 

166def select_shown_value(defn: SettingDef, value: str) -> str: 

167 """The value a Select shows for *value*: the matching choice, else blank. 

168 

169 A Select's ``value`` is not set from its constructor until it mounts, so this does not read it. 

170 """ 

171 if value in editor_choices(defn): 

172 return value 

173 return "" 

174 

175 

176def unset_as_blank(value: str) -> str: 

177 """Return *value*, or blank for the sentinel "None" display text of an unset setting.""" 

178 return "" if value == "None" else value 

179 

180 

181def model_picker_label(key: str) -> str: 

182 """Render the picker button label as the human-friendly model name.""" 

183 from lilbee.catalog.formatting import display_label_for_ref 

184 

185 ref = getattr(cfg, key, None) or "" 

186 label = display_label_for_ref(str(ref)) 

187 return label or msg.MODEL_VALUE_NONE 

188 

189 

190def config_toml_path() -> str: 

191 """Effective path to the config.toml lilbee reads and writes.""" 

192 return str(cfg.data_dir / CONFIG_TOML_FILENAME) 

193 

194 

195def effective_value(key: str) -> str: 

196 """Return the value a setting shows: its text, or "None" when unset.""" 

197 return str(getattr(cfg, key, None)) 

198 

199 

200def is_writable(key: str) -> bool: 

201 """Check if a setting key is writable (derived from SETTINGS_MAP).""" 

202 defn = SETTINGS_MAP.get(key) 

203 return defn is not None and defn.writable 

204 

205 

206def type_pill(defn: SettingDef) -> Content: 

207 """Create a colored pill badge for a setting's type.""" 

208 type_name = defn.type.__name__ 

209 if editor_choices(defn): 

210 type_name = "select" 

211 bg, fg = _TYPE_COLORS.get(type_name, ("$surface", "$text")) 

212 return pill(type_name, bg, fg) 

213 

214 

215def user_pill() -> Content: 

216 """The accent pill that marks a value set by you.""" 

217 return pill(msg.SETTINGS_SOURCE_USER_PILL, "$accent", "$text") 

218 

219 

220def _user_pill(_key: str, _profile_name: str | None) -> Content: 

221 return user_pill() 

222 

223 

224def _env_pill(key: str, _profile_name: str | None) -> Content: 

225 return pill(env_var_name(key), "$warning", "$text") 

226 

227 

228def _profile_pill(_key: str, profile_name: str | None) -> Content: 

229 label = ( 

230 msg.SETTINGS_SOURCE_PROFILE_NAMED_PILL.format(name=profile_name) 

231 if profile_name 

232 else msg.SETTINGS_SOURCE_PROFILE_PILL 

233 ) 

234 return pill(label, "$secondary", "$text") 

235 

236 

237def _auto_pill(_key: str, _profile_name: str | None) -> Content: 

238 return pill(msg.SETTINGS_SOURCE_AUTO_PILL, "$primary", "$text") 

239 

240 

241_SOURCE_PILLS: dict[SettingSource, Callable[[str, str | None], Content]] = { 

242 SettingSource.USER: _user_pill, 

243 SettingSource.ENV: _env_pill, 

244 SettingSource.PROFILE: _profile_pill, 

245 SettingSource.AUTO: _auto_pill, 

246} 

247 

248 

249def active_profile_name() -> str | None: 

250 """The active profile's name, one config.toml read; call once per render, not per row.""" 

251 return read_profile_table(cfg.data_root).name 

252 

253 

254def source_pill(key: str, source: SettingSource, profile_name: str | None = None) -> Content | None: 

255 """Pill for a value the user, an env var, a profile or lilbee itself sets; None for built-in. 

256 

257 *profile_name* is the caller's own ``active_profile_name()`` read, passed in rather than 

258 read here, so a pane with several profile-set rows shares one config.toml read. 

259 """ 

260 build = _SOURCE_PILLS.get(source) 

261 return None if build is None else build(key, profile_name) 

262 

263 

264def help_content(key: str, defn: SettingDef) -> Content: 

265 """Build help text, plus the scanned-pages line on an OCR row; the editor shows the value.""" 

266 help_text = Content(defn.help_text) 

267 if key not in OCR_SETTING_KEYS: 

268 return help_text 

269 return Content.assemble(help_text, "\n", scanned_pages_line()) 

270 

271 

272def title_content( 

273 key: str, defn: SettingDef, source: SettingSource, profile_name: str | None = None 

274) -> Content: 

275 """Assemble the setting-row title: key name, type pill, and the source pill for an override.""" 

276 parts: list[Content] = [Content(key + " "), type_pill(defn)] 

277 source_badge = source_pill(key, source, profile_name) 

278 if source_badge is not None: 

279 parts.append(Content(" ")) 

280 parts.append(source_badge) 

281 return Content.assemble(*parts) 

282 

283 

284def advanced_title(keys: Iterable[str], sources: Mapping[str, SettingSource]) -> str: 

285 """The collapsed Advanced section's title: its row count, and how many a profile sets.""" 

286 names = list(keys) 

287 profile_set = sum(1 for key in names if sources[key] is SettingSource.PROFILE) 

288 if not profile_set: 

289 return msg.SETTINGS_ADVANCED_TITLE.format(count=len(names)) 

290 return msg.SETTINGS_ADVANCED_TITLE_PROFILE.format(count=len(names), profile_set=profile_set) 

291 

292 

293def _providers_installed() -> bool: 

294 from lilbee.providers import availability 

295 

296 return availability.provider_sdk_available() 

297 

298 

299def _crawler_installed() -> bool: 

300 return crawler_available() 

301 

302 

303def _wiki_enabled() -> bool: 

304 return bool(cfg.wiki) 

305 

306 

307_FEATURE_GATED_GROUPS: dict[SettingGroup, Callable[[], bool]] = { 

308 SettingGroup.API_KEYS: _providers_installed, 

309 SettingGroup.LOCAL_SERVERS: _providers_installed, 

310 SettingGroup.CRAWLING: _crawler_installed, 

311 SettingGroup.WIKI: _wiki_enabled, 

312} 

313 

314 

315def _tesseract_reads() -> bool: 

316 return not cfg.vision_model 

317 

318 

319# Rows shown only while their gate holds; the screen re-checks them on an OCR key change. 

320GATED_ROWS: dict[str, Callable[[], bool]] = {"ocr_language": _tesseract_reads} 

321 

322 

323def row_visible(key: str) -> bool: 

324 """Whether a setting row shows now: ocr_language only while Tesseract reads.""" 

325 gate = GATED_ROWS.get(key) 

326 return gate is None or gate() 

327 

328 

329def group_settings() -> dict[SettingGroup, list[tuple[str, SettingDef]]]: 

330 """Group settings by group field, skipping hidden entries and gated features.""" 

331 groups: dict[SettingGroup, list[tuple[str, SettingDef]]] = defaultdict(list) 

332 for key, defn in SETTINGS_MAP.items(): 

333 if defn.hidden: 

334 continue 

335 gate = _FEATURE_GATED_GROUPS.get(defn.group) 

336 if gate is not None and not gate(): 

337 continue 

338 groups[defn.group].append((key, defn)) 

339 return dict(groups) 

340 

341 

342def make_editor(key: str, defn: SettingDef) -> Collapsible | SettingEditor: 

343 """Create the appropriate editor widget for a setting.""" 

344 # A list never reaches an Input: str(list) would be saved back as the value. 

345 if defn.type is list: 

346 return make_list_editor(key) 

347 value = effective_value(key) 

348 if editor_choices(defn): 

349 return make_select(key, defn, value) 

350 if defn.type is bool: 

351 return make_checkbox(key, value) 

352 if defn.render is RenderStyle.MULTILINE: 

353 return make_multiline_editor(key, value) 

354 return make_input(key, value, secret=defn.secret) 

355 

356 

357def make_multiline_editor(key: str, value: str) -> ListTextArea: 

358 """Create a multi-line editor for string settings (system prompts, etc.).""" 

359 display = unset_as_blank(value) 

360 return ListTextArea( 

361 text=display, 

362 show_line_numbers=False, 

363 name=key, 

364 id=f"{EDITOR_ID_PREFIX}{key}", 

365 classes="setting-editor setting-multiline-editor", 

366 soft_wrap=True, 

367 ) 

368 

369 

370def make_list_editor(key: str) -> Collapsible: 

371 """Create a Collapsible with a line-numbered TextArea for a list setting.""" 

372 current = getattr(cfg, key, None) or [] 

373 title = msg.SETTINGS_LIST_EDITOR_TITLE.format(key=key, count=len(current)) 

374 editor = ListTextArea( 

375 text=list_editor_text(key, current), 

376 show_line_numbers=True, 

377 name=key, 

378 id=f"{EDITOR_ID_PREFIX}{key}", 

379 classes="setting-list-editor", 

380 ) 

381 error = Static( 

382 "", id=f"{LIST_ERROR_ID_PREFIX}{key}", classes="setting-list-error", markup=False 

383 ) 

384 reset = Button( 

385 msg.SETTINGS_LIST_EDITOR_RESTORE_DEFAULTS, 

386 id=f"{LIST_RESTORE_PREFIX}{key}", 

387 classes="setting-list-restore", 

388 ) 

389 return Collapsible( 

390 editor, 

391 error, 

392 reset, 

393 title=title, 

394 collapsed=True, 

395 id=f"collapsible-{key}", 

396 ) 

397 

398 

399def make_select(key: str, defn: SettingDef, value: str) -> Select[str]: 

400 """Create a Select widget for choice-based settings.""" 

401 labels = msg.SETTING_CHOICE_LABELS.get(key, {}) 

402 choices = [(labels.get(c, c), c) for c in editor_choices(defn)] 

403 # The blank row is the unset state; a nullable bool names it "Auto". 

404 prompt = msg.SETTING_AUTO_LABEL if defn.type is bool else "Select" 

405 if value in {c[1] for c in choices}: 

406 return Select( 

407 choices, 

408 value=select_shown_value(defn, value), 

409 allow_blank=defn.nullable, 

410 prompt=prompt, 

411 name=key, 

412 classes="setting-editor", 

413 id=f"{EDITOR_ID_PREFIX}{key}", 

414 ) 

415 return Select( 

416 choices, 

417 prompt=prompt, 

418 name=key, 

419 classes="setting-editor", 

420 id=f"{EDITOR_ID_PREFIX}{key}", 

421 ) 

422 

423 

424def make_checkbox(key: str, value: str) -> Checkbox: 

425 """Create a Checkbox widget for boolean settings.""" 

426 checked = value.lower() in ("true", "1", "yes", "on") 

427 return Checkbox( 

428 value=checked, name=key, classes="setting-editor", id=f"{EDITOR_ID_PREFIX}{key}" 

429 ) 

430 

431 

432def make_input(key: str, value: str, *, secret: bool = False) -> Input: 

433 """Create an Input widget for string/number settings. 

434 

435 ``secret`` renders the field masked. Textual masks only the rendering, so 

436 the real value is still submitted and saved, and text pasted into a masked 

437 field never appears on screen. 

438 """ 

439 display = unset_as_blank(value) 

440 return SettingInput( 

441 value=display, 

442 name=key, 

443 password=secret, 

444 classes="setting-editor", 

445 id=f"{EDITOR_ID_PREFIX}{key}", 

446 )