Coverage for src / lilbee / cli / tui / widgets / status_bar.py: 100%
58 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"""ViewTabs: view tab strip with mode and active-model indicator."""
3from __future__ import annotations
5from pathlib import Path
6from typing import ClassVar
8from textual.app import ComposeResult
9from textual.content import Content
10from textual.reactive import reactive
11from textual.widget import Widget
12from textual.widgets import Static
14from lilbee.cli.tui import messages as msg
15from lilbee.cli.tui.pill import DOT_SEP, pill
16from lilbee.config import cfg
18_CSS_FILE = Path(__file__).parent / "status_bar.tcss"
20_MODE_COLORS: dict[str, str] = {
21 msg.MODE_NORMAL: "$primary",
22 msg.MODE_INSERT: "$success",
23}
25_DEFAULT_MODE_COLOR = "$error"
27# Settings keys that trigger a model-pill refresh.
28_MODEL_PILL_KEYS = frozenset({"chat_model"})
31class ViewTabs(Widget):
32 """View tab strip with mode and active-model indicator."""
34 # NOTE: no ``dock: bottom`` here. ViewTabs is always mounted inside a
35 # ``BottomBars`` container that owns the dock; multiple dock-bottom
36 # siblings overlap at the same row in Textual (see BottomBars docstring).
37 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
38 active_view: reactive[str] = reactive(msg.DEFAULT_VIEW)
39 mode_text: reactive[str] = reactive("")
41 def compose(self) -> ComposeResult:
42 yield Static(id="view-tabs-content")
44 def on_mount(self) -> None:
45 self.active_view = getattr(self.app, "active_view", msg.DEFAULT_VIEW)
46 signal = getattr(self.app, "settings_changed_signal", None)
47 if signal is not None:
48 signal.subscribe(self, self._on_settings_changed)
49 # Defer the initial paint: update() during on_mount can no-op while
50 # the inner Static is still completing its mount cycle.
51 self.call_after_refresh(self._refresh)
53 def watch_active_view(self, value: str) -> None:
54 self._refresh()
56 def watch_mode_text(self, value: str) -> None:
57 self._refresh()
59 def _on_settings_changed(self, payload: tuple[str, object]) -> None:
60 """Refresh the model pill when the active chat model changes."""
61 key, _value = payload
62 if key in _MODEL_PILL_KEYS:
63 self._refresh()
65 def _refresh(self) -> None:
66 if not self.is_mounted:
67 return
68 parts: list[Content | str | tuple[str, str]] = []
70 tab_parts: list[Content | str | tuple[str, str]] = []
71 for name in msg.get_nav_views():
72 if name == self.active_view:
73 tab_parts.append(pill(f" {name} ", "$primary", "$text"))
74 else:
75 tab_parts.append((f" {name} ", "dim"))
76 joined: list[Content | str | tuple[str, str]] = []
77 for i, part in enumerate(tab_parts):
78 if i > 0:
79 joined.append((DOT_SEP, "$text-muted"))
80 joined.append(part)
81 parts.extend(joined)
83 # ModelBar already shows the active chat model on the chat screen,
84 # so the pill would just duplicate it there. Show it everywhere else.
85 if cfg.chat_model and self.active_view != msg.DEFAULT_VIEW:
86 parts.append(" ")
87 parts.append(pill(f" {cfg.chat_model} ", "$accent", "$text"))
89 if self.mode_text:
90 color = _MODE_COLORS.get(self.mode_text, _DEFAULT_MODE_COLOR)
91 parts.append(" ")
92 parts.append(pill(f" {self.mode_text} ", color, "$text"))
94 self.query_one("#view-tabs-content", Static).update(Content.assemble(*parts))