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
« 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."""
3from __future__ import annotations
5import os
6from collections import defaultdict
7from collections.abc import Callable
8from typing import TYPE_CHECKING
10from textual.content import Content
11from textual.widget import Widget
12from textual.widgets import Button, Checkbox, Collapsible, Input, Select, Static, TextArea
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
22if TYPE_CHECKING:
23 from lilbee.catalog.types import ModelTask
24 from lilbee.cli.tui.screens.model_picker import PickerScope
26ROW_ID_PREFIX = "row-"
27EDITOR_ID_PREFIX = "ed-"
28RESET_BUTTON_ID_PREFIX = "reset-"
29RESET_BUTTON_LABEL = "↺"
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}
39_DEFAULTS_REMAP: dict[str, str] = {"top_k_sampling": "top_k"}
41LIST_RESTORE_PREFIX = "list-restore-"
42LIST_ERROR_ID_PREFIX = "err-"
43LIST_ERROR_VISIBLE_CLASS = "-visible"
45API_KEYS_GROUP = SettingGroup.API_KEYS
46API_KEYS_WARNING_CLASS = "api-keys-warning"
47CONFIG_TOML_FILENAME = "config.toml"
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
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
65 return {
66 "chat": _ModelTask.CHAT,
67 "embed": _ModelTask.EMBEDDING,
68 "vision": _ModelTask.VISION,
69 "rerank": _ModelTask.RERANK,
70 }[scope]
73MODEL_PICKER_BUTTON_PREFIX = "model-pick-"
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))
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
98 ref = getattr(cfg, key, None) or ""
99 label = display_label_for_ref(str(ref))
100 return label or msg.MODEL_VALUE_NONE
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)
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"
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
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)
138def env_var_name(key: str) -> str:
139 """Return the LILBEE_* env var name for a config key."""
140 return f"LILBEE_{key.upper()}"
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")
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)
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)
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)
182def _litellm_installed() -> bool:
183 from lilbee.providers.litellm_sdk import litellm_available
185 return litellm_available()
188def _crawler_installed() -> bool:
189 from lilbee.crawler import crawler_available
191 return crawler_available()
194def _wiki_enabled() -> bool:
195 return bool(cfg.wiki)
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}
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)
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)
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 )
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 )
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}")
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 )
298def make_input(key: str, value: str, *, secret: bool = False) -> Input:
299 """Create an Input widget for string/number settings.
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 )