Coverage for src / lilbee / cli / tui / screens / status.py: 100%

122 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-04-29 19:16 +0000

1"""Status screen — knowledge base info with collapsible sections.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from pathlib import Path 

7from typing import ClassVar 

8 

9from textual.app import ComposeResult 

10from textual.binding import Binding, BindingType 

11from textual.containers import VerticalScroll 

12from textual.content import Content 

13from textual.screen import Screen 

14from textual.widgets import Collapsible, DataTable, Static 

15 

16from lilbee.cli.tui.pill import pill 

17from lilbee.config import cfg 

18from lilbee.model_info import ModelArchInfo, get_model_architecture 

19from lilbee.services import get_services 

20from lilbee.store import SourceRecord 

21 

22log = logging.getLogger(__name__) 

23 

24 

25def _model_pill(name: str) -> Content: 

26 """Return a green 'loaded' pill if name is set, red 'not set' otherwise.""" 

27 if name: 

28 return pill("loaded", "$success", "$text") 

29 return pill("not set", "$error", "$text") 

30 

31 

32# Label-column width used across the status sections so keys line up 

33# when scanned vertically. Values past this column render bold. 

34_KV_LABEL_WIDTH = 14 

35 

36 

37def _kv_line(label: str, value: str | Content, status: Content | None = None) -> Content: 

38 """Assemble one key/value row: dim padded label, bold value, optional pill.""" 

39 padded = label.ljust(_KV_LABEL_WIDTH) 

40 parts: list[Content] = [Content.styled(padded, "$text-muted")] 

41 if isinstance(value, Content): 

42 parts.append(value) 

43 else: 

44 parts.append(Content.styled(value, "bold")) 

45 if status is not None: 

46 parts.append(Content(" ")) 

47 parts.append(status) 

48 return Content.assemble(*parts) 

49 

50 

51def _collapse_home(path: Path | str) -> str: 

52 """Replace the user's home prefix with '~' so long paths stay scannable.""" 

53 text = str(path) 

54 home = str(Path.home()) 

55 return text.replace(home, "~", 1) if text.startswith(home) else text 

56 

57 

58def _ocr_label() -> str: 

59 """Return a human-readable OCR status string.""" 

60 if cfg.enable_ocr is True: 

61 return "enabled" 

62 if cfg.enable_ocr is False: 

63 return "disabled" 

64 return "auto" 

65 

66 

67def _ocr_pill() -> Content: 

68 """Return a pill reflecting OCR status.""" 

69 if cfg.enable_ocr is True: 

70 return pill("on", "$success", "$text") 

71 if cfg.enable_ocr is False: 

72 return pill("off", "$warning", "$text") 

73 return pill("auto", "$accent", "$text") 

74 

75 

76def _data_dir_pill() -> Content: 

77 """Return a pill based on whether the data directory exists.""" 

78 if Path(cfg.data_dir).exists(): 

79 return pill("exists", "$success", "$text") 

80 return pill("missing", "$error", "$text") 

81 

82 

83def _build_config_content() -> Content: 

84 """Build the configuration section content.""" 

85 lines = [ 

86 _kv_line("Data dir", _collapse_home(cfg.data_dir), _data_dir_pill()), 

87 _kv_line("Chat model", cfg.chat_model or "(disabled)", _model_pill(cfg.chat_model)), 

88 _kv_line( 

89 "Embed model", cfg.embedding_model or "(disabled)", _model_pill(cfg.embedding_model) 

90 ), 

91 _kv_line("Vision model", cfg.vision_model or "(disabled)", _model_pill(cfg.vision_model)), 

92 _kv_line("Reranker", cfg.reranker_model or "(disabled)", _model_pill(cfg.reranker_model)), 

93 _kv_line("OCR", _ocr_label(), _ocr_pill()), 

94 ] 

95 return Content("\n").join(lines) 

96 

97 

98def _build_storage_content(doc_count: int) -> Content: 

99 """Build the storage section content.""" 

100 lines = [ 

101 _kv_line("Documents", str(doc_count)), 

102 _kv_line("Data dir", _collapse_home(cfg.data_dir)), 

103 _kv_line("Models dir", _collapse_home(cfg.models_dir)), 

104 ] 

105 return Content("\n").join(lines) 

106 

107 

108def _build_arch_content(info: ModelArchInfo) -> Content: 

109 """Build the model architecture section from GGUF metadata.""" 

110 lines = [ 

111 _kv_line("Chat arch", info.chat_arch), 

112 _kv_line("Embed arch", info.embed_arch), 

113 _kv_line("Handler", pill(info.active_handler, "$accent", "$text")), 

114 ] 

115 if info.vision_projector: 

116 lines.append(_kv_line("Vision proj", info.vision_projector)) 

117 return Content("\n").join(lines) 

118 

119 

120class StatusScreen(Screen[None]): 

121 """Knowledge base status view with collapsible sections.""" 

122 

123 CSS_PATH = "status.tcss" 

124 AUTO_FOCUS = "CollapsibleTitle" 

125 HELP = ( 

126 "Knowledge base status.\n\n" 

127 "View configuration, documents, model architecture, and storage info." 

128 ) 

129 

130 BINDINGS: ClassVar[list[BindingType]] = [ 

131 Binding("q", "go_back", "Back", show=True), 

132 Binding("escape", "go_back", "Back", show=False), 

133 Binding("tab", "app.focus_next", "Next section", show=True), 

134 Binding("shift+tab", "app.focus_previous", "Prev section", show=True), 

135 Binding("j", "cursor_down", "Nav", show=False), 

136 Binding("k", "cursor_up", "Nav", show=False), 

137 Binding("g", "jump_top", "Top", show=False), 

138 Binding("G", "jump_bottom", "End", show=False), 

139 ] 

140 

141 def compose(self) -> ComposeResult: 

142 from textual.widgets import Footer 

143 

144 from lilbee.cli.tui.widgets.bottom_bars import BottomBars 

145 from lilbee.cli.tui.widgets.status_bar import ViewTabs 

146 from lilbee.cli.tui.widgets.task_bar import TaskBar 

147 from lilbee.cli.tui.widgets.top_bars import TopBars 

148 

149 with TopBars(): 

150 yield ViewTabs() 

151 yield VerticalScroll( 

152 Collapsible(Static(id="config-info"), title="Configuration", id="config-section"), 

153 Collapsible(DataTable(id="docs-table"), title="Documents", id="docs-section"), 

154 Collapsible(Static(id="arch-info"), title="Model Architecture", id="arch-section"), 

155 Collapsible(Static(id="storage-info"), title="Storage", id="storage-section"), 

156 id="status-scroll", 

157 ) 

158 with BottomBars(): 

159 yield TaskBar() 

160 yield Footer() 

161 

162 def on_mount(self) -> None: 

163 self._load_config() 

164 sources = self._fetch_sources() 

165 self._load_documents(sources) 

166 self._load_arch() 

167 self._load_storage(len(sources)) 

168 

169 def _fetch_sources(self) -> list[SourceRecord]: 

170 """Fetch sources once from the store.""" 

171 try: 

172 return get_services().store.get_sources() 

173 except Exception: 

174 log.debug("Failed to read store for status screen", exc_info=True) 

175 return [] 

176 

177 def _load_config(self) -> None: 

178 """Populate the configuration section.""" 

179 self.query_one("#config-info", Static).update(_build_config_content()) 

180 

181 def _load_documents(self, sources: list[SourceRecord]) -> None: 

182 """Populate the documents table.""" 

183 table = self.query_one("#docs-table", DataTable) 

184 table.add_columns("Document", "Chunks") 

185 table.cursor_type = "row" 

186 self._fill_doc_rows(table, sources) 

187 

188 def _fill_doc_rows(self, table: DataTable, sources: list[SourceRecord]) -> None: 

189 """Fill the documents table with source data.""" 

190 if not sources: 

191 table.add_row("(unable to read store)", "") 

192 return 

193 for src in sources: 

194 table.add_row(src.get("filename", "?"), str(src.get("chunk_count", 0))) 

195 

196 def _load_arch(self) -> None: 

197 """Populate the model architecture section.""" 

198 info = get_model_architecture() 

199 self.query_one("#arch-info", Static).update(_build_arch_content(info)) 

200 

201 def _load_storage(self, doc_count: int) -> None: 

202 """Populate the storage section.""" 

203 self.query_one("#storage-info", Static).update(_build_storage_content(doc_count)) 

204 

205 def action_go_back(self) -> None: 

206 from lilbee.cli.tui.app import LilbeeApp 

207 

208 if isinstance(self.app, LilbeeApp): # test apps aren't LilbeeApp 

209 self.app.switch_view("Chat") 

210 else: 

211 self.app.pop_screen() 

212 

213 def action_cursor_down(self) -> None: 

214 self.query_one("#status-scroll", VerticalScroll).scroll_down() 

215 

216 def action_cursor_up(self) -> None: 

217 self.query_one("#status-scroll", VerticalScroll).scroll_up() 

218 

219 def action_jump_top(self) -> None: 

220 self.query_one("#status-scroll", VerticalScroll).scroll_home() 

221 

222 def action_jump_bottom(self) -> None: 

223 self.query_one("#status-scroll", VerticalScroll).scroll_end()