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

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

32 

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

34 

35 

36class ModelListSection(NamedTuple): 

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

38 

39 heading: str | None 

40 rows: list[CatalogRow] 

41 

42 

43class ModelList(OptionList): 

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

45 

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

47 

48 @dataclass 

49 class Selected(Message): 

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

51 

52 row: CatalogRow 

53 

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

55 super().__init__(id=id) 

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

57 

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) 

65 

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) 

75 

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) 

80 

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) 

84 

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) 

97 

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 

110 

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

120 

121 

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 ) 

128 

129 

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

131 if row.kind == CatalogRowKind.FRONTIER: 

132 return _render_frontier(row) 

133 return _render_local(row) 

134 

135 

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

146 

147 

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

152 

153 

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 

164 

165 

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

175 

176 

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 

180 

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

186 

187 

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 

198 

199 

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 

210 

211 

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)