copper-pilot-cli 0.1.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.
- copper_pilot_cli/__init__.py +37 -0
- copper_pilot_cli/__main__.py +6 -0
- copper_pilot_cli/_upstream/__init__.py +0 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +46 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +222 -0
- copper_pilot_cli/_version.py +32 -0
- copper_pilot_cli/clipboard.py +107 -0
- copper_pilot_cli/copper_api.py +24 -0
- copper_pilot_cli/copper_app.py +1071 -0
- copper_pilot_cli/copper_auth.py +232 -0
- copper_pilot_cli/copper_config.py +62 -0
- copper_pilot_cli/copper_features.py +263 -0
- copper_pilot_cli/copper_graph.py +178 -0
- copper_pilot_cli/copper_hooks.py +49 -0
- copper_pilot_cli/copper_main.py +345 -0
- copper_pilot_cli/copper_preferences.py +85 -0
- copper_pilot_cli/copper_presentation.py +325 -0
- copper_pilot_cli/copper_protocol.py +421 -0
- copper_pilot_cli/copper_theme.py +34 -0
- copper_pilot_cli/copper_tools.py +23 -0
- copper_pilot_cli/copper_update.py +32 -0
- copper_pilot_cli/copper_widgets.py +1224 -0
- copper_pilot_cli/copper_workspace.py +151 -0
- copper_pilot_cli/deepagents_tools.py +556 -0
- copper_pilot_cli/diagnostics.py +55 -0
- copper_pilot_cli/langchain.py +215 -0
- copper_pilot_cli/media_utils.py +626 -0
- copper_pilot_cli/py.typed +0 -0
- copper_pilot_cli/sessions.py +1578 -0
- copper_pilot_cli/textual_patches.py +35 -0
- copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/NOTICE +13 -0
- copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/UPSTREAM.md +44 -0
- copper_pilot_cli-0.1.1.dist-info/METADATA +211 -0
- copper_pilot_cli-0.1.1.dist-info/RECORD +39 -0
- copper_pilot_cli-0.1.1.dist-info/WHEEL +4 -0
- copper_pilot_cli-0.1.1.dist-info/entry_points.txt +3 -0
- copper_pilot_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
- copper_pilot_cli-0.1.1.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1,1224 @@
|
|
|
1
|
+
"""Curated Textual widgets adapted from the pinned dcode presentation fork."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from difflib import unified_diff
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from time import monotonic
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from textual import events, on
|
|
17
|
+
from textual.app import ComposeResult
|
|
18
|
+
from textual.binding import Binding
|
|
19
|
+
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
|
20
|
+
from textual.content import Content
|
|
21
|
+
from textual.message import Message
|
|
22
|
+
from textual.reactive import var
|
|
23
|
+
from textual.screen import ModalScreen
|
|
24
|
+
from textual.timer import Timer
|
|
25
|
+
from textual.widget import Widget
|
|
26
|
+
from textual.widgets import Checkbox, Input, Label, Markdown, OptionList, Static, TextArea
|
|
27
|
+
from textual.widgets.option_list import Option
|
|
28
|
+
|
|
29
|
+
from copper_pilot_cli.copper_config import paths
|
|
30
|
+
from copper_pilot_cli.copper_tools import ApprovalRequest, canonical_tool_name
|
|
31
|
+
from copper_pilot_cli.sessions import ThreadInfo, list_threads
|
|
32
|
+
|
|
33
|
+
MAX_WIDGET_TEXT = 64 * 1024
|
|
34
|
+
_TERMINAL_ESCAPE = re.compile(r"\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def literal_text(value: object, limit: int = MAX_WIDGET_TEXT) -> Content:
|
|
38
|
+
"""Render untrusted hosted/local text without interpreting Rich markup."""
|
|
39
|
+
text = _TERMINAL_ESCAPE.sub("", str(value))
|
|
40
|
+
text = "".join(character for character in text if character in "\n\t" or ord(character) >= 32)
|
|
41
|
+
if len(text) > limit:
|
|
42
|
+
text = text[:limit] + "\n[display truncated]"
|
|
43
|
+
return Content(text)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def bounded_widget_text(value: object, limit: int = MAX_WIDGET_TEXT) -> str:
|
|
47
|
+
text = str(value)
|
|
48
|
+
return text if len(text) <= limit else text[:limit] + "\n[display truncated]"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class CompletionEntry:
|
|
53
|
+
name: str
|
|
54
|
+
description: str
|
|
55
|
+
display_name: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ComposerTextArea(TextArea):
|
|
59
|
+
"""Text area that reserves bare Enter for prompt submission."""
|
|
60
|
+
|
|
61
|
+
class Submitted(Message):
|
|
62
|
+
def __init__(self, value: str) -> None:
|
|
63
|
+
super().__init__()
|
|
64
|
+
self.value = value
|
|
65
|
+
|
|
66
|
+
def __init__(self, history: list[str], **kwargs: Any) -> None:
|
|
67
|
+
super().__init__(**kwargs)
|
|
68
|
+
self.input_history = history
|
|
69
|
+
self.history_index = len(history)
|
|
70
|
+
|
|
71
|
+
def submit(self) -> None:
|
|
72
|
+
value = self.text.strip()
|
|
73
|
+
if value:
|
|
74
|
+
self.text = ""
|
|
75
|
+
self.post_message(self.Submitted(value))
|
|
76
|
+
|
|
77
|
+
async def _on_key(self, event: events.Key) -> None:
|
|
78
|
+
completion_active = bool(getattr(self.parent, "completion_active", False))
|
|
79
|
+
if completion_active and event.key in {
|
|
80
|
+
"up",
|
|
81
|
+
"down",
|
|
82
|
+
"tab",
|
|
83
|
+
"enter",
|
|
84
|
+
"space",
|
|
85
|
+
"escape",
|
|
86
|
+
}:
|
|
87
|
+
event.prevent_default()
|
|
88
|
+
return
|
|
89
|
+
if event.key == "enter":
|
|
90
|
+
self.submit()
|
|
91
|
+
event.stop()
|
|
92
|
+
event.prevent_default()
|
|
93
|
+
return
|
|
94
|
+
row, _ = self.cursor_location
|
|
95
|
+
if event.key == "up" and self.input_history and row == 0:
|
|
96
|
+
self.history_index = max(self.history_index - 1, 0)
|
|
97
|
+
self.text = self.input_history[self.history_index]
|
|
98
|
+
self.move_cursor((len(self.document.lines) - 1, len(self.document.lines[-1])))
|
|
99
|
+
event.stop()
|
|
100
|
+
event.prevent_default()
|
|
101
|
+
return
|
|
102
|
+
if event.key == "down" and self.input_history and row == len(self.document.lines) - 1:
|
|
103
|
+
self.history_index = min(self.history_index + 1, len(self.input_history))
|
|
104
|
+
self.text = (
|
|
105
|
+
self.input_history[self.history_index]
|
|
106
|
+
if self.history_index < len(self.input_history)
|
|
107
|
+
else ""
|
|
108
|
+
)
|
|
109
|
+
event.stop()
|
|
110
|
+
event.prevent_default()
|
|
111
|
+
return
|
|
112
|
+
await super()._on_key(event)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ChatInput(Vertical):
|
|
116
|
+
"""Multiline dcode-style composer with slash and `@file` completion."""
|
|
117
|
+
|
|
118
|
+
DEFAULT_CSS = """
|
|
119
|
+
ChatInput {
|
|
120
|
+
height: auto;
|
|
121
|
+
max-height: 16;
|
|
122
|
+
border: solid $primary;
|
|
123
|
+
background: $surface;
|
|
124
|
+
}
|
|
125
|
+
ChatInput TextArea {
|
|
126
|
+
height: auto;
|
|
127
|
+
min-height: 3;
|
|
128
|
+
max-height: 10;
|
|
129
|
+
border: none;
|
|
130
|
+
background: transparent;
|
|
131
|
+
}
|
|
132
|
+
ChatInput OptionList {
|
|
133
|
+
height: auto;
|
|
134
|
+
max-height: 7;
|
|
135
|
+
display: none;
|
|
136
|
+
border-top: solid $primary;
|
|
137
|
+
}
|
|
138
|
+
ChatInput.-completing OptionList {
|
|
139
|
+
display: block;
|
|
140
|
+
}
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
class Submitted(Message):
|
|
144
|
+
def __init__(self, value: str) -> None:
|
|
145
|
+
super().__init__()
|
|
146
|
+
self.value = value
|
|
147
|
+
self.mode = "normal"
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
cwd: str | Path | None = None,
|
|
152
|
+
history_file: Path | None = None,
|
|
153
|
+
image_tracker: Any | None = None,
|
|
154
|
+
**kwargs: Any,
|
|
155
|
+
) -> None:
|
|
156
|
+
del image_tracker
|
|
157
|
+
super().__init__(**kwargs)
|
|
158
|
+
self.cwd = Path(cwd or ".").resolve()
|
|
159
|
+
self.history_file = history_file or paths().history
|
|
160
|
+
try:
|
|
161
|
+
self.history = [
|
|
162
|
+
value
|
|
163
|
+
for line in self.history_file.read_text(encoding="utf-8").splitlines()
|
|
164
|
+
if line
|
|
165
|
+
for value in [json.loads(line)]
|
|
166
|
+
if isinstance(value, str)
|
|
167
|
+
][-500:]
|
|
168
|
+
except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
169
|
+
self.history = []
|
|
170
|
+
self.commands: list[CompletionEntry] = []
|
|
171
|
+
self._matches: list[CompletionEntry] = []
|
|
172
|
+
self._completion_kind: str | None = None
|
|
173
|
+
self._selected_index = 0
|
|
174
|
+
self.last_changed_at = 0.0
|
|
175
|
+
self._typing_changed = asyncio.Event()
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def completion_active(self) -> bool:
|
|
179
|
+
return bool(self._matches)
|
|
180
|
+
|
|
181
|
+
def compose(self) -> ComposeResult:
|
|
182
|
+
yield ComposerTextArea(self.history, id="chat-input", soft_wrap=True)
|
|
183
|
+
yield OptionList(id="completions")
|
|
184
|
+
|
|
185
|
+
def focus(self, scroll_visible: bool = True) -> ChatInput:
|
|
186
|
+
self.query_one(TextArea).focus(scroll_visible)
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def update_slash_commands(self, commands: Sequence[Any]) -> None:
|
|
190
|
+
self.commands = [
|
|
191
|
+
CompletionEntry(
|
|
192
|
+
name=str(item.name),
|
|
193
|
+
description=str(item.description),
|
|
194
|
+
display_name=str(getattr(item, "display_name", "") or ""),
|
|
195
|
+
)
|
|
196
|
+
for item in commands
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
def set_cwd(self, cwd: str | Path) -> None:
|
|
200
|
+
self.cwd = Path(cwd).resolve()
|
|
201
|
+
|
|
202
|
+
def _current_token(self) -> str:
|
|
203
|
+
text = self.query_one(TextArea).text
|
|
204
|
+
return re.split(r"\s", text)[-1] if text else ""
|
|
205
|
+
|
|
206
|
+
def _file_entries(self, token: str) -> list[CompletionEntry]:
|
|
207
|
+
query = token.removeprefix("@").lower()
|
|
208
|
+
entries: list[CompletionEntry] = []
|
|
209
|
+
for path in self.cwd.rglob("*"):
|
|
210
|
+
if len(entries) >= 100:
|
|
211
|
+
break
|
|
212
|
+
if not path.is_file() or any(
|
|
213
|
+
part in {".git", "node_modules", "__pycache__", ".venv"}
|
|
214
|
+
for part in path.relative_to(self.cwd).parts
|
|
215
|
+
):
|
|
216
|
+
continue
|
|
217
|
+
relative = path.relative_to(self.cwd).as_posix()
|
|
218
|
+
if query in relative.lower():
|
|
219
|
+
entries.append(CompletionEntry(f"@{relative}", "File", f"@{relative} [file]"))
|
|
220
|
+
return sorted(entries, key=lambda item: (len(item.name), item.name.lower()))
|
|
221
|
+
|
|
222
|
+
def _refresh_completions(self) -> None:
|
|
223
|
+
token = self._current_token()
|
|
224
|
+
if token.startswith("/"):
|
|
225
|
+
query = token.lower()
|
|
226
|
+
self._completion_kind = "slash"
|
|
227
|
+
self._matches = [
|
|
228
|
+
item
|
|
229
|
+
for item in self.commands
|
|
230
|
+
if query in item.name.lower() or query in item.description.lower()
|
|
231
|
+
][:50]
|
|
232
|
+
elif token.startswith("@"):
|
|
233
|
+
self._completion_kind = "file"
|
|
234
|
+
self._matches = self._file_entries(token)
|
|
235
|
+
else:
|
|
236
|
+
self._completion_kind = None
|
|
237
|
+
self._matches = []
|
|
238
|
+
self._selected_index = 0
|
|
239
|
+
options = self.query_one(OptionList)
|
|
240
|
+
options.clear_options()
|
|
241
|
+
options.add_options(
|
|
242
|
+
Option(
|
|
243
|
+
literal_text(f"{item.display_name or item.name} {item.description}"),
|
|
244
|
+
id=str(index),
|
|
245
|
+
)
|
|
246
|
+
for index, item in enumerate(self._matches)
|
|
247
|
+
)
|
|
248
|
+
options.highlighted = 0 if self._matches else None
|
|
249
|
+
self.set_class(bool(self._matches), "-completing")
|
|
250
|
+
|
|
251
|
+
@on(TextArea.Changed)
|
|
252
|
+
def changed(self) -> None:
|
|
253
|
+
self.last_changed_at = monotonic()
|
|
254
|
+
self._typing_changed.set()
|
|
255
|
+
self._refresh_completions()
|
|
256
|
+
|
|
257
|
+
async def wait_until_typing_idle(self, idle_for: float = 0.6, max_wait: float = 5.0) -> None:
|
|
258
|
+
"""Avoid letting approval shortcuts consume prompt text being typed."""
|
|
259
|
+
try:
|
|
260
|
+
async with asyncio.timeout(max_wait):
|
|
261
|
+
while self.query_one(TextArea).has_focus:
|
|
262
|
+
remaining = idle_for - (monotonic() - self.last_changed_at)
|
|
263
|
+
if remaining <= 0:
|
|
264
|
+
return
|
|
265
|
+
self._typing_changed.clear()
|
|
266
|
+
try:
|
|
267
|
+
async with asyncio.timeout(remaining):
|
|
268
|
+
await self._typing_changed.wait()
|
|
269
|
+
except TimeoutError:
|
|
270
|
+
return
|
|
271
|
+
except TimeoutError:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
@on(OptionList.OptionSelected)
|
|
275
|
+
def complete(self, event: OptionList.OptionSelected) -> None:
|
|
276
|
+
self._apply_completion(int(str(event.option.id)))
|
|
277
|
+
|
|
278
|
+
def _dismiss_completion(self) -> None:
|
|
279
|
+
self._matches = []
|
|
280
|
+
self._completion_kind = None
|
|
281
|
+
self._selected_index = 0
|
|
282
|
+
self.query_one(OptionList).clear_options()
|
|
283
|
+
self.set_class(False, "-completing")
|
|
284
|
+
|
|
285
|
+
def _apply_completion(self, index: int, *, trailing_space: bool = True) -> None:
|
|
286
|
+
if not 0 <= index < len(self._matches):
|
|
287
|
+
return
|
|
288
|
+
area = self.query_one(TextArea)
|
|
289
|
+
text = area.text
|
|
290
|
+
token = self._current_token()
|
|
291
|
+
suffix = " " if trailing_space else ""
|
|
292
|
+
area.text = text[: len(text) - len(token)] + self._matches[index].name + suffix
|
|
293
|
+
area.move_cursor((len(area.document.lines) - 1, len(area.document.lines[-1])))
|
|
294
|
+
self._dismiss_completion()
|
|
295
|
+
area.focus()
|
|
296
|
+
|
|
297
|
+
async def on_key(self, event: events.Key) -> None:
|
|
298
|
+
if not self._matches:
|
|
299
|
+
return
|
|
300
|
+
if event.key in {"up", "down"}:
|
|
301
|
+
delta = -1 if event.key == "up" else 1
|
|
302
|
+
self._selected_index = (self._selected_index + delta) % len(self._matches)
|
|
303
|
+
self.query_one(OptionList).highlighted = self._selected_index
|
|
304
|
+
elif event.key == "escape":
|
|
305
|
+
self._dismiss_completion()
|
|
306
|
+
elif event.key == "enter":
|
|
307
|
+
kind = self._completion_kind
|
|
308
|
+
self._apply_completion(
|
|
309
|
+
self._selected_index,
|
|
310
|
+
trailing_space=kind != "slash",
|
|
311
|
+
)
|
|
312
|
+
if kind == "slash":
|
|
313
|
+
self.query_one(ComposerTextArea).submit()
|
|
314
|
+
elif event.key == "tab" or (event.key == "space" and self._completion_kind == "slash"):
|
|
315
|
+
self._apply_completion(self._selected_index)
|
|
316
|
+
elif event.key == "space":
|
|
317
|
+
self.query_one(ComposerTextArea).insert(" ")
|
|
318
|
+
else:
|
|
319
|
+
return
|
|
320
|
+
event.stop()
|
|
321
|
+
event.prevent_default()
|
|
322
|
+
|
|
323
|
+
@on(ComposerTextArea.Submitted)
|
|
324
|
+
def text_submitted(self, event: ComposerTextArea.Submitted) -> None:
|
|
325
|
+
self.history.append(event.value)
|
|
326
|
+
self.history[:] = self.history[-500:]
|
|
327
|
+
area = self.query_one(ComposerTextArea)
|
|
328
|
+
area.history_index = len(self.history)
|
|
329
|
+
try:
|
|
330
|
+
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
with self.history_file.open("a", encoding="utf-8") as handle:
|
|
332
|
+
handle.write(json.dumps(event.value) + "\n")
|
|
333
|
+
if self.history_file.exists() and self.history_file.stat().st_size > 2_000_000:
|
|
334
|
+
self.history_file.write_text(
|
|
335
|
+
"\n".join(json.dumps(value) for value in self.history) + "\n",
|
|
336
|
+
encoding="utf-8",
|
|
337
|
+
)
|
|
338
|
+
if self.history_file.exists() and self.history_file.parent.exists():
|
|
339
|
+
self.history_file.parent.chmod(stat.S_IRWXU)
|
|
340
|
+
self.history_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
341
|
+
except OSError:
|
|
342
|
+
pass
|
|
343
|
+
self.post_message(self.Submitted(event.value))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class UserMessage(Static):
|
|
347
|
+
DEFAULT_CSS = """
|
|
348
|
+
UserMessage {
|
|
349
|
+
height: auto;
|
|
350
|
+
padding: 1;
|
|
351
|
+
margin-bottom: 1;
|
|
352
|
+
background: $primary 12%;
|
|
353
|
+
border-left: wide $primary;
|
|
354
|
+
}
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
def __init__(self, content: str) -> None:
|
|
358
|
+
super().__init__(Content(content))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class AssistantMessage(Vertical):
|
|
362
|
+
DEFAULT_CSS = """
|
|
363
|
+
AssistantMessage { height: auto; padding: 0 1; margin-bottom: 1; }
|
|
364
|
+
AssistantMessage Markdown { padding: 0; }
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
def __init__(self, content: str = "") -> None:
|
|
368
|
+
super().__init__()
|
|
369
|
+
self.content = bounded_widget_text(content)
|
|
370
|
+
self._rendered_content = self.content
|
|
371
|
+
self._flush_timer: Timer | None = None
|
|
372
|
+
|
|
373
|
+
def compose(self) -> ComposeResult:
|
|
374
|
+
yield Markdown(self.content)
|
|
375
|
+
|
|
376
|
+
async def append_content(self, text: str) -> None:
|
|
377
|
+
self.content = bounded_widget_text(self.content + text)
|
|
378
|
+
if self._flush_timer is None:
|
|
379
|
+
self._flush_timer = self.set_timer(0.05, self._flush)
|
|
380
|
+
|
|
381
|
+
async def _flush(self) -> None:
|
|
382
|
+
self._flush_timer = None
|
|
383
|
+
if self.content != self._rendered_content and self.is_mounted:
|
|
384
|
+
self._rendered_content = self.content
|
|
385
|
+
await self.query_one(Markdown).update(self.content)
|
|
386
|
+
|
|
387
|
+
async def stop_stream(self) -> None:
|
|
388
|
+
if self._flush_timer is not None:
|
|
389
|
+
self._flush_timer.stop()
|
|
390
|
+
self._flush_timer = None
|
|
391
|
+
await self._flush()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class ReasoningMessage(Vertical):
|
|
395
|
+
DEFAULT_CSS = """
|
|
396
|
+
ReasoningMessage { height: auto; color: $text-muted; padding: 0 1; }
|
|
397
|
+
ReasoningMessage #reasoning { display: block; }
|
|
398
|
+
ReasoningMessage.-collapsed #reasoning { display: none; }
|
|
399
|
+
"""
|
|
400
|
+
collapsed = var(False, toggle_class="-collapsed")
|
|
401
|
+
|
|
402
|
+
def __init__(self, content: str = "") -> None:
|
|
403
|
+
super().__init__()
|
|
404
|
+
self.content = bounded_widget_text(content)
|
|
405
|
+
|
|
406
|
+
def compose(self) -> ComposeResult:
|
|
407
|
+
yield Static("Reasoning [click to collapse]", classes="reasoning-header")
|
|
408
|
+
yield Static(literal_text(self.content), id="reasoning")
|
|
409
|
+
|
|
410
|
+
@on(events.Click)
|
|
411
|
+
def toggle(self) -> None:
|
|
412
|
+
self.collapsed = not self.collapsed
|
|
413
|
+
|
|
414
|
+
async def append_content(self, text: str) -> None:
|
|
415
|
+
self.content = bounded_widget_text(self.content + text)
|
|
416
|
+
self.query_one("#reasoning", Static).update(literal_text(self.content))
|
|
417
|
+
|
|
418
|
+
async def stop_stream(self) -> None:
|
|
419
|
+
self.collapsed = True
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _tool_path(args: dict[str, Any]) -> str:
|
|
423
|
+
return str(args.get("file_path") or args.get("path") or "")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _tool_label(tool_name: str, args: dict[str, Any]) -> str:
|
|
427
|
+
name = canonical_tool_name(tool_name)
|
|
428
|
+
if name == "execute":
|
|
429
|
+
command = str(args.get("command") or "")
|
|
430
|
+
return f"$ {command}" if command else "Run shell command"
|
|
431
|
+
if name == "read_file":
|
|
432
|
+
return f"Read {_tool_path(args)}"
|
|
433
|
+
if name == "write_file":
|
|
434
|
+
return f"Write {_tool_path(args)}"
|
|
435
|
+
if name == "edit_file":
|
|
436
|
+
return f"Edit {_tool_path(args)}"
|
|
437
|
+
if name == "delete":
|
|
438
|
+
return f"Delete {_tool_path(args)}"
|
|
439
|
+
if name == "glob":
|
|
440
|
+
return f"Glob {args.get('pattern') or ''}"
|
|
441
|
+
if name == "grep":
|
|
442
|
+
suffix = f" in {args['path']}" if args.get("path") else ""
|
|
443
|
+
return f"Grep {args.get('pattern') or ''}{suffix}"
|
|
444
|
+
if name == "read_binary_file":
|
|
445
|
+
return f"Read binary {_tool_path(args)}"
|
|
446
|
+
if name == "write_binary_file":
|
|
447
|
+
return f"Write binary {_tool_path(args)}"
|
|
448
|
+
if name == "ask_user_question":
|
|
449
|
+
return "Ask user"
|
|
450
|
+
return tool_name
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _display_output(value: object) -> str:
|
|
454
|
+
if isinstance(value, str):
|
|
455
|
+
return value
|
|
456
|
+
if isinstance(value, dict):
|
|
457
|
+
if value.get("error"):
|
|
458
|
+
return str(value["error"])
|
|
459
|
+
if "result" in value:
|
|
460
|
+
result = value["result"]
|
|
461
|
+
return result if isinstance(result, str) else json.dumps(result, default=str, indent=2)
|
|
462
|
+
return json.dumps(value, default=str, indent=2)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _compact_command(command: str, limit: int = 120) -> str:
|
|
466
|
+
"""Keep shell rows to one visual line until explicitly expanded."""
|
|
467
|
+
line = " ".join(command.split())
|
|
468
|
+
return line if len(line) <= limit else line[: limit - 1] + "…"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
_TOOL_ACTIVITY = {
|
|
472
|
+
"execute": ("Running", "Ran", "shell command"),
|
|
473
|
+
"read_file": ("Reading", "Read", "file"),
|
|
474
|
+
"read_binary_file": ("Reading", "Read", "binary file"),
|
|
475
|
+
"write_file": ("Writing", "Wrote", "file"),
|
|
476
|
+
"write_binary_file": ("Writing", "Wrote", "binary file"),
|
|
477
|
+
"edit_file": ("Editing", "Edited", "file"),
|
|
478
|
+
"delete": ("Deleting", "Deleted", "file"),
|
|
479
|
+
"glob": ("Searching", "Searched", "path"),
|
|
480
|
+
"grep": ("Searching", "Searched", "file"),
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def format_work_duration(elapsed_seconds: float) -> str:
|
|
485
|
+
"""Format elapsed work using the CopperPilot desktop client's duration contract."""
|
|
486
|
+
total_seconds = max(1, round(max(0.0, elapsed_seconds)))
|
|
487
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
488
|
+
minutes, seconds = divmod(remainder, 60)
|
|
489
|
+
if hours:
|
|
490
|
+
label = f"{hours} {'hr' if hours == 1 else 'hrs'}"
|
|
491
|
+
return label if not minutes else f"{label} {minutes} {'min' if minutes == 1 else 'mins'}"
|
|
492
|
+
if minutes:
|
|
493
|
+
label = f"{minutes} {'min' if minutes == 1 else 'mins'}"
|
|
494
|
+
return label if not seconds else f"{label} {seconds}s"
|
|
495
|
+
return f"{total_seconds}s"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class WorkRunGroup(Vertical):
|
|
499
|
+
"""Electron-style live work window that disappears into a duration label."""
|
|
500
|
+
|
|
501
|
+
DEFAULT_CSS = """
|
|
502
|
+
WorkRunGroup { height: auto; margin-bottom: 1; }
|
|
503
|
+
WorkRunGroup .work-run-header { height: 1; color: $text-muted; padding: 0 1; }
|
|
504
|
+
WorkRunGroup .work-run-body { height: auto; }
|
|
505
|
+
WorkRunGroup.-complete .work-run-body { display: none; }
|
|
506
|
+
WorkRunGroup.-failed .work-run-body { display: block; }
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
def __init__(self, *, timed: bool = True) -> None:
|
|
510
|
+
super().__init__()
|
|
511
|
+
self.units: list[Widget] = []
|
|
512
|
+
self.started_at = monotonic() if timed else None
|
|
513
|
+
self.finished_at: float | None = None
|
|
514
|
+
self.failed = False
|
|
515
|
+
self._timer: Timer | None = None
|
|
516
|
+
|
|
517
|
+
def compose(self) -> ComposeResult:
|
|
518
|
+
yield Static(self._label(), classes="work-run-header")
|
|
519
|
+
yield Vertical(classes="work-run-body")
|
|
520
|
+
|
|
521
|
+
def on_mount(self) -> None:
|
|
522
|
+
self._refresh_header()
|
|
523
|
+
if self.started_at is not None:
|
|
524
|
+
self._timer = self.set_interval(1.0, self._refresh_header)
|
|
525
|
+
|
|
526
|
+
async def add_unit(self, widget: Widget) -> None:
|
|
527
|
+
self.units.append(widget)
|
|
528
|
+
widget.add_class("work-run-unit")
|
|
529
|
+
await self.query_one(".work-run-body", Vertical).mount(widget)
|
|
530
|
+
self._refresh_visibility()
|
|
531
|
+
|
|
532
|
+
def touch_unit(self, widget: Widget) -> None:
|
|
533
|
+
"""Move a reused live summary to the latest position in the work window."""
|
|
534
|
+
if widget not in self.units:
|
|
535
|
+
return
|
|
536
|
+
self.units.remove(widget)
|
|
537
|
+
self.units.append(widget)
|
|
538
|
+
body = self.query_one(".work-run-body", Vertical)
|
|
539
|
+
if body.children and body.children[-1] is not widget:
|
|
540
|
+
body.move_child(widget, after=body.children[-1])
|
|
541
|
+
self._refresh_visibility()
|
|
542
|
+
|
|
543
|
+
def mark_failed(self) -> None:
|
|
544
|
+
self.failed = True
|
|
545
|
+
self.add_class("-failed")
|
|
546
|
+
self._refresh_visibility()
|
|
547
|
+
|
|
548
|
+
def finalize(self) -> None:
|
|
549
|
+
if self.finished_at is not None:
|
|
550
|
+
return
|
|
551
|
+
self.finished_at = monotonic() if self.started_at is not None else None
|
|
552
|
+
if self._timer is not None:
|
|
553
|
+
self._timer.stop()
|
|
554
|
+
self._timer = None
|
|
555
|
+
self.add_class("-complete")
|
|
556
|
+
self._refresh_header()
|
|
557
|
+
self._refresh_visibility()
|
|
558
|
+
|
|
559
|
+
def _refresh_header(self) -> None:
|
|
560
|
+
if not self.is_mounted:
|
|
561
|
+
return
|
|
562
|
+
self.query_one(".work-run-header", Static).update(literal_text(self._label()))
|
|
563
|
+
|
|
564
|
+
def _label(self) -> str:
|
|
565
|
+
label = "Worked"
|
|
566
|
+
if self.started_at is not None:
|
|
567
|
+
end = self.finished_at if self.finished_at is not None else monotonic()
|
|
568
|
+
label = f"Worked for {format_work_duration(end - self.started_at)}"
|
|
569
|
+
return label
|
|
570
|
+
|
|
571
|
+
def _refresh_visibility(self) -> None:
|
|
572
|
+
recent = set(self.units[-3:])
|
|
573
|
+
for unit in self.units:
|
|
574
|
+
failed_tool_group = isinstance(unit, ToolGroupSummary) and unit.has_failures
|
|
575
|
+
unit.display = unit in recent or (self.failed and failed_tool_group)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class ToolGroupSummary(Vertical):
|
|
579
|
+
"""Dcode-style aggregate that folds consecutive successful tool calls."""
|
|
580
|
+
|
|
581
|
+
DEFAULT_CSS = """
|
|
582
|
+
ToolGroupSummary { height: auto; margin-bottom: 1; }
|
|
583
|
+
ToolGroupSummary .tool-group-summary { height: 1; color: $text-muted; padding: 0 1; }
|
|
584
|
+
ToolGroupSummary.-live .tool-group-summary { color: $tool; }
|
|
585
|
+
ToolGroupSummary > ToolCallMessage { display: none; }
|
|
586
|
+
ToolGroupSummary.-expanded > ToolCallMessage { display: block; }
|
|
587
|
+
ToolGroupSummary > ToolCallMessage.-status-error,
|
|
588
|
+
ToolGroupSummary > ToolCallMessage.-status-rejected { display: block; }
|
|
589
|
+
"""
|
|
590
|
+
|
|
591
|
+
def __init__(self) -> None:
|
|
592
|
+
super().__init__()
|
|
593
|
+
self.tools: list[ToolCallMessage] = []
|
|
594
|
+
self.finalized = False
|
|
595
|
+
self.expanded = False
|
|
596
|
+
self.work_run: WorkRunGroup | None = None
|
|
597
|
+
|
|
598
|
+
def compose(self) -> ComposeResult:
|
|
599
|
+
yield Static("", classes="tool-group-summary")
|
|
600
|
+
|
|
601
|
+
async def add_tool(self, tool: ToolCallMessage) -> None:
|
|
602
|
+
self.tools.append(tool)
|
|
603
|
+
tool.group = self
|
|
604
|
+
await self.mount(tool)
|
|
605
|
+
self.refresh_summary()
|
|
606
|
+
|
|
607
|
+
@on(events.Click, ".tool-group-summary")
|
|
608
|
+
def toggle(self) -> None:
|
|
609
|
+
if not self.finalized or any(tool.status != "success" for tool in self.tools):
|
|
610
|
+
return
|
|
611
|
+
self.expanded = not self.expanded
|
|
612
|
+
self.set_class(self.expanded, "-expanded")
|
|
613
|
+
self.refresh_summary()
|
|
614
|
+
|
|
615
|
+
def finalize(self) -> None:
|
|
616
|
+
self.finalized = True
|
|
617
|
+
self.refresh_summary()
|
|
618
|
+
|
|
619
|
+
@property
|
|
620
|
+
def has_failures(self) -> bool:
|
|
621
|
+
return any(tool.status in {"error", "rejected"} for tool in self.tools)
|
|
622
|
+
|
|
623
|
+
def refresh_summary(self) -> None:
|
|
624
|
+
if not self.is_mounted:
|
|
625
|
+
return
|
|
626
|
+
running = [tool for tool in self.tools if tool.status in {"pending", "running", "approval"}]
|
|
627
|
+
failed = [tool for tool in self.tools if tool.status in {"error", "rejected"}]
|
|
628
|
+
succeeded = [tool for tool in self.tools if tool.status == "success"]
|
|
629
|
+
self.set_class(bool(running), "-live")
|
|
630
|
+
|
|
631
|
+
if failed:
|
|
632
|
+
self.query_one(".tool-group-summary", Static).display = bool(succeeded or running)
|
|
633
|
+
text = self._summary(succeeded, completed=True)
|
|
634
|
+
if running:
|
|
635
|
+
live = self._summary(running, completed=False)
|
|
636
|
+
text = f"{text}, {live.lower()}" if text else live
|
|
637
|
+
elif running:
|
|
638
|
+
self.query_one(".tool-group-summary", Static).display = True
|
|
639
|
+
text = self._summary(running, completed=False) + "…"
|
|
640
|
+
else:
|
|
641
|
+
self.query_one(".tool-group-summary", Static).display = True
|
|
642
|
+
disclosure = "▾" if self.expanded else "▸"
|
|
643
|
+
text = f"{disclosure} {self._summary(succeeded, completed=True)}"
|
|
644
|
+
self.query_one(".tool-group-summary", Static).update(literal_text(text))
|
|
645
|
+
|
|
646
|
+
@staticmethod
|
|
647
|
+
def _summary(tools: list[ToolCallMessage], *, completed: bool) -> str:
|
|
648
|
+
counts: dict[str, int] = {}
|
|
649
|
+
order: list[str] = []
|
|
650
|
+
for tool in tools:
|
|
651
|
+
if tool.canonical_name not in counts:
|
|
652
|
+
counts[tool.canonical_name] = 0
|
|
653
|
+
order.append(tool.canonical_name)
|
|
654
|
+
counts[tool.canonical_name] += 1
|
|
655
|
+
parts: list[str] = []
|
|
656
|
+
for name in order:
|
|
657
|
+
present, past, noun = _TOOL_ACTIVITY.get(name, ("Running", "Ran", "tool"))
|
|
658
|
+
count = counts[name]
|
|
659
|
+
verb = past if completed else present
|
|
660
|
+
parts.append(f"{verb} {count} {noun}{'' if count == 1 else 's'}")
|
|
661
|
+
return ", ".join(parts) or ("Completed work" if completed else "Working")
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
class ToolCallMessage(Vertical):
|
|
665
|
+
"""Pinned dcode-style tool lifecycle row for hosted tool calls."""
|
|
666
|
+
|
|
667
|
+
DEFAULT_CSS = """
|
|
668
|
+
ToolCallMessage {
|
|
669
|
+
height: auto;
|
|
670
|
+
padding: 0 1 0 0;
|
|
671
|
+
margin-bottom: 1;
|
|
672
|
+
background: transparent;
|
|
673
|
+
border-left: wide $tool;
|
|
674
|
+
}
|
|
675
|
+
ToolCallMessage .tool-name {
|
|
676
|
+
color: $tool;
|
|
677
|
+
text-style: bold;
|
|
678
|
+
margin-left: 1;
|
|
679
|
+
}
|
|
680
|
+
ToolCallMessage .tool-status {
|
|
681
|
+
color: $warning;
|
|
682
|
+
margin-left: 3;
|
|
683
|
+
}
|
|
684
|
+
ToolCallMessage .tool-output {
|
|
685
|
+
color: $text-muted;
|
|
686
|
+
margin-left: 3;
|
|
687
|
+
height: auto;
|
|
688
|
+
}
|
|
689
|
+
ToolCallMessage .tool-output-hint {
|
|
690
|
+
color: $text-muted;
|
|
691
|
+
margin-left: 3;
|
|
692
|
+
}
|
|
693
|
+
ToolCallMessage.-status-success {
|
|
694
|
+
border-left: wide $success;
|
|
695
|
+
background: $success 8%;
|
|
696
|
+
}
|
|
697
|
+
ToolCallMessage.-status-error {
|
|
698
|
+
border-left: wide $error;
|
|
699
|
+
background: $error 10%;
|
|
700
|
+
}
|
|
701
|
+
ToolCallMessage.-status-rejected {
|
|
702
|
+
border-left: wide $warning;
|
|
703
|
+
background: $warning 8%;
|
|
704
|
+
}
|
|
705
|
+
"""
|
|
706
|
+
|
|
707
|
+
def __init__(
|
|
708
|
+
self,
|
|
709
|
+
tool_name: str,
|
|
710
|
+
args: dict[str, Any] | None = None,
|
|
711
|
+
*,
|
|
712
|
+
workspace: Path | None = None,
|
|
713
|
+
) -> None:
|
|
714
|
+
super().__init__()
|
|
715
|
+
self.tool_name = tool_name
|
|
716
|
+
self.canonical_name = canonical_tool_name(tool_name)
|
|
717
|
+
self.args = args or {}
|
|
718
|
+
self.workspace = workspace
|
|
719
|
+
self.status = "pending"
|
|
720
|
+
self.output = ""
|
|
721
|
+
self.expanded = False
|
|
722
|
+
self._started: float | None = None
|
|
723
|
+
self._timer: Timer | None = None
|
|
724
|
+
self.group: ToolGroupSummary | None = None
|
|
725
|
+
self._before_content = self._capture_before()
|
|
726
|
+
|
|
727
|
+
def compose(self) -> ComposeResult:
|
|
728
|
+
yield Static(literal_text(self._label()), classes="tool-name")
|
|
729
|
+
yield Static("", classes="tool-status")
|
|
730
|
+
yield Static("", classes="tool-output")
|
|
731
|
+
yield Static("", classes="tool-output-hint")
|
|
732
|
+
|
|
733
|
+
def _label(self) -> str:
|
|
734
|
+
if self.canonical_name == "execute":
|
|
735
|
+
command = str(self.args.get("command") or "")
|
|
736
|
+
if self.expanded:
|
|
737
|
+
return f"$ {command}" if command else "Run shell command"
|
|
738
|
+
return f"$ {_compact_command(command)}" if command else "Run shell command"
|
|
739
|
+
return _tool_label(self.tool_name, self.args)
|
|
740
|
+
|
|
741
|
+
async def on_mount(self) -> None:
|
|
742
|
+
if self.status == "running" and self._timer is None:
|
|
743
|
+
self._timer = self.set_interval(0.25, self._refresh_display)
|
|
744
|
+
self._refresh_display()
|
|
745
|
+
|
|
746
|
+
@on(events.Click)
|
|
747
|
+
def toggle_output(self) -> None:
|
|
748
|
+
if self._has_hidden_output() or self._has_hidden_arguments():
|
|
749
|
+
self.expanded = not self.expanded
|
|
750
|
+
self._refresh_display()
|
|
751
|
+
|
|
752
|
+
def set_running(self) -> None:
|
|
753
|
+
self.status = "running"
|
|
754
|
+
self._started = monotonic()
|
|
755
|
+
if self.is_mounted and self._timer is None:
|
|
756
|
+
self._timer = self.set_interval(0.25, self._refresh_display)
|
|
757
|
+
self._refresh_display()
|
|
758
|
+
|
|
759
|
+
def set_awaiting_approval(self) -> None:
|
|
760
|
+
self.status = "approval"
|
|
761
|
+
self._stop_timer()
|
|
762
|
+
self._refresh_display()
|
|
763
|
+
|
|
764
|
+
def set_success(self, result: object = "") -> None:
|
|
765
|
+
self._finish("success", self._mutation_diff() or _display_output(result))
|
|
766
|
+
|
|
767
|
+
def set_error(self, error: object) -> None:
|
|
768
|
+
if self.status == "rejected":
|
|
769
|
+
return
|
|
770
|
+
self.expanded = True
|
|
771
|
+
self._finish("error", _display_output(error))
|
|
772
|
+
|
|
773
|
+
def set_rejected(self, reason: str | None = None) -> None:
|
|
774
|
+
if self.status == "rejected" and self.output:
|
|
775
|
+
return
|
|
776
|
+
self.expanded = True
|
|
777
|
+
self._finish("rejected", reason or "Rejected by user")
|
|
778
|
+
|
|
779
|
+
def _finish(self, status: str, output: str) -> None:
|
|
780
|
+
self.status = status
|
|
781
|
+
self.output = bounded_widget_text(output)
|
|
782
|
+
self._stop_timer()
|
|
783
|
+
self._refresh_display()
|
|
784
|
+
|
|
785
|
+
def _stop_timer(self) -> None:
|
|
786
|
+
if self._timer is not None:
|
|
787
|
+
self._timer.stop()
|
|
788
|
+
self._timer = None
|
|
789
|
+
|
|
790
|
+
def _preview(self) -> str:
|
|
791
|
+
if self.expanded:
|
|
792
|
+
return self.output
|
|
793
|
+
lines = self.output.splitlines()
|
|
794
|
+
shown = "\n".join(lines[:4])
|
|
795
|
+
return shown[:400]
|
|
796
|
+
|
|
797
|
+
def _has_hidden_output(self) -> bool:
|
|
798
|
+
return len(self.output) > 400 or len(self.output.splitlines()) > 4
|
|
799
|
+
|
|
800
|
+
def _has_hidden_arguments(self) -> bool:
|
|
801
|
+
command = str(self.args.get("command") or "")
|
|
802
|
+
return self.canonical_name == "execute" and (
|
|
803
|
+
len(command) > 120 or len(command.splitlines()) > 1
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
def _capture_before(self) -> str | None:
|
|
807
|
+
path_value = _tool_path(self.args)
|
|
808
|
+
if (
|
|
809
|
+
self.workspace is None
|
|
810
|
+
or not path_value
|
|
811
|
+
or self.canonical_name not in {"write_file", "edit_file", "delete"}
|
|
812
|
+
or _sensitive_path(path_value)
|
|
813
|
+
):
|
|
814
|
+
return None
|
|
815
|
+
root = self.workspace.resolve()
|
|
816
|
+
candidate = Path(path_value)
|
|
817
|
+
resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
818
|
+
try:
|
|
819
|
+
resolved.relative_to(root)
|
|
820
|
+
return resolved.read_text(encoding="utf-8") if resolved.exists() else ""
|
|
821
|
+
except (OSError, UnicodeError, ValueError):
|
|
822
|
+
return None
|
|
823
|
+
|
|
824
|
+
def _mutation_diff(self) -> str:
|
|
825
|
+
if self._before_content is None:
|
|
826
|
+
return ""
|
|
827
|
+
after = self._before_content
|
|
828
|
+
if self.canonical_name == "write_file":
|
|
829
|
+
after = str(self.args.get("content") or "")
|
|
830
|
+
elif self.canonical_name == "edit_file":
|
|
831
|
+
old = str(self.args.get("old_string") or "")
|
|
832
|
+
new = str(self.args.get("new_string") or "")
|
|
833
|
+
if not old or old not in after:
|
|
834
|
+
return ""
|
|
835
|
+
after = after.replace(old, new, -1 if self.args.get("replace_all") else 1)
|
|
836
|
+
elif self.canonical_name == "delete":
|
|
837
|
+
after = ""
|
|
838
|
+
else:
|
|
839
|
+
return ""
|
|
840
|
+
path = _tool_path(self.args)
|
|
841
|
+
return "\n".join(
|
|
842
|
+
unified_diff(
|
|
843
|
+
self._before_content.splitlines(),
|
|
844
|
+
after.splitlines(),
|
|
845
|
+
fromfile=path,
|
|
846
|
+
tofile=path if self.canonical_name != "delete" else "/dev/null",
|
|
847
|
+
lineterm="",
|
|
848
|
+
)
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
def _refresh_display(self) -> None:
|
|
852
|
+
if not self.is_mounted:
|
|
853
|
+
return
|
|
854
|
+
for name in ("success", "error", "rejected"):
|
|
855
|
+
self.set_class(self.status == name, f"-status-{name}")
|
|
856
|
+
elapsed = monotonic() - self._started if self._started is not None else 0
|
|
857
|
+
statuses = {
|
|
858
|
+
"pending": "",
|
|
859
|
+
"approval": "Awaiting approval",
|
|
860
|
+
"running": f"Running…{f' ({elapsed:.0f}s)' if elapsed >= 10 else ''}",
|
|
861
|
+
"success": "Success",
|
|
862
|
+
"error": "Error",
|
|
863
|
+
"rejected": "Rejected",
|
|
864
|
+
}
|
|
865
|
+
status = statuses.get(self.status, self.status)
|
|
866
|
+
self.query_one(".tool-name", Static).update(literal_text(self._label()))
|
|
867
|
+
self.query_one(".tool-status", Static).update(literal_text(status))
|
|
868
|
+
self.query_one(".tool-status", Static).display = bool(status)
|
|
869
|
+
preview = self._preview()
|
|
870
|
+
self.query_one(".tool-output", Static).update(literal_text(preview))
|
|
871
|
+
self.query_one(".tool-output", Static).display = bool(preview)
|
|
872
|
+
hint = ""
|
|
873
|
+
if self._has_hidden_output() or self._has_hidden_arguments():
|
|
874
|
+
hint = "Click to collapse" if self.expanded else "Click to expand"
|
|
875
|
+
self.query_one(".tool-output-hint", Static).update(literal_text(hint))
|
|
876
|
+
self.query_one(".tool-output-hint", Static).display = bool(hint)
|
|
877
|
+
if self.group is not None:
|
|
878
|
+
self.group.refresh_summary()
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
@dataclass(frozen=True, slots=True)
|
|
882
|
+
class ApprovalDecision:
|
|
883
|
+
"""Decision returned by the tool approval screen."""
|
|
884
|
+
|
|
885
|
+
type: str
|
|
886
|
+
reason: str | None = None
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _sensitive_path(path: str) -> bool:
|
|
890
|
+
parts = {part.lower() for part in Path(path).parts}
|
|
891
|
+
name = Path(path).name.lower()
|
|
892
|
+
return bool(parts & {".git", ".ssh", ".copperpilot"}) or name == ".env"
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _approval_preview(request: ApprovalRequest, workspace: Path) -> str:
|
|
896
|
+
name = request.canonical_name or canonical_tool_name(request.tool_name)
|
|
897
|
+
args = request.arguments
|
|
898
|
+
path_value = _tool_path(args)
|
|
899
|
+
if name == "execute":
|
|
900
|
+
return str(args.get("command") or "")
|
|
901
|
+
if _sensitive_path(path_value):
|
|
902
|
+
return f"File: {path_value}\n\nContents hidden — file may contain credentials"
|
|
903
|
+
if name in {"write_file", "write_binary_file"}:
|
|
904
|
+
content = args.get("content", "")
|
|
905
|
+
if name == "write_binary_file":
|
|
906
|
+
content = "[base64 binary content omitted]"
|
|
907
|
+
return f"File: {path_value}\n\n{content}"
|
|
908
|
+
if name == "edit_file":
|
|
909
|
+
before = str(args.get("old_string") or "")
|
|
910
|
+
after = str(args.get("new_string") or "")
|
|
911
|
+
resolved = Path(path_value)
|
|
912
|
+
if not resolved.is_absolute():
|
|
913
|
+
resolved = workspace / resolved
|
|
914
|
+
try:
|
|
915
|
+
current = resolved.read_text(encoding="utf-8")
|
|
916
|
+
except (OSError, UnicodeError):
|
|
917
|
+
current = ""
|
|
918
|
+
if before and before in current:
|
|
919
|
+
updated = current.replace(
|
|
920
|
+
before,
|
|
921
|
+
after,
|
|
922
|
+
-1 if args.get("replace_all") else 1,
|
|
923
|
+
)
|
|
924
|
+
old_lines = current.splitlines()
|
|
925
|
+
new_lines = updated.splitlines()
|
|
926
|
+
else:
|
|
927
|
+
old_lines = before.splitlines()
|
|
928
|
+
new_lines = after.splitlines()
|
|
929
|
+
diff = "\n".join(
|
|
930
|
+
unified_diff(old_lines, new_lines, fromfile=path_value, tofile=path_value, lineterm="")
|
|
931
|
+
)
|
|
932
|
+
return diff or f"File: {path_value}\n\nNo changes to display"
|
|
933
|
+
if name == "delete":
|
|
934
|
+
resolved = Path(path_value)
|
|
935
|
+
if not resolved.is_absolute():
|
|
936
|
+
resolved = workspace / resolved
|
|
937
|
+
try:
|
|
938
|
+
content = resolved.read_text(encoding="utf-8")
|
|
939
|
+
except (OSError, UnicodeError):
|
|
940
|
+
return f"Delete: {path_value}"
|
|
941
|
+
removed = "\n".join(
|
|
942
|
+
unified_diff(
|
|
943
|
+
content.splitlines(),
|
|
944
|
+
[],
|
|
945
|
+
fromfile=path_value,
|
|
946
|
+
tofile="/dev/null",
|
|
947
|
+
lineterm="",
|
|
948
|
+
)
|
|
949
|
+
)
|
|
950
|
+
return removed
|
|
951
|
+
return json.dumps(args, default=str, indent=2)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _unicode_warning(arguments: dict[str, Any]) -> str:
|
|
955
|
+
text = json.dumps(arguments, default=str, ensure_ascii=False)
|
|
956
|
+
suspicious = [
|
|
957
|
+
character
|
|
958
|
+
for character in text
|
|
959
|
+
if ord(character) in {*range(0x202A, 0x202F), *range(0x2066, 0x206A)}
|
|
960
|
+
]
|
|
961
|
+
return "Warning: hidden bidirectional Unicode detected." if suspicious else ""
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
class ApprovalMenu(Container):
|
|
965
|
+
"""Inline dcode-style approval menu driven by a CopperPilot approval request."""
|
|
966
|
+
|
|
967
|
+
can_focus = True
|
|
968
|
+
can_focus_children = False
|
|
969
|
+
BINDINGS = [
|
|
970
|
+
Binding("up", "move_up", "Up", show=False),
|
|
971
|
+
Binding("k", "move_up", "Up", show=False),
|
|
972
|
+
Binding("down", "move_down", "Down", show=False),
|
|
973
|
+
Binding("j", "move_down", "Down", show=False),
|
|
974
|
+
Binding("enter", "select", "Select", show=False),
|
|
975
|
+
Binding("1", "select_position(0)", "Approve", show=False),
|
|
976
|
+
Binding("2", "select_position(1)", "Auto", show=False),
|
|
977
|
+
Binding("3", "select_position(2)", "Reject", show=False),
|
|
978
|
+
Binding("y", "approve", "Approve", show=False),
|
|
979
|
+
Binding("a", "auto", "Auto", show=False),
|
|
980
|
+
Binding("n", "reject", "Reject", show=False),
|
|
981
|
+
Binding("escape", "reject", "Reject", show=False),
|
|
982
|
+
Binding("tab", "reason", "Reject with feedback", show=False),
|
|
983
|
+
]
|
|
984
|
+
DEFAULT_CSS = """
|
|
985
|
+
ApprovalMenu {
|
|
986
|
+
height: auto;
|
|
987
|
+
margin: 1 0;
|
|
988
|
+
padding: 0 1;
|
|
989
|
+
background: $surface;
|
|
990
|
+
border: solid $warning;
|
|
991
|
+
}
|
|
992
|
+
ApprovalMenu .approval-title { height: auto; color: $warning; text-style: bold; }
|
|
993
|
+
ApprovalMenu .approval-preview { height: auto; max-height: 10; color: $text-muted; }
|
|
994
|
+
ApprovalMenu .approval-option { height: 1; padding: 0 1; }
|
|
995
|
+
ApprovalMenu .approval-option-selected { background: $primary; text-style: bold; }
|
|
996
|
+
ApprovalMenu .approval-help { height: auto; color: $text-muted; text-style: italic; }
|
|
997
|
+
ApprovalMenu .approval-reason-input {
|
|
998
|
+
display: none; height: 3; margin-top: 1; border: solid $warning;
|
|
999
|
+
}
|
|
1000
|
+
"""
|
|
1001
|
+
|
|
1002
|
+
def __init__(self, request: ApprovalRequest, workspace: Path) -> None:
|
|
1003
|
+
super().__init__()
|
|
1004
|
+
self.request = request
|
|
1005
|
+
self.workspace = workspace
|
|
1006
|
+
self._selected = 0
|
|
1007
|
+
self._options: list[tuple[str, str]] = [
|
|
1008
|
+
("Approve (y)", "approve"),
|
|
1009
|
+
("Enable Auto (a)", "auto"),
|
|
1010
|
+
("Reject (n)", "reject"),
|
|
1011
|
+
]
|
|
1012
|
+
self._option_widgets: list[Static] = []
|
|
1013
|
+
self._reason_active = False
|
|
1014
|
+
self._future: asyncio.Future[ApprovalDecision] = asyncio.get_running_loop().create_future()
|
|
1015
|
+
|
|
1016
|
+
def compose(self) -> ComposeResult:
|
|
1017
|
+
yield Static(
|
|
1018
|
+
literal_text(
|
|
1019
|
+
f">>> {canonical_tool_name(self.request.tool_name)} Requires Approval <<<"
|
|
1020
|
+
),
|
|
1021
|
+
classes="approval-title",
|
|
1022
|
+
)
|
|
1023
|
+
warning = _unicode_warning(self.request.arguments)
|
|
1024
|
+
if warning:
|
|
1025
|
+
yield Static(literal_text(warning), classes="approval-warning")
|
|
1026
|
+
with VerticalScroll(classes="approval-preview"):
|
|
1027
|
+
yield Static(
|
|
1028
|
+
literal_text(_approval_preview(self.request, self.workspace), limit=32 * 1024)
|
|
1029
|
+
)
|
|
1030
|
+
for index, (_label, _decision) in enumerate(self._options):
|
|
1031
|
+
widget = Static("", id=f"approval-option-{index}", classes="approval-option")
|
|
1032
|
+
self._option_widgets.append(widget)
|
|
1033
|
+
yield widget
|
|
1034
|
+
yield Input(
|
|
1035
|
+
placeholder="Reason (Enter to submit, Esc to cancel)",
|
|
1036
|
+
classes="approval-reason-input",
|
|
1037
|
+
select_on_focus=False,
|
|
1038
|
+
)
|
|
1039
|
+
yield Static("", classes="approval-help")
|
|
1040
|
+
|
|
1041
|
+
def on_mount(self) -> None:
|
|
1042
|
+
self._refresh_options()
|
|
1043
|
+
self.focus()
|
|
1044
|
+
|
|
1045
|
+
async def wait(self) -> ApprovalDecision:
|
|
1046
|
+
return await self._future
|
|
1047
|
+
|
|
1048
|
+
def _refresh_options(self) -> None:
|
|
1049
|
+
for index, ((label, _decision), widget) in enumerate(
|
|
1050
|
+
zip(self._options, self._option_widgets, strict=True)
|
|
1051
|
+
):
|
|
1052
|
+
widget.update(f"{'›' if index == self._selected else ' '} {index + 1}. {label}")
|
|
1053
|
+
widget.set_class(index == self._selected, "approval-option-selected")
|
|
1054
|
+
help_text = (
|
|
1055
|
+
"Enter submit • Esc cancel • leave blank to reject"
|
|
1056
|
+
if self._reason_active
|
|
1057
|
+
else "↑/↓ or j/k navigate • Enter select • y/a/n quick keys • Tab reject with feedback"
|
|
1058
|
+
)
|
|
1059
|
+
self.query_one(".approval-help", Static).update(help_text)
|
|
1060
|
+
|
|
1061
|
+
def action_move_up(self) -> None:
|
|
1062
|
+
if not self._reason_active:
|
|
1063
|
+
self._selected = (self._selected - 1) % len(self._options)
|
|
1064
|
+
self._refresh_options()
|
|
1065
|
+
|
|
1066
|
+
def action_move_down(self) -> None:
|
|
1067
|
+
if not self._reason_active:
|
|
1068
|
+
self._selected = (self._selected + 1) % len(self._options)
|
|
1069
|
+
self._refresh_options()
|
|
1070
|
+
|
|
1071
|
+
def action_select(self) -> None:
|
|
1072
|
+
if self._reason_active:
|
|
1073
|
+
self._submit_reason(self.query_one(Input).value)
|
|
1074
|
+
else:
|
|
1075
|
+
self._decide(self._options[self._selected][1])
|
|
1076
|
+
|
|
1077
|
+
def action_select_position(self, position: int) -> None:
|
|
1078
|
+
if not self._reason_active and 0 <= position < len(self._options):
|
|
1079
|
+
self._decide(self._options[position][1])
|
|
1080
|
+
|
|
1081
|
+
@on(events.Click, ".approval-option")
|
|
1082
|
+
def clicked_option(self, event: events.Click) -> None:
|
|
1083
|
+
if event.widget is None:
|
|
1084
|
+
return
|
|
1085
|
+
identifier = event.widget.id or ""
|
|
1086
|
+
try:
|
|
1087
|
+
position = int(identifier.rsplit("-", 1)[-1])
|
|
1088
|
+
except ValueError:
|
|
1089
|
+
return
|
|
1090
|
+
self.action_select_position(position)
|
|
1091
|
+
|
|
1092
|
+
def action_approve(self) -> None:
|
|
1093
|
+
if not self._reason_active:
|
|
1094
|
+
self._decide("approve")
|
|
1095
|
+
|
|
1096
|
+
def action_auto(self) -> None:
|
|
1097
|
+
if not self._reason_active:
|
|
1098
|
+
self._decide("auto")
|
|
1099
|
+
|
|
1100
|
+
def action_reject(self) -> None:
|
|
1101
|
+
if self._reason_active:
|
|
1102
|
+
self._exit_reason()
|
|
1103
|
+
else:
|
|
1104
|
+
self._decide("reject")
|
|
1105
|
+
|
|
1106
|
+
def action_reason(self) -> None:
|
|
1107
|
+
if self._reason_active:
|
|
1108
|
+
return
|
|
1109
|
+
self._selected = 2
|
|
1110
|
+
self._reason_active = True
|
|
1111
|
+
field = self.query_one(Input)
|
|
1112
|
+
field.display = True
|
|
1113
|
+
self._refresh_options()
|
|
1114
|
+
field.focus()
|
|
1115
|
+
|
|
1116
|
+
@on(Input.Submitted)
|
|
1117
|
+
def submitted_reason(self, event: Input.Submitted) -> None:
|
|
1118
|
+
if event.input is self.query_one(Input) and self._reason_active:
|
|
1119
|
+
event.stop()
|
|
1120
|
+
self._submit_reason(event.value)
|
|
1121
|
+
|
|
1122
|
+
def _submit_reason(self, value: str) -> None:
|
|
1123
|
+
self._reason_active = False
|
|
1124
|
+
self._decide("reject", value.strip() or None)
|
|
1125
|
+
|
|
1126
|
+
def _exit_reason(self) -> None:
|
|
1127
|
+
self._reason_active = False
|
|
1128
|
+
self.query_one(Input).display = False
|
|
1129
|
+
self._refresh_options()
|
|
1130
|
+
self.focus()
|
|
1131
|
+
|
|
1132
|
+
def _decide(self, decision: str, reason: str | None = None) -> None:
|
|
1133
|
+
if not self._future.done():
|
|
1134
|
+
self._future.set_result(ApprovalDecision(decision, reason))
|
|
1135
|
+
self.display = False
|
|
1136
|
+
|
|
1137
|
+
def on_blur(self) -> None:
|
|
1138
|
+
if not self._reason_active:
|
|
1139
|
+
self.call_after_refresh(self.focus)
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
class ErrorMessage(Static):
|
|
1143
|
+
DEFAULT_CSS = """
|
|
1144
|
+
ErrorMessage { height: auto; color: $error; border-left: wide $error; padding: 0 1; }
|
|
1145
|
+
"""
|
|
1146
|
+
|
|
1147
|
+
def __init__(self, content: str) -> None:
|
|
1148
|
+
super().__init__(literal_text(content))
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
class ThreadSelectorScreen(ModalScreen[str | None]):
|
|
1152
|
+
"""Folder-scoped thread picker with an explicit all-folders toggle."""
|
|
1153
|
+
|
|
1154
|
+
DEFAULT_CSS = """
|
|
1155
|
+
ThreadSelectorScreen { align: center middle; }
|
|
1156
|
+
ThreadSelectorScreen #thread-box {
|
|
1157
|
+
width: 90%;
|
|
1158
|
+
max-width: 120;
|
|
1159
|
+
height: 80%;
|
|
1160
|
+
border: solid $primary;
|
|
1161
|
+
background: $surface;
|
|
1162
|
+
padding: 1 2;
|
|
1163
|
+
}
|
|
1164
|
+
ThreadSelectorScreen #thread-box > Horizontal { height: auto; }
|
|
1165
|
+
ThreadSelectorScreen OptionList { height: 1fr; }
|
|
1166
|
+
"""
|
|
1167
|
+
|
|
1168
|
+
def __init__(self, current_thread: str | None = None, *, filter_cwd: str | None = None) -> None:
|
|
1169
|
+
super().__init__()
|
|
1170
|
+
self.current_thread = current_thread
|
|
1171
|
+
self.filter_cwd = filter_cwd
|
|
1172
|
+
self.threads: list[ThreadInfo] = []
|
|
1173
|
+
|
|
1174
|
+
def compose(self) -> ComposeResult:
|
|
1175
|
+
with Vertical(id="thread-box"):
|
|
1176
|
+
yield Label("Previous chats")
|
|
1177
|
+
with Horizontal():
|
|
1178
|
+
yield Input(placeholder="Filter chats", id="thread-filter")
|
|
1179
|
+
yield Checkbox("All folders", id="all-folders")
|
|
1180
|
+
yield OptionList(id="threads")
|
|
1181
|
+
|
|
1182
|
+
async def on_mount(self) -> None:
|
|
1183
|
+
await self._load()
|
|
1184
|
+
|
|
1185
|
+
async def _load(self) -> None:
|
|
1186
|
+
all_folders = self.query_one("#all-folders", Checkbox).value
|
|
1187
|
+
self.threads = await list_threads(
|
|
1188
|
+
limit=200,
|
|
1189
|
+
cwd=None if all_folders else self.filter_cwd,
|
|
1190
|
+
include_message_count=True,
|
|
1191
|
+
sort_by="updated",
|
|
1192
|
+
)
|
|
1193
|
+
self._render_threads()
|
|
1194
|
+
|
|
1195
|
+
def _render_threads(self) -> None:
|
|
1196
|
+
query = self.query_one("#thread-filter", Input).value.lower()
|
|
1197
|
+
options = self.query_one("#threads", OptionList)
|
|
1198
|
+
options.clear_options()
|
|
1199
|
+
for row in self.threads:
|
|
1200
|
+
prompt = str(row.get("initial_prompt") or "")
|
|
1201
|
+
identifier = row["thread_id"]
|
|
1202
|
+
label = (
|
|
1203
|
+
f"{'* ' if identifier == self.current_thread else ''}"
|
|
1204
|
+
f"{identifier[:10]} {row.get('message_count', 0)} msgs "
|
|
1205
|
+
f"{row.get('updated_at') or ''} {prompt}"
|
|
1206
|
+
)
|
|
1207
|
+
if query in label.lower():
|
|
1208
|
+
options.add_option(Option(literal_text(label), id=identifier))
|
|
1209
|
+
|
|
1210
|
+
@on(Input.Changed)
|
|
1211
|
+
def filtered(self) -> None:
|
|
1212
|
+
self._render_threads()
|
|
1213
|
+
|
|
1214
|
+
@on(Checkbox.Changed)
|
|
1215
|
+
async def scope_changed(self) -> None:
|
|
1216
|
+
await self._load()
|
|
1217
|
+
|
|
1218
|
+
@on(OptionList.OptionSelected)
|
|
1219
|
+
def selected(self, event: OptionList.OptionSelected) -> None:
|
|
1220
|
+
self.dismiss(str(event.option.id))
|
|
1221
|
+
|
|
1222
|
+
async def on_key(self, event: events.Key) -> None:
|
|
1223
|
+
if event.key == "escape":
|
|
1224
|
+
self.dismiss(None)
|