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
« 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.
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.
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"""
15from __future__ import annotations
17from pathlib import Path
18from typing import TYPE_CHECKING, ClassVar
20from textual import containers, widgets
21from textual.app import ComposeResult
22from textual.content import Content
23from textual.reactive import reactive
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
36if TYPE_CHECKING:
37 pass
39_CSS_FILE = Path(__file__).parent / "model_card.tcss"
41_NAME_MAX_CHARS = 28
42"""Maximum displayed model-name length; longer names are ellipsis-truncated."""
44_ELLIPSIS = "…"
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
54class ModelCard(containers.VerticalGroup):
55 """A single model card displaying name, task pill, specs, and status."""
57 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
59 selected: reactive[bool] = reactive(False)
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")
67 @property
68 def row(self) -> CatalogRow:
69 return self._row
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))
82 def compose(self) -> ComposeResult:
83 yield widgets.Static(
84 _render(self._row, selected=self.selected),
85 classes="card-body",
86 markup=False,
87 )
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)
97def _render_local(row: LocalCatalogRow, *, selected: bool) -> Content:
98 from lilbee.cli.tui import messages as msg
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)
110 compat_chip = _compat_pill(row.compat)
111 secondary_line = compat_chip if compat_chip is not None else None
113 specs = _build_specs(row.params, row.quant, row.size)
114 status = _build_local_status(row)
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)
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])
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
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")
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")
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))
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