Coverage for src/lilbee/cli/tui/widgets/model_list.py: 100%
134 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-12 00:44 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-12 00:44 +0000
1"""Virtualized model list backed by Textual's OptionList.
3OptionList renders only on-screen rows, so frontier-tab populations of
4hundreds of cloud models stay smooth. ``set_rows`` rebuilds the list
5from a flat sequence of ``ModelListSection``; ``Selected`` is posted
6when the user activates a non-heading row.
7"""
9from __future__ import annotations
11from dataclasses import dataclass
12from pathlib import Path
13from typing import ClassVar, NamedTuple
15from textual import on
16from textual.content import Content
17from textual.message import Message
18from textual.widgets import OptionList
19from textual.widgets.option_list import Option
21from lilbee.catalog.types import ModelCompat
22from lilbee.cli.tui.screens.catalog_utils import (
23 NATIVE_BACKEND,
24 CatalogRow,
25 CatalogRowKind,
26 FrontierCatalogRow,
27 KeyStatus,
28 LocalCatalogRow,
29)
30from lilbee.cli.tui.widgets.catalog_card_shared import (
31 _FIT_LEVEL_BACKGROUND,
32 _FIT_LEVEL_LABEL_COMPACT,
33 _compat_label,
34 _spec_strip,
35)
36from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS
37from lilbee.cli.tui.widgets.clamped_option_list import ClampedOptionList
38from lilbee.modelhub.models import FEATURED_STAR
39from lilbee.runtime.hardware import FitChip
41_CSS_FILE = Path(__file__).parent / "model_list.tcss"
44class ModelListSection(NamedTuple):
45 """One contiguous block of rows under an optional heading."""
47 heading: str | None
48 rows: list[CatalogRow]
51class ModelList(ClampedOptionList):
52 """OptionList specialized for catalog rows, posting Selected on activate."""
54 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
56 @dataclass
57 class Selected(Message):
58 """Posted when a non-heading row is activated."""
60 row: CatalogRow
62 def __init__(self, *, id: str | None = None) -> None:
63 super().__init__(id=id)
64 self._row_by_option_id: dict[str, CatalogRow] = {}
66 def set_rows(self, sections: list[ModelListSection]) -> None:
67 """Replace the contents with options derived from *sections*."""
68 self._row_by_option_id.clear()
69 self.clear_options()
70 options = self._build_options(sections, start_idx=0)
71 if options:
72 self.add_options(options)
74 def append_rows(self, rows: list[CatalogRow]) -> None:
75 """Append rows under the existing sections without rebuilding."""
76 if not rows:
77 return
78 start = len(self._row_by_option_id)
79 section = ModelListSection(heading=None, rows=rows)
80 options = self._build_options([section], start_idx=start)
81 if options:
82 self.add_options(options)
84 @property
85 def row_count(self) -> int:
86 """Number of selectable rows currently mounted (excludes section headings)."""
87 return len(self._row_by_option_id)
89 def row_at(self, option_id: str) -> CatalogRow | None:
90 """Return the CatalogRow for the given option id, or None when unknown."""
91 return self._row_by_option_id.get(option_id)
93 def highlighted_row(self) -> CatalogRow | None:
94 """Return the CatalogRow under the highlight cursor, or None."""
95 idx = self.highlighted
96 if idx is None:
97 return None
98 try:
99 opt = self.get_option_at_index(idx)
100 except IndexError:
101 return None
102 if opt.id is None:
103 return None
104 return self.row_at(opt.id)
106 def _build_options(self, sections: list[ModelListSection], *, start_idx: int) -> list[Option]:
107 options: list[Option] = []
108 idx = start_idx
109 for section_n, section in enumerate(sections):
110 if section.heading:
111 options.append(_heading_option(section.heading, start_idx + section_n))
112 for row in section.rows:
113 option_id = f"row-{idx}"
114 self._row_by_option_id[option_id] = row
115 options.append(Option(_render_row(row), id=option_id))
116 idx += 1
117 return options
119 @on(OptionList.OptionSelected)
120 def _on_option_selected(self, event: OptionList.OptionSelected) -> None:
121 if event.option.id is None:
122 return
123 row = self._row_by_option_id.get(event.option.id)
124 if row is None:
125 return
126 event.stop()
127 self.post_message(self.Selected(row))
130def _heading_option(heading: str, n: int) -> Option:
131 return Option(
132 Content.styled(heading, "bold $accent"),
133 id=f"heading-{n}",
134 disabled=True,
135 )
138def _render_row(row: CatalogRow) -> Content:
139 if row.kind == CatalogRowKind.FRONTIER:
140 return _render_frontier(row)
141 return _render_local(row)
144def _render_frontier(row: FrontierCatalogRow) -> Content:
145 # Two-line row mirroring the local layout: name + key-status tag on top,
146 # provider strip below. Plain text styling, no colored pills.
147 line1: list[Content] = [Content(" "), Content.styled(row.name, "bold")]
148 if row.key_status == KeyStatus.READY:
149 line1.append(Content.styled(" ready", "$success italic"))
150 else:
151 line1.append(Content.styled(" needs key", "$warning italic"))
152 line2: list[Content] = [Content(" "), Content.styled(row.provider, "dim $text-muted")]
153 return Content.assemble(*line1, Content("\n"), *line2, Content("\n"))
156def _render_local(row: LocalCatalogRow) -> Content:
157 line1 = _render_local_headline(row)
158 line2 = _render_local_meta(row)
159 return Content.assemble(*line1, Content("\n"), *line2, Content("\n"))
162def _render_local_headline(row: LocalCatalogRow) -> list[Content]:
163 parts: list[Content] = [
164 Content.styled(f"{FEATURED_STAR} ", "$warning") if row.featured else Content(" "),
165 Content.styled(row.name, "bold"),
166 ]
167 if row.installed:
168 parts.append(Content.styled(" installed", "$success italic"))
169 parts.extend(_fit_tag(row.fit))
170 parts.extend(_compat_tag(row.compat))
171 return parts
174def _fit_tag(fit: FitChip | None) -> list[Content]:
175 """List-style fit indicator (italic colored text), matching the grid card chip set."""
176 if fit is None:
177 return []
178 label = _FIT_LEVEL_LABEL_COMPACT[fit.level]
179 return [Content.styled(f" {label}", f"{_FIT_LEVEL_BACKGROUND[fit.level]} italic")]
182def _compat_tag(compat: ModelCompat) -> list[Content]:
183 """List-style compat indicator. Empty for SUPPORTED to keep the row visually quiet."""
184 label = _compat_label(compat)
185 if label is None:
186 return []
187 color = "$warning" if compat is ModelCompat.UNSUPPORTED else "$text-muted"
188 return [Content.styled(f" {label}", f"{color} italic")]
191def _render_local_meta(row: LocalCatalogRow) -> list[Content]:
192 parts: list[Content] = [Content(" ")]
193 if row.task:
194 task_color = TASK_COLORS.get(row.task, "$text-muted")
195 parts.append(Content.styled(row.task, f"{task_color} italic"))
196 parts.append(Content.styled(f" {MIDDLE_DOT} ", "dim $text-muted"))
197 rest = [s for s in _local_meta_strip(row) if s]
198 if rest:
199 parts.append(Content.styled(f" {MIDDLE_DOT} ".join(rest), "dim $text-muted"))
200 return parts
203def _local_meta_strip(row: LocalCatalogRow) -> list[str]:
204 rest: list[str] = []
205 if row.backend and row.backend != NATIVE_BACKEND:
206 rest.append(row.backend)
207 specs = _spec_strip(row.params, row.quant, row.size)
208 if specs:
209 rest.append(specs)
210 if row.downloads and row.downloads != "--":
211 rest.append(f"↓ {row.downloads}")
212 return rest