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
« 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."""
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.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
20if TYPE_CHECKING:
21 from lilbee.catalog.types import ModelTask
22 from lilbee.cli.tui.screens.model_picker import PickerScope
24ROW_ID_PREFIX = "row-"
25EDITOR_ID_PREFIX = "ed-"
26RESET_BUTTON_ID_PREFIX = "reset-"
27RESET_BUTTON_LABEL = "↺"
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}
37_DEFAULTS_REMAP: dict[str, str] = {"top_k_sampling": "top_k"}
39LIST_RESTORE_PREFIX = "list-restore-"
40LIST_ERROR_ID_PREFIX = "err-"
41LIST_ERROR_VISIBLE_CLASS = "-visible"
43API_KEYS_GROUP = "API-Keys"
44API_KEYS_WARNING_CLASS = "api-keys-warning"
45CONFIG_TOML_FILENAME = "config.toml"
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
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
63 return {
64 "chat": _ModelTask.CHAT,
65 "embed": _ModelTask.EMBEDDING,
66 "vision": _ModelTask.VISION,
67 "rerank": _ModelTask.RERANK,
68 }[scope]
71MODEL_PICKER_BUTTON_PREFIX = "model-pick-"
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))
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
96 ref = getattr(cfg, key, None) or ""
97 label = display_label_for_ref(str(ref))
98 return label or msg.MODEL_VALUE_NONE
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)
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"
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 os.environ.get(env_name) is None:
147 return None
148 return pill(env_name, "$warning", "$text")
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("")
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)
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)
177def _litellm_installed() -> bool:
178 from lilbee.providers.litellm_sdk import litellm_available
180 return litellm_available()
183def _crawler_installed() -> bool:
184 from lilbee.crawler import crawler_available
186 return crawler_available()
189def _wiki_enabled() -> bool:
190 return bool(cfg.wiki)
193_FEATURE_GATED_GROUPS: dict[str, Callable[[], bool]] = {
194 "API-Keys": _litellm_installed,
195 "Crawling": _crawler_installed,
196 "Wiki": _wiki_enabled,
197}
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)
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)
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 )
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 )
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}")
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 )
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}")