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
« 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."""
3from __future__ import annotations
5import logging
6from pathlib import Path
7from typing import ClassVar
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
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
22log = logging.getLogger(__name__)
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")
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
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)
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
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"
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")
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")
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)
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)
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)
120class StatusScreen(Screen[None]):
121 """Knowledge base status view with collapsible sections."""
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 )
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 ]
141 def compose(self) -> ComposeResult:
142 from textual.widgets import Footer
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
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()
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))
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 []
177 def _load_config(self) -> None:
178 """Populate the configuration section."""
179 self.query_one("#config-info", Static).update(_build_config_content())
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)
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)))
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))
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))
205 def action_go_back(self) -> None:
206 from lilbee.cli.tui.app import LilbeeApp
208 if isinstance(self.app, LilbeeApp): # test apps aren't LilbeeApp
209 self.app.switch_view("Chat")
210 else:
211 self.app.pop_screen()
213 def action_cursor_down(self) -> None:
214 self.query_one("#status-scroll", VerticalScroll).scroll_down()
216 def action_cursor_up(self) -> None:
217 self.query_one("#status-scroll", VerticalScroll).scroll_up()
219 def action_jump_top(self) -> None:
220 self.query_one("#status-scroll", VerticalScroll).scroll_home()
222 def action_jump_bottom(self) -> None:
223 self.query_one("#status-scroll", VerticalScroll).scroll_end()