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
@@ -4,76 +4,17 @@ import re
4
4
  import time
5
5
  from pathlib import Path
6
6
 
7
- import pyperclip
8
-
9
7
  from textual.app import ComposeResult
10
8
  from textual.binding import Binding
11
9
  from textual.containers import Horizontal, Vertical
12
10
  from textual.message import Message
13
11
  from textual.widgets import Markdown, TextArea, Static
14
12
 
15
- from claudechic.cursor import PointerMixin, set_pointer
13
+ from claudechic.widgets.base.cursor import PointerMixin, set_pointer
16
14
  from claudechic.errors import log_exception
17
- from claudechic.profiling import profile
18
- from claudechic.widgets.button import Button
19
-
20
-
21
- class CopyButton(Button):
22
- """Copy button with hand cursor on hover."""
23
-
24
- pass
25
-
26
-
27
- class Spinner(Static):
28
- """Animated spinner - all instances share a single timer for efficiency."""
29
-
30
- FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
31
- DEFAULT_CSS = """
32
- Spinner {
33
- width: 1;
34
- height: 1;
35
- color: $text-muted;
36
- }
37
- """
38
-
39
- # Class-level shared state
40
- _instances: set["Spinner"] = set()
41
- _frame: int = 0
42
- _timer = None
43
-
44
- def __init__(self, text: str = "") -> None:
45
- self._text = f" {text}" if text else ""
46
- super().__init__()
47
-
48
- def render(self) -> str:
49
- """Return current frame from shared counter."""
50
- return f"{self.FRAMES[Spinner._frame]}{self._text}"
51
-
52
- def on_mount(self) -> None:
53
- Spinner._instances.add(self)
54
- # Start shared timer if this is the first spinner
55
- # Use app.set_interval so timer survives widget unmount
56
- if Spinner._timer is None:
57
- Spinner._timer = self.app.set_interval(1 / 10, Spinner._tick_all) # 10 FPS
58
-
59
- def on_unmount(self) -> None:
60
- Spinner._instances.discard(self)
61
- # Stop timer if no spinners left
62
- if not Spinner._instances and Spinner._timer is not None:
63
- Spinner._timer.stop()
64
- Spinner._timer = None
65
-
66
- @staticmethod
67
- @profile
68
- def _tick_all() -> None:
69
- """Advance frame and refresh all spinners.
70
-
71
- Note: We don't check visibility - refresh() on hidden widgets is cheap,
72
- and the DOM-walking visibility check was more expensive than the savings.
73
- """
74
- Spinner._frame = (Spinner._frame + 1) % len(Spinner.FRAMES)
75
- for spinner in list(Spinner._instances):
76
- spinner.refresh()
15
+ from claudechic.widgets.primitives.button import Button
16
+ from claudechic.widgets.primitives.spinner import Spinner
17
+ from claudechic.widgets.base.copyable import CopyButton, CopyableMixin
77
18
 
78
19
 
79
20
  class ThinkingIndicator(Spinner):
@@ -128,7 +69,7 @@ class SystemInfo(Static):
128
69
  yield Markdown(self._message, id="content")
129
70
 
130
71
 
131
- class ChatMessage(Static, PointerMixin):
72
+ class ChatMessage(Static, PointerMixin, CopyableMixin):
132
73
  """A single chat message with copy button.
133
74
 
134
75
  Uses Textual's MarkdownStream for efficient incremental rendering.
@@ -152,15 +93,15 @@ class ChatMessage(Static, PointerMixin):
152
93
  self._pending_text = "" # Accumulated text waiting to be flushed
153
94
  self._flush_timer = None # Timer for debounced flush
154
95
 
96
+ def _is_streaming(self) -> bool:
97
+ """Check if we're actively streaming content."""
98
+ return bool(self._pending_text) or self._flush_timer is not None
99
+
155
100
  def on_enter(self) -> None:
156
101
  set_pointer(self.pointer_style)
157
- if not self.has_class("hovered"):
158
- self.add_class("hovered")
159
102
 
160
103
  def on_leave(self) -> None:
161
104
  set_pointer("default")
162
- if self.has_class("hovered"):
163
- self.remove_class("hovered")
164
105
 
165
106
  def compose(self) -> ComposeResult:
166
107
  yield CopyButton("⧉", id="copy-btn", classes="copy-btn")
@@ -228,17 +169,15 @@ class ChatMessage(Static, PointerMixin):
228
169
  self.call_later(self._stream.stop)
229
170
  self._stream = None
230
171
 
231
- def get_raw_content(self) -> str:
172
+ def get_copyable_content(self) -> str:
232
173
  """Get raw content for copying."""
233
174
  return self._content
234
175
 
176
+ # Alias for backwards compatibility
177
+ get_raw_content = get_copyable_content
178
+
235
179
  def on_button_pressed(self, event: Button.Pressed) -> None:
236
- if event.button.id == "copy-btn":
237
- try:
238
- pyperclip.copy(self.get_raw_content())
239
- self.app.notify("Copied to clipboard")
240
- except Exception as e:
241
- self.app.notify(f"Copy failed: {e}", severity="error")
180
+ self.handle_copy_button(event)
242
181
 
243
182
 
244
183
  class ChatAttachment(Button):
@@ -339,7 +278,6 @@ class ChatInput(TextArea, PointerMixin):
339
278
  Binding("ctrl+j", "newline", "Newline", priority=True, show=False),
340
279
  Binding("up", "history_prev", "Previous", priority=True, show=False),
341
280
  Binding("down", "history_next", "Next", priority=True, show=False),
342
- Binding("ctrl+a", "select_all", "Select all", priority=True, show=False),
343
281
  Binding(
344
282
  "alt+backspace",
345
283
  "delete_word_left",
@@ -347,6 +285,17 @@ class ChatInput(TextArea, PointerMixin):
347
285
  priority=True,
348
286
  show=False,
349
287
  ),
288
+ # Readline/emacs bindings (override Textual defaults where needed)
289
+ Binding("ctrl+f", "cursor_right", "Forward char", priority=True, show=False),
290
+ Binding("ctrl+b", "cursor_left", "Backward char", priority=True, show=False),
291
+ Binding("ctrl+p", "cursor_up", "Previous line", priority=True, show=False),
292
+ Binding("ctrl+n", "cursor_down", "Next line", priority=True, show=False),
293
+ Binding(
294
+ "alt+f", "cursor_word_right", "Forward word", priority=True, show=False
295
+ ),
296
+ Binding(
297
+ "alt+b", "cursor_word_left", "Backward word", priority=True, show=False
298
+ ),
350
299
  ]
351
300
 
352
301
  class Submitted(Message):
@@ -5,16 +5,14 @@ import logging
5
5
  import re
6
6
  from pathlib import Path
7
7
 
8
- import pyperclip
9
8
  from rich.text import Text
10
9
 
11
10
  from textual.app import ComposeResult
12
11
  from textual.message import Message
13
12
  from textual.widgets import Markdown, Static
14
13
 
15
- from claudechic.widgets.button import Button
16
-
17
- from claudechic.widgets.collapsible import QuietCollapsible
14
+ from claudechic.widgets.primitives.button import Button
15
+ from claudechic.widgets.primitives.collapsible import QuietCollapsible
18
16
 
19
17
  from claude_agent_sdk import ToolUseBlock, ToolResultBlock
20
18
 
@@ -26,9 +24,10 @@ from claudechic.formatting import (
26
24
  get_lang_from_path,
27
25
  make_relative,
28
26
  )
29
- from claudechic.widgets.diff import DiffWidget
30
- from claudechic.widgets.chat import ChatMessage, Spinner, CopyButton
31
- from claudechic.cursor import HoverableMixin
27
+ from claudechic.widgets.content.diff import DiffWidget
28
+ from claudechic.widgets.content.message import ChatMessage
29
+ from claudechic.widgets.primitives.spinner import Spinner
30
+ from claudechic.widgets.base.copyable import CopyButton, CopyableMixin
32
31
 
33
32
  log = logging.getLogger(__name__)
34
33
 
@@ -79,7 +78,7 @@ class EditPlanRequested(Message):
79
78
  self.plan_path = plan_path
80
79
 
81
80
 
82
- class ToolUseWidget(Static, HoverableMixin):
81
+ class ToolUseWidget(Static, CopyableMixin):
83
82
  """A collapsible widget showing a tool use."""
84
83
 
85
84
  can_focus = False
@@ -170,14 +169,9 @@ class ToolUseWidget(Static, HoverableMixin):
170
169
  return "\n\n".join(parts)
171
170
 
172
171
  def on_button_pressed(self, event: Button.Pressed) -> None:
173
- if "copy-btn" in event.button.classes:
174
- event.stop()
175
- try:
176
- pyperclip.copy(self.get_copyable_content())
177
- self.app.notify("Copied tool output")
178
- except Exception as e:
179
- self.app.notify(f"Copy failed: {e}", severity="error")
180
- elif "edit-plan-btn" in event.button.classes:
172
+ if self.handle_copy_button(event):
173
+ return
174
+ if "edit-plan-btn" in event.button.classes:
181
175
  event.stop()
182
176
  if hasattr(self, "_plan_path"):
183
177
  self.post_message(EditPlanRequested(self._plan_path))
@@ -374,7 +368,7 @@ class TaskWidget(Static):
374
368
  pass # Widget may not be mounted
375
369
 
376
370
 
377
- class ShellOutputWidget(Static, HoverableMixin):
371
+ class ShellOutputWidget(Static, CopyableMixin):
378
372
  """Collapsible widget showing inline shell command output."""
379
373
 
380
374
  can_focus = False
@@ -413,13 +407,7 @@ class ShellOutputWidget(Static, HoverableMixin):
413
407
  return "\n".join(parts)
414
408
 
415
409
  def on_button_pressed(self, event: Button.Pressed) -> None:
416
- if "copy-btn" in event.button.classes:
417
- event.stop()
418
- try:
419
- pyperclip.copy(self.get_copyable_content())
420
- self.app.notify("Copied shell output")
421
- except Exception as e:
422
- self.app.notify(f"Copy failed: {e}", severity="error")
410
+ self.handle_copy_button(event)
423
411
 
424
412
 
425
413
  class AgentListWidget(Static):
@@ -0,0 +1,9 @@
1
+ """Input widgets - text areas, autocomplete, search."""
2
+
3
+ from claudechic.widgets.input.autocomplete import TextAreaAutoComplete
4
+ from claudechic.widgets.input.history_search import HistorySearch
5
+
6
+ __all__ = [
7
+ "TextAreaAutoComplete",
8
+ "HistorySearch",
9
+ ]
@@ -0,0 +1,51 @@
1
+ """Layout widgets - chat view, sidebar, footer."""
2
+
3
+ from claudechic.widgets.layout.chat_view import ChatView
4
+ from claudechic.widgets.layout.sidebar import (
5
+ AgentItem,
6
+ AgentSection,
7
+ WorktreeItem,
8
+ PlanItem,
9
+ PlanSection,
10
+ SidebarSection,
11
+ SidebarItem,
12
+ HamburgerButton,
13
+ SessionItem,
14
+ )
15
+ from claudechic.widgets.layout.footer import (
16
+ AutoEditLabel,
17
+ ModelLabel,
18
+ StatusFooter,
19
+ )
20
+ from claudechic.widgets.layout.indicators import (
21
+ IndicatorWidget,
22
+ CPUBar,
23
+ ContextBar,
24
+ ProcessIndicator,
25
+ )
26
+ from claudechic.widgets.layout.processes import (
27
+ ProcessPanel,
28
+ ProcessItem,
29
+ )
30
+
31
+ __all__ = [
32
+ "ChatView",
33
+ "AgentItem",
34
+ "AgentSection",
35
+ "WorktreeItem",
36
+ "PlanItem",
37
+ "PlanSection",
38
+ "SidebarSection",
39
+ "SidebarItem",
40
+ "HamburgerButton",
41
+ "SessionItem",
42
+ "AutoEditLabel",
43
+ "ModelLabel",
44
+ "StatusFooter",
45
+ "IndicatorWidget",
46
+ "CPUBar",
47
+ "ContextBar",
48
+ "ProcessIndicator",
49
+ "ProcessPanel",
50
+ "ProcessItem",
51
+ ]
@@ -4,22 +4,25 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
+ from textual.widget import Widget
8
+
7
9
  from claudechic.agent import (
8
10
  Agent,
9
11
  ImageAttachment,
10
12
  UserContent,
11
13
  AssistantContent,
12
14
  ToolUse,
15
+ TextBlock,
13
16
  )
14
17
  from claudechic.enums import ToolName
15
- from claudechic.widgets.chat import (
18
+ from claudechic.widgets.content.message import (
16
19
  ChatMessage,
17
20
  ChatAttachment,
18
21
  ThinkingIndicator,
19
22
  SystemInfo,
20
23
  )
21
- from claudechic.widgets.scroll import AutoHideScroll
22
- from claudechic.widgets.tools import ToolUseWidget, TaskWidget, AgentToolWidget
24
+ from claudechic.widgets.primitives.scroll import AutoHideScroll
25
+ from claudechic.widgets.content.tools import ToolUseWidget, TaskWidget, AgentToolWidget
23
26
 
24
27
  if TYPE_CHECKING:
25
28
  from claude_agent_sdk import ToolUseBlock, ToolResultBlock
@@ -75,30 +78,103 @@ class ChatView(AutoHideScroll):
75
78
  self._render_full()
76
79
 
77
80
  def _render_full(self) -> None:
78
- """Fully re-render the chat view from agent.messages."""
81
+ """Fully re-render the chat view from agent.messages.
82
+
83
+ Uses mount_all() to batch all widget mounts into a single CSS recalculation,
84
+ which is much faster than mounting widgets one at a time.
85
+ """
79
86
  self.clear()
80
87
  if not self._agent:
81
88
  return
82
89
 
90
+ # Count total tool uses to determine which to collapse
91
+ # (collapse all except last RECENT_TOOLS_EXPANDED)
92
+ total_tools = sum(
93
+ sum(1 for b in item.content.blocks if isinstance(b, ToolUse))
94
+ for item in self._agent.messages
95
+ if item.role == "assistant" and isinstance(item.content, AssistantContent)
96
+ )
97
+ collapse_threshold = total_tools - RECENT_TOOLS_EXPANDED
98
+ tool_index = 0
99
+
100
+ # Build all widgets first, then mount in one batch
101
+ widgets: list[Widget] = []
83
102
  for item in self._agent.messages:
84
103
  if item.role == "user" and isinstance(item.content, UserContent):
85
- self._mount_user_message(item.content.text, item.content.images)
104
+ widgets.extend(
105
+ self._create_user_widgets(item.content.text, item.content.images)
106
+ )
86
107
  elif item.role == "assistant" and isinstance(
87
108
  item.content, AssistantContent
88
109
  ):
89
- self._render_assistant_history(item.content)
110
+ new_widgets, tool_index = self._create_assistant_widgets(
111
+ item.content, tool_index, collapse_threshold
112
+ )
113
+ widgets.extend(new_widgets)
90
114
 
115
+ # Single mount_all triggers one CSS recalculation instead of N
116
+ self.mount_all(widgets)
91
117
  self.scroll_end(animate=False)
92
118
 
93
- def _render_assistant_history(self, content: AssistantContent) -> None:
94
- """Render an assistant message from history."""
95
- if content.text:
96
- msg = ChatMessage(content.text)
97
- msg.add_class("assistant-message")
98
- self.mount(msg)
119
+ def _create_user_widgets(
120
+ self, text: str, images: list[ImageAttachment], is_agent: bool = False
121
+ ) -> list[Widget]:
122
+ """Create widgets for a user message (without mounting)."""
123
+ widgets: list[Widget] = []
124
+ msg = ChatMessage(text, is_agent=is_agent)
125
+ msg.add_class("agent-message" if is_agent else "user-message")
126
+ widgets.append(msg)
127
+
128
+ for i, img in enumerate(images):
129
+ if img.filename.lower().startswith("screenshot"):
130
+ display_name = f"Screenshot #{i + 1}"
131
+ else:
132
+ display_name = img.filename
133
+ widgets.append(ChatAttachment(img.filename, display_name))
134
+
135
+ return widgets
136
+
137
+ def _create_assistant_widgets(
138
+ self, content: AssistantContent, tool_index: int, collapse_threshold: int
139
+ ) -> tuple[list[Widget], int]:
140
+ """Create widgets for an assistant message (without mounting).
141
+
142
+ Iterates over blocks in order to preserve text/tool interleaving.
143
+ Returns (widgets, updated_tool_index).
144
+ """
145
+ widgets: list[Widget] = []
146
+ for block in content.blocks:
147
+ if isinstance(block, TextBlock):
148
+ msg = ChatMessage(block.text)
149
+ msg.add_class("assistant-message")
150
+ widgets.append(msg)
151
+ elif isinstance(block, ToolUse):
152
+ collapse = tool_index < collapse_threshold
153
+ widgets.append(
154
+ self._create_tool_widget(block, completed=True, collapsed=collapse)
155
+ )
156
+ tool_index += 1
157
+
158
+ return widgets, tool_index
159
+
160
+ def _create_tool_widget(
161
+ self, tool: ToolUse, completed: bool = False, collapsed: bool = False
162
+ ) -> Widget:
163
+ """Create a tool widget (without mounting)."""
164
+ from claude_agent_sdk import ToolUseBlock
165
+
166
+ block = ToolUseBlock(id=tool.id, name=tool.name, input=tool.input)
167
+ should_collapse = collapsed or tool.name in COLLAPSE_BY_DEFAULT
168
+ cwd = self._agent.cwd if self._agent else None
99
169
 
100
- for tool in content.tool_uses:
101
- self._mount_tool_widget(tool, completed=True)
170
+ if tool.name == ToolName.TASK:
171
+ return TaskWidget(block, collapsed=should_collapse, cwd=cwd)
172
+ elif tool.name.startswith("mcp__chic__"):
173
+ return AgentToolWidget(block, cwd=cwd)
174
+ else:
175
+ return ToolUseWidget(
176
+ block, collapsed=should_collapse, completed=completed, cwd=cwd
177
+ )
102
178
 
103
179
  # -----------------------------------------------------------------------
104
180
  # Streaming API - called by ChatApp during live response
@@ -233,35 +309,8 @@ class ChatView(AutoHideScroll):
233
309
  self, text: str, images: list[ImageAttachment], is_agent: bool = False
234
310
  ) -> None:
235
311
  """Mount a user message widget with optional image attachments."""
236
- msg = ChatMessage(text, is_agent=is_agent)
237
- msg.add_class("agent-message" if is_agent else "user-message")
238
- self.mount(msg)
239
-
240
- for i, img in enumerate(images):
241
- if img.filename.lower().startswith("screenshot"):
242
- display_name = f"Screenshot #{i + 1}"
243
- else:
244
- display_name = img.filename
245
- self.mount(ChatAttachment(img.filename, display_name))
246
-
247
- def _mount_tool_widget(self, tool: ToolUse, completed: bool = False) -> None:
248
- """Mount a tool widget (for history rendering)."""
249
- from claude_agent_sdk import ToolUseBlock
250
-
251
- block = ToolUseBlock(id=tool.id, name=tool.name, input=tool.input)
252
- collapsed = tool.name in COLLAPSE_BY_DEFAULT
253
- cwd = self._agent.cwd if self._agent else None
254
-
255
- if tool.name == ToolName.TASK:
256
- widget = TaskWidget(block, collapsed=collapsed, cwd=cwd)
257
- elif tool.name.startswith("mcp__chic__"):
258
- widget = AgentToolWidget(block, cwd=cwd)
259
- else:
260
- widget = ToolUseWidget(
261
- block, collapsed=collapsed, completed=completed, cwd=cwd
262
- )
263
-
264
- self.mount(widget)
312
+ for widget in self._create_user_widgets(text, images, is_agent):
313
+ self.mount(widget)
265
314
 
266
315
  def _hide_thinking(self) -> None:
267
316
  """Remove thinking indicator if present."""
@@ -8,11 +8,12 @@ from textual.reactive import reactive
8
8
  from textual.containers import Horizontal
9
9
  from textual.widgets import Static
10
10
 
11
- from claudechic.widgets.button import Button
12
- from claudechic.widgets.indicators import CPUBar, ContextBar
11
+ from claudechic.widgets.base.clickable import ClickableLabel
12
+ from claudechic.widgets.layout.indicators import CPUBar, ContextBar, ProcessIndicator
13
+ from claudechic.processes import BackgroundProcess
13
14
 
14
15
 
15
- class AutoEditLabel(Button):
16
+ class AutoEditLabel(ClickableLabel):
16
17
  """Clickable auto-edit status label."""
17
18
 
18
19
  class Toggled(Message):
@@ -22,14 +23,14 @@ class AutoEditLabel(Button):
22
23
  self.post_message(self.Toggled())
23
24
 
24
25
 
25
- class ModelLabel(Button):
26
+ class ModelLabel(ClickableLabel):
26
27
  """Clickable model label."""
27
28
 
28
- class Clicked(Message):
29
- """Emitted when model label is clicked."""
29
+ class ModelChangeRequested(Message):
30
+ """Emitted when user wants to change the model."""
30
31
 
31
32
  def on_click(self, event) -> None:
32
- self.post_message(self.Clicked())
33
+ self.post_message(self.ModelChangeRequested())
33
34
 
34
35
 
35
36
  async def get_git_branch(cwd: str | None = None) -> str:
@@ -72,6 +73,7 @@ class StatusFooter(Static):
72
73
  "Auto-edit: off", id="auto-edit-label", classes="footer-label"
73
74
  )
74
75
  yield Static("", id="footer-spacer")
76
+ yield ProcessIndicator(id="process-indicator", classes="hidden")
75
77
  yield ContextBar(id="context-bar")
76
78
  yield CPUBar(id="cpu-bar")
77
79
  yield Static("", id="branch-label", classes="footer-label")
@@ -100,3 +102,11 @@ class StatusFooter(Static):
100
102
  label.set_class(value, "active")
101
103
  except Exception:
102
104
  pass
105
+
106
+ def update_processes(self, processes: list[BackgroundProcess]) -> None:
107
+ """Update the process indicator."""
108
+ try:
109
+ indicator = self.query_one("#process-indicator", ProcessIndicator)
110
+ indicator.update_processes(processes)
111
+ except Exception:
112
+ pass
@@ -1,21 +1,32 @@
1
- """Resource indicator widgets - context bar and CPU monitor."""
1
+ """Resource indicator widgets - context bar, CPU monitor, and process indicator."""
2
2
 
3
3
  import psutil
4
4
 
5
5
  from textual.app import RenderResult
6
6
  from textual.reactive import reactive
7
+ from textual.widgets import Static
7
8
  from rich.text import Text
8
9
 
9
- from claudechic.widgets.button import Button
10
+ from claudechic.widgets.base.cursor import ClickableMixin
10
11
  from claudechic.formatting import MAX_CONTEXT_TOKENS
11
12
  from claudechic.profiling import profile, timed
13
+ from claudechic.processes import BackgroundProcess
12
14
 
13
15
 
14
- class CPUBar(Button):
16
+ class IndicatorWidget(Static, ClickableMixin):
17
+ """Base class for clickable indicator widgets in the footer.
18
+
19
+ Inherits from Static (for render()) and ClickableMixin (for pointer cursor).
20
+ Override on_click() to handle click events.
21
+ """
22
+
23
+ can_focus = True
24
+
25
+
26
+ class CPUBar(IndicatorWidget):
15
27
  """Display CPU usage. Click to show profiling stats."""
16
28
 
17
29
  cpu_pct = reactive(0.0)
18
- can_focus = True
19
30
 
20
31
  def on_mount(self) -> None:
21
32
  self._process = psutil.Process()
@@ -46,17 +57,16 @@ class CPUBar(Button):
46
57
 
47
58
  def on_click(self, event) -> None:
48
59
  """Show profile modal on click."""
49
- from claudechic.widgets.profile_modal import ProfileModal
60
+ from claudechic.widgets.modals.profile import ProfileModal
50
61
 
51
62
  self.app.push_screen(ProfileModal())
52
63
 
53
64
 
54
- class ContextBar(Button):
65
+ class ContextBar(IndicatorWidget):
55
66
  """Display context usage as a progress bar. Click to run /context."""
56
67
 
57
68
  tokens = reactive(0)
58
69
  max_tokens = reactive(MAX_CONTEXT_TOKENS)
59
- can_focus = True
60
70
 
61
71
  def render(self) -> RenderResult:
62
72
  pct = min(self.tokens / self.max_tokens, 1.0) if self.max_tokens else 0
@@ -89,3 +99,41 @@ class ContextBar(Button):
89
99
 
90
100
  if isinstance(self.app, ChatApp):
91
101
  self.app._handle_prompt("/context")
102
+
103
+
104
+ class ProcessIndicator(IndicatorWidget):
105
+ """Display count of background processes. Click to show details."""
106
+
107
+ DEFAULT_CSS = """
108
+ ProcessIndicator {
109
+ width: auto;
110
+ padding: 0 1;
111
+ }
112
+ ProcessIndicator:hover {
113
+ background: $panel;
114
+ }
115
+ ProcessIndicator.hidden {
116
+ display: none;
117
+ }
118
+ """
119
+
120
+ count = reactive(0)
121
+
122
+ def __init__(self, *args, **kwargs) -> None:
123
+ super().__init__(*args, **kwargs)
124
+ self._processes: list[BackgroundProcess] = []
125
+
126
+ def update_processes(self, processes: list[BackgroundProcess]) -> None:
127
+ """Update the process list and count."""
128
+ self._processes = processes
129
+ self.count = len(processes)
130
+ self.set_class(self.count == 0, "hidden")
131
+
132
+ def render(self) -> RenderResult:
133
+ return Text.assemble(("⚙ ", "yellow"), (f"{self.count}", ""))
134
+
135
+ def on_click(self, event) -> None:
136
+ """Show process modal on click."""
137
+ from claudechic.widgets.modals.process_modal import ProcessModal
138
+
139
+ self.app.push_screen(ProcessModal(self._processes))