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

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

30 

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

32 

33 

34class ModelListSection(NamedTuple): 

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

36 

37 heading: str | None 

38 rows: list[CatalogRow] 

39 

40 

41class ModelList(OptionList): 

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

43 

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

45 

46 @dataclass 

47 class Selected(Message): 

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

49 

50 row: CatalogRow 

51 

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

53 super().__init__(id=id) 

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

55 

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) 

63 

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) 

73 

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) 

78 

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) 

82 

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) 

95 

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 

108 

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

118 

119 

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 ) 

126 

127 

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

129 if row.kind == CatalogRowKind.FRONTIER: 

130 return _render_frontier(row) 

131 return _render_local(row) 

132 

133 

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

144 

145 

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

150 

151 

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 

160 

161 

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 

172 

173 

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 

184 

185 

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)