Coverage for src/lilbee/cli/tui/widgets/model_list.py: 100%
132 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +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 LocalCatalogRow,
28)
29from lilbee.cli.tui.widgets.catalog_card_shared import (
30 _FIT_LEVEL_BACKGROUND,
31 _FIT_LEVEL_LABEL_COMPACT,
32 KEY_STATUS_COLOR,
33 KEY_STATUS_LABEL,
34 _compat_label,
35 _spec_strip,
36)
37from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS
38from lilbee.cli.tui.widgets.clamped_option_list import ClampedOptionList
39from lilbee.modelhub.models import FEATURED_STAR
40from lilbee.runtime.hardware import FitChip
42_CSS_FILE = Path(__file__).parent / "model_list.tcss"
45class ModelListSection(NamedTuple):
46 """One contiguous block of rows under an optional heading."""
48 heading: str | None
49 rows: list[CatalogRow]
52class ModelList(ClampedOptionList):
53 """OptionList specialized for catalog rows, posting Selected on activate."""
55 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
57 @dataclass
58 class Selected(Message):
59 """Posted when a non-heading row is activated."""
61 row: CatalogRow
63 def __init__(self, *, id: str | None = None) -> None:
64 super().__init__(id=id)
65 self._row_by_option_id: dict[str, CatalogRow] = {}
67 def set_rows(self, sections: list[ModelListSection]) -> None:
68 """Replace the contents with options derived from *sections*."""
69 self._row_by_option_id.clear()
70 self.clear_options()
71 options = self._build_options(sections, start_idx=0)
72 if options:
73 self.add_options(options)
75 def append_rows(self, rows: list[CatalogRow]) -> None:
76 """Append rows under the existing sections without rebuilding."""
77 if not rows:
78 return
79 start = len(self._row_by_option_id)
80 section = ModelListSection(heading=None, rows=rows)
81 options = self._build_options([section], start_idx=start)
82 if options:
83 self.add_options(options)
85 @property
86 def row_count(self) -> int:
87 """Number of selectable rows currently mounted (excludes section headings)."""
88 return len(self._row_by_option_id)
90 def row_at(self, option_id: str) -> CatalogRow | None:
91 """Return the CatalogRow for the given option id, or None when unknown."""
92 return self._row_by_option_id.get(option_id)
94 def highlighted_row(self) -> CatalogRow | None:
95 """Return the CatalogRow under the highlight cursor, or None."""
96 idx = self.highlighted
97 if idx is None:
98 return None
99 try:
100 opt = self.get_option_at_index(idx)
101 except IndexError:
102 return None
103 if opt.id is None:
104 return None
105 return self.row_at(opt.id)
107 def _build_options(self, sections: list[ModelListSection], *, start_idx: int) -> list[Option]:
108 options: list[Option] = []
109 idx = start_idx
110 for section_n, section in enumerate(sections):
111 if section.heading:
112 options.append(_heading_option(section.heading, start_idx + section_n))
113 for row in section.rows:
114 option_id = f"row-{idx}"
115 self._row_by_option_id[option_id] = row
116 options.append(Option(_render_row(row), id=option_id))
117 idx += 1
118 return options
120 @on(OptionList.OptionSelected)
121 def _on_option_selected(self, event: OptionList.OptionSelected) -> None:
122 if event.option.id is None:
123 return
124 row = self._row_by_option_id.get(event.option.id)
125 if row is None:
126 return
127 event.stop()
128 self.post_message(self.Selected(row))
131def _heading_option(heading: str, n: int) -> Option:
132 return Option(
133 Content.styled(heading, "bold $accent"),
134 id=f"heading-{n}",
135 disabled=True,
136 )
139def _render_row(row: CatalogRow) -> Content:
140 if row.kind == CatalogRowKind.FRONTIER:
141 return _render_frontier(row)
142 return _render_local(row)
145def _render_frontier(row: FrontierCatalogRow) -> Content:
146 # Two-line row mirroring the local layout: name + key-status tag on top,
147 # provider strip below. Plain text styling, no colored pills.
148 line1: list[Content] = [Content(" "), Content.styled(row.name, "bold")]
149 line1.append(
150 Content.styled(
151 f" {KEY_STATUS_LABEL[row.key_status]}",
152 f"{KEY_STATUS_COLOR[row.key_status]} italic",
153 )
154 )
155 line2: list[Content] = [Content(" "), Content.styled(row.provider, "dim $text-muted")]
156 return Content.assemble(*line1, Content("\n"), *line2, Content("\n"))
159def _render_local(row: LocalCatalogRow) -> Content:
160 line1 = _render_local_headline(row)
161 line2 = _render_local_meta(row)
162 return Content.assemble(*line1, Content("\n"), *line2, Content("\n"))
165def _render_local_headline(row: LocalCatalogRow) -> list[Content]:
166 parts: list[Content] = [
167 Content.styled(f"{FEATURED_STAR} ", "$warning") if row.featured else Content(" "),
168 Content.styled(row.name, "bold"),
169 ]
170 if row.installed:
171 parts.append(Content.styled(" installed", "$success italic"))
172 parts.extend(_fit_tag(row.fit))
173 parts.extend(_compat_tag(row.compat))
174 return parts
177def _fit_tag(fit: FitChip | None) -> list[Content]:
178 """List-style fit indicator (italic colored text), matching the grid card chip set."""
179 if fit is None:
180 return []
181 label = _FIT_LEVEL_LABEL_COMPACT[fit.level]
182 return [Content.styled(f" {label}", f"{_FIT_LEVEL_BACKGROUND[fit.level]} italic")]
185def _compat_tag(compat: ModelCompat) -> list[Content]:
186 """List-style compat indicator. Empty for SUPPORTED to keep the row visually quiet."""
187 label = _compat_label(compat)
188 if label is None:
189 return []
190 color = "$warning" if compat is ModelCompat.UNSUPPORTED else "$text-muted"
191 return [Content.styled(f" {label}", f"{color} italic")]
194def _render_local_meta(row: LocalCatalogRow) -> list[Content]:
195 parts: list[Content] = [Content(" ")]
196 if row.task:
197 task_color = TASK_COLORS.get(row.task, "$text-muted")
198 parts.append(Content.styled(row.task, f"{task_color} italic"))
199 parts.append(Content.styled(f" {MIDDLE_DOT} ", "dim $text-muted"))
200 rest = [s for s in _local_meta_strip(row) if s]
201 if rest:
202 parts.append(Content.styled(f" {MIDDLE_DOT} ".join(rest), "dim $text-muted"))
203 return parts
206def _local_meta_strip(row: LocalCatalogRow) -> list[str]:
207 rest: list[str] = []
208 if row.backend and row.backend != NATIVE_BACKEND:
209 rest.append(row.backend)
210 specs = _spec_strip(row.params, row.quant, row.size)
211 if specs:
212 rest.append(specs)
213 if row.downloads and row.downloads != "--":
214 rest.append(f"↓ {row.downloads}")
215 return rest