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

155 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +0000

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

2 

3from __future__ import annotations 

4 

5import os 

6from collections import defaultdict 

7from collections.abc import Callable 

8from typing import TYPE_CHECKING 

9 

10from textual.content import Content 

11from textual.widget import Widget 

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

13 

14from lilbee.app.settings import OCR_SETTING_KEYS, ocr_engine_note, ocr_off_warning 

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

16from lilbee.cli.tui import messages as msg 

17from lilbee.cli.tui.pill import pill 

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

19from lilbee.core.config import cfg 

20from lilbee.core.config.model import value_is_set 

21 

22if TYPE_CHECKING: 

23 from lilbee.catalog.types import ModelTask 

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

25 

26ROW_ID_PREFIX = "row-" 

27EDITOR_ID_PREFIX = "ed-" 

28RESET_BUTTON_ID_PREFIX = "reset-" 

29RESET_BUTTON_LABEL = "↺" 

30 

31_TYPE_COLORS: dict[str, tuple[str, str]] = { 

32 "str": ("$secondary", "$text"), 

33 "int": ("$primary", "$text"), 

34 "float": ("$primary", "$text"), 

35 "bool": ("$success", "$text"), 

36 "select": ("$warning", "$text"), 

37} 

38 

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

40 

41LIST_RESTORE_PREFIX = "list-restore-" 

42LIST_ERROR_ID_PREFIX = "err-" 

43LIST_ERROR_VISIBLE_CLASS = "-visible" 

44 

45API_KEYS_GROUP = SettingGroup.API_KEYS 

46API_KEYS_WARNING_CLASS = "api-keys-warning" 

47CONFIG_TOML_FILENAME = "config.toml" 

48 

49 

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

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

52 mapping: dict[str, PickerScope] = { 

53 "chat_model": "chat", 

54 "embedding_model": "embed", 

55 "vision_model": "vision", 

56 "reranker_model": "rerank", 

57 } 

58 return mapping 

59 

60 

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

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

63 from lilbee.catalog.types import ModelTask as _ModelTask 

64 

65 return { 

66 "chat": _ModelTask.CHAT, 

67 "embed": _ModelTask.EMBEDDING, 

68 "vision": _ModelTask.VISION, 

69 "rerank": _ModelTask.RERANK, 

70 }[scope] 

71 

72 

73MODEL_PICKER_BUTTON_PREFIX = "model-pick-" 

74 

75 

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

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

78 if isinstance(widget, Input): 

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

80 elif isinstance(widget, Checkbox): 

81 widget.value = bool(value) 

82 elif isinstance(widget, Select): 

83 if value is None: 

84 widget.clear() 

85 else: 

86 widget.value = str(value) 

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

88 if isinstance(value, list): 

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

90 else: 

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

92 

93 

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

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

96 from lilbee.catalog.formatting import display_label_for_ref 

97 

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

99 label = display_label_for_ref(str(ref)) 

100 return label or msg.MODEL_VALUE_NONE 

101 

102 

103def config_toml_path() -> str: 

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

105 return str(cfg.data_dir / CONFIG_TOML_FILENAME) 

106 

107 

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

109 """Return the effective value for a setting, including model defaults.""" 

110 user_value = getattr(cfg, key, None) 

111 if user_value is not None: 

112 return str(user_value) 

113 defaults = cfg.model_defaults 

114 if defaults is None: 

115 return "None" 

116 defaults_key = _DEFAULTS_REMAP.get(key, key) 

117 default_val = getattr(defaults, defaults_key, None) 

118 if default_val is not None: 

119 return f"{default_val} (model default)" 

120 return "None" 

121 

122 

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

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

125 defn = SETTINGS_MAP.get(key) 

126 return defn is not None and defn.writable 

127 

128 

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

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

131 type_name = defn.type.__name__ 

132 if defn.choices: 

133 type_name = "select" 

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

135 return pill(type_name, bg, fg) 

136 

137 

138def env_var_name(key: str) -> str: 

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

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

141 

142 

143def env_pill(key: str) -> Content | None: 

144 """Pill warning that an env var is overriding TUI edits, or None.""" 

145 env_name = env_var_name(key) 

146 if not value_is_set(key, os.environ.get(env_name)): 

147 return None 

148 return pill(env_name, "$warning", "$text") 

149 

150 

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

152 """Build help text, plus which OCR engine runs on an OCR row; the editor shows the value.""" 

153 help_text = Content(defn.help_text) 

154 if key not in OCR_SETTING_KEYS: 

155 return help_text 

156 warning = ocr_off_warning() 

157 if warning is not None: 

158 return Content.assemble(help_text, "\n", Content.styled(warning, "$warning")) 

159 note = ocr_engine_note() 

160 return help_text if note is None else Content.assemble(help_text, "\n", note) 

161 

162 

163def title_content(key: str, defn: SettingDef) -> Content: 

164 """Assemble the setting-row title: key name, type pill, and env pill when set.""" 

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

166 env_badge = env_pill(key) 

167 if env_badge is not None: 

168 parts.append(Content(" ")) 

169 parts.append(env_badge) 

170 return Content.assemble(*parts) 

171 

172 

173def stringify_default(default: object) -> str: 

174 """Serialize a default for the TOML settings store.""" 

175 if default is None: 

176 return "" 

177 if isinstance(default, list): 

178 return "\n".join(str(item) for item in default) 

179 return str(default) 

180 

181 

182def _litellm_installed() -> bool: 

183 from lilbee.providers.litellm_sdk import litellm_available 

184 

185 return litellm_available() 

186 

187 

188def _crawler_installed() -> bool: 

189 from lilbee.crawler import crawler_available 

190 

191 return crawler_available() 

192 

193 

194def _wiki_enabled() -> bool: 

195 return bool(cfg.wiki) 

196 

197 

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

199 SettingGroup.API_KEYS: _litellm_installed, 

200 SettingGroup.LOCAL_SERVERS: _litellm_installed, 

201 SettingGroup.CRAWLING: _crawler_installed, 

202 SettingGroup.WIKI: _wiki_enabled, 

203} 

204 

205 

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

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

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

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

210 if defn.hidden: 

211 continue 

212 gate = _FEATURE_GATED_GROUPS.get(defn.group) 

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

214 continue 

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

216 return dict(groups) 

217 

218 

219def make_editor(key: str, defn: SettingDef) -> Widget: 

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

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

222 if defn.type is list: 

223 return make_list_editor(key) 

224 value = effective_value(key) 

225 if defn.choices: 

226 return make_select(key, defn, value) 

227 if defn.type is bool: 

228 return make_checkbox(key, value) 

229 if defn.render is RenderStyle.MULTILINE: 

230 return make_multiline_editor(key, value) 

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

232 

233 

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

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

236 display = "" if value == "None" else value 

237 return ListTextArea( 

238 text=display, 

239 show_line_numbers=False, 

240 name=key, 

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

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

243 soft_wrap=True, 

244 ) 

245 

246 

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

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

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

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

251 editor = ListTextArea( 

252 text="\n".join(str(item) for item in current), 

253 show_line_numbers=True, 

254 name=key, 

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

256 classes="setting-list-editor", 

257 ) 

258 error = Static( 

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

260 ) 

261 reset = Button( 

262 msg.SETTINGS_LIST_EDITOR_RESTORE_DEFAULTS, 

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

264 classes="setting-list-restore", 

265 ) 

266 return Collapsible( 

267 editor, 

268 error, 

269 reset, 

270 title=title, 

271 collapsed=True, 

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

273 ) 

274 

275 

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

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

278 choices = [(c, c) for c in (defn.choices or ())] 

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

280 return Select( 

281 choices, 

282 value=value, 

283 name=key, 

284 classes="setting-editor", 

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

286 ) 

287 return Select(choices, name=key, classes="setting-editor", id=f"{EDITOR_ID_PREFIX}{key}") 

288 

289 

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

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

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

293 return Checkbox( 

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

295 ) 

296 

297 

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

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

300 

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

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

303 field never appears on screen. 

304 """ 

305 display = "" if value == "None" else value.replace(" (model default)", "") 

306 return Input( 

307 value=display, 

308 name=key, 

309 password=secret, 

310 classes="setting-editor", 

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

312 )