Coverage for src / lilbee / cli / tui / widgets / model_list.py: 100%
139 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +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 CatalogRow,
24 CatalogRowKind,
25 FrontierCatalogRow,
26 KeyStatus,
27 LocalCatalogRow,
28)
29from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS
30from lilbee.modelhub.models import FEATURED_STAR
31from lilbee.runtime.hardware import FitChip, FitLevel
33_CSS_FILE = Path(__file__).parent / "model_list.tcss"
36class ModelListSection(NamedTuple):
37 """One contiguous block of rows under an optional heading."""
39 heading: str | None
40 rows: list[CatalogRow]
43class ModelList(OptionList):
44 """OptionList specialized for catalog rows, posting Selected on activate."""
46 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
48 @dataclass
49 class Selected(Message):
50 """Posted when a non-heading row is activated."""
52 row: CatalogRow
54 def __init__(self, *, id: str | None = None) -> None:
55 super().__init__(id=id)
56 self._row_by_option_id: dict[str, CatalogRow] = {}
58 def set_rows(self, sections: list[ModelListSection]) -> None:
59 """Replace the contents with options derived from *sections*."""
60 self._row_by_option_id.clear()
61 self.clear_options()
62 options = self._build_options(sections, start_idx=0)
63 if options:
64 self.add_options(options)
66 def append_rows(self, rows: list[CatalogRow]) -> None:
67 """Append rows under the existing sections without rebuilding."""
68 if not rows:
69 return
70 start = len(self._row_by_option_id)
71 section = ModelListSection(heading=None, rows=rows)
72 options = self._build_options([section], start_idx=start)
73 if options:
74 self.add_options(options)
76 @property
77 def row_count(self) -> int:
78 """Number of selectable rows currently mounted (excludes section headings)."""
79 return len(self._row_by_option_id)
81 def row_at(self, option_id: str) -> CatalogRow | None:
82 """Return the CatalogRow for the given option id, or None when unknown."""
83 return self._row_by_option_id.get(option_id)
85 def highlighted_row(self) -> CatalogRow | None:
86 """Return the CatalogRow under the highlight cursor, or None."""
87 idx = self.highlighted
88 if idx is None:
89 return None
90 try:
91 opt = self.get_option_at_index(idx)
92 except IndexError:
93 return None
94 if opt.id is None:
95 return None
96 return self.row_at(opt.id)
98 def _build_options(self, sections: list[ModelListSection], *, start_idx: int) -> list[Option]:
99 options: list[Option] = []
100 idx = start_idx
101 for section_n, section in enumerate(sections):
102 if section.heading:
103 options.append(_heading_option(section.heading, start_idx + section_n))
104 for row in section.rows:
105 option_id = f"row-{idx}"
106 self._row_by_option_id[option_id] = row
107 options.append(Option(_render_row(row), id=option_id))
108 idx += 1
109 return options
111 @on(OptionList.OptionSelected)
112 def _on_option_selected(self, event: OptionList.OptionSelected) -> None:
113 if event.option.id is None:
114 return
115 row = self._row_by_option_id.get(event.option.id)
116 if row is None:
117 return
118 event.stop()
119 self.post_message(self.Selected(row))
122def _heading_option(heading: str, n: int) -> Option:
123 return Option(
124 Content.styled(heading, "bold $accent"),
125 id=f"heading-{n}",
126 disabled=True,
127 )
130def _render_row(row: CatalogRow) -> Content:
131 if row.kind == CatalogRowKind.FRONTIER:
132 return _render_frontier(row)
133 return _render_local(row)
136def _render_frontier(row: FrontierCatalogRow) -> Content:
137 # Two-line row mirroring the local layout: name + key-status tag on top,
138 # provider strip below. Plain text styling, no colored pills.
139 line1: list[Content] = [Content(" "), Content.styled(row.name, "bold")]
140 if row.key_status == KeyStatus.READY:
141 line1.append(Content.styled(" ready", "$success italic"))
142 else:
143 line1.append(Content.styled(" needs key", "$warning italic"))
144 line2: list[Content] = [Content(" "), Content.styled(row.provider, "dim $text-muted")]
145 return Content.assemble(*line1, Content("\n"), *line2, Content("\n"))
148def _render_local(row: LocalCatalogRow) -> Content:
149 line1 = _render_local_headline(row)
150 line2 = _render_local_meta(row)
151 return Content.assemble(*line1, Content("\n"), *line2, Content("\n"))
154def _render_local_headline(row: LocalCatalogRow) -> list[Content]:
155 parts: list[Content] = [
156 Content.styled(f"{FEATURED_STAR} ", "$warning") if row.featured else Content(" "),
157 Content.styled(row.name, "bold"),
158 ]
159 if row.installed:
160 parts.append(Content.styled(" installed", "$success italic"))
161 parts.extend(_fit_tag(row.fit))
162 parts.extend(_compat_tag(row.compat))
163 return parts
166def _fit_tag(fit: FitChip | None) -> list[Content]:
167 """List-style fit indicator (italic colored text), matching the grid card chip set."""
168 if fit is None:
169 return []
170 if fit.level is FitLevel.FITS:
171 return [Content.styled(" fits", "$success italic")]
172 if fit.level is FitLevel.TIGHT:
173 return [Content.styled(" tight", "$warning italic")]
174 return [Content.styled(" won't run", "$error italic")]
177def _compat_tag(compat: ModelCompat) -> list[Content]:
178 """List-style compat indicator. Empty for SUPPORTED to keep the row visually quiet."""
179 from lilbee.cli.tui import messages as msg
181 if compat is ModelCompat.SUPPORTED:
182 return []
183 if compat is ModelCompat.UNSUPPORTED:
184 return [Content.styled(f" {msg.COMPAT_PILL_UNSUPPORTED}", "$warning italic")]
185 return [Content.styled(f" {msg.COMPAT_PILL_UNKNOWN}", "$text-muted italic")]
188def _render_local_meta(row: LocalCatalogRow) -> list[Content]:
189 parts: list[Content] = [Content(" ")]
190 if row.task:
191 task_color = TASK_COLORS.get(row.task, "$text-muted")
192 parts.append(Content.styled(row.task, f"{task_color} italic"))
193 parts.append(Content.styled(f" {MIDDLE_DOT} ", "dim $text-muted"))
194 rest = [s for s in _local_meta_strip(row) if s]
195 if rest:
196 parts.append(Content.styled(f" {MIDDLE_DOT} ".join(rest), "dim $text-muted"))
197 return parts
200def _local_meta_strip(row: LocalCatalogRow) -> list[str]:
201 rest: list[str] = []
202 if row.backend and row.backend != "native":
203 rest.append(row.backend)
204 specs = _format_specs(row)
205 if specs:
206 rest.append(specs)
207 if row.downloads and row.downloads != "--":
208 rest.append(f"↓ {row.downloads}")
209 return rest
212def _format_specs(row: LocalCatalogRow) -> str:
213 parts = [p for p in (row.params, row.quant, row.size) if p and p != "--"]
214 return f" {MIDDLE_DOT} ".join(parts)