claudechic 0.2.2__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. claudechic/__init__.py +3 -1
  2. claudechic/__main__.py +12 -1
  3. claudechic/agent.py +60 -19
  4. claudechic/agent_manager.py +8 -2
  5. claudechic/analytics.py +62 -0
  6. claudechic/app.py +267 -158
  7. claudechic/commands.py +120 -6
  8. claudechic/config.py +80 -0
  9. claudechic/features/worktree/commands.py +70 -1
  10. claudechic/help_data.py +200 -0
  11. claudechic/messages.py +0 -17
  12. claudechic/processes.py +120 -0
  13. claudechic/profiling.py +18 -1
  14. claudechic/protocols.py +1 -1
  15. claudechic/remote.py +249 -0
  16. claudechic/sessions.py +60 -50
  17. claudechic/styles.tcss +19 -18
  18. claudechic/widgets/__init__.py +112 -41
  19. claudechic/widgets/base/__init__.py +20 -0
  20. claudechic/widgets/base/clickable.py +23 -0
  21. claudechic/widgets/base/copyable.py +55 -0
  22. claudechic/{cursor.py → widgets/base/cursor.py} +9 -28
  23. claudechic/widgets/base/tool_protocol.py +30 -0
  24. claudechic/widgets/content/__init__.py +41 -0
  25. claudechic/widgets/{diff.py → content/diff.py} +11 -65
  26. claudechic/widgets/{chat.py → content/message.py} +25 -76
  27. claudechic/widgets/{tools.py → content/tools.py} +12 -24
  28. claudechic/widgets/input/__init__.py +9 -0
  29. claudechic/widgets/layout/__init__.py +51 -0
  30. claudechic/widgets/{chat_view.py → layout/chat_view.py} +92 -43
  31. claudechic/widgets/{footer.py → layout/footer.py} +17 -7
  32. claudechic/widgets/{indicators.py → layout/indicators.py} +55 -7
  33. claudechic/widgets/layout/processes.py +68 -0
  34. claudechic/widgets/{agents.py → layout/sidebar.py} +163 -82
  35. claudechic/widgets/modals/__init__.py +9 -0
  36. claudechic/widgets/modals/process_modal.py +121 -0
  37. claudechic/widgets/{profile_modal.py → modals/profile.py} +2 -1
  38. claudechic/widgets/primitives/__init__.py +13 -0
  39. claudechic/widgets/{button.py → primitives/button.py} +1 -1
  40. claudechic/widgets/{collapsible.py → primitives/collapsible.py} +5 -1
  41. claudechic/widgets/{scroll.py → primitives/scroll.py} +2 -0
  42. claudechic/widgets/primitives/spinner.py +57 -0
  43. claudechic/widgets/prompts.py +146 -17
  44. claudechic/widgets/reports/__init__.py +10 -0
  45. claudechic-0.3.1.dist-info/METADATA +88 -0
  46. claudechic-0.3.1.dist-info/RECORD +71 -0
  47. {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/WHEEL +1 -1
  48. claudechic-0.3.1.dist-info/licenses/LICENSE +21 -0
  49. claudechic/features/worktree/prompts.py +0 -101
  50. claudechic/widgets/model_prompt.py +0 -56
  51. claudechic-0.2.2.dist-info/METADATA +0 -58
  52. claudechic-0.2.2.dist-info/RECORD +0 -54
  53. /claudechic/widgets/{todo.py → content/todo.py} +0 -0
  54. /claudechic/widgets/{autocomplete.py → input/autocomplete.py} +0 -0
  55. /claudechic/widgets/{history_search.py → input/history_search.py} +0 -0
  56. /claudechic/widgets/{context_report.py → reports/context.py} +0 -0
  57. /claudechic/widgets/{usage.py → reports/usage.py} +0 -0
  58. {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/entry_points.txt +0 -0
  59. {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,68 @@
1
+ """Process panel widget for displaying background processes."""
2
+
3
+ from rich.text import Text
4
+ from textual.app import ComposeResult
5
+ from textual.widget import Widget
6
+ from textual.widgets import Static
7
+
8
+ from claudechic.processes import BackgroundProcess
9
+
10
+
11
+ class ProcessItem(Static):
12
+ """Single process item with PID and command."""
13
+
14
+ can_focus = False
15
+
16
+ def __init__(self, process: BackgroundProcess) -> None:
17
+ super().__init__()
18
+ self.process = process
19
+
20
+ def render(self) -> Text:
21
+ # Show running indicator and truncated command
22
+ cmd = self.process.command
23
+ if len(cmd) > 20:
24
+ cmd = cmd[:19] + "…"
25
+ return Text.assemble(("● ", "yellow"), (cmd, ""))
26
+
27
+
28
+ class ProcessPanel(Widget):
29
+ """Sidebar panel for background processes."""
30
+
31
+ DEFAULT_CSS = """
32
+ ProcessPanel {
33
+ width: 100%;
34
+ height: auto;
35
+ max-height: 30%;
36
+ border-top: solid $panel;
37
+ padding: 1;
38
+ }
39
+ ProcessPanel.hidden {
40
+ display: none;
41
+ }
42
+ ProcessPanel .process-title {
43
+ color: $text-muted;
44
+ text-style: bold;
45
+ padding: 0 0 1 0;
46
+ }
47
+ ProcessItem {
48
+ height: 1;
49
+ }
50
+ """
51
+
52
+ can_focus = False
53
+
54
+ def compose(self) -> ComposeResult:
55
+ yield Static("Processes", classes="process-title")
56
+
57
+ def update_processes(self, processes: list[BackgroundProcess]) -> None:
58
+ """Replace processes with new list."""
59
+ # Remove old items
60
+ for item in self.query(ProcessItem):
61
+ item.remove()
62
+
63
+ # Add new items
64
+ for proc in processes:
65
+ self.mount(ProcessItem(proc))
66
+
67
+ # Show/hide based on whether we have processes
68
+ self.set_class(len(processes) == 0, "hidden")
@@ -1,5 +1,6 @@
1
1
  """Agent sidebar widget for multi-agent management."""
2
2
 
3
+ import time
3
4
  from pathlib import Path
4
5
 
5
6
  from textual.app import ComposeResult
@@ -7,19 +8,111 @@ from textual.events import Click
7
8
  from textual.message import Message
8
9
  from textual.reactive import reactive
9
10
  from textual.widget import Widget
10
- from textual.widgets import Static
11
+ from textual.widgets import Static, Label, ListItem
11
12
  from rich.text import Text
12
13
 
13
14
  from claudechic.enums import AgentStatus
14
- from claudechic.cursor import ClickableMixin
15
- from claudechic.widgets.button import Button
15
+ from claudechic.widgets.base.cursor import ClickableMixin, PointerMixin
16
+ from claudechic.widgets.primitives.button import Button
17
+
18
+
19
+ class SidebarItem(Widget, ClickableMixin):
20
+ """Base class for clickable sidebar items."""
21
+
22
+ DEFAULT_CSS = """
23
+ SidebarItem {
24
+ height: 3;
25
+ min-height: 3;
26
+ padding: 1 1 1 2;
27
+ }
28
+ SidebarItem.compact {
29
+ height: 1;
30
+ min-height: 1;
31
+ padding: 0 1 0 2;
32
+ }
33
+ SidebarItem:hover {
34
+ background: $surface-lighten-1;
35
+ }
36
+ """
37
+
38
+ max_name_length: int = 16
39
+
40
+ def truncate_name(self, name: str) -> str:
41
+ """Truncate name with ellipsis if too long."""
42
+ if len(name) > self.max_name_length:
43
+ return name[: self.max_name_length - 1] + "…"
44
+ return name
45
+
46
+
47
+ class SidebarSection(Widget):
48
+ """Base component for sidebar sections with a title and items."""
49
+
50
+ DEFAULT_CSS = """
51
+ SidebarSection {
52
+ width: 100%;
53
+ height: auto;
54
+ padding: 0;
55
+ }
56
+ SidebarSection .section-title {
57
+ color: $text-muted;
58
+ text-style: bold;
59
+ padding: 1 1 1 1;
60
+ }
61
+ SidebarSection.hidden {
62
+ display: none;
63
+ }
64
+ """
65
+
66
+ def __init__(self, title: str, *args, **kwargs) -> None:
67
+ super().__init__(*args, **kwargs)
68
+ self._title = title
69
+
70
+ def compose(self) -> ComposeResult:
71
+ yield Static(self._title, classes="section-title")
72
+
73
+
74
+ def _format_time_ago(mtime: float) -> str:
75
+ """Format a timestamp as relative time (e.g., '2 hours ago')."""
76
+ delta = time.time() - mtime
77
+ if delta < 60:
78
+ return "just now"
79
+ elif delta < 3600:
80
+ mins = int(delta / 60)
81
+ return f"{mins} min{'s' if mins != 1 else ''} ago"
82
+ elif delta < 86400:
83
+ hours = int(delta / 3600)
84
+ return f"{hours} hour{'s' if hours != 1 else ''} ago"
85
+ elif delta < 604800:
86
+ days = int(delta / 86400)
87
+ return f"{days} day{'s' if days != 1 else ''} ago"
88
+ else:
89
+ weeks = int(delta / 604800)
90
+ return f"{weeks} week{'s' if weeks != 1 else ''} ago"
91
+
92
+
93
+ class SessionItem(ListItem, PointerMixin):
94
+ """A session in the session picker sidebar."""
95
+
96
+ def __init__(
97
+ self, session_id: str, title: str, mtime: float, msg_count: int = 0
98
+ ) -> None:
99
+ super().__init__()
100
+ self.session_id = session_id
101
+ self.title = title
102
+ self.mtime = mtime
103
+ self.msg_count = msg_count
104
+
105
+ def compose(self) -> ComposeResult:
106
+ yield Label(self.title, classes="session-preview")
107
+ time_ago = _format_time_ago(self.mtime)
108
+ yield Label(f"{time_ago} · {self.msg_count} msgs", classes="session-meta")
16
109
 
17
110
 
18
111
  class HamburgerButton(Button):
19
112
  """Floating hamburger button for narrow screens."""
20
113
 
21
- class Clicked(Message):
22
- """Posted when hamburger is clicked."""
114
+ class SidebarToggled(Message):
115
+ """Posted when hamburger is pressed to toggle sidebar."""
23
116
 
24
117
  pass
25
118
 
@@ -56,43 +149,64 @@ class HamburgerButton(Button):
56
149
  return "Agents"
57
150
 
58
151
  def on_click(self, event) -> None:
59
- self.post_message(self.Clicked())
152
+ self.post_message(self.SidebarToggled())
60
153
 
61
154
 
62
- class PlanButton(Button):
63
- """Button to open the current session's plan file."""
155
+ class PlanItem(SidebarItem):
156
+ """Clickable plan item that opens the plan file."""
64
157
 
65
- class Clicked(Message):
66
- """Posted when plan button is clicked."""
158
+ class PlanRequested(Message):
159
+ """Posted when plan item is clicked."""
67
160
 
68
161
  def __init__(self, plan_path: Path) -> None:
69
162
  self.plan_path = plan_path
70
163
  super().__init__()
71
164
 
72
- DEFAULT_CSS = """
73
- PlanButton {
74
- height: 3;
75
- min-height: 3;
76
- padding: 1 1 1 2;
77
- dock: bottom;
78
- }
79
- PlanButton:hover {
80
- background: $surface-lighten-1;
81
- }
82
- """
165
+ max_name_length: int = 18
83
166
 
84
167
  def __init__(self, plan_path: Path) -> None:
85
168
  super().__init__()
86
169
  self.plan_path = plan_path
87
170
 
88
171
  def render(self) -> Text:
89
- return Text.assemble(("📋", ""), " ", ("Plan", "dim"))
172
+ name = self.truncate_name(self.plan_path.name)
173
+ return Text.assemble(("📋", ""), " ", (name, ""))
90
174
 
91
175
  def on_click(self, event) -> None:
92
- self.post_message(self.Clicked(self.plan_path))
176
+ self.post_message(self.PlanRequested(self.plan_path))
177
+
178
+
179
+ class PlanSection(SidebarSection):
180
+ """Sidebar section for plan files."""
181
+
182
+ DEFAULT_CSS = """
183
+ PlanSection {
184
+ border-top: solid $panel;
185
+ }
186
+ """
93
187
 
188
+ def __init__(self, *args, **kwargs) -> None:
189
+ super().__init__("Plan", *args, **kwargs)
190
+ self._plan_item: PlanItem | None = None
94
191
 
95
- class WorktreeItem(Widget, ClickableMixin):
192
+ def set_plan(self, plan_path: Path | None) -> None:
193
+ """Show or hide the plan."""
194
+ if plan_path:
195
+ if self._plan_item is None:
196
+ self._plan_item = PlanItem(plan_path)
197
+ self.mount(self._plan_item)
198
+ else:
199
+ self._plan_item.plan_path = plan_path
200
+ self._plan_item.refresh()
201
+ self.remove_class("hidden")
202
+ else:
203
+ if self._plan_item is not None:
204
+ self._plan_item.remove()
205
+ self._plan_item = None
206
+ self.add_class("hidden")
207
+
208
+
209
+ class WorktreeItem(SidebarItem):
96
210
  """A ghost worktree in the sidebar (not yet an agent)."""
97
211
 
98
212
  class Selected(Message):
@@ -103,33 +217,20 @@ class WorktreeItem(Widget, ClickableMixin):
103
217
  self.path = path
104
218
  super().__init__()
105
219
 
106
- DEFAULT_CSS = """
107
- WorktreeItem {
108
- height: 3;
109
- min-height: 3;
110
- padding: 1 1 1 2;
111
- }
112
- WorktreeItem:hover {
113
- background: $surface-lighten-1;
114
- }
115
- """
116
-
117
220
  def __init__(self, branch: str, path: Path) -> None:
118
221
  super().__init__()
119
222
  self.branch = branch
120
223
  self.path = path
121
224
 
122
225
  def render(self) -> Text:
123
- name = self.branch
124
- if len(name) > 16:
125
- name = name[:15] + "…"
226
+ name = self.truncate_name(self.branch)
126
227
  return Text.assemble(("◌", ""), " ", (name, "dim"))
127
228
 
128
229
  def on_click(self, event) -> None:
129
230
  self.post_message(self.Selected(self.branch, self.path))
130
231
 
131
232
 
132
- class AgentItem(Widget, ClickableMixin):
233
+ class AgentItem(SidebarItem):
133
234
  """A single agent in the sidebar."""
134
235
 
135
236
  class Selected(Message):
@@ -148,18 +249,16 @@ class AgentItem(Widget, ClickableMixin):
148
249
 
149
250
  DEFAULT_CSS = """
150
251
  AgentItem {
151
- height: 3;
152
- padding: 1 1 1 2;
153
252
  layout: horizontal;
154
253
  }
155
- AgentItem:hover {
156
- background: $surface-lighten-1;
157
- }
158
254
  AgentItem.active {
159
255
  padding: 1 1 1 1;
160
256
  border-left: wide $primary;
161
257
  background: $surface;
162
258
  }
259
+ AgentItem.active.compact {
260
+ padding: 0 1 0 1;
261
+ }
163
262
  AgentItem .agent-label {
164
263
  width: 1fr;
165
264
  height: 1;
@@ -181,6 +280,8 @@ class AgentItem(Widget, ClickableMixin):
181
280
  }
182
281
  """
183
282
 
283
+ max_name_length: int = 14
284
+
184
285
  status: reactive[AgentStatus] = reactive(AgentStatus.IDLE)
185
286
 
186
287
  def __init__(
@@ -205,9 +306,7 @@ class AgentItem(Widget, ClickableMixin):
205
306
  else:
206
307
  indicator = "\u25cb"
207
308
  style = "dim"
208
- name = self.display_name
209
- if len(name) > 14:
210
- name = name[:13] + "…"
309
+ name = self.truncate_name(self.display_name)
211
310
  return Text.assemble((indicator, style), " ", (name, ""))
212
311
 
213
312
  def watch_status(self, _status: str) -> None:
@@ -227,31 +326,22 @@ class AgentItem(Widget, ClickableMixin):
227
326
  self.post_message(self.Selected(self.agent_id))
228
327
 
229
328
 
230
- class AgentSidebar(Widget):
231
- """Sidebar showing all agents with status indicators."""
232
-
233
- DEFAULT_CSS = """
234
- AgentSidebar {
235
- width: 24;
236
- height: 100%;
237
- padding: 0;
238
- overflow-y: auto;
239
- }
240
- AgentSidebar .sidebar-title {
241
- color: $text-muted;
242
- text-style: bold;
243
- padding: 1 1 1 1;
244
- }
245
- """
329
+ class AgentSection(SidebarSection):
330
+ """Sidebar section showing all agents with status indicators."""
246
331
 
247
332
  def __init__(self, *args, **kwargs) -> None:
248
- super().__init__(*args, **kwargs)
333
+ super().__init__("Agents", *args, **kwargs)
249
334
  self._agents: dict[str, AgentItem] = {}
250
335
  self._worktrees: dict[str, WorktreeItem] = {} # branch -> item
251
- self._plan_button: PlanButton | None = None
252
336
 
253
- def compose(self) -> ComposeResult:
254
- yield Static("Agents", classes="sidebar-title")
337
+ def _update_compact_mode(self) -> None:
338
+ """Apply compact mode when there are many items."""
339
+ total = len(self._agents) + len(self._worktrees)
340
+ compact = total > 6
341
+ for item in self._agents.values():
342
+ item.set_class(compact, "compact")
343
+ for item in self._worktrees.values():
344
+ item.set_class(compact, "compact")
255
345
 
256
346
  def add_agent(
257
347
  self, agent_id: str, name: str, status: AgentStatus = AgentStatus.IDLE
@@ -264,15 +354,18 @@ class AgentSidebar(Widget):
264
354
  self._worktrees[name].remove()
265
355
  del self._worktrees[name]
266
356
  item = AgentItem(agent_id, name, status)
267
- item.id = f"agent-{agent_id}"
357
+ # Sanitize for Textual ID (no slashes allowed)
358
+ item.id = f"agent-{agent_id.replace('/', '-')}"
268
359
  self._agents[agent_id] = item
269
360
  self.mount(item)
361
+ self._update_compact_mode()
270
362
 
271
363
  def remove_agent(self, agent_id: str) -> None:
272
364
  """Remove an agent from the sidebar."""
273
365
  if agent_id in self._agents:
274
366
  self._agents[agent_id].remove()
275
367
  del self._agents[agent_id]
368
+ self._update_compact_mode()
276
369
 
277
370
  def set_active(self, agent_id: str) -> None:
278
371
  """Mark an agent as active (selected)."""
@@ -299,23 +392,11 @@ class AgentSidebar(Widget):
299
392
  item.id = f"worktree-{branch}"
300
393
  self._worktrees[branch] = item
301
394
  self.mount(item)
395
+ self._update_compact_mode()
302
396
 
303
397
  def remove_worktree(self, branch: str) -> None:
304
398
  """Remove a ghost worktree from the sidebar."""
305
399
  if branch in self._worktrees:
306
400
  self._worktrees[branch].remove()
307
401
  del self._worktrees[branch]
308
-
309
- def set_plan(self, plan_path: Path | None) -> None:
310
- """Show or hide the plan button."""
311
- if plan_path:
312
- if self._plan_button is None:
313
- self._plan_button = PlanButton(plan_path)
314
- self._plan_button.id = "plan-button"
315
- self.mount(self._plan_button)
316
- else:
317
- self._plan_button.plan_path = plan_path
318
- else:
319
- if self._plan_button is not None:
320
- self._plan_button.remove()
321
- self._plan_button = None
402
+ self._update_compact_mode()
@@ -0,0 +1,9 @@
1
+ """Modal screen widgets."""
2
+
3
+ from claudechic.widgets.modals.profile import ProfileModal
4
+ from claudechic.widgets.modals.process_modal import ProcessModal
5
+
6
+ __all__ = [
7
+ "ProfileModal",
8
+ "ProcessModal",
9
+ ]
@@ -0,0 +1,121 @@
1
+ """Process list modal."""
2
+
3
+ from datetime import datetime
4
+
5
+ from rich.table import Table
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Vertical, Horizontal
10
+ from textual.screen import ModalScreen
11
+ from textual.widgets import Static, Button
12
+
13
+ from claudechic.processes import BackgroundProcess
14
+
15
+
16
+ def _format_duration(start_time: datetime) -> str:
17
+ """Format duration since start_time."""
18
+ delta = datetime.now() - start_time
19
+ secs = int(delta.total_seconds())
20
+ if secs < 60:
21
+ return f"{secs}s"
22
+ elif secs < 3600:
23
+ return f"{secs // 60}m {secs % 60}s"
24
+ else:
25
+ hours = secs // 3600
26
+ mins = (secs % 3600) // 60
27
+ return f"{hours}h {mins}m"
28
+
29
+
30
+ def _get_process_table(processes: list[BackgroundProcess]) -> Table:
31
+ """Build a table of running processes."""
32
+ table = Table(
33
+ box=None,
34
+ padding=(0, 2),
35
+ collapse_padding=True,
36
+ show_header=True,
37
+ )
38
+ table.add_column("PID", justify="right", style="dim")
39
+ table.add_column("Command")
40
+ table.add_column("Duration", justify="right", style="dim")
41
+
42
+ for proc in processes:
43
+ cmd = proc.command
44
+ if len(cmd) > 50:
45
+ cmd = cmd[:47] + "..."
46
+ table.add_row(str(proc.pid), cmd, _format_duration(proc.start_time))
47
+
48
+ return table
49
+
50
+
51
+ class ProcessModal(ModalScreen):
52
+ """Modal showing running background processes."""
53
+
54
+ BINDINGS = [
55
+ Binding("escape", "dismiss", "Close"),
56
+ ]
57
+
58
+ DEFAULT_CSS = """
59
+ ProcessModal {
60
+ align: center middle;
61
+ }
62
+
63
+ ProcessModal #process-container {
64
+ width: auto;
65
+ min-width: 50;
66
+ max-width: 80%;
67
+ height: auto;
68
+ max-height: 60%;
69
+ background: $surface;
70
+ border: solid $panel;
71
+ padding: 1 2;
72
+ }
73
+
74
+ ProcessModal #process-header {
75
+ height: 1;
76
+ margin-bottom: 1;
77
+ }
78
+
79
+ ProcessModal #process-title {
80
+ width: 1fr;
81
+ }
82
+
83
+ ProcessModal #process-content {
84
+ height: auto;
85
+ }
86
+
87
+ ProcessModal #process-footer {
88
+ height: 1;
89
+ margin-top: 1;
90
+ align: center middle;
91
+ }
92
+
93
+ ProcessModal #close-btn {
94
+ min-width: 10;
95
+ }
96
+ """
97
+
98
+ def __init__(self, processes: list[BackgroundProcess]) -> None:
99
+ super().__init__()
100
+ self._processes = processes
101
+
102
+ def compose(self) -> ComposeResult:
103
+ with Vertical(id="process-container"):
104
+ with Horizontal(id="process-header"):
105
+ yield Static(
106
+ "[bold]Background Processes[/]", id="process-title", markup=True
107
+ )
108
+ if self._processes:
109
+ yield Static(_get_process_table(self._processes), id="process-content")
110
+ else:
111
+ yield Static(
112
+ "[dim]No background processes running.[/]",
113
+ id="process-content",
114
+ markup=True,
115
+ )
116
+ with Horizontal(id="process-footer"):
117
+ yield Button("Close", id="close-btn")
118
+
119
+ def on_button_pressed(self, event: Button.Pressed) -> None:
120
+ if event.button.id == "close-btn":
121
+ self.dismiss()
@@ -1,6 +1,5 @@
1
1
  """Profile statistics modal."""
2
2
 
3
- import pyperclip
4
3
  from rich.table import Table
5
4
 
6
5
  from textual.app import ComposeResult
@@ -186,6 +185,8 @@ class ProfileModal(ModalScreen):
186
185
  def on_button_pressed(self, event: Button.Pressed) -> None:
187
186
  if event.button.id == "copy-btn":
188
187
  try:
188
+ import pyperclip
189
+
189
190
  text = get_stats_text() + "\n" + _get_sampling_text()
190
191
  pyperclip.copy(text)
191
192
  self.notify("Copied to clipboard")
@@ -0,0 +1,13 @@
1
+ """Primitive building-block widgets."""
2
+
3
+ from claudechic.widgets.primitives.button import Button
4
+ from claudechic.widgets.primitives.collapsible import QuietCollapsible
5
+ from claudechic.widgets.primitives.scroll import AutoHideScroll
6
+ from claudechic.widgets.primitives.spinner import Spinner
7
+
8
+ __all__ = [
9
+ "Button",
10
+ "QuietCollapsible",
11
+ "AutoHideScroll",
12
+ "Spinner",
13
+ ]
@@ -3,7 +3,7 @@
3
3
  from textual.message import Message
4
4
  from textual.widgets import Static
5
5
 
6
- from claudechic.cursor import ClickableMixin
6
+ from claudechic.widgets.base.cursor import ClickableMixin
7
7
 
8
8
 
9
9
  class Button(Static, ClickableMixin):
@@ -2,13 +2,17 @@
2
2
 
3
3
  from textual.widgets import Collapsible
4
4
 
5
+ from claudechic.widgets.base.cursor import ClickableMixin
5
6
 
6
- class QuietCollapsible(Collapsible):
7
+
8
+ class QuietCollapsible(Collapsible, ClickableMixin):
7
9
  """Collapsible that doesn't scroll itself into view on toggle.
8
10
 
9
11
  The default Textual Collapsible calls scroll_visible() whenever it's
10
12
  toggled, which causes scroll jumping in chat interfaces where we want
11
13
  to control scrolling ourselves (e.g., tail-following new content).
14
+
15
+ Also shows pointer cursor to indicate clickability.
12
16
  """
13
17
 
14
18
  def _watch_collapsed(self, collapsed: bool) -> None:
@@ -13,6 +13,8 @@ class AutoHideScroll(VerticalScroll):
13
13
  only when user explicitly scrolls to bottom.
14
14
  """
15
15
 
16
+ can_focus = False
17
+
16
18
  DEFAULT_CSS = """
17
19
  AutoHideScroll {
18
20
  scrollbar-size-vertical: 1;