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,1071 @@
|
|
|
1
|
+
"""Branded Textual application for the hosted CopperPilot agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import contextlib
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import pyperclip
|
|
15
|
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
16
|
+
from textual import on
|
|
17
|
+
from textual.app import App, ComposeResult
|
|
18
|
+
from textual.containers import VerticalScroll
|
|
19
|
+
from textual.content import Content
|
|
20
|
+
from textual.events import MouseUp
|
|
21
|
+
from textual.screen import ModalScreen
|
|
22
|
+
from textual.theme import Theme
|
|
23
|
+
from textual.widgets import Input, Label, OptionList, TextArea
|
|
24
|
+
from textual.widgets.option_list import Option
|
|
25
|
+
|
|
26
|
+
from copper_pilot_cli import copper_theme
|
|
27
|
+
from copper_pilot_cli import textual_patches as _textual_patches # noqa: F401
|
|
28
|
+
from copper_pilot_cli._version import __version__
|
|
29
|
+
from copper_pilot_cli.clipboard import copy_selection_to_clipboard
|
|
30
|
+
from copper_pilot_cli.copper_api import token_limits
|
|
31
|
+
from copper_pilot_cli.copper_auth import DeviceLogin, clear_credential
|
|
32
|
+
from copper_pilot_cli.copper_features import (
|
|
33
|
+
LocalSkill,
|
|
34
|
+
encode_runtime_context,
|
|
35
|
+
looks_like_kicad_snippet,
|
|
36
|
+
parse_plan,
|
|
37
|
+
path_first_attachment,
|
|
38
|
+
resolve_skill_manifest,
|
|
39
|
+
scan_local_skills,
|
|
40
|
+
)
|
|
41
|
+
from copper_pilot_cli.copper_graph import CopperGraphRuntime
|
|
42
|
+
from copper_pilot_cli.copper_hooks import HookRunner
|
|
43
|
+
from copper_pilot_cli.copper_preferences import (
|
|
44
|
+
acknowledge_auto_notice,
|
|
45
|
+
acknowledge_yolo,
|
|
46
|
+
auto_notice_acknowledged,
|
|
47
|
+
save_approval_mode,
|
|
48
|
+
save_shell_auto_allow_settings,
|
|
49
|
+
shell_auto_allow_settings,
|
|
50
|
+
yolo_acknowledged,
|
|
51
|
+
)
|
|
52
|
+
from copper_pilot_cli.copper_presentation import (
|
|
53
|
+
ChatScroll,
|
|
54
|
+
LoadingWidget,
|
|
55
|
+
StatusBar,
|
|
56
|
+
TurnEvent,
|
|
57
|
+
TurnPhase,
|
|
58
|
+
TurnState,
|
|
59
|
+
WelcomeBanner,
|
|
60
|
+
)
|
|
61
|
+
from copper_pilot_cli.copper_protocol import CopperMode, HostedChatClient
|
|
62
|
+
from copper_pilot_cli.copper_tools import (
|
|
63
|
+
ApprovalMode,
|
|
64
|
+
ApprovalRequest,
|
|
65
|
+
LocalToolBroker,
|
|
66
|
+
)
|
|
67
|
+
from copper_pilot_cli.copper_update import check_for_update
|
|
68
|
+
from copper_pilot_cli.copper_widgets import (
|
|
69
|
+
MAX_WIDGET_TEXT,
|
|
70
|
+
ApprovalDecision,
|
|
71
|
+
ApprovalMenu,
|
|
72
|
+
AssistantMessage,
|
|
73
|
+
ChatInput,
|
|
74
|
+
CompletionEntry,
|
|
75
|
+
ErrorMessage,
|
|
76
|
+
ReasoningMessage,
|
|
77
|
+
ThreadSelectorScreen,
|
|
78
|
+
ToolCallMessage,
|
|
79
|
+
ToolGroupSummary,
|
|
80
|
+
UserMessage,
|
|
81
|
+
WorkRunGroup,
|
|
82
|
+
)
|
|
83
|
+
from copper_pilot_cli.copper_workspace import (
|
|
84
|
+
discover_workspace,
|
|
85
|
+
recent_workspaces,
|
|
86
|
+
remember_workspace,
|
|
87
|
+
)
|
|
88
|
+
from copper_pilot_cli.media_utils import get_clipboard_image
|
|
89
|
+
from copper_pilot_cli.sessions import generate_thread_id
|
|
90
|
+
|
|
91
|
+
logger = logging.getLogger(__name__)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ChoiceScreen(ModalScreen[str | None]):
|
|
95
|
+
"""Small reusable command/approval picker."""
|
|
96
|
+
|
|
97
|
+
DEFAULT_CSS = """
|
|
98
|
+
ChoiceScreen {
|
|
99
|
+
align: center middle;
|
|
100
|
+
}
|
|
101
|
+
ChoiceScreen > #choice-box {
|
|
102
|
+
width: 72;
|
|
103
|
+
max-width: 95%;
|
|
104
|
+
height: auto;
|
|
105
|
+
max-height: 80%;
|
|
106
|
+
border: solid $primary;
|
|
107
|
+
background: $surface;
|
|
108
|
+
padding: 1 2;
|
|
109
|
+
}
|
|
110
|
+
ChoiceScreen OptionList {
|
|
111
|
+
height: auto;
|
|
112
|
+
max-height: 16;
|
|
113
|
+
}
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, title: str, options: list[tuple[str, str]]) -> None:
|
|
117
|
+
super().__init__()
|
|
118
|
+
self.title_text = title
|
|
119
|
+
self.options = options
|
|
120
|
+
|
|
121
|
+
def compose(self) -> ComposeResult:
|
|
122
|
+
with VerticalScroll(id="choice-box"):
|
|
123
|
+
yield Label(Content(self.title_text))
|
|
124
|
+
yield OptionList(*(Option(Content(label), id=value) for value, label in self.options))
|
|
125
|
+
|
|
126
|
+
@on(OptionList.OptionSelected)
|
|
127
|
+
def selected(self, event: OptionList.OptionSelected) -> None:
|
|
128
|
+
self.dismiss(str(event.option.id))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class QuestionScreen(ModalScreen[str | None]):
|
|
132
|
+
"""Free-form hosted ask-user prompt."""
|
|
133
|
+
|
|
134
|
+
DEFAULT_CSS = """
|
|
135
|
+
QuestionScreen { align: center middle; }
|
|
136
|
+
QuestionScreen > #question-box {
|
|
137
|
+
width: 72;
|
|
138
|
+
max-width: 95%;
|
|
139
|
+
height: auto;
|
|
140
|
+
border: solid $primary;
|
|
141
|
+
background: $surface;
|
|
142
|
+
padding: 1 2;
|
|
143
|
+
}
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, question: str) -> None:
|
|
147
|
+
super().__init__()
|
|
148
|
+
self.question = question
|
|
149
|
+
|
|
150
|
+
def compose(self) -> ComposeResult:
|
|
151
|
+
with VerticalScroll(id="question-box"):
|
|
152
|
+
yield Label(Content(self.question))
|
|
153
|
+
yield Input(placeholder="Type your answer and press Enter", id="answer")
|
|
154
|
+
|
|
155
|
+
@on(Input.Submitted)
|
|
156
|
+
def answer(self, event: Input.Submitted) -> None:
|
|
157
|
+
self.dismiss(event.value)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class CopperPilotApp(App[None]):
|
|
161
|
+
"""Textual client retaining dcode's core chat widgets."""
|
|
162
|
+
|
|
163
|
+
TITLE = "CopperPilot"
|
|
164
|
+
SUB_TITLE = "Hosted electronics design agent"
|
|
165
|
+
BINDINGS = [
|
|
166
|
+
("ctrl+c", "cancel_or_quit", "Cancel"),
|
|
167
|
+
("ctrl+v", "paste_or_attach", "Paste"),
|
|
168
|
+
("ctrl+r", "resume_thread", "Resume"),
|
|
169
|
+
("shift+tab", "cycle_approval", "Approval"),
|
|
170
|
+
("ctrl+q", "quit", "Quit"),
|
|
171
|
+
]
|
|
172
|
+
CSS = """
|
|
173
|
+
Screen {
|
|
174
|
+
background: $background;
|
|
175
|
+
}
|
|
176
|
+
#transcript {
|
|
177
|
+
height: 1fr;
|
|
178
|
+
padding: 1 2;
|
|
179
|
+
}
|
|
180
|
+
#copper-status {
|
|
181
|
+
height: 1;
|
|
182
|
+
padding: 0 2;
|
|
183
|
+
color: $text-muted;
|
|
184
|
+
background: $surface;
|
|
185
|
+
}
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
runtime: CopperGraphRuntime,
|
|
191
|
+
broker: LocalToolBroker,
|
|
192
|
+
workspace: Path,
|
|
193
|
+
thread_id: str,
|
|
194
|
+
*,
|
|
195
|
+
mode: CopperMode = CopperMode.AGENT,
|
|
196
|
+
initial_message: str | None = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
super().__init__()
|
|
199
|
+
self.runtime = runtime
|
|
200
|
+
self.broker = broker
|
|
201
|
+
self.workspace = workspace
|
|
202
|
+
self.thread_id = thread_id
|
|
203
|
+
self.copper_mode = mode
|
|
204
|
+
self.initial_message = initial_message
|
|
205
|
+
self._turn_task: asyncio.Task[None] | None = None
|
|
206
|
+
self._assistant: AssistantMessage | None = None
|
|
207
|
+
self._last_assistant_content = ""
|
|
208
|
+
self._reasoning: ReasoningMessage | None = None
|
|
209
|
+
self._tools: dict[str, ToolCallMessage] = {}
|
|
210
|
+
self._tool_groups: dict[str, ToolGroupSummary] = {}
|
|
211
|
+
self._tool_group: ToolGroupSummary | None = None
|
|
212
|
+
self._work_run: WorkRunGroup | None = None
|
|
213
|
+
self._pending_media: list[Any] = []
|
|
214
|
+
self._skills: dict[str, LocalSkill] = {}
|
|
215
|
+
self._selected_skill: str | None = None
|
|
216
|
+
self._turn_error_rendered = False
|
|
217
|
+
self._loading: LoadingWidget | None = None
|
|
218
|
+
self._turn_state = TurnState()
|
|
219
|
+
self._register_copper_theme()
|
|
220
|
+
self.hooks = HookRunner(workspace, broker)
|
|
221
|
+
self.broker.approve = self.request_approval
|
|
222
|
+
self.broker.ask_user = self.request_question
|
|
223
|
+
|
|
224
|
+
def _register_copper_theme(self) -> None:
|
|
225
|
+
self.register_theme(
|
|
226
|
+
Theme(
|
|
227
|
+
name="copper-pilot",
|
|
228
|
+
primary="#EA580C",
|
|
229
|
+
secondary="#FB923C",
|
|
230
|
+
accent="#F97316",
|
|
231
|
+
foreground="#C0CAF5",
|
|
232
|
+
background="#11121D",
|
|
233
|
+
surface="#1A1B2E",
|
|
234
|
+
panel="#25283B",
|
|
235
|
+
success="#9ECE6A",
|
|
236
|
+
warning="#F97316",
|
|
237
|
+
error="#F7768E",
|
|
238
|
+
dark=True,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
self.theme = "copper-pilot"
|
|
242
|
+
|
|
243
|
+
def get_theme_variable_defaults(self) -> dict[str, str]:
|
|
244
|
+
return copper_theme.css_variable_defaults()
|
|
245
|
+
|
|
246
|
+
def compose(self) -> ComposeResult:
|
|
247
|
+
with ChatScroll(id="transcript"):
|
|
248
|
+
yield WelcomeBanner(self.workspace)
|
|
249
|
+
yield ChatInput(cwd=self.workspace)
|
|
250
|
+
yield StatusBar(self.workspace, self.broker.mode.value)
|
|
251
|
+
|
|
252
|
+
async def on_mount(self) -> None:
|
|
253
|
+
await self._hydrate()
|
|
254
|
+
self._load_skills()
|
|
255
|
+
if self.broker.mode is ApprovalMode.YOLO and not yolo_acknowledged():
|
|
256
|
+
self.broker.mode = ApprovalMode.MANUAL
|
|
257
|
+
self.call_after_refresh(self._activate_approval, ApprovalMode.YOLO)
|
|
258
|
+
self._update_status()
|
|
259
|
+
self.query_one(ChatInput).focus()
|
|
260
|
+
if self.initial_message:
|
|
261
|
+
self.call_after_refresh(self._submit, self.initial_message)
|
|
262
|
+
|
|
263
|
+
def on_mouse_up(self, event: MouseUp) -> None: # noqa: ARG002
|
|
264
|
+
"""Copy selection after Textual completes its click-chain update."""
|
|
265
|
+
self.call_after_refresh(
|
|
266
|
+
copy_selection_to_clipboard,
|
|
267
|
+
self,
|
|
268
|
+
screen=self.screen,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
async def on_unmount(self) -> None:
|
|
272
|
+
try:
|
|
273
|
+
if self._turn_task and not self._turn_task.done():
|
|
274
|
+
with contextlib.suppress(Exception):
|
|
275
|
+
await self.runtime.acancel()
|
|
276
|
+
self._turn_task.cancel()
|
|
277
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
278
|
+
await self._turn_task
|
|
279
|
+
finally:
|
|
280
|
+
await self.broker.cancel_all()
|
|
281
|
+
|
|
282
|
+
def _config(self) -> dict[str, Any]:
|
|
283
|
+
return self.runtime.config(self.thread_id, self.workspace)
|
|
284
|
+
|
|
285
|
+
async def _hydrate(self) -> None:
|
|
286
|
+
state = await self.runtime.aget_state(self._config())
|
|
287
|
+
values = getattr(state, "values", {}) or {}
|
|
288
|
+
self._tools.clear()
|
|
289
|
+
self._tool_groups.clear()
|
|
290
|
+
self._tool_group = None
|
|
291
|
+
self._work_run = None
|
|
292
|
+
self._assistant = None
|
|
293
|
+
self._reasoning = None
|
|
294
|
+
messages = values.get("messages", [])
|
|
295
|
+
for message in messages if isinstance(messages, list) else []:
|
|
296
|
+
if isinstance(message, HumanMessage):
|
|
297
|
+
self._finalize_work_run()
|
|
298
|
+
await self._mount(UserMessage(str(message.content)))
|
|
299
|
+
elif isinstance(message, ToolMessage):
|
|
300
|
+
tool = self._tools.get(str(message.tool_call_id))
|
|
301
|
+
if tool:
|
|
302
|
+
if message.additional_kwargs.get("copper_rejected") is True:
|
|
303
|
+
tool.set_rejected(str(message.content))
|
|
304
|
+
if tool.group is not None and tool.group.work_run is not None:
|
|
305
|
+
tool.group.work_run.mark_failed()
|
|
306
|
+
elif getattr(message, "status", None) == "error" or (
|
|
307
|
+
isinstance(message.artifact, dict)
|
|
308
|
+
and isinstance(message.artifact.get("exit_code"), int)
|
|
309
|
+
and message.artifact["exit_code"] != 0
|
|
310
|
+
):
|
|
311
|
+
tool.set_error(str(message.content))
|
|
312
|
+
if tool.group is not None and tool.group.work_run is not None:
|
|
313
|
+
tool.group.work_run.mark_failed()
|
|
314
|
+
else:
|
|
315
|
+
tool.set_success(str(message.content))
|
|
316
|
+
elif isinstance(message, AIMessage):
|
|
317
|
+
if message.content:
|
|
318
|
+
self._finalize_work_run()
|
|
319
|
+
self._last_assistant_content = str(message.content)[:MAX_WIDGET_TEXT]
|
|
320
|
+
await self._mount(AssistantMessage(str(message.content)))
|
|
321
|
+
for call in message.tool_calls:
|
|
322
|
+
arguments = call.get("args")
|
|
323
|
+
tool = ToolCallMessage(
|
|
324
|
+
str(call.get("name") or "tool"),
|
|
325
|
+
arguments if isinstance(arguments, dict) else {},
|
|
326
|
+
workspace=self.workspace,
|
|
327
|
+
)
|
|
328
|
+
tool_id = str(call.get("id") or "")
|
|
329
|
+
self._tools[tool_id] = tool
|
|
330
|
+
group = await self._ensure_tool_group(timed=False)
|
|
331
|
+
self._tool_groups[tool_id] = group
|
|
332
|
+
await group.add_tool(tool)
|
|
333
|
+
tool.set_running()
|
|
334
|
+
self._finalize_work_run()
|
|
335
|
+
try:
|
|
336
|
+
self.copper_mode = CopperMode(values.get("copper_mode", self.copper_mode.value))
|
|
337
|
+
except ValueError:
|
|
338
|
+
self.copper_mode = CopperMode.AGENT
|
|
339
|
+
self.runtime.agent.mode = self.copper_mode
|
|
340
|
+
events = values.get("copper_events", [])
|
|
341
|
+
for event in events if isinstance(events, list) else []:
|
|
342
|
+
if not isinstance(event, dict):
|
|
343
|
+
continue
|
|
344
|
+
if str(event.get("type")) not in {
|
|
345
|
+
"copper.text",
|
|
346
|
+
"copper.tool_request",
|
|
347
|
+
"copper.tool_result",
|
|
348
|
+
}:
|
|
349
|
+
await self._custom_event(event)
|
|
350
|
+
if messages or events:
|
|
351
|
+
self.query_one("#transcript", ChatScroll).scroll_end(animate=False)
|
|
352
|
+
|
|
353
|
+
def _load_skills(self) -> None:
|
|
354
|
+
skills, diagnostics = scan_local_skills(self.workspace)
|
|
355
|
+
self._skills = {skill.id: skill for skill in skills}
|
|
356
|
+
command_names = {
|
|
357
|
+
"context",
|
|
358
|
+
"copy",
|
|
359
|
+
"feedback",
|
|
360
|
+
"help",
|
|
361
|
+
"hooks",
|
|
362
|
+
"mode",
|
|
363
|
+
"resume",
|
|
364
|
+
"threads",
|
|
365
|
+
"approval",
|
|
366
|
+
"clear",
|
|
367
|
+
"cwd",
|
|
368
|
+
"auth",
|
|
369
|
+
"tools",
|
|
370
|
+
"tokens",
|
|
371
|
+
"theme",
|
|
372
|
+
"update",
|
|
373
|
+
"version",
|
|
374
|
+
"build",
|
|
375
|
+
"quit",
|
|
376
|
+
"exit",
|
|
377
|
+
}
|
|
378
|
+
entries = [
|
|
379
|
+
CompletionEntry(
|
|
380
|
+
name=f"/{name}",
|
|
381
|
+
description=f"Command · {name}",
|
|
382
|
+
display_name=f"/{name} [command]",
|
|
383
|
+
)
|
|
384
|
+
for name in sorted(command_names)
|
|
385
|
+
]
|
|
386
|
+
entries.extend(skill.command_entry() for skill in skills if skill.id not in command_names)
|
|
387
|
+
self.query_one(ChatInput).update_slash_commands(entries)
|
|
388
|
+
for message in diagnostics[:3]:
|
|
389
|
+
self.notify(message, severity="warning", markup=False)
|
|
390
|
+
if skills:
|
|
391
|
+
self.run_worker(self._resolve_skills(skills), exclusive=True, group="skills")
|
|
392
|
+
|
|
393
|
+
async def _resolve_skills(self, skills: list[LocalSkill]) -> None:
|
|
394
|
+
credential = self.runtime.agent.credential
|
|
395
|
+
if credential is None:
|
|
396
|
+
return
|
|
397
|
+
try:
|
|
398
|
+
response = await resolve_skill_manifest(credential, skills)
|
|
399
|
+
except Exception:
|
|
400
|
+
return
|
|
401
|
+
self.runtime.agent.skill_manifest_id = response.get("effective_manifest_id")
|
|
402
|
+
|
|
403
|
+
async def _mount(self, widget: Any) -> None:
|
|
404
|
+
transcript = self.query_one("#transcript", ChatScroll)
|
|
405
|
+
if self._loading is not None and widget is not self._loading and self._loading.is_mounted:
|
|
406
|
+
await transcript.mount(widget, before=self._loading)
|
|
407
|
+
else:
|
|
408
|
+
await transcript.mount(widget)
|
|
409
|
+
|
|
410
|
+
async def _ensure_tool_group(self, *, timed: bool = True) -> ToolGroupSummary:
|
|
411
|
+
work_run = await self._ensure_work_run(timed=timed)
|
|
412
|
+
if self._tool_group is None:
|
|
413
|
+
self._tool_group = ToolGroupSummary()
|
|
414
|
+
self._tool_group.work_run = work_run
|
|
415
|
+
await work_run.add_unit(self._tool_group)
|
|
416
|
+
else:
|
|
417
|
+
work_run.touch_unit(self._tool_group)
|
|
418
|
+
return self._tool_group
|
|
419
|
+
|
|
420
|
+
async def _ensure_work_run(self, *, timed: bool = True) -> WorkRunGroup:
|
|
421
|
+
if self._work_run is None:
|
|
422
|
+
self._work_run = WorkRunGroup(timed=timed)
|
|
423
|
+
await self._mount(self._work_run)
|
|
424
|
+
return self._work_run
|
|
425
|
+
|
|
426
|
+
def _finalize_tool_group(self) -> None:
|
|
427
|
+
if self._tool_group is not None:
|
|
428
|
+
self._tool_group.finalize()
|
|
429
|
+
self._tool_group = None
|
|
430
|
+
|
|
431
|
+
def _finalize_work_run(self) -> None:
|
|
432
|
+
self._finalize_tool_group()
|
|
433
|
+
if self._work_run is not None:
|
|
434
|
+
self._work_run.finalize()
|
|
435
|
+
self._work_run = None
|
|
436
|
+
|
|
437
|
+
async def _reset_transcript(self) -> None:
|
|
438
|
+
transcript = self.query_one("#transcript", ChatScroll)
|
|
439
|
+
await transcript.remove_children()
|
|
440
|
+
await transcript.mount(WelcomeBanner(self.workspace))
|
|
441
|
+
self._assistant = None
|
|
442
|
+
self._reasoning = None
|
|
443
|
+
self._tools.clear()
|
|
444
|
+
self._tool_groups.clear()
|
|
445
|
+
self._tool_group = None
|
|
446
|
+
self._work_run = None
|
|
447
|
+
|
|
448
|
+
async def _wait_for_modal(self, screen: ModalScreen[Any]) -> Any:
|
|
449
|
+
"""Await a modal without requiring the caller to be a Textual worker."""
|
|
450
|
+
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
|
451
|
+
|
|
452
|
+
def dismissed(result: Any) -> None:
|
|
453
|
+
if not future.done():
|
|
454
|
+
future.set_result(result)
|
|
455
|
+
|
|
456
|
+
self.push_screen(screen, dismissed)
|
|
457
|
+
try:
|
|
458
|
+
return await future
|
|
459
|
+
except asyncio.CancelledError:
|
|
460
|
+
if self.screen is screen:
|
|
461
|
+
screen.dismiss(None)
|
|
462
|
+
raise
|
|
463
|
+
|
|
464
|
+
def _update_status(self, detail: str = "") -> None:
|
|
465
|
+
bar = self.query_one(StatusBar)
|
|
466
|
+
bar.set_approval_mode(self.broker.mode.value)
|
|
467
|
+
bar.set_status(detail)
|
|
468
|
+
|
|
469
|
+
async def _start_loading(self) -> None:
|
|
470
|
+
await self._stop_loading()
|
|
471
|
+
self._loading = LoadingWidget()
|
|
472
|
+
await self._mount(self._loading)
|
|
473
|
+
|
|
474
|
+
async def _stop_loading(self) -> None:
|
|
475
|
+
if self._loading is None:
|
|
476
|
+
return
|
|
477
|
+
loading, self._loading = self._loading, None
|
|
478
|
+
loading.stop()
|
|
479
|
+
if loading.is_mounted:
|
|
480
|
+
await loading.remove()
|
|
481
|
+
|
|
482
|
+
async def _close_streams(self) -> None:
|
|
483
|
+
if self._assistant is not None:
|
|
484
|
+
await self._assistant.stop_stream()
|
|
485
|
+
if self._reasoning is not None:
|
|
486
|
+
await self._reasoning.stop_stream()
|
|
487
|
+
|
|
488
|
+
def _settle_active_tools(self, message: str) -> None:
|
|
489
|
+
for tool in self._tools.values():
|
|
490
|
+
if tool.status in {"pending", "running", "approval"}:
|
|
491
|
+
tool.set_error(message)
|
|
492
|
+
|
|
493
|
+
async def _finalize_turn(self, phase: TurnPhase, message: str | None = None) -> None:
|
|
494
|
+
await self._close_streams()
|
|
495
|
+
await self._stop_loading()
|
|
496
|
+
if phase is TurnPhase.CANCELLED:
|
|
497
|
+
self._settle_active_tools(message or "Cancelled by user")
|
|
498
|
+
elif phase is TurnPhase.FAILED:
|
|
499
|
+
self._settle_active_tools(message or "Turn failed before the tool completed")
|
|
500
|
+
if phase in {TurnPhase.CANCELLED, TurnPhase.FAILED} and self._work_run is not None:
|
|
501
|
+
self._work_run.mark_failed()
|
|
502
|
+
self._finalize_work_run()
|
|
503
|
+
self._turn_state.phase = phase
|
|
504
|
+
self._update_status()
|
|
505
|
+
|
|
506
|
+
@on(ChatInput.Submitted)
|
|
507
|
+
def submitted(self, event: ChatInput.Submitted) -> None:
|
|
508
|
+
self.run_worker(
|
|
509
|
+
self._handle_submission(event.value),
|
|
510
|
+
group="chat-submission",
|
|
511
|
+
exclusive=True,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
async def _handle_submission(self, submitted_value: str) -> None:
|
|
515
|
+
value = submitted_value.strip()
|
|
516
|
+
if not value:
|
|
517
|
+
return
|
|
518
|
+
token, _, remainder = value.partition(" ")
|
|
519
|
+
skill = self._skills.get(token.removeprefix("/").lower())
|
|
520
|
+
if skill is not None:
|
|
521
|
+
self._selected_skill = skill.id
|
|
522
|
+
self.runtime.agent.workflow = skill.id
|
|
523
|
+
await self._submit(remainder.strip() or f"Apply the {skill.label} skill.")
|
|
524
|
+
elif value.startswith("/"):
|
|
525
|
+
await self._command(value)
|
|
526
|
+
else:
|
|
527
|
+
await self._submit(self._prepare_attachments(value))
|
|
528
|
+
|
|
529
|
+
def _prepare_attachments(self, value: str) -> str:
|
|
530
|
+
attachments: list[dict[str, Any]] = []
|
|
531
|
+
media = list(self._pending_media)
|
|
532
|
+
if media:
|
|
533
|
+
root = self.workspace / ".copperpilot" / "attachments"
|
|
534
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
535
|
+
for item in media:
|
|
536
|
+
suffix = str(item.format).lower().replace("jpeg", "jpg")
|
|
537
|
+
file = root / f"{uuid.uuid4()}.{suffix}"
|
|
538
|
+
file.write_bytes(base64.b64decode(item.base64_data))
|
|
539
|
+
attachments.append(path_first_attachment(file, self.workspace))
|
|
540
|
+
for item in media:
|
|
541
|
+
value = value.replace(item.placeholder, "", 1).strip()
|
|
542
|
+
self._pending_media.clear()
|
|
543
|
+
snippet = value if looks_like_kicad_snippet(value) else None
|
|
544
|
+
self.runtime.agent.runtime_context = encode_runtime_context(
|
|
545
|
+
attachments=attachments,
|
|
546
|
+
snippet=snippet,
|
|
547
|
+
)
|
|
548
|
+
return "Analyze the pasted KiCad snippet." if snippet else value
|
|
549
|
+
|
|
550
|
+
async def _submit(self, value: str) -> None:
|
|
551
|
+
if self._turn_task and not self._turn_task.done():
|
|
552
|
+
self.notify("A turn is already running.", severity="warning", markup=False)
|
|
553
|
+
return
|
|
554
|
+
self._finalize_work_run()
|
|
555
|
+
self.query_one("#transcript", ChatScroll).anchor()
|
|
556
|
+
await self._mount(UserMessage(value))
|
|
557
|
+
self._assistant = None
|
|
558
|
+
self._last_assistant_content = ""
|
|
559
|
+
self._reasoning = None
|
|
560
|
+
self._turn_error_rendered = False
|
|
561
|
+
self._turn_task = asyncio.create_task(self._run_turn(value))
|
|
562
|
+
|
|
563
|
+
async def _run_turn(self, value: str) -> None:
|
|
564
|
+
self._turn_state = TurnState()
|
|
565
|
+
self._turn_state.apply(TurnEvent("turn_started"))
|
|
566
|
+
await self._start_loading()
|
|
567
|
+
self._update_status("Thinking")
|
|
568
|
+
try:
|
|
569
|
+
await self.hooks.run("before_turn")
|
|
570
|
+
async for item in self.runtime.astream(
|
|
571
|
+
{
|
|
572
|
+
"messages": [HumanMessage(content=value)],
|
|
573
|
+
"copper_mode": self.copper_mode.value,
|
|
574
|
+
},
|
|
575
|
+
self._config(),
|
|
576
|
+
):
|
|
577
|
+
namespace, mode, data = item
|
|
578
|
+
del namespace
|
|
579
|
+
if mode == "custom" and isinstance(data, dict):
|
|
580
|
+
await self._custom_event(data)
|
|
581
|
+
await self.hooks.run("after_turn")
|
|
582
|
+
await self._finalize_turn(TurnPhase.COMPLETED)
|
|
583
|
+
except asyncio.CancelledError:
|
|
584
|
+
self.notify("Turn cancelled.", severity="warning", markup=False)
|
|
585
|
+
await self._finalize_turn(TurnPhase.CANCELLED)
|
|
586
|
+
except Exception as exc:
|
|
587
|
+
if not self._turn_error_rendered:
|
|
588
|
+
await self._mount(ErrorMessage(str(exc)))
|
|
589
|
+
await self._finalize_turn(TurnPhase.FAILED, str(exc))
|
|
590
|
+
finally:
|
|
591
|
+
self.runtime.agent.workflow = None
|
|
592
|
+
self.runtime.agent.plan_context = None
|
|
593
|
+
self.runtime.agent.runtime_context = None
|
|
594
|
+
self._selected_skill = None
|
|
595
|
+
self._turn_task = None
|
|
596
|
+
|
|
597
|
+
async def _custom_event(self, event: dict[str, Any]) -> None:
|
|
598
|
+
kind = str(event.get("type") or "")
|
|
599
|
+
data = event.get("data")
|
|
600
|
+
state_kind = kind.removeprefix("copper.")
|
|
601
|
+
state_data = data
|
|
602
|
+
if state_kind == "tool_request":
|
|
603
|
+
state_kind = "tool_requested"
|
|
604
|
+
elif state_kind == "tool_result" and isinstance(data, dict):
|
|
605
|
+
result = data.get("result")
|
|
606
|
+
rejected = isinstance(result, dict) and result.get("rejected") is True
|
|
607
|
+
failed = isinstance(result, dict) and (
|
|
608
|
+
bool(result.get("error"))
|
|
609
|
+
or (isinstance(result.get("exit_code"), int) and result["exit_code"] != 0)
|
|
610
|
+
)
|
|
611
|
+
state_kind = "tool_phase"
|
|
612
|
+
state_data = {
|
|
613
|
+
"tool_call_id": data.get("tool_call_id"),
|
|
614
|
+
"phase": "rejected" if rejected else "error" if failed else "success",
|
|
615
|
+
}
|
|
616
|
+
elif state_kind == "final":
|
|
617
|
+
failed = isinstance(data, dict) and (
|
|
618
|
+
data.get("success") is False or bool(data.get("error"))
|
|
619
|
+
)
|
|
620
|
+
state_kind = "turn_failed" if failed else "turn_completed"
|
|
621
|
+
elif state_kind == "error":
|
|
622
|
+
state_kind = "turn_failed"
|
|
623
|
+
if not self._turn_state.apply(
|
|
624
|
+
TurnEvent(
|
|
625
|
+
state_kind,
|
|
626
|
+
state_data,
|
|
627
|
+
title=str(event.get("title")) if event.get("title") else None,
|
|
628
|
+
turn_id=str(event.get("turn_id")) if event.get("turn_id") else None,
|
|
629
|
+
sequence=event.get("sequence") if isinstance(event.get("sequence"), int) else None,
|
|
630
|
+
resume_attempt=(
|
|
631
|
+
event.get("resume_attempt")
|
|
632
|
+
if isinstance(event.get("resume_attempt"), int)
|
|
633
|
+
else None
|
|
634
|
+
),
|
|
635
|
+
)
|
|
636
|
+
):
|
|
637
|
+
return
|
|
638
|
+
if kind == "copper.text":
|
|
639
|
+
self._finalize_work_run()
|
|
640
|
+
if self._reasoning is not None:
|
|
641
|
+
await self._reasoning.stop_stream()
|
|
642
|
+
self._reasoning = None
|
|
643
|
+
self._last_assistant_content = (self._last_assistant_content + str(data))[
|
|
644
|
+
:MAX_WIDGET_TEXT
|
|
645
|
+
]
|
|
646
|
+
if self._assistant is None:
|
|
647
|
+
self._assistant = AssistantMessage()
|
|
648
|
+
await self._mount(self._assistant)
|
|
649
|
+
await self._assistant.append_content(str(data))
|
|
650
|
+
elif kind == "copper.reasoning":
|
|
651
|
+
if self._reasoning is None:
|
|
652
|
+
self._reasoning = ReasoningMessage()
|
|
653
|
+
work_run = await self._ensure_work_run()
|
|
654
|
+
await work_run.add_unit(self._reasoning)
|
|
655
|
+
await self._reasoning.append_content(str(data))
|
|
656
|
+
elif kind == "copper.tool_request" and isinstance(data, dict):
|
|
657
|
+
await self._close_streams()
|
|
658
|
+
self._assistant = None
|
|
659
|
+
self._reasoning = None
|
|
660
|
+
arguments = data.get("arguments")
|
|
661
|
+
tool = ToolCallMessage(
|
|
662
|
+
str(data.get("tool_name") or "tool"),
|
|
663
|
+
arguments if isinstance(arguments, dict) else {},
|
|
664
|
+
workspace=self.workspace,
|
|
665
|
+
)
|
|
666
|
+
tool_id = str(data.get("tool_call_id"))
|
|
667
|
+
self._tools[tool_id] = tool
|
|
668
|
+
group = await self._ensure_tool_group()
|
|
669
|
+
self._tool_groups[tool_id] = group
|
|
670
|
+
await group.add_tool(tool)
|
|
671
|
+
tool.set_running()
|
|
672
|
+
elif kind == "copper.tool_result" and isinstance(data, dict):
|
|
673
|
+
tool = self._tools.get(str(data.get("tool_call_id")))
|
|
674
|
+
if tool:
|
|
675
|
+
result = data.get("result")
|
|
676
|
+
if isinstance(result, dict) and result.get("rejected") is True:
|
|
677
|
+
tool.set_rejected(str(result.get("error") or "Rejected by user"))
|
|
678
|
+
if tool.group is not None and tool.group.work_run is not None:
|
|
679
|
+
tool.group.work_run.mark_failed()
|
|
680
|
+
elif isinstance(result, dict) and (
|
|
681
|
+
result.get("error")
|
|
682
|
+
or (isinstance(result.get("exit_code"), int) and result["exit_code"] != 0)
|
|
683
|
+
):
|
|
684
|
+
tool.set_error(str(result.get("error") or result.get("result") or result))
|
|
685
|
+
if tool.group is not None and tool.group.work_run is not None:
|
|
686
|
+
tool.group.work_run.mark_failed()
|
|
687
|
+
else:
|
|
688
|
+
tool.set_success(result)
|
|
689
|
+
self._assistant = None
|
|
690
|
+
self._reasoning = None
|
|
691
|
+
elif kind == "copper.status":
|
|
692
|
+
self._update_status(str(data)[:120].replace("\n", " "))
|
|
693
|
+
elif kind == "copper.status_event" and isinstance(data, dict):
|
|
694
|
+
label = data.get("message") or data.get("status") or data.get("type")
|
|
695
|
+
if label:
|
|
696
|
+
self._update_status(str(label)[:120].replace("\n", " "))
|
|
697
|
+
elif kind == "copper.final" and isinstance(data, dict):
|
|
698
|
+
if (data.get("success") is False or data.get("error")) and self._work_run is not None:
|
|
699
|
+
self._work_run.mark_failed()
|
|
700
|
+
self._finalize_work_run()
|
|
701
|
+
await self._close_streams()
|
|
702
|
+
await self._stop_loading()
|
|
703
|
+
if data.get("success") is False or data.get("error"):
|
|
704
|
+
await self._mount(ErrorMessage(str(data.get("error") or "Hosted chat failed.")))
|
|
705
|
+
elif kind == "copper.error":
|
|
706
|
+
self._turn_error_rendered = True
|
|
707
|
+
self._settle_active_tools(str(data))
|
|
708
|
+
if self._work_run is not None:
|
|
709
|
+
self._work_run.mark_failed()
|
|
710
|
+
self._finalize_work_run()
|
|
711
|
+
await self._mount(ErrorMessage(str(data)))
|
|
712
|
+
|
|
713
|
+
async def _command(self, value: str) -> None:
|
|
714
|
+
command, _, argument = value.partition(" ")
|
|
715
|
+
if command in {"/quit", "/exit"}:
|
|
716
|
+
self.exit()
|
|
717
|
+
elif command == "/help":
|
|
718
|
+
await self._mount(
|
|
719
|
+
AssistantMessage(
|
|
720
|
+
"Commands: `/mode`, `/resume`, `/approval`, `/context`, `/copy`, "
|
|
721
|
+
"`/cwd`, `/build`, `/hooks`, `/auth`, `/tools`, `/tokens`, "
|
|
722
|
+
"`/theme`, `/update`, `/version`, `/feedback`, `/clear`, `/quit`"
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
elif command == "/clear":
|
|
726
|
+
await self._reset_transcript()
|
|
727
|
+
elif command == "/copy":
|
|
728
|
+
if self._last_assistant_content:
|
|
729
|
+
pyperclip.copy(self._last_assistant_content)
|
|
730
|
+
self.notify("Copied the last CopperPilot response.", markup=False)
|
|
731
|
+
else:
|
|
732
|
+
await self._mount(ErrorMessage("There is no assistant response to copy."))
|
|
733
|
+
elif command == "/context":
|
|
734
|
+
context = self.runtime.agent.context
|
|
735
|
+
await self._mount(
|
|
736
|
+
AssistantMessage(
|
|
737
|
+
"\n".join(
|
|
738
|
+
[
|
|
739
|
+
f"- Workspace: `{self.workspace}`",
|
|
740
|
+
f"- Thread: `{self.thread_id}`",
|
|
741
|
+
f"- CopperPilot mode: `{self.copper_mode.value}`",
|
|
742
|
+
f"- Approval: `{self.broker.mode.value}`",
|
|
743
|
+
f"- Schematic: `{context.schematic_path or 'none'}`",
|
|
744
|
+
f"- PCB: `{context.pcb_path or 'none'}`",
|
|
745
|
+
]
|
|
746
|
+
)
|
|
747
|
+
)
|
|
748
|
+
)
|
|
749
|
+
elif command == "/cwd":
|
|
750
|
+
recent = recent_workspaces()
|
|
751
|
+
if not recent:
|
|
752
|
+
await self._mount(AssistantMessage("No recent project folders."))
|
|
753
|
+
return
|
|
754
|
+
choice = await self._wait_for_modal(
|
|
755
|
+
ChoiceScreen(
|
|
756
|
+
"Switch project folder",
|
|
757
|
+
[(str(path), str(path)) for path in recent],
|
|
758
|
+
)
|
|
759
|
+
)
|
|
760
|
+
if choice:
|
|
761
|
+
workspace = Path(choice)
|
|
762
|
+
self.workspace = workspace
|
|
763
|
+
self.thread_id = generate_thread_id()
|
|
764
|
+
self.broker.set_workspace(workspace)
|
|
765
|
+
self.hooks = HookRunner(workspace, self.broker)
|
|
766
|
+
self.runtime.agent.workspace = workspace
|
|
767
|
+
self.runtime.agent.context = discover_workspace(workspace)
|
|
768
|
+
self.runtime.agent.conversation_id = self.thread_id
|
|
769
|
+
self.query_one(ChatInput).set_cwd(workspace)
|
|
770
|
+
self.query_one(StatusBar).set_workspace(workspace)
|
|
771
|
+
remember_workspace(self.runtime.agent.context)
|
|
772
|
+
await self._reset_transcript()
|
|
773
|
+
self._load_skills()
|
|
774
|
+
self._update_status()
|
|
775
|
+
elif command == "/mode":
|
|
776
|
+
choice = await self._wait_for_modal(
|
|
777
|
+
ChoiceScreen(
|
|
778
|
+
"CopperPilot mode",
|
|
779
|
+
[(item.value, item.value.title()) for item in CopperMode],
|
|
780
|
+
)
|
|
781
|
+
)
|
|
782
|
+
if choice:
|
|
783
|
+
self.copper_mode = CopperMode(choice)
|
|
784
|
+
self.runtime.agent.mode = self.copper_mode
|
|
785
|
+
self._update_status()
|
|
786
|
+
elif command == "/build":
|
|
787
|
+
if not argument:
|
|
788
|
+
await self._mount(ErrorMessage("Usage: /build .copperpilot/plans/<name>.plan.md"))
|
|
789
|
+
return
|
|
790
|
+
try:
|
|
791
|
+
plan = parse_plan(self.workspace / argument, self.workspace)
|
|
792
|
+
except (OSError, ValueError) as exc:
|
|
793
|
+
await self._mount(ErrorMessage(str(exc)))
|
|
794
|
+
return
|
|
795
|
+
self.runtime.agent.plan_context = plan
|
|
796
|
+
self.runtime.agent.mode = CopperMode.AGENT
|
|
797
|
+
self.copper_mode = CopperMode.AGENT
|
|
798
|
+
await self._submit(f"Build the attached plan: {plan.name}")
|
|
799
|
+
elif command in {"/resume", "/threads"}:
|
|
800
|
+
await self.action_resume_thread()
|
|
801
|
+
elif command == "/approval":
|
|
802
|
+
if argument.strip().lower() == "shell":
|
|
803
|
+
await self._choose_shell_approval()
|
|
804
|
+
else:
|
|
805
|
+
await self._choose_approval()
|
|
806
|
+
elif command == "/tools":
|
|
807
|
+
await self._mount(
|
|
808
|
+
AssistantMessage(
|
|
809
|
+
"Local tools: read_file, write_file, edit_file, delete, "
|
|
810
|
+
"glob, grep, execute. CopperPilot aliases (bash, read, write, edit, "
|
|
811
|
+
"delete_file) are normalized by the hosted-protocol adapter."
|
|
812
|
+
)
|
|
813
|
+
)
|
|
814
|
+
elif command == "/hooks":
|
|
815
|
+
before = self.hooks.commands("before_turn")
|
|
816
|
+
after = self.hooks.commands("after_turn")
|
|
817
|
+
await self._mount(
|
|
818
|
+
AssistantMessage(
|
|
819
|
+
f"Before-turn hooks: {len(before)}\n\nAfter-turn hooks: {len(after)}"
|
|
820
|
+
)
|
|
821
|
+
)
|
|
822
|
+
elif command == "/feedback":
|
|
823
|
+
await self._mount(
|
|
824
|
+
AssistantMessage("Send CopperPilot feedback at https://copperpilot.ai.")
|
|
825
|
+
)
|
|
826
|
+
elif command == "/auth":
|
|
827
|
+
action = argument.strip().lower()
|
|
828
|
+
if action == "logout":
|
|
829
|
+
clear_credential()
|
|
830
|
+
self.runtime.agent.credential = None
|
|
831
|
+
await self._mount(AssistantMessage("Logged out. Use `/auth login` to reconnect."))
|
|
832
|
+
elif action == "login":
|
|
833
|
+
try:
|
|
834
|
+
credential = await DeviceLogin().login()
|
|
835
|
+
except Exception as exc:
|
|
836
|
+
await self._mount(ErrorMessage(str(exc)))
|
|
837
|
+
else:
|
|
838
|
+
self.runtime.agent.credential = credential
|
|
839
|
+
self.runtime.agent._client = HostedChatClient(
|
|
840
|
+
credential.base_url,
|
|
841
|
+
credential.api_key,
|
|
842
|
+
credential.fingerprint,
|
|
843
|
+
)
|
|
844
|
+
await self._mount(AssistantMessage("Authenticated with CopperPilot."))
|
|
845
|
+
else:
|
|
846
|
+
state = "authenticated" if self.runtime.agent.credential else "not authenticated"
|
|
847
|
+
await self._mount(AssistantMessage(f"CopperPilot is {state}."))
|
|
848
|
+
elif command == "/tokens":
|
|
849
|
+
credential = self.runtime.agent.credential
|
|
850
|
+
if credential is None:
|
|
851
|
+
await self._mount(ErrorMessage("Not authenticated."))
|
|
852
|
+
else:
|
|
853
|
+
try:
|
|
854
|
+
limits = await token_limits(credential)
|
|
855
|
+
except Exception as exc:
|
|
856
|
+
await self._mount(ErrorMessage(str(exc)))
|
|
857
|
+
else:
|
|
858
|
+
await self._mount(
|
|
859
|
+
AssistantMessage(
|
|
860
|
+
"```json\n" + json.dumps(limits, indent=2, default=str) + "\n```"
|
|
861
|
+
)
|
|
862
|
+
)
|
|
863
|
+
elif command == "/update":
|
|
864
|
+
try:
|
|
865
|
+
update = await check_for_update()
|
|
866
|
+
except Exception as exc:
|
|
867
|
+
await self._mount(ErrorMessage(str(exc)))
|
|
868
|
+
else:
|
|
869
|
+
message = (
|
|
870
|
+
f"CopperPilot CLI {update.latest} is available.\n\n`{update.install_command}`"
|
|
871
|
+
if update.available
|
|
872
|
+
else f"CopperPilot CLI {update.current} is current."
|
|
873
|
+
)
|
|
874
|
+
await self._mount(AssistantMessage(message))
|
|
875
|
+
elif command == "/theme":
|
|
876
|
+
await self._mount(
|
|
877
|
+
AssistantMessage(
|
|
878
|
+
"CopperPilot uses the dcode dark theme with copper-colored accents."
|
|
879
|
+
)
|
|
880
|
+
)
|
|
881
|
+
elif command == "/version":
|
|
882
|
+
await self._mount(AssistantMessage(f"CopperPilot CLI {__version__}"))
|
|
883
|
+
elif argument:
|
|
884
|
+
await self._mount(ErrorMessage(f"Unknown command: {value}"))
|
|
885
|
+
else:
|
|
886
|
+
await self._mount(ErrorMessage(f"Unknown command: {command}"))
|
|
887
|
+
|
|
888
|
+
async def _choose_approval(self) -> None:
|
|
889
|
+
choice = await self._wait_for_modal(
|
|
890
|
+
ChoiceScreen(
|
|
891
|
+
"Local approval mode",
|
|
892
|
+
[(item.value, item.value.title()) for item in ApprovalMode],
|
|
893
|
+
)
|
|
894
|
+
)
|
|
895
|
+
if choice:
|
|
896
|
+
await self._activate_approval(ApprovalMode(choice))
|
|
897
|
+
|
|
898
|
+
async def _choose_shell_approval(self) -> None:
|
|
899
|
+
normal, dangerous = shell_auto_allow_settings()
|
|
900
|
+
choice = await self._wait_for_modal(
|
|
901
|
+
ChoiceScreen(
|
|
902
|
+
f"Auto shell settings (normal={'ON' if normal else 'OFF'}, "
|
|
903
|
+
f"dangerous={'ON' if dangerous else 'OFF'})",
|
|
904
|
+
[
|
|
905
|
+
("safe", "Normal shell ON; dangerous shell OFF"),
|
|
906
|
+
("manual", "All shell commands require approval"),
|
|
907
|
+
("unrestricted", "Normal and dangerous shell ON"),
|
|
908
|
+
],
|
|
909
|
+
)
|
|
910
|
+
)
|
|
911
|
+
if not choice:
|
|
912
|
+
return
|
|
913
|
+
normal = choice in {"safe", "unrestricted"}
|
|
914
|
+
dangerous = choice == "unrestricted"
|
|
915
|
+
save_shell_auto_allow_settings(normal=normal, dangerous=dangerous)
|
|
916
|
+
self.broker.set_shell_auto_allow(normal=normal, dangerous=dangerous)
|
|
917
|
+
self.notify("Auto shell settings updated.", markup=False)
|
|
918
|
+
|
|
919
|
+
async def _activate_approval(self, mode: ApprovalMode) -> ApprovalMode:
|
|
920
|
+
if mode is ApprovalMode.AUTO and not auto_notice_acknowledged():
|
|
921
|
+
accepted = await self._wait_for_modal(
|
|
922
|
+
ChoiceScreen(
|
|
923
|
+
"Auto allows routine in-workspace edits and normal shell commands. "
|
|
924
|
+
"Deletes, sensitive files, external paths, and dangerous shell commands "
|
|
925
|
+
"still require approval.",
|
|
926
|
+
[
|
|
927
|
+
("cancel", "Stay in Manual"),
|
|
928
|
+
("enable", "Enable Auto (normal shell ON, dangerous shell OFF)"),
|
|
929
|
+
],
|
|
930
|
+
)
|
|
931
|
+
)
|
|
932
|
+
if accepted != "enable":
|
|
933
|
+
mode = ApprovalMode.MANUAL
|
|
934
|
+
else:
|
|
935
|
+
save_shell_auto_allow_settings(normal=True, dangerous=False)
|
|
936
|
+
self.broker.set_shell_auto_allow(normal=True, dangerous=False)
|
|
937
|
+
acknowledge_auto_notice()
|
|
938
|
+
if mode is ApprovalMode.YOLO and not yolo_acknowledged():
|
|
939
|
+
accepted = await self._wait_for_modal(
|
|
940
|
+
ChoiceScreen(
|
|
941
|
+
"YOLO executes local side effects without review. Continue?",
|
|
942
|
+
[("no", "Stay in Manual"), ("yes", "I understand; enable YOLO")],
|
|
943
|
+
)
|
|
944
|
+
)
|
|
945
|
+
if accepted != "yes":
|
|
946
|
+
mode = ApprovalMode.MANUAL
|
|
947
|
+
else:
|
|
948
|
+
acknowledge_yolo()
|
|
949
|
+
self.broker.mode = mode
|
|
950
|
+
if mode is not ApprovalMode.YOLO:
|
|
951
|
+
save_approval_mode(mode)
|
|
952
|
+
self._update_status()
|
|
953
|
+
return mode
|
|
954
|
+
|
|
955
|
+
async def action_cycle_approval(self) -> None:
|
|
956
|
+
if isinstance(self.screen, ModalScreen):
|
|
957
|
+
return
|
|
958
|
+
menus = list(self.query(ApprovalMenu))
|
|
959
|
+
if menus:
|
|
960
|
+
menus[-1].focus()
|
|
961
|
+
return
|
|
962
|
+
order = [ApprovalMode.MANUAL, ApprovalMode.AUTO, ApprovalMode.YOLO]
|
|
963
|
+
mode = order[(order.index(self.broker.mode) + 1) % len(order)]
|
|
964
|
+
await self._activate_approval(mode)
|
|
965
|
+
|
|
966
|
+
async def action_resume_thread(self) -> None:
|
|
967
|
+
choice = await self._wait_for_modal(
|
|
968
|
+
ThreadSelectorScreen(
|
|
969
|
+
current_thread=self.thread_id,
|
|
970
|
+
filter_cwd=str(self.workspace),
|
|
971
|
+
)
|
|
972
|
+
)
|
|
973
|
+
if choice and choice != self.thread_id:
|
|
974
|
+
self.thread_id = choice
|
|
975
|
+
self.runtime.agent.conversation_id = choice
|
|
976
|
+
await self._reset_transcript()
|
|
977
|
+
await self._hydrate()
|
|
978
|
+
|
|
979
|
+
async def request_approval(self, request: ApprovalRequest) -> bool:
|
|
980
|
+
tool = self._tools.get(request.tool_call_id)
|
|
981
|
+
if tool:
|
|
982
|
+
tool.set_awaiting_approval()
|
|
983
|
+
if self._loading is not None:
|
|
984
|
+
self._loading.pause()
|
|
985
|
+
composer = self.query_one(ChatInput)
|
|
986
|
+
if composer.query_one(TextArea).has_focus:
|
|
987
|
+
self._update_status("Waiting for typing to finish…")
|
|
988
|
+
await composer.wait_until_typing_idle()
|
|
989
|
+
self._update_status()
|
|
990
|
+
decision: ApprovalDecision | None = None
|
|
991
|
+
try:
|
|
992
|
+
while True:
|
|
993
|
+
menu = ApprovalMenu(request, self.workspace)
|
|
994
|
+
await self._mount(menu)
|
|
995
|
+
try:
|
|
996
|
+
decision = await menu.wait()
|
|
997
|
+
finally:
|
|
998
|
+
if menu.is_mounted:
|
|
999
|
+
await menu.remove()
|
|
1000
|
+
if not isinstance(decision, ApprovalDecision) or decision.type != "auto":
|
|
1001
|
+
break
|
|
1002
|
+
if await self._activate_approval(ApprovalMode.AUTO) is ApprovalMode.AUTO:
|
|
1003
|
+
break
|
|
1004
|
+
finally:
|
|
1005
|
+
if self._loading is not None:
|
|
1006
|
+
self._loading.resume()
|
|
1007
|
+
if composer.is_mounted:
|
|
1008
|
+
composer.focus()
|
|
1009
|
+
if not isinstance(decision, ApprovalDecision):
|
|
1010
|
+
if tool:
|
|
1011
|
+
tool.set_rejected()
|
|
1012
|
+
return False
|
|
1013
|
+
if decision.type == "auto":
|
|
1014
|
+
# The mode was activated inside the loop so cancelling its notice
|
|
1015
|
+
# leaves this approval pending instead of approving by accident.
|
|
1016
|
+
assert self.broker.mode is ApprovalMode.AUTO
|
|
1017
|
+
if decision.type in {"approve", "auto"}:
|
|
1018
|
+
if tool:
|
|
1019
|
+
tool.set_running()
|
|
1020
|
+
return True
|
|
1021
|
+
if tool:
|
|
1022
|
+
tool.set_rejected(decision.reason)
|
|
1023
|
+
return False
|
|
1024
|
+
|
|
1025
|
+
async def request_question(self, arguments: Any) -> Any:
|
|
1026
|
+
questions = arguments.get("questions") if isinstance(arguments, dict) else None
|
|
1027
|
+
if isinstance(questions, list) and questions:
|
|
1028
|
+
answers: list[dict[str, Any]] = []
|
|
1029
|
+
for row in questions:
|
|
1030
|
+
if not isinstance(row, dict):
|
|
1031
|
+
continue
|
|
1032
|
+
prompt = str(row.get("question") or row.get("prompt") or "")
|
|
1033
|
+
answer = await self._wait_for_modal(QuestionScreen(prompt))
|
|
1034
|
+
answers.append({"question": prompt, "answer": answer or ""})
|
|
1035
|
+
return {"answers": answers}
|
|
1036
|
+
prompt = (
|
|
1037
|
+
str(arguments.get("question") or arguments.get("prompt") or "")
|
|
1038
|
+
if isinstance(arguments, dict)
|
|
1039
|
+
else str(arguments)
|
|
1040
|
+
)
|
|
1041
|
+
return await self._wait_for_modal(QuestionScreen(prompt)) or "No response"
|
|
1042
|
+
|
|
1043
|
+
async def action_cancel_or_quit(self) -> None:
|
|
1044
|
+
if self._turn_task and not self._turn_task.done():
|
|
1045
|
+
try:
|
|
1046
|
+
await self.runtime.acancel()
|
|
1047
|
+
except Exception as exc:
|
|
1048
|
+
logger.warning("Hosted cancellation failed (%s)", type(exc).__name__)
|
|
1049
|
+
finally:
|
|
1050
|
+
self._turn_task.cancel()
|
|
1051
|
+
if isinstance(self.screen, ModalScreen):
|
|
1052
|
+
self.screen.dismiss(None)
|
|
1053
|
+
await self.broker.cancel_all()
|
|
1054
|
+
else:
|
|
1055
|
+
self.exit()
|
|
1056
|
+
|
|
1057
|
+
async def action_paste_or_attach(self) -> None:
|
|
1058
|
+
image = await asyncio.to_thread(get_clipboard_image)
|
|
1059
|
+
area = self.query_one(ChatInput).query_one(TextArea)
|
|
1060
|
+
if image is not None:
|
|
1061
|
+
image.placeholder = f"[image {len(self._pending_media) + 1}]"
|
|
1062
|
+
self._pending_media.append(image)
|
|
1063
|
+
area.insert(image.placeholder + " ")
|
|
1064
|
+
self.notify("Attached clipboard image.", markup=False)
|
|
1065
|
+
return
|
|
1066
|
+
try:
|
|
1067
|
+
text = await asyncio.to_thread(pyperclip.paste)
|
|
1068
|
+
except pyperclip.PyperclipException:
|
|
1069
|
+
return
|
|
1070
|
+
if text:
|
|
1071
|
+
area.insert(str(text))
|