Coverage for src / lilbee / cli / tui / widgets / status_bar.py: 100%
88 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
1"""ViewTabs: view tab strip with mode and active-model indicator."""
3from __future__ import annotations
5from pathlib import Path
6from typing import TYPE_CHECKING, ClassVar
8if TYPE_CHECKING:
9 from lilbee.cli.tui.app import LilbeeApp
11from textual.app import ComposeResult
12from textual.binding import Binding, BindingType
13from textual.containers import Horizontal
14from textual.content import Content
15from textual.reactive import reactive
16from textual.widget import Widget
17from textual.widgets import Label, Static
19from lilbee.cli.tui import messages as msg
20from lilbee.cli.tui.pill import DOT_SEP, pill
21from lilbee.core.config import cfg
23_CSS_FILE = Path(__file__).parent / "status_bar.tcss"
25_MODE_COLORS: dict[str, str] = {
26 msg.MODE_NORMAL: "$primary",
27 msg.MODE_INSERT: "$success",
28}
30_DEFAULT_MODE_COLOR = "$error"
32# Settings keys that trigger a model-pill refresh.
33_MODEL_PILL_KEYS = frozenset({"chat_model"})
36class ViewTab(Label, can_focus=True):
37 """A focusable, clickable tab label inside ViewTabs.
39 Owns its `view_name`. Click and Enter / Space when focused both
40 fire the app's view switcher. Active and focus styling are
41 handled in status_bar.tcss via the ``-active`` and ``:focus``
42 pseudo-classes.
43 """
45 app: LilbeeApp # type: ignore[assignment]
47 BINDINGS: ClassVar[list[BindingType]] = [
48 Binding("enter", "activate", "Switch view", show=False),
49 Binding("space", "activate", "Switch view", show=False),
50 ]
52 def __init__(self, view_name: str) -> None:
53 super().__init__(id=f"view-tab-{view_name.lower()}", classes="view-tab")
54 self.view_name = view_name
56 def set_active(self, active: bool) -> None:
57 self.set_class(active, "-active")
58 if active:
59 # Bold $primary on a $surface background pill, mirroring the
60 # Settings sub-tab aesthetic (#settings-tabs Tab.-active in
61 # screens/settings.tcss). Background comes from the .-active
62 # CSS class so the pill fills the padded label region.
63 self.update(Content.styled(f" {self.view_name} ", "bold $primary"))
64 else:
65 self.update(Content.assemble((f" {self.view_name} ", "dim")))
67 def on_click(self) -> None:
68 self._switch()
70 def action_activate(self) -> None:
71 self._switch()
73 def _switch(self) -> None:
74 self.app.switch_view(self.view_name)
77class ViewTabs(Widget):
78 """View tab strip with mode and active-model indicator."""
80 app: LilbeeApp # type: ignore[assignment]
82 # NOTE: no ``dock: bottom`` here. ViewTabs is always mounted inside a
83 # ``BottomBars`` container that owns the dock; multiple dock-bottom
84 # siblings overlap at the same row in Textual (see BottomBars docstring).
85 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
86 active_view: reactive[str] = reactive(msg.DEFAULT_VIEW)
87 mode_text: reactive[str] = reactive("")
89 def compose(self) -> ComposeResult:
90 # Compose every nav view including Wiki; visibility is toggled at
91 # runtime via _apply_wiki_visibility so the user can flip the wiki
92 # setting without restarting.
93 all_views = [*msg._BASE_NAV_VIEWS, "Wiki"]
94 with Horizontal(id="view-tabs-row"):
95 for i, name in enumerate(all_views):
96 if i > 0:
97 yield Static(
98 DOT_SEP,
99 classes="view-tab-sep",
100 id=f"view-tab-sep-{name.lower()}",
101 )
102 yield ViewTab(name)
103 yield Static(id="view-tabs-trailing")
105 def on_mount(self) -> None:
106 self.active_view = self.app.active_view
107 self.app.settings_changed_signal.subscribe(self, self._on_settings_changed)
108 # Wiki visibility AND the initial paint both deferred: query() during
109 # on_mount can no-op while ViewTab children are still completing their
110 # mount cycle, leaving the Wiki tab visible even when cfg.wiki=False.
111 self.call_after_refresh(self._apply_wiki_visibility)
112 self.call_after_refresh(self._refresh)
114 def watch_active_view(self, value: str) -> None:
115 self._refresh()
117 def watch_mode_text(self, value: str) -> None:
118 self._refresh()
120 def _on_settings_changed(self, payload: tuple[str, object]) -> None:
121 """Refresh the model pill, and toggle Wiki tab visibility on wiki."""
122 key, _value = payload
123 if key == "wiki":
124 self._apply_wiki_visibility()
125 return
126 if key in _MODEL_PILL_KEYS:
127 self._refresh()
129 def _apply_wiki_visibility(self) -> None:
130 """Show or hide the Wiki tab and its preceding separator based on cfg.wiki."""
131 if not self.is_mounted:
132 return
133 visible = bool(cfg.wiki)
134 for selector in ("#view-tab-wiki", "#view-tab-sep-wiki"):
135 for widget in self.query(selector):
136 widget.display = visible
138 def _refresh(self) -> None:
139 if not self.is_mounted:
140 return
141 for tab in self.query(ViewTab):
142 tab.set_active(tab.view_name == self.active_view)
143 self._update_trailing()
145 def _update_trailing(self) -> None:
146 from lilbee.catalog import display_label_for_ref
148 parts: list[Content | str | tuple[str, str]] = []
149 # ModelBar already shows the active chat model on the chat screen,
150 # so the pill would just duplicate it there. Show it everywhere else.
151 if cfg.chat_model and self.active_view != msg.DEFAULT_VIEW:
152 label = display_label_for_ref(cfg.chat_model) or cfg.chat_model
153 parts.append(" ")
154 parts.append(pill(label, "$accent", "$text"))
155 if self.mode_text:
156 color = _MODE_COLORS.get(self.mode_text, _DEFAULT_MODE_COLOR)
157 parts.append(" ")
158 parts.append(pill(self.mode_text, color, "$text"))
159 self.query_one("#view-tabs-trailing", Static).update(Content.assemble(*parts))