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

78 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-05-15 20:55 +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 self._refresh() 

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

106 

107 def _refresh_visibility(self) -> None: 

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

109 self.set_class(not active, _HIDDEN_CLASS) 

110 

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

112 key, _value = payload 

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

114 self._refresh_visibility() 

115 

116 def _refresh(self) -> None: 

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

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

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

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

121 

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

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

124 if self._scope is target: 

125 return 

126 self._scope = target 

127 self._refresh() 

128 

129 def cycle_scope(self) -> SearchScope: 

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

131 idx = _SCOPE_CYCLE.index(self._scope) 

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

133 return self._scope 

134 

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

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

137 widget = event.widget 

138 if widget is None: 

139 return 

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

141 if target is None: 

142 return 

143 event.stop() 

144 self._set_scope(target)