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

80 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-05-15 20:55 +0000

1"""Right-pane detail drawer for the catalog screen. 

2 

3Focus-following: the catalog screen wires ``ModelGrid.Highlighted`` to 

4``CatalogDetailDrawer.update_for_row``. The drawer renders the focused 

5row's name, fit chip, every size variant with its per-variant fit, the 

6license, and a description preview. Visibility toggles via the 

7``-collapsed`` CSS class so width changes are a single layout pass. 

8""" 

9 

10from __future__ import annotations 

11 

12from pathlib import Path 

13from typing import ClassVar 

14 

15from textual.app import ComposeResult 

16from textual.containers import Vertical 

17from textual.content import Content 

18from textual.widgets import Static 

19 

20from lilbee.cli.tui.pill import pill 

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

22 CatalogRow, 

23 CatalogRowKind, 

24 FrontierCatalogRow, 

25 LocalCatalogRow, 

26 SizeVariant, 

27) 

28from lilbee.runtime.hardware import FitChip, FitLevel 

29 

30_CSS_FILE = Path(__file__).parent / "catalog_detail.tcss" 

31 

32_FIT_LEVEL_BACKGROUND: dict[FitLevel, str] = { 

33 FitLevel.FITS: "$success", 

34 FitLevel.TIGHT: "$warning", 

35 FitLevel.WONT_RUN: "$error", 

36} 

37 

38_EMPTY_HINT = "Highlight a model to see details." 

39 

40 

41class CatalogDetailDrawer(Vertical): 

42 """Right-side panel that mirrors the highlighted catalog row. 

43 

44 Designed as a passive renderer: the screen calls update_for_row on 

45 every ``ModelGrid.Highlighted`` event. There is no event subscription 

46 inside the drawer so it stays test-friendly and decoupled from the 

47 grid widget's message routing. 

48 """ 

49 

50 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") if _CSS_FILE.exists() else "" 

51 

52 def compose(self) -> ComposeResult: 

53 yield Static(_EMPTY_HINT, id="catalog-detail-name", classes="catalog-detail-name") 

54 yield Static("", id="catalog-detail-fit", classes="catalog-detail-fit") 

55 yield Static("", id="catalog-detail-sizes", classes="catalog-detail-sizes") 

56 yield Static("", id="catalog-detail-license", classes="catalog-detail-license") 

57 yield Static("", id="catalog-detail-description", classes="catalog-detail-description") 

58 

59 def update_for_row(self, row: CatalogRow | None) -> None: 

60 """Render the drawer for *row*; clearing back to the empty hint when None.""" 

61 if row is None: 

62 self._clear() 

63 return 

64 if row.kind == CatalogRowKind.FRONTIER: 

65 self._render_frontier(row) 

66 return 

67 self._render_local(row) 

68 

69 def _clear(self) -> None: 

70 self.query_one("#catalog-detail-name", Static).update(_EMPTY_HINT) 

71 for selector in ( 

72 "#catalog-detail-fit", 

73 "#catalog-detail-sizes", 

74 "#catalog-detail-license", 

75 "#catalog-detail-description", 

76 ): 

77 self.query_one(selector, Static).update("") 

78 

79 def _render_local(self, row: LocalCatalogRow) -> None: 

80 self.query_one("#catalog-detail-name", Static).update(row.name) 

81 fit_widget = self.query_one("#catalog-detail-fit", Static) 

82 if row.fit is not None: 

83 fit_widget.update(_render_fit_pill(row.fit)) 

84 else: 

85 fit_widget.update("") 

86 sizes = self.query_one("#catalog-detail-sizes", Static) 

87 sizes.update(_render_sizes_block(row.size_variants)) 

88 license_widget = self.query_one("#catalog-detail-license", Static) 

89 license_widget.update(_license_text(row)) 

90 description = self.query_one("#catalog-detail-description", Static) 

91 description.update(_description_text(row)) 

92 

93 def _render_frontier(self, row: FrontierCatalogRow) -> None: 

94 self.query_one("#catalog-detail-name", Static).update(row.name) 

95 self.query_one("#catalog-detail-fit", Static).update("") 

96 self.query_one("#catalog-detail-sizes", Static).update("") 

97 self.query_one("#catalog-detail-license", Static).update(f"Provider {row.provider}") 

98 self.query_one("#catalog-detail-description", Static).update( 

99 f"Cloud model accessed via the {row.provider} API." 

100 ) 

101 

102 

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

104 if fit.level is FitLevel.FITS: 

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

106 elif fit.level is FitLevel.TIGHT: 

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

108 else: 

109 text = f"won't {fit.headroom_gb:.1f} GB" 

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

111 

112 

113def _render_sizes_block(variants: list[SizeVariant]) -> str: 

114 """Multi-line plain-text listing of every variant the row carries.""" 

115 if not variants: 

116 return "" 

117 lines = ["Sizes"] 

118 for v in variants: 

119 suffix = "" 

120 if v.fit is not None: 

121 if v.fit.level is FitLevel.FITS: 

122 suffix = " ✓" 

123 elif v.fit.level is FitLevel.TIGHT: 

124 suffix = " ⚠" 

125 else: 

126 suffix = " ✗" 

127 lines.append(f" {v.label} {v.size_gb:.1f} GB{suffix}") 

128 return "\n".join(lines) 

129 

130 

131def _license_text(_row: LocalCatalogRow) -> str: 

132 """License placeholder; CatalogModel/ModelFamily don't carry one yet. 

133 

134 Kept as a stub so callers have a stable seam: future plumbing for 

135 per-row license strings (HF metadata fetch, family-level config) can 

136 fill this in without touching the drawer's render path. 

137 """ 

138 return "" 

139 

140 

141def _description_text(row: LocalCatalogRow) -> str: 

142 if row.catalog_model is not None and row.catalog_model.description: 

143 return row.catalog_model.description 

144 if row.family is not None and row.family.description: 

145 return row.family.description 

146 return ""