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

1"""ViewTabs: view tab strip with mode and active-model indicator.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from typing import TYPE_CHECKING, ClassVar 

7 

8if TYPE_CHECKING: 

9 from lilbee.cli.tui.app import LilbeeApp 

10 

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 

18 

19from lilbee.cli.tui import messages as msg 

20from lilbee.cli.tui.pill import DOT_SEP, pill 

21from lilbee.core.config import cfg 

22 

23_CSS_FILE = Path(__file__).parent / "status_bar.tcss" 

24 

25_MODE_COLORS: dict[str, str] = { 

26 msg.MODE_NORMAL: "$primary", 

27 msg.MODE_INSERT: "$success", 

28} 

29 

30_DEFAULT_MODE_COLOR = "$error" 

31 

32# Settings keys that trigger a model-pill refresh. 

33_MODEL_PILL_KEYS = frozenset({"chat_model"}) 

34 

35 

36class ViewTab(Label, can_focus=True): 

37 """A focusable, clickable tab label inside ViewTabs. 

38 

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 """ 

44 

45 app: LilbeeApp # type: ignore[assignment] 

46 

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

48 Binding("enter", "activate", "Switch view", show=False), 

49 Binding("space", "activate", "Switch view", show=False), 

50 ] 

51 

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 

55 

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"))) 

66 

67 def on_click(self) -> None: 

68 self._switch() 

69 

70 def action_activate(self) -> None: 

71 self._switch() 

72 

73 def _switch(self) -> None: 

74 self.app.switch_view(self.view_name) 

75 

76 

77class ViewTabs(Widget): 

78 """View tab strip with mode and active-model indicator.""" 

79 

80 app: LilbeeApp # type: ignore[assignment] 

81 

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("") 

88 

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") 

104 

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) 

113 

114 def watch_active_view(self, value: str) -> None: 

115 self._refresh() 

116 

117 def watch_mode_text(self, value: str) -> None: 

118 self._refresh() 

119 

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() 

128 

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 

137 

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() 

144 

145 def _update_trailing(self) -> None: 

146 from lilbee.catalog import display_label_for_ref 

147 

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))