Coverage for src / lilbee / cli / tui / widgets / model_list.py: 100%
120 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"""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.cli.tui.screens.catalog_utils import (
22 CatalogRow,
23 CatalogRowKind,
24 FrontierCatalogRow,
25 KeyStatus,
26 LocalCatalogRow,
27)
28from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS
29from lilbee.modelhub.models import FEATURED_STAR
31_CSS_FILE = Path(__file__).parent / "model_list.tcss"
34class ModelListSection(NamedTuple):
35 """One contiguous block of rows under an optional heading."""
37 heading: str | None
38 rows: list[CatalogRow]
41class ModelList(OptionList):
42 """OptionList specialized for catalog rows, posting Selected on activate."""
44 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
46 @dataclass
47 class Selected(Message):
48 """Posted when a non-heading row is activated."""
50 row: CatalogRow
52 def __init__(self, *, id: str | None = None) -> None:
53 super().__init__(id=id)
54 self._row_by_option_id: dict[str, CatalogRow] = {}
56 def set_rows(self, sections: list[ModelListSection]) -> None:
57 """Replace the contents with options derived from *sections*."""
58 self._row_by_option_id.clear()
59 self.clear_options()
60 options = self._build_options(sections, start_idx=0)
61 if options:
62 self.add_options(options)
64 def append_rows(self, rows: list[CatalogRow]) -> None:
65 """Append rows under the existing sections without rebuilding."""
66 if not rows:
67 return
68 start = len(self._row_by_option_id)
69 section = ModelListSection(heading=None, rows=rows)
70 options = self._build_options([section], start_idx=start)
71 if options:
72 self.add_options(options)
74 @property
75 def row_count(self) -> int:
76 """Number of selectable rows currently mounted (excludes section headings)."""
77 return len(self._row_by_option_id)
79 def row_at(self, option_id: str) -> CatalogRow | None:
80 """Return the CatalogRow for the given option id, or None when unknown."""
81 return self._row_by_option_id.get(option_id)
83 def highlighted_row(self) -> CatalogRow | None:
84 """Return the CatalogRow under the highlight cursor, or None."""
85 idx = self.highlighted
86 if idx is None:
87 return None
88 try:
89 opt = self.get_option_at_index(idx)
90 except IndexError:
91 return None
92 if opt.id is None:
93 return None
94 return self.row_at(opt.id)
96 def _build_options(self, sections: list[ModelListSection], *, start_idx: int) -> list[Option]:
97 options: list[Option] = []
98 idx = start_idx
99 for section_n, section in enumerate(sections):
100 if section.heading:
101 options.append(_heading_option(section.heading, start_idx + section_n))
102 for row in section.rows:
103 option_id = f"row-{idx}"
104 self._row_by_option_id[option_id] = row
105 options.append(Option(_render_row(row), id=option_id))
106 idx += 1
107 return options
109 @on(OptionList.OptionSelected)
110 def _on_option_selected(self, event: OptionList.OptionSelected) -> None:
111 if event.option.id is None:
112 return
113 row = self._row_by_option_id.get(event.option.id)
114 if row is None:
115 return
116 event.stop()
117 self.post_message(self.Selected(row))
120def _heading_option(heading: str, n: int) -> Option:
121 return Option(
122 Content.styled(heading, "bold $accent"),
123 id=f"heading-{n}",
124 disabled=True,
125 )
128def _render_row(row: CatalogRow) -> Content:
129 if row.kind == CatalogRowKind.FRONTIER:
130 return _render_frontier(row)
131 return _render_local(row)
134def _render_frontier(row: FrontierCatalogRow) -> Content:
135 # Two-line row mirroring the local layout: name + key-status tag on top,
136 # provider strip below. Plain text styling, no colored pills.
137 line1: list[Content] = [Content(" "), Content.styled(row.name, "bold")]
138 if row.key_status == KeyStatus.READY:
139 line1.append(Content.styled(" ready", "$success italic"))
140 else:
141 line1.append(Content.styled(" needs key", "$warning italic"))
142 line2: list[Content] = [Content(" "), Content.styled(row.provider, "dim $text-muted")]
143 return Content.assemble(*line1, Content("\n"), *line2, Content("\n"))
146def _render_local(row: LocalCatalogRow) -> Content:
147 line1 = _render_local_headline(row)
148 line2 = _render_local_meta(row)
149 return Content.assemble(*line1, Content("\n"), *line2, Content("\n"))
152def _render_local_headline(row: LocalCatalogRow) -> list[Content]:
153 parts: list[Content] = [
154 Content.styled(f"{FEATURED_STAR} ", "$warning") if row.featured else Content(" "),
155 Content.styled(row.name, "bold"),
156 ]
157 if row.installed:
158 parts.append(Content.styled(" installed", "$success italic"))
159 return parts
162def _render_local_meta(row: LocalCatalogRow) -> list[Content]:
163 parts: list[Content] = [Content(" ")]
164 if row.task:
165 task_color = TASK_COLORS.get(row.task, "$text-muted")
166 parts.append(Content.styled(row.task, f"{task_color} italic"))
167 parts.append(Content.styled(f" {MIDDLE_DOT} ", "dim $text-muted"))
168 rest = [s for s in _local_meta_strip(row) if s]
169 if rest:
170 parts.append(Content.styled(f" {MIDDLE_DOT} ".join(rest), "dim $text-muted"))
171 return parts
174def _local_meta_strip(row: LocalCatalogRow) -> list[str]:
175 rest: list[str] = []
176 if row.backend and row.backend != "native":
177 rest.append(row.backend)
178 specs = _format_specs(row)
179 if specs:
180 rest.append(specs)
181 if row.downloads and row.downloads != "--":
182 rest.append(f"↓ {row.downloads}")
183 return rest
186def _format_specs(row: LocalCatalogRow) -> str:
187 parts = [p for p in (row.params, row.quant, row.size) if p and p != "--"]
188 return f" {MIDDLE_DOT} ".join(parts)