Coverage for src/lilbee/cli/tui/widgets/scope_chip.py: 100%

78 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Scope chip: search-only filter with three side-by-side pills.""" 

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 import events 

12from textual.app import ComposeResult 

13from textual.binding import Binding, BindingType 

14from textual.containers import Horizontal 

15from textual.widget import Widget 

16from textual.widgets import Static 

17 

18from lilbee.cli.tui import messages as msg 

19from lilbee.core.config import cfg 

20from lilbee.core.config.enums import ChatMode 

21from lilbee.data.store import SearchScope 

22 

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

24 

25_HIDDEN_CLASS = "-hidden" 

26_ACTIVE_CLASS = "-active" 

27_SCOPE_PILL_CLASS = "scope-pill" 

28 

29_SCOPE_BOTH_PILL_ID = "scope-pill-both" 

30_SCOPE_WIKI_PILL_ID = "scope-pill-wiki" 

31_SCOPE_RAW_PILL_ID = "scope-pill-raw" 

32 

33# Pill id -> scope value, used for click routing. 

34_PILL_TO_SCOPE: dict[str, SearchScope] = { 

35 _SCOPE_BOTH_PILL_ID: SearchScope.BOTH, 

36 _SCOPE_WIKI_PILL_ID: SearchScope.WIKI, 

37 _SCOPE_RAW_PILL_ID: SearchScope.RAW, 

38} 

39 

40# Cycle order: Both -> Wiki -> Raw -> Both. 

41_SCOPE_CYCLE: tuple[SearchScope, ...] = ( 

42 SearchScope.BOTH, 

43 SearchScope.WIKI, 

44 SearchScope.RAW, 

45) 

46 

47 

48class ScopePill(Static, can_focus=True): 

49 """Single focusable scope pill; Enter / Space activates the parent scope.""" 

50 

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

52 Binding("enter", "select", "Pick", show=False), 

53 Binding("space", "select", "Pick", show=False), 

54 ] 

55 

56 def action_select(self) -> None: 

57 """Tell the enclosing ScopeChip to switch to this pill's scope.""" 

58 chip = next( 

59 (n for n in self.ancestors_with_self if isinstance(n, ScopeChip)), 

60 None, 

61 ) 

62 if chip is None or self.id is None: 

63 return 

64 target = _PILL_TO_SCOPE.get(self.id) 

65 if target is not None: 

66 chip._set_scope(target) 

67 

68 

69class ScopeChip(Widget): 

70 """Three-pill search filter; visible when cfg.chat_mode=='search' and cfg.wiki. 

71 

72 Scope is **session-only** state held on ``self._scope``; it is not 

73 persisted to ``cfg``. ``ChatScreen`` reads ``chip.scope`` at submit 

74 time to derive ``chunk_type``. 

75 """ 

76 

77 app: LilbeeApp # type: ignore[assignment] 

78 

79 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") 

80 

81 def __init__( 

82 self, 

83 *, 

84 name: str | None = None, 

85 id: str | None = None, 

86 classes: str | None = None, 

87 ) -> None: 

88 super().__init__(name=name, id=id, classes=classes) 

89 self._scope: SearchScope = SearchScope.BOTH 

90 

91 def compose(self) -> ComposeResult: 

92 with Horizontal(): 

93 yield ScopePill(msg.SCOPE_PILL_BOTH, id=_SCOPE_BOTH_PILL_ID, classes=_SCOPE_PILL_CLASS) 

94 yield ScopePill(msg.SCOPE_PILL_WIKI, id=_SCOPE_WIKI_PILL_ID, classes=_SCOPE_PILL_CLASS) 

95 yield ScopePill(msg.SCOPE_PILL_RAW, id=_SCOPE_RAW_PILL_ID, classes=_SCOPE_PILL_CLASS) 

96 

97 @property 

98 def scope(self) -> SearchScope: 

99 """Current scope selection; consumed by ChatScreen for chunk_type.""" 

100 return self._scope 

101 

102 def on_mount(self) -> None: 

103 self._refresh_visibility() 

104 # _refresh queries the pills this widget composes, which are not guaranteed 

105 # to be mounted yet when on_mount runs. Defer it a frame. 

106 self.call_after_refresh(self._refresh) 

107 self.app.settings_changed_signal.subscribe(self, self._on_settings_changed) 

108 

109 def _refresh_visibility(self) -> None: 

110 active = cfg.chat_mode == ChatMode.SEARCH.value and cfg.wiki 

111 self.set_class(not active, _HIDDEN_CLASS) 

112 

113 def _on_settings_changed(self, payload: tuple[str, object]) -> None: 

114 key, _value = payload 

115 if key in {"chat_mode", "wiki"}: 

116 self._refresh_visibility() 

117 

118 def _refresh(self) -> None: 

119 """Toggle the ``-active`` class on each pill based on ``self._scope``.""" 

120 for pill_id, scope in _PILL_TO_SCOPE.items(): 

121 pill = self.query_one(f"#{pill_id}", ScopePill) 

122 pill.set_class(scope is self._scope, _ACTIVE_CLASS) 

123 

124 def _set_scope(self, target: SearchScope) -> None: 

125 """Apply *target* and repaint if it differs from the current scope.""" 

126 if self._scope is target: 

127 return 

128 self._scope = target 

129 self._refresh() 

130 

131 def cycle_scope(self) -> SearchScope: 

132 """Advance to the next scope in the cycle; return the new scope.""" 

133 idx = _SCOPE_CYCLE.index(self._scope) 

134 self._set_scope(_SCOPE_CYCLE[(idx + 1) % len(_SCOPE_CYCLE)]) 

135 return self._scope 

136 

137 def on_click(self, event: events.Click) -> None: 

138 """Route a child-pill click into the scope it represents.""" 

139 widget = event.widget 

140 if widget is None: 

141 return 

142 target = _PILL_TO_SCOPE.get(widget.id or "") 

143 if target is None: 

144 return 

145 event.stop() 

146 self._set_scope(target)