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
|
@@ -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
|
|
22
|
-
"""Posted when hamburger is
|
|
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.
|
|
152
|
+
self.post_message(self.SidebarToggled())
|
|
60
153
|
|
|
61
154
|
|
|
62
|
-
class
|
|
63
|
-
"""
|
|
155
|
+
class PlanItem(SidebarItem):
|
|
156
|
+
"""Clickable plan item that opens the plan file."""
|
|
64
157
|
|
|
65
|
-
class
|
|
66
|
-
"""Posted when plan
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
254
|
-
|
|
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
|
-
|
|
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,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
|
+
]
|
|
@@ -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
|
-
|
|
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:
|