James Lindsay Claude commited on
Commit
c4ba2e7
·
unverified ·
1 Parent(s): ef6e064

feat: customization hooks for ChatApp subclasses (#12)

Browse files

- CSS_PATH is now absolute so subclasses inherit the upstream stylesheet
instead of looking for a non-existent "app.tcss" next to their own module.
- New LANDING_WIDGET_CLS class attribute lets subclasses swap the landing
widget without overriding on_mount (which Textual dispatches to every
class in the MRO, so an override alone leaves the default mounted).
- New command_filter constructor arg runs after auto-discovery and lets
callers restrict the active slash-command set declaratively.

Adds 8 unit tests covering all three hooks.

Co-authored-by: Claude <agent@claude.ai>

src/cli_textual/app.py CHANGED
@@ -39,7 +39,11 @@ from cli_textual.ui.widgets.landing_page import LandingPage
39
  class ChatApp(App):
40
  """Refactored ChatApp using modular architecture."""
41
 
42
- CSS_PATH = "app.tcss"
 
 
 
 
43
 
44
  BINDINGS = [
45
  Binding("ctrl+c", "quit", "Quit", show=False),
@@ -49,10 +53,18 @@ class ChatApp(App):
49
  Binding("ctrl+p", "prev_tab", "Prev Tab", show=False, priority=True),
50
  ]
51
 
 
 
 
 
 
 
 
52
  def __init__(
53
  self,
54
  tools: Optional[List[Callable]] = None,
55
  command_packages: Optional[List[str]] = None,
 
56
  model: Optional[str] = None,
57
  safe_mode: Optional[bool] = None,
58
  log: bool = False,
@@ -116,6 +128,17 @@ class ChatApp(App):
116
  for pkg in command_packages or []:
117
  self.command_manager.auto_discover(pkg)
118
 
 
 
 
 
 
 
 
 
 
 
 
119
  def compose(self) -> ComposeResult:
120
  yield Header(show_clock=True)
121
  yield VerticalScroll(id="history-container")
@@ -135,10 +158,10 @@ class ChatApp(App):
135
  yield Label(str(self.workspace_root), classes="path-info")
136
  yield Footer()
137
 
138
- def on_mount(self) -> None:
139
  self.query_one("#main-input").focus()
140
  history = self.query_one("#history-container")
141
- history.mount(LandingPage())
142
 
143
 
144
  @on(OptionList.OptionSelected, "#mode-select-list")
 
39
  class ChatApp(App):
40
  """Refactored ChatApp using modular architecture."""
41
 
42
+ # Absolute path so subclasses defined in other packages inherit the
43
+ # correct stylesheet location. Textual resolves a relative ``CSS_PATH``
44
+ # against the subclass's own module file, which would break any
45
+ # third-party ``ChatApp`` subclass.
46
+ CSS_PATH = str(Path(__file__).parent / "app.tcss")
47
 
48
  BINDINGS = [
49
  Binding("ctrl+c", "quit", "Quit", show=False),
 
53
  Binding("ctrl+p", "prev_tab", "Prev Tab", show=False, priority=True),
54
  ]
55
 
56
+ # Landing-page widget class mounted by ``on_mount``. Subclasses can
57
+ # override this class attribute to swap in their own branded widget
58
+ # without having to override ``on_mount`` (which Textual dispatches
59
+ # to every class in the MRO, so an override alone would leave the
60
+ # default landing page mounted alongside the custom one).
61
+ LANDING_WIDGET_CLS: type = LandingPage
62
+
63
  def __init__(
64
  self,
65
  tools: Optional[List[Callable]] = None,
66
  command_packages: Optional[List[str]] = None,
67
+ command_filter: Optional[Callable[[str], bool]] = None,
68
  model: Optional[str] = None,
69
  safe_mode: Optional[bool] = None,
70
  log: bool = False,
 
128
  for pkg in command_packages or []:
129
  self.command_manager.auto_discover(pkg)
130
 
131
+ # Optional post-discovery filter. Lets callers restrict the active
132
+ # command set (for example, to a read-only allowlist in a sandboxed
133
+ # deployment) without having to mutate ``command_manager.commands``
134
+ # after the fact.
135
+ if command_filter is not None:
136
+ self.command_manager.commands = {
137
+ name: cmd
138
+ for name, cmd in self.command_manager.commands.items()
139
+ if command_filter(name)
140
+ }
141
+
142
  def compose(self) -> ComposeResult:
143
  yield Header(show_clock=True)
144
  yield VerticalScroll(id="history-container")
 
158
  yield Label(str(self.workspace_root), classes="path-info")
159
  yield Footer()
160
 
161
+ def on_mount(self) -> None:
162
  self.query_one("#main-input").focus()
163
  history = self.query_one("#history-container")
164
+ history.mount(self.LANDING_WIDGET_CLS())
165
 
166
 
167
  @on(OptionList.OptionSelected, "#mode-select-list")
tests/unit/test_customization_hooks.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for ChatApp customization hooks.
2
+
3
+ Covers three subclass-friendly hooks added so third-party apps can rebrand
4
+ the TUI without monkey-patching the cli_textual package:
5
+
6
+ 1. ``CSS_PATH`` — absolute path so subclasses inherit it correctly
7
+ 2. ``LANDING_WIDGET_CLS`` — class attr to swap the landing-page widget
8
+ 3. ``command_filter`` — constructor arg to restrict the active command set
9
+ """
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+ from textual.app import ComposeResult
15
+ from textual.containers import Container
16
+ from textual.widgets import Label, Static
17
+
18
+ from cli_textual.app import ChatApp
19
+ from cli_textual.ui.widgets.landing_page import LandingPage
20
+
21
+ pytestmark = pytest.mark.timeout(5)
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # CSS_PATH absolute
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def test_css_path_is_absolute():
30
+ """CSS_PATH must be absolute so subclasses inherit it correctly.
31
+
32
+ Textual resolves relative ``CSS_PATH`` strings against the module file
33
+ where the class is defined. If ``ChatApp.CSS_PATH`` were the literal
34
+ ``"app.tcss"``, a subclass in a different package would look for
35
+ ``<their_package>/app.tcss`` and crash at startup.
36
+ """
37
+ assert os.path.isabs(ChatApp.CSS_PATH), (
38
+ f"CSS_PATH must be absolute, got {ChatApp.CSS_PATH!r}"
39
+ )
40
+ assert Path(ChatApp.CSS_PATH).exists(), (
41
+ f"CSS_PATH points at a non-existent file: {ChatApp.CSS_PATH}"
42
+ )
43
+ assert Path(ChatApp.CSS_PATH).name == "app.tcss"
44
+
45
+
46
+ def test_css_path_inherited_by_subclass():
47
+ """A subclass defined in another module inherits the absolute path."""
48
+
49
+ class MyApp(ChatApp):
50
+ pass
51
+
52
+ assert MyApp.CSS_PATH == ChatApp.CSS_PATH
53
+ assert Path(MyApp.CSS_PATH).exists()
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # LANDING_WIDGET_CLS
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ class _CustomLanding(Static):
62
+ """Marker widget used to verify the subclass override took effect."""
63
+
64
+ def compose(self) -> ComposeResult:
65
+ with Container(id="landing-container"):
66
+ yield Label("custom landing page", id="custom-landing-marker")
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_landing_widget_cls_default_is_landing_page(monkeypatch):
71
+ """Without an override, the default ``LandingPage`` is mounted."""
72
+ # The agent model is constructed lazily on app start; give it a
73
+ # placeholder key so OpenAI client construction doesn't explode in CI.
74
+ monkeypatch.setenv("OPENAI_API_KEY", "test-key")
75
+
76
+ assert ChatApp.LANDING_WIDGET_CLS is LandingPage
77
+
78
+ app = ChatApp()
79
+ async with app.run_test(size=(120, 40)) as pilot:
80
+ await pilot.pause()
81
+ history = app.query_one("#history-container")
82
+ assert list(history.query(LandingPage)), (
83
+ "default LandingPage should be mounted"
84
+ )
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_landing_widget_cls_subclass_override(monkeypatch):
89
+ """A subclass setting ``LANDING_WIDGET_CLS`` mounts its own widget and
90
+ the default ``LandingPage`` is *not* mounted alongside it."""
91
+ monkeypatch.setenv("OPENAI_API_KEY", "test-key")
92
+
93
+ class MyApp(ChatApp):
94
+ LANDING_WIDGET_CLS = _CustomLanding
95
+
96
+ app = MyApp()
97
+ async with app.run_test(size=(120, 40)) as pilot:
98
+ await pilot.pause()
99
+ history = app.query_one("#history-container")
100
+
101
+ assert list(history.query(_CustomLanding)), (
102
+ "custom landing widget should be mounted"
103
+ )
104
+ assert not list(history.query(LandingPage)), (
105
+ "default LandingPage must not be mounted when the subclass "
106
+ "overrides LANDING_WIDGET_CLS"
107
+ )
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # command_filter
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def test_command_filter_none_keeps_all_commands():
116
+ """With no filter, every auto-discovered built-in is registered."""
117
+ app = ChatApp()
118
+ cmds = set(app.command_manager.commands.keys())
119
+ # The built-ins shipped in cli_textual.plugins.commands should all be here.
120
+ assert "/help" in cmds
121
+ assert "/clear" in cmds
122
+ assert "/mode" in cmds
123
+
124
+
125
+ def test_command_filter_restricts_commands():
126
+ """A ``command_filter`` predicate drops commands that return False."""
127
+ allowed = {"/help", "/clear"}
128
+
129
+ app = ChatApp(command_filter=lambda name: name in allowed)
130
+ registered = set(app.command_manager.commands.keys())
131
+
132
+ assert registered == allowed, (
133
+ f"expected exactly {allowed}, got {registered}"
134
+ )
135
+
136
+
137
+ def test_command_filter_applies_after_custom_packages():
138
+ """The filter applies uniformly across built-ins *and* user packages."""
139
+ # A filter that only keeps "/help" must drop every other built-in.
140
+ app = ChatApp(
141
+ command_filter=lambda name: name == "/help",
142
+ )
143
+ assert set(app.command_manager.commands.keys()) == {"/help"}
144
+
145
+
146
+ def test_command_filter_receives_lowercased_name():
147
+ """Confirm the filter callable receives the same key the manager uses
148
+ for dispatch — this pins the API contract."""
149
+ seen = []
150
+
151
+ def _filter(name: str) -> bool:
152
+ seen.append(name)
153
+ return True
154
+
155
+ ChatApp(command_filter=_filter)
156
+
157
+ assert seen, "filter was never called"
158
+ # CommandManager stores names lowercased; the filter must see the same.
159
+ for name in seen:
160
+ assert name == name.lower(), (
161
+ f"filter received non-lowercased name: {name!r}"
162
+ )