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

95 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +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 import messages as msg 

16from lilbee.cli.tui.pill import pill 

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

18 NATIVE_BACKEND, 

19 KeyStatus, 

20 LocalCatalogRow, 

21 SizeVariant, 

22) 

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

24from lilbee.runtime.hardware import FitChip, FitLevel 

25 

26_NAME_MAX_CHARS = 28 

27_ELLIPSIS = "…" 

28 

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

30_FIT_LEVEL_BACKGROUND: dict[FitLevel, str] = { 

31 FitLevel.FITS: "$success", 

32 FitLevel.TIGHT: "$warning", 

33 FitLevel.WONT_RUN: "$error", 

34} 

35 

36# Label and color per hosted-provider key status, shared by the cards and the list. 

37KEY_STATUS_LABEL: dict[KeyStatus, str] = { 

38 KeyStatus.READY: msg.KEY_STATUS_READY, 

39 KeyStatus.MISSING_KEY: msg.KEY_STATUS_NEEDS_KEY, 

40 KeyStatus.INVALID_KEY: msg.KEY_STATUS_KEY_REJECTED, 

41} 

42 

43KEY_STATUS_COLOR: dict[KeyStatus, str] = { 

44 KeyStatus.READY: "$success", 

45 KeyStatus.MISSING_KEY: "$warning", 

46 KeyStatus.INVALID_KEY: "$error", 

47} 

48 

49_FIT_LEVEL_LABEL_COMPACT: dict[FitLevel, str] = { 

50 FitLevel.FITS: "fits", 

51 FitLevel.TIGHT: "tight", 

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

53} 

54 

55 

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

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

58 

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

60 label reports the shortfall as a positive amount. 

61 """ 

62 if fit.level is FitLevel.FITS: 

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

64 elif fit.level is FitLevel.TIGHT: 

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

66 else: 

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

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

69 

70 

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

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

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

74 

75 

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

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

78 if compat is ModelCompat.SUPPORTED: 

79 return None 

80 if compat is ModelCompat.UNSUPPORTED: 

81 return msg.COMPAT_PILL_UNSUPPORTED 

82 return msg.COMPAT_PILL_UNKNOWN 

83 

84 

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

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

87 label = _compat_label(compat) 

88 if label is None: 

89 return None 

90 if compat is ModelCompat.UNSUPPORTED: 

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

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

93 

94 

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

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

97 if len(name) <= _NAME_MAX_CHARS: 

98 return name 

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

100 

101 

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

103 return pill(KEY_STATUS_LABEL[status], KEY_STATUS_COLOR[status], "$text") 

104 

105 

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

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

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

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

110 

111 

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

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

114 text = _spec_strip(params, quant, size) 

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

116 

117 

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

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

120 if row.installed: 

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

122 if row.sort_downloads > 0: 

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

124 return None 

125 

126 

127def _local_card_lines( 

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

129) -> list[Content | None]: 

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

131 

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

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

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

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

136 """ 

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

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

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

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

141 primary_pills: list[Content] = [] 

142 if row.featured: 

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

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

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

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

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

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

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

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

151 

152 secondary_pills: list[Content] = [] 

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

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

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

156 # can render it without competing for card width. 

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

158 compat_chip = _compat_pill(row.compat) 

159 if compat_chip is not None: 

160 secondary_pills.append(compat_chip) 

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

162 

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

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

165 # at a glance without expanding into the drawer. 

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

167 specs = _build_size_variant_strip(row.size_variants, body_width) 

168 else: 

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

170 status = _build_local_status(row) 

171 

172 hint: Content | None = None 

173 if selected: 

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

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

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

177 

178 

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

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

181 

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

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

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

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

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

187 detail when a card is highlighted. 

188 

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

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

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

192 """ 

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

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

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

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

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

198 sep = f" {MIDDLE_DOT} " 

199 shown = len(labels) 

200 while shown > 1: 

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

202 hidden = len(labels) - shown 

203 if hidden: 

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

205 if len(text) <= width: 

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

207 shown -= 1 

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