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

96 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Rendering helpers shared by the catalog card, grid, and list widgets. 

2 

3The local-card body (name, pill lines, specs, status, hint) is built here 

4once; ``model_grid`` renders the slots as a fixed-height card and 

5``model_card`` as an auto-height card. ``model_list`` keeps its own two-line 

6text presentation but draws its labels, colors, and spec strip from the same 

7maps so the three surfaces cannot drift apart. 

8""" 

9 

10from __future__ import annotations 

11 

12from textual.content import Content 

13 

14from lilbee.catalog.types import ModelCompat 

15from lilbee.cli.tui.pill import pill 

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

17 NATIVE_BACKEND, 

18 KeyStatus, 

19 LocalCatalogRow, 

20 SizeVariant, 

21) 

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

23from lilbee.runtime.hardware import FitChip, FitLevel 

24 

25_NAME_MAX_CHARS = 28 

26_ELLIPSIS = "…" 

27 

28# Pill background per fit level, shared by the grid card and the detail drawer. 

29_FIT_LEVEL_BACKGROUND: dict[FitLevel, str] = { 

30 FitLevel.FITS: "$success", 

31 FitLevel.TIGHT: "$warning", 

32 FitLevel.WONT_RUN: "$error", 

33} 

34 

35_FIT_LEVEL_LABEL_COMPACT: dict[FitLevel, str] = { 

36 FitLevel.FITS: "fits", 

37 FitLevel.TIGHT: "tight", 

38 FitLevel.WONT_RUN: "won't run", 

39} 

40 

41 

42def _render_fit_pill(fit: FitChip) -> Content: 

43 """Verbose fit chip with signed headroom GB, used by the detail drawer. 

44 

45 Negative headroom means the model overflows available memory; the won't-run 

46 label reports the shortfall as a positive amount. 

47 """ 

48 if fit.level is FitLevel.FITS: 

49 text = f"fits +{fit.headroom_gb:.1f} GB" 

50 elif fit.level is FitLevel.TIGHT: 

51 text = f"tight +{max(0.0, fit.headroom_gb):.1f} GB" 

52 else: 

53 text = f"won't run, short by {abs(fit.headroom_gb):.1f} GB" 

54 return pill(text, _FIT_LEVEL_BACKGROUND[fit.level], "$text") 

55 

56 

57def _fit_pill_compact(fit: FitChip) -> Content: 

58 """Card-side compact fit chip: just ``fits`` / ``tight`` / ``won't run``.""" 

59 return pill(_FIT_LEVEL_LABEL_COMPACT[fit.level], _FIT_LEVEL_BACKGROUND[fit.level], "$text") 

60 

61 

62def _compat_label(compat: ModelCompat) -> str | None: 

63 """Compat text for non-SUPPORTED rows, or None for SUPPORTED.""" 

64 from lilbee.cli.tui import messages as msg 

65 

66 if compat is ModelCompat.SUPPORTED: 

67 return None 

68 if compat is ModelCompat.UNSUPPORTED: 

69 return msg.COMPAT_PILL_UNSUPPORTED 

70 return msg.COMPAT_PILL_UNKNOWN 

71 

72 

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

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

75 label = _compat_label(compat) 

76 if label is None: 

77 return None 

78 if compat is ModelCompat.UNSUPPORTED: 

79 return pill(label, "$warning", "$text") 

80 return pill(label, "$panel", "$text-muted") 

81 

82 

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

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

85 if len(name) <= _NAME_MAX_CHARS: 

86 return name 

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

88 

89 

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

91 if status == KeyStatus.READY: 

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

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

94 

95 

96def _spec_strip(params: str, quant: str, size: str) -> str: 

97 """Dot-joined spec fragments, skipping empty / placeholder values.""" 

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

99 return f" {MIDDLE_DOT} ".join(parts) 

100 

101 

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

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

104 text = _spec_strip(params, quant, size) 

105 return Content(text) if text else Content("--") 

106 

107 

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

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

110 if row.installed: 

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

112 if row.sort_downloads > 0: 

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

114 return None 

115 

116 

117def _local_card_lines( 

118 row: LocalCatalogRow, *, selected: bool, body_width: int | None = None 

119) -> list[Content | None]: 

120 """Semantic card slots: name, primary pills, secondary pills, specs, status, hint. 

121 

122 A slot is None when the row has nothing for it; the grid paints None as a 

123 blank line (fixed-height card) while the wizard card omits the slot 

124 (auto-height). *body_width* selects the grid presentation: the compact fit 

125 pill and the inline size-variant strip, both sized to the card column. 

126 """ 

127 from lilbee.cli.tui import messages as msg 

128 

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

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

131 # Two pill rows so wide secondary chips (fit + 'unsupported') don't push 

132 # the card border out of alignment on narrow grid columns. 

133 primary_pills: list[Content] = [] 

134 if row.featured: 

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

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

137 # Drop the 'native' backend pill on cards to free horizontal space; the 

138 # backend is implied for local models. Remote backends (ollama, etc.) 

139 # still surface their pill since that's a meaningful distinction. 

140 if row.backend and row.backend != NATIVE_BACKEND: 

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

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

143 

144 secondary_pills: list[Content] = [] 

145 if body_width is not None and row.fit is not None: 

146 # Grid card uses the compact 'fits' / 'tight' / "won't run" label only; 

147 # the headroom GB lives in the detail drawer where the wider pane 

148 # can render it without competing for card width. 

149 secondary_pills.append(_fit_pill_compact(row.fit)) 

150 compat_chip = _compat_pill(row.compat) 

151 if compat_chip is not None: 

152 secondary_pills.append(compat_chip) 

153 secondary_line = Content(" ").join(secondary_pills) if secondary_pills else None 

154 

155 # Family card with multiple quants: replace the simple specs line 

156 # with an inline chip strip so the user sees every available size 

157 # at a glance without expanding into the drawer. 

158 if body_width is not None and len(row.size_variants) > 1: 

159 specs = _build_size_variant_strip(row.size_variants, body_width) 

160 else: 

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

162 status = _build_local_status(row) 

163 

164 hint: Content | None = None 

165 if selected: 

166 hint_text = msg.INSTALLED_CARD_HINT if row.installed else msg.SETUP_CARD_HINT 

167 hint = Content.styled(hint_text, "$text-muted 40% italic") 

168 return [name, primary_line, secondary_line, specs, status, hint] 

169 

170 

171def _build_size_variant_strip(variants: list[SizeVariant], width: int) -> Content: 

172 """Inline chip strip showing the quants of a family-aggregated card. 

173 

174 Renders compact 'Q4 · Q5 · F16' style chips so the eye reads the 

175 available sizes at a glance. A family that varies by parameter count 

176 rather than quant would render identical chips, so colliding quants 

177 fall back to the full per-variant label. Per-variant fit colors aren't 

178 applied here; the drawer (right pane) carries the full fit-per-size 

179 detail when a card is highlighted. 

180 

181 Duplicate labels collapse, then chips that do not fit *width* are dropped 

182 and counted as ``+N``: a family can hold more variants than a card column 

183 has room for, and the long fallback labels reach that limit quickly. 

184 """ 

185 quants = [v.quant if v.quant != "--" else v.label for v in variants] 

186 labels = quants if len(set(quants)) == len(quants) else [v.label for v in variants] 

187 # Two repos in one family can share a parameter count and quant, which 

188 # renders the same chip twice and reads as a bug. 

189 labels = list(dict.fromkeys(labels)) 

190 sep = f" {MIDDLE_DOT} " 

191 shown = len(labels) 

192 while shown > 1: 

193 text = sep.join(labels[:shown]) 

194 hidden = len(labels) - shown 

195 if hidden: 

196 text = f"{text} +{hidden}" 

197 if len(text) <= width: 

198 return Content.styled(text, "$text-muted") 

199 shown -= 1 

200 return Content.styled(labels[0][:width] if labels else "", "$text-muted")