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
« 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."""
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 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
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
23_CSS_FILE = Path(__file__).parent / "scope_chip.tcss"
25_HIDDEN_CLASS = "-hidden"
26_ACTIVE_CLASS = "-active"
27_SCOPE_PILL_CLASS = "scope-pill"
29_SCOPE_BOTH_PILL_ID = "scope-pill-both"
30_SCOPE_WIKI_PILL_ID = "scope-pill-wiki"
31_SCOPE_RAW_PILL_ID = "scope-pill-raw"
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}
40# Cycle order: Both -> Wiki -> Raw -> Both.
41_SCOPE_CYCLE: tuple[SearchScope, ...] = (
42 SearchScope.BOTH,
43 SearchScope.WIKI,
44 SearchScope.RAW,
45)
48class ScopePill(Static, can_focus=True):
49 """Single focusable scope pill; Enter / Space activates the parent scope."""
51 BINDINGS: ClassVar[list[BindingType]] = [
52 Binding("enter", "select", "Pick", show=False),
53 Binding("space", "select", "Pick", show=False),
54 ]
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)
69class ScopeChip(Widget):
70 """Three-pill search filter; visible when cfg.chat_mode=='search' and cfg.wiki.
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 """
77 app: LilbeeApp # type: ignore[assignment]
79 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
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
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)
97 @property
98 def scope(self) -> SearchScope:
99 """Current scope selection; consumed by ChatScreen for chunk_type."""
100 return self._scope
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)
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)
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()
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)
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()
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
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)