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

1"""Virtualized model list backed by Textual's OptionList. 

2 

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""" 

8 

9from __future__ import annotations 

10 

11from dataclasses import dataclass 

12from pathlib import Path 

13from typing import ClassVar, NamedTuple 

14 

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 

20 

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 

40 

41_CSS_FILE = Path(__file__).parent / "model_list.tcss" 

42 

43 

44class ModelListSection(NamedTuple): 

45 """One contiguous block of rows under an optional heading.""" 

46 

47 heading: str | None 

48 rows: list[CatalogRow] 

49 

50 

51class ModelList(ClampedOptionList): 

52 """OptionList specialized for catalog rows, posting Selected on activate.""" 

53 

54 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") 

55 

56 @dataclass 

57 class Selected(Message): 

58 """Posted when a non-heading row is activated.""" 

59 

60 row: CatalogRow 

61 

62 def __init__(self, *, id: str | None = None) -> None: 

63 super().__init__(id=id) 

64 self._row_by_option_id: dict[str, CatalogRow] = {} 

65 

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) 

73 

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) 

83 

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) 

88 

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) 

92 

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) 

105 

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 

118 

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)) 

128 

129 

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 ) 

136 

137 

138def _render_row(row: CatalogRow) -> Content: 

139 if row.kind == CatalogRowKind.FRONTIER: 

140 return _render_frontier(row) 

141 return _render_local(row) 

142 

143 

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")) 

154 

155 

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")) 

160 

161 

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 

172 

173 

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")] 

180 

181 

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")] 

189 

190 

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 

201 

202 

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