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.
- claudechic/__init__.py +3 -1
- claudechic/__main__.py +12 -1
- claudechic/agent.py +60 -19
- claudechic/agent_manager.py +8 -2
- claudechic/analytics.py +62 -0
- claudechic/app.py +267 -158
- claudechic/commands.py +120 -6
- claudechic/config.py +80 -0
- claudechic/features/worktree/commands.py +70 -1
- claudechic/help_data.py +200 -0
- claudechic/messages.py +0 -17
- claudechic/processes.py +120 -0
- claudechic/profiling.py +18 -1
- claudechic/protocols.py +1 -1
- claudechic/remote.py +249 -0
- claudechic/sessions.py +60 -50
- claudechic/styles.tcss +19 -18
- claudechic/widgets/__init__.py +112 -41
- claudechic/widgets/base/__init__.py +20 -0
- claudechic/widgets/base/clickable.py +23 -0
- claudechic/widgets/base/copyable.py +55 -0
- claudechic/{cursor.py → widgets/base/cursor.py} +9 -28
- claudechic/widgets/base/tool_protocol.py +30 -0
- claudechic/widgets/content/__init__.py +41 -0
- claudechic/widgets/{diff.py → content/diff.py} +11 -65
- claudechic/widgets/{chat.py → content/message.py} +25 -76
- claudechic/widgets/{tools.py → content/tools.py} +12 -24
- claudechic/widgets/input/__init__.py +9 -0
- claudechic/widgets/layout/__init__.py +51 -0
- claudechic/widgets/{chat_view.py → layout/chat_view.py} +92 -43
- claudechic/widgets/{footer.py → layout/footer.py} +17 -7
- claudechic/widgets/{indicators.py → layout/indicators.py} +55 -7
- claudechic/widgets/layout/processes.py +68 -0
- claudechic/widgets/{agents.py → layout/sidebar.py} +163 -82
- claudechic/widgets/modals/__init__.py +9 -0
- claudechic/widgets/modals/process_modal.py +121 -0
- claudechic/widgets/{profile_modal.py → modals/profile.py} +2 -1
- claudechic/widgets/primitives/__init__.py +13 -0
- claudechic/widgets/{button.py → primitives/button.py} +1 -1
- claudechic/widgets/{collapsible.py → primitives/collapsible.py} +5 -1
- claudechic/widgets/{scroll.py → primitives/scroll.py} +2 -0
- claudechic/widgets/primitives/spinner.py +57 -0
- claudechic/widgets/prompts.py +146 -17
- claudechic/widgets/reports/__init__.py +10 -0
- claudechic-0.3.1.dist-info/METADATA +88 -0
- claudechic-0.3.1.dist-info/RECORD +71 -0
- {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/WHEEL +1 -1
- claudechic-0.3.1.dist-info/licenses/LICENSE +21 -0
- claudechic/features/worktree/prompts.py +0 -101
- claudechic/widgets/model_prompt.py +0 -56
- claudechic-0.2.2.dist-info/METADATA +0 -58
- claudechic-0.2.2.dist-info/RECORD +0 -54
- /claudechic/widgets/{todo.py → content/todo.py} +0 -0
- /claudechic/widgets/{autocomplete.py → input/autocomplete.py} +0 -0
- /claudechic/widgets/{history_search.py → input/history_search.py} +0 -0
- /claudechic/widgets/{context_report.py → reports/context.py} +0 -0
- /claudechic/widgets/{usage.py → reports/usage.py} +0 -0
- {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/entry_points.txt +0 -0
- {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.
|
|
18
|
-
from claudechic.widgets.
|
|
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
|
|
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
|
-
|
|
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.
|
|
31
|
-
from claudechic.
|
|
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,
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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,
|
|
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
|
-
|
|
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,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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
26
|
+
class ModelLabel(ClickableLabel):
|
|
26
27
|
"""Clickable model label."""
|
|
27
28
|
|
|
28
|
-
class
|
|
29
|
-
"""Emitted when
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
60
|
+
from claudechic.widgets.modals.profile import ProfileModal
|
|
50
61
|
|
|
51
62
|
self.app.push_screen(ProfileModal())
|
|
52
63
|
|
|
53
64
|
|
|
54
|
-
class ContextBar(
|
|
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))
|