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.
Files changed (39) hide show
  1. copper_pilot_cli/__init__.py +37 -0
  2. copper_pilot_cli/__main__.py +6 -0
  3. copper_pilot_cli/_upstream/__init__.py +0 -0
  4. copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +46 -0
  5. copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
  6. copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +222 -0
  7. copper_pilot_cli/_version.py +32 -0
  8. copper_pilot_cli/clipboard.py +107 -0
  9. copper_pilot_cli/copper_api.py +24 -0
  10. copper_pilot_cli/copper_app.py +1071 -0
  11. copper_pilot_cli/copper_auth.py +232 -0
  12. copper_pilot_cli/copper_config.py +62 -0
  13. copper_pilot_cli/copper_features.py +263 -0
  14. copper_pilot_cli/copper_graph.py +178 -0
  15. copper_pilot_cli/copper_hooks.py +49 -0
  16. copper_pilot_cli/copper_main.py +345 -0
  17. copper_pilot_cli/copper_preferences.py +85 -0
  18. copper_pilot_cli/copper_presentation.py +325 -0
  19. copper_pilot_cli/copper_protocol.py +421 -0
  20. copper_pilot_cli/copper_theme.py +34 -0
  21. copper_pilot_cli/copper_tools.py +23 -0
  22. copper_pilot_cli/copper_update.py +32 -0
  23. copper_pilot_cli/copper_widgets.py +1224 -0
  24. copper_pilot_cli/copper_workspace.py +151 -0
  25. copper_pilot_cli/deepagents_tools.py +556 -0
  26. copper_pilot_cli/diagnostics.py +55 -0
  27. copper_pilot_cli/langchain.py +215 -0
  28. copper_pilot_cli/media_utils.py +626 -0
  29. copper_pilot_cli/py.typed +0 -0
  30. copper_pilot_cli/sessions.py +1578 -0
  31. copper_pilot_cli/textual_patches.py +35 -0
  32. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/NOTICE +13 -0
  33. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/UPSTREAM.md +44 -0
  34. copper_pilot_cli-0.1.1.dist-info/METADATA +211 -0
  35. copper_pilot_cli-0.1.1.dist-info/RECORD +39 -0
  36. copper_pilot_cli-0.1.1.dist-info/WHEEL +4 -0
  37. copper_pilot_cli-0.1.1.dist-info/entry_points.txt +3 -0
  38. copper_pilot_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
  39. 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))