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

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

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from typing import ClassVar 

7 

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 

13 

14from lilbee.cli.tui import messages as msg 

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

16from lilbee.config import cfg 

17 

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

19 

20_MODE_COLORS: dict[str, str] = { 

21 msg.MODE_NORMAL: "$primary", 

22 msg.MODE_INSERT: "$success", 

23} 

24 

25_DEFAULT_MODE_COLOR = "$error" 

26 

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

28_MODEL_PILL_KEYS = frozenset({"chat_model"}) 

29 

30 

31class ViewTabs(Widget): 

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

33 

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

40 

41 def compose(self) -> ComposeResult: 

42 yield Static(id="view-tabs-content") 

43 

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) 

52 

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

54 self._refresh() 

55 

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

57 self._refresh() 

58 

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

64 

65 def _refresh(self) -> None: 

66 if not self.is_mounted: 

67 return 

68 parts: list[Content | str | tuple[str, str]] = [] 

69 

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) 

82 

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

88 

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

93 

94 self.query_one("#view-tabs-content", Static).update(Content.assemble(*parts))