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

150 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-05-15 20:55 +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.cli.settings_map import SETTINGS_MAP, RenderStyle, SettingDef 

15from lilbee.cli.tui import messages as msg 

16from lilbee.cli.tui.pill import pill 

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

18from lilbee.core.config import cfg 

19 

20if TYPE_CHECKING: 

21 from lilbee.catalog.types import ModelTask 

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

23 

24ROW_ID_PREFIX = "row-" 

25EDITOR_ID_PREFIX = "ed-" 

26RESET_BUTTON_ID_PREFIX = "reset-" 

27RESET_BUTTON_LABEL = "↺" 

28 

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

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

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

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

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

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

35} 

36 

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

38 

39LIST_RESTORE_PREFIX = "list-restore-" 

40LIST_ERROR_ID_PREFIX = "err-" 

41LIST_ERROR_VISIBLE_CLASS = "-visible" 

42 

43API_KEYS_GROUP = "API-Keys" 

44API_KEYS_WARNING_CLASS = "api-keys-warning" 

45CONFIG_TOML_FILENAME = "config.toml" 

46 

47 

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

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

50 mapping: dict[str, PickerScope] = { 

51 "chat_model": "chat", 

52 "embedding_model": "embed", 

53 "vision_model": "vision", 

54 "reranker_model": "rerank", 

55 } 

56 return mapping 

57 

58 

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

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

61 from lilbee.catalog.types import ModelTask as _ModelTask 

62 

63 return { 

64 "chat": _ModelTask.CHAT, 

65 "embed": _ModelTask.EMBEDDING, 

66 "vision": _ModelTask.VISION, 

67 "rerank": _ModelTask.RERANK, 

68 }[scope] 

69 

70 

71MODEL_PICKER_BUTTON_PREFIX = "model-pick-" 

72 

73 

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

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

76 if isinstance(widget, Input): 

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

78 elif isinstance(widget, Checkbox): 

79 widget.value = bool(value) 

80 elif isinstance(widget, Select): 

81 if value is None: 

82 widget.clear() 

83 else: 

84 widget.value = str(value) 

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

86 if isinstance(value, list): 

87 widget.load_text("\n".join(value)) 

88 else: 

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

90 

91 

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

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

94 from lilbee.catalog.formatting import display_label_for_ref 

95 

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

97 label = display_label_for_ref(str(ref)) 

98 return label or msg.MODEL_VALUE_NONE 

99 

100 

101def config_toml_path() -> str: 

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

103 return str(cfg.data_dir / CONFIG_TOML_FILENAME) 

104 

105 

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

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

108 user_value = getattr(cfg, key, None) 

109 if user_value is not None: 

110 if isinstance(user_value, list): 

111 return f"{len(user_value)} lines" 

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 os.environ.get(env_name) is None: 

147 return None 

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

149 

150 

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

152 """Build help text; the editor widget already shows the current value.""" 

153 if defn.help_text: 

154 return Content(defn.help_text) 

155 return Content("") 

156 

157 

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

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

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

161 env_badge = env_pill(key) 

162 if env_badge is not None: 

163 parts.append(Content(" ")) 

164 parts.append(env_badge) 

165 return Content.assemble(*parts) 

166 

167 

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

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

170 if default is None: 

171 return "" 

172 if isinstance(default, list): 

173 return "\n".join(default) 

174 return str(default) 

175 

176 

177def _litellm_installed() -> bool: 

178 from lilbee.providers.litellm_sdk import litellm_available 

179 

180 return litellm_available() 

181 

182 

183def _crawler_installed() -> bool: 

184 from lilbee.crawler import crawler_available 

185 

186 return crawler_available() 

187 

188 

189def _wiki_enabled() -> bool: 

190 return bool(cfg.wiki) 

191 

192 

193_FEATURE_GATED_GROUPS: dict[str, Callable[[], bool]] = { 

194 "API-Keys": _litellm_installed, 

195 "Crawling": _crawler_installed, 

196 "Wiki": _wiki_enabled, 

197} 

198 

199 

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

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

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

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

204 if defn.hidden: 

205 continue 

206 gate = _FEATURE_GATED_GROUPS.get(defn.group) 

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

208 continue 

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

210 return dict(groups) 

211 

212 

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

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

215 if defn.render is RenderStyle.LIST_COLLAPSED: 

216 return make_list_editor(key) 

217 value = effective_value(key) 

218 if defn.choices: 

219 return make_select(key, defn, value) 

220 if defn.type is bool: 

221 return make_checkbox(key, value) 

222 if defn.render is RenderStyle.MULTILINE: 

223 return make_multiline_editor(key, value) 

224 return make_input(key, value) 

225 

226 

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

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

229 display = "" if value == "None" else value 

230 return ListTextArea( 

231 text=display, 

232 show_line_numbers=False, 

233 name=key, 

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

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

236 soft_wrap=True, 

237 ) 

238 

239 

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

241 """Create a Collapsible with a line-numbered TextArea for list[str] settings.""" 

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

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

244 editor = ListTextArea( 

245 text="\n".join(current), 

246 show_line_numbers=True, 

247 name=key, 

248 id=f"ed-{key}", 

249 classes="setting-list-editor", 

250 ) 

251 error = Static("", id=f"{LIST_ERROR_ID_PREFIX}{key}", classes="setting-list-error") 

252 reset = Button( 

253 msg.SETTINGS_LIST_EDITOR_RESTORE_DEFAULTS, 

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

255 classes="setting-list-restore", 

256 ) 

257 return Collapsible( 

258 editor, 

259 error, 

260 reset, 

261 title=title, 

262 collapsed=True, 

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

264 ) 

265 

266 

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

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

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

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

271 return Select( 

272 choices, 

273 value=value, 

274 name=key, 

275 classes="setting-editor", 

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

277 ) 

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

279 

280 

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

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

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

284 return Checkbox( 

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

286 ) 

287 

288 

289def make_input(key: str, value: str) -> Input: 

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

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

292 return Input(value=display, name=key, classes="setting-editor", id=f"{EDITOR_ID_PREFIX}{key}")