Coverage for src / lilbee / cli / tui / widgets / model_card.py: 100%

97 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-06-28 01:01 +0000

1"""ModelCard: compact card widget for the catalog grid view. 

2 

3Renders both ``LocalCatalogRow`` (installable / installed GGUFs) and 

4``FrontierCatalogRow`` (cloud chat models) via type dispatch so the 

5catalog can show a Frontier section above local sections without a 

6second widget class. 

7 

8A card composes its name + pills + specs + status + (highlight-only) 

9hint into a single ``Static`` rendering one ``Content`` object. This 

10keeps the per-card widget count to two (the container + one Static) 

11so the catalog's grid view can scale to thousands of rows without 

12saturating the compositor with mount/reflow work. 

13""" 

14 

15from __future__ import annotations 

16 

17from pathlib import Path 

18from typing import TYPE_CHECKING, ClassVar 

19 

20from textual import containers, widgets 

21from textual.app import ComposeResult 

22from textual.content import Content 

23from textual.reactive import reactive 

24 

25from lilbee.catalog.types import ModelCompat 

26from lilbee.cli.tui.pill import pill 

27from lilbee.cli.tui.screens.catalog_utils import ( 

28 CatalogRow, 

29 CatalogRowKind, 

30 FrontierCatalogRow, 

31 KeyStatus, 

32 LocalCatalogRow, 

33) 

34from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS 

35 

36if TYPE_CHECKING: 

37 pass 

38 

39_CSS_FILE = Path(__file__).parent / "model_card.tcss" 

40 

41_NAME_MAX_CHARS = 28 

42"""Maximum displayed model-name length; longer names are ellipsis-truncated.""" 

43 

44_ELLIPSIS = "…" 

45 

46 

47def _truncate_name(name: str) -> str: 

48 """Return *name* shortened to ``_NAME_MAX_CHARS`` with an ellipsis tail.""" 

49 if len(name) <= _NAME_MAX_CHARS: 

50 return name 

51 return name[: _NAME_MAX_CHARS - 1].rstrip() + _ELLIPSIS 

52 

53 

54class ModelCard(containers.VerticalGroup): 

55 """A single model card displaying name, task pill, specs, and status.""" 

56 

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

58 

59 selected: reactive[bool] = reactive(False) 

60 

61 def __init__(self, row: CatalogRow) -> None: 

62 self._row = row 

63 super().__init__() 

64 if row.kind == CatalogRowKind.FRONTIER: 

65 self.add_class("-frontier") 

66 

67 @property 

68 def row(self) -> CatalogRow: 

69 return self._row 

70 

71 def watch_selected(self, selected: bool) -> None: 

72 self.set_class(selected, "-selected") 

73 # Re-render so the highlight-only hint appears / disappears. 

74 # Cheap: one Static.update() per highlight move beats the 

75 # CSS-driven display: none toggle on a separate Label widget. 

76 try: 

77 body = self.query_one(".card-body", widgets.Static) 

78 except Exception: 

79 return 

80 body.update(_render(self._row, selected=selected)) 

81 

82 def compose(self) -> ComposeResult: 

83 yield widgets.Static( 

84 _render(self._row, selected=self.selected), 

85 classes="card-body", 

86 markup=False, 

87 ) 

88 

89 

90def _render(row: CatalogRow, *, selected: bool) -> Content: 

91 """Compose the full card content (name + pills + specs + status + hint).""" 

92 if row.kind == CatalogRowKind.FRONTIER: 

93 return _render_frontier(row) 

94 return _render_local(row, selected=selected) 

95 

96 

97def _render_local(row: LocalCatalogRow, *, selected: bool) -> Content: 

98 from lilbee.cli.tui import messages as msg 

99 

100 bg = TASK_COLORS.get(row.task, "$primary") 

101 name = Content.styled(_truncate_name(row.name), "bold") 

102 primary_pills: list[Content] = [] 

103 if row.featured: 

104 primary_pills.append(pill("pick", "$warning", "$text")) 

105 primary_pills.append(pill(row.task, bg, "$text")) 

106 if row.backend: 

107 primary_pills.append(pill(row.backend, "$accent", "$text")) 

108 primary_line = Content(" ").join(primary_pills) 

109 

110 compat_chip = _compat_pill(row.compat) 

111 secondary_line = compat_chip if compat_chip is not None else None 

112 

113 specs = _build_specs(row.params, row.quant, row.size) 

114 status = _build_local_status(row) 

115 

116 parts: list[Content] = [name, primary_line] 

117 if secondary_line is not None: 

118 parts.append(secondary_line) 

119 parts.append(specs) 

120 if status is not None: 

121 parts.append(status) 

122 if selected: 

123 hint = msg.INSTALLED_CARD_HINT if row.installed else msg.SETUP_CARD_HINT 

124 parts.append(Content.styled(hint, "$text-muted 40% italic")) 

125 return Content("\n").join(parts) 

126 

127 

128def _render_frontier(row: FrontierCatalogRow) -> Content: 

129 name = Content.styled(_truncate_name(row.name), "bold") 

130 backend_pill = pill(row.provider, "$accent", "$text") 

131 status_pill = _key_status_pill(row.key_status) 

132 pill_line = Content(" ").join([backend_pill, status_pill]) 

133 info = Content.styled(f"Cloud via {row.provider} API", "$text-muted") 

134 return Content("\n").join([name, pill_line, info]) 

135 

136 

137def _compat_pill(compat: ModelCompat) -> Content | None: 

138 """Return the compat chip Content for non-SUPPORTED rows, or None for SUPPORTED.""" 

139 from lilbee.cli.tui import messages as msg 

140 

141 if compat is ModelCompat.SUPPORTED: 

142 return None 

143 if compat is ModelCompat.UNSUPPORTED: 

144 return pill(msg.COMPAT_PILL_UNSUPPORTED, "$warning", "$text") 

145 return pill(msg.COMPAT_PILL_UNKNOWN, "$panel", "$text-muted") 

146 

147 

148def _key_status_pill(status: KeyStatus) -> Content: 

149 if status == KeyStatus.READY: 

150 return pill("ready", "$success", "$text") 

151 return pill("needs key", "$warning", "$text") 

152 

153 

154def _build_specs(params: str, quant: str, size: str) -> Content: 

155 """Build the specs line: params · quant · size.""" 

156 parts = [p for p in (params, quant, size) if p and p != "--"] 

157 if not parts: 

158 return Content("--") 

159 return Content(f" {MIDDLE_DOT} ".join(parts)) 

160 

161 

162def _build_local_status(row: LocalCatalogRow) -> Content | None: 

163 """Build the status pill for installed or download count.""" 

164 if row.installed: 

165 return pill("installed", "$success", "$text") 

166 if row.sort_downloads > 0: 

167 return Content.styled(f"{row.downloads}", "$text-muted") 

168 return None