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

45 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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 LocalCatalogRow, 

31) 

32from lilbee.cli.tui.widgets.catalog_card_shared import ( 

33 _key_status_pill, 

34 _local_card_lines, 

35 _truncate_name, 

36) 

37 

38if TYPE_CHECKING: 

39 pass 

40 

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

42 

43 

44class ModelCard(containers.VerticalGroup): 

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

46 

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

48 

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

50 

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

52 self._row = row 

53 super().__init__() 

54 if row.kind == CatalogRowKind.FRONTIER: 

55 self.add_class("-frontier") 

56 

57 @property 

58 def row(self) -> CatalogRow: 

59 return self._row 

60 

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

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

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

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

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

66 try: 

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

68 except Exception: 

69 return 

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

71 

72 def compose(self) -> ComposeResult: 

73 yield widgets.Static( 

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

75 classes="card-body", 

76 markup=False, 

77 ) 

78 

79 

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

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

82 if row.kind == CatalogRowKind.FRONTIER: 

83 return _render_frontier(row) 

84 return _render_local(row, selected=selected) 

85 

86 

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

88 """Auto-height presentation of the shared card slots: absent slots are omitted.""" 

89 slots = _local_card_lines(row, selected=selected) 

90 return Content("\n").join([line for line in slots if line is not None]) 

91 

92 

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

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

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

96 status_pill = _key_status_pill(row.key_status) 

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

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

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

100 

101 

102# _local_card_lines / _key_status_pill / _truncate_name and the compat chip 

103# live in catalog_card_shared.