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

84 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-05-15 20:55 +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.cli.tui.pill import pill 

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

27 CatalogRow, 

28 CatalogRowKind, 

29 FrontierCatalogRow, 

30 KeyStatus, 

31 LocalCatalogRow, 

32) 

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

34 

35if TYPE_CHECKING: 

36 pass 

37 

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

39 

40_NAME_MAX_CHARS = 28 

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

42 

43_ELLIPSIS = "…" 

44 

45 

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

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

48 if len(name) <= _NAME_MAX_CHARS: 

49 return name 

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

51 

52 

53class ModelCard(containers.VerticalGroup): 

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

55 

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

57 

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

59 

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

61 self._row = row 

62 super().__init__() 

63 if row.kind == CatalogRowKind.FRONTIER: 

64 self.add_class("-frontier") 

65 

66 @property 

67 def row(self) -> CatalogRow: 

68 return self._row 

69 

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

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

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

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

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

75 try: 

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

77 except Exception: 

78 return 

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

80 

81 def compose(self) -> ComposeResult: 

82 yield widgets.Static( 

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

84 classes="card-body", 

85 markup=False, 

86 ) 

87 

88 

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

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

91 if row.kind == CatalogRowKind.FRONTIER: 

92 return _render_frontier(row) 

93 return _render_local(row, selected=selected) 

94 

95 

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

97 from lilbee.cli.tui import messages as msg 

98 

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

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

101 pills: list[Content] = [] 

102 if row.featured: 

103 pills.append(pill("pick", "$warning", "$text")) 

104 pills.append(pill(row.task, bg, "$text")) 

105 if row.backend: 

106 pills.append(pill(row.backend, "$accent", "$text")) 

107 pill_line = Content(" ").join(pills) 

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

109 status = _build_local_status(row) 

110 

111 parts: list[Content] = [name, pill_line, specs] 

112 if status is not None: 

113 parts.append(status) 

114 if selected: 

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

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

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

118 

119 

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

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

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

123 status_pill = _key_status_pill(row.key_status) 

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

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

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

127 

128 

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

130 if status == KeyStatus.READY: 

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

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

133 

134 

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

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

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

138 if not parts: 

139 return Content("--") 

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

141 

142 

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

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

145 if row.installed: 

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

147 if row.sort_downloads > 0: 

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

149 return None