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,325 @@
1
+ """Thin dcode-compatible presentation seam for hosted CopperPilot turns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from time import monotonic
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from textual import events
12
+ from textual.app import ComposeResult
13
+ from textual.containers import Horizontal, Vertical, VerticalScroll
14
+ from textual.content import Content
15
+ from textual.widgets import Static
16
+
17
+ from copper_pilot_cli._version import __version__
18
+
19
+ if TYPE_CHECKING:
20
+ from textual.geometry import Size
21
+ from textual.layout import DockArrangeResult
22
+
23
+ SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
24
+
25
+
26
+ class ChatScroll(VerticalScroll):
27
+ """Dcode-compatible transcript anchoring that respects manual scrolling."""
28
+
29
+ FOCUS_ON_CLICK = False
30
+
31
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
32
+ super().__init__(*args, **kwargs)
33
+ self._follow_bottom_when_scrollable = False
34
+
35
+ def anchor(self, anchor: bool = True) -> None:
36
+ """Follow output only after it overflows, keeping short chats top-aligned."""
37
+ self._follow_bottom_when_scrollable = anchor
38
+ if not anchor:
39
+ super().anchor(False)
40
+ return
41
+ self._anchor_released = False
42
+ if self.max_scroll_y > 0:
43
+ super().anchor(True)
44
+ return
45
+ super().anchor(False)
46
+ self.scroll_y = 0
47
+ self.scroll_target_y = 0
48
+
49
+ def release_anchor(self) -> None:
50
+ """Stop following as soon as the user moves away from the bottom."""
51
+ self._follow_bottom_when_scrollable = False
52
+ super().release_anchor()
53
+
54
+ def arrange(self, size: Size, optimal: bool = False) -> DockArrangeResult:
55
+ """Engage the armed anchor once newly streamed content begins overflowing."""
56
+ result = super().arrange(size, optimal=optimal)
57
+ if not self._follow_bottom_when_scrollable or self._anchor_released:
58
+ return result
59
+ viewport_height = self.container_size.height - self.scrollbar_size_horizontal
60
+ if result.spatial_map.total_region.bottom > viewport_height:
61
+ self._anchored = True
62
+ else:
63
+ self._anchored = False
64
+ self.set_reactive(VerticalScroll.scroll_y, 0.0)
65
+ self.set_reactive(VerticalScroll.scroll_target_y, 0.0)
66
+ return result
67
+
68
+
69
+ def _home_path(path: str | Path) -> str:
70
+ candidate = Path(path)
71
+ try:
72
+ home = Path.home()
73
+ if candidate == home:
74
+ return "~"
75
+ if candidate.is_relative_to(home):
76
+ return "~/" + candidate.relative_to(home).as_posix()
77
+ except (RuntimeError, ValueError):
78
+ pass
79
+ return str(candidate)
80
+
81
+
82
+ def _git_branch(workspace: Path) -> str:
83
+ """Read the current branch without launching a subprocess."""
84
+ current = workspace.resolve()
85
+ for root in (current, *current.parents):
86
+ head = root / ".git" / "HEAD"
87
+ try:
88
+ value = head.read_text(encoding="utf-8").strip()
89
+ except OSError:
90
+ continue
91
+ prefix = "ref: refs/heads/"
92
+ return value.removeprefix(prefix) if value.startswith(prefix) else value[:8]
93
+ return ""
94
+
95
+
96
+ class TurnPhase(StrEnum):
97
+ IDLE = "idle"
98
+ RUNNING = "running"
99
+ AWAITING_LOCAL_INPUT = "awaiting_local_input"
100
+ CANCELLING = "cancelling"
101
+ COMPLETED = "completed"
102
+ FAILED = "failed"
103
+ CANCELLED = "cancelled"
104
+
105
+
106
+ @dataclass(frozen=True, slots=True)
107
+ class TurnEvent:
108
+ """Transport-neutral event consumed by the Textual presenter."""
109
+
110
+ kind: str
111
+ data: Any = None
112
+ title: str | None = None
113
+ turn_id: str | None = None
114
+ sequence: int | None = None
115
+ resume_attempt: int | None = None
116
+
117
+
118
+ @dataclass(slots=True)
119
+ class TurnState:
120
+ phase: TurnPhase = TurnPhase.IDLE
121
+ status: str = ""
122
+ tools: dict[str, str] = field(default_factory=dict)
123
+ input_tokens: int | None = None
124
+ output_tokens: int | None = None
125
+ context_tokens: int | None = None
126
+ context_limit: int | None = None
127
+ last_sequence: int | None = None
128
+ turn_id: str | None = None
129
+ resume_attempt: int | None = None
130
+
131
+ def apply(self, event: TurnEvent) -> bool:
132
+ """Apply an event, returning false for a duplicate/older sequence."""
133
+ if event.turn_id is not None and event.turn_id != self.turn_id:
134
+ self.turn_id = event.turn_id
135
+ self.resume_attempt = event.resume_attempt
136
+ self.last_sequence = None
137
+ elif event.resume_attempt is not None:
138
+ if self.resume_attempt is not None and event.resume_attempt < self.resume_attempt:
139
+ return False
140
+ if self.resume_attempt is None or event.resume_attempt > self.resume_attempt:
141
+ self.resume_attempt = event.resume_attempt
142
+ self.last_sequence = None
143
+ if event.sequence is not None:
144
+ if self.last_sequence is not None and event.sequence <= self.last_sequence:
145
+ return False
146
+ self.last_sequence = event.sequence
147
+ if event.kind == "turn_started":
148
+ self.phase = TurnPhase.RUNNING
149
+ elif event.kind == "status":
150
+ self.status = str(event.data or "")
151
+ elif event.kind == "tool_requested" and isinstance(event.data, dict):
152
+ self.tools[str(event.data.get("tool_call_id") or "")] = "running"
153
+ elif event.kind == "tool_phase" and isinstance(event.data, dict):
154
+ self.tools[str(event.data.get("tool_call_id") or "")] = str(
155
+ event.data.get("phase") or "running"
156
+ )
157
+ elif event.kind == "usage" and isinstance(event.data, dict):
158
+ aliases = {
159
+ "input_tokens": ("input_tokens", "tokens_input"),
160
+ "output_tokens": ("output_tokens", "tokens_output"),
161
+ "context_tokens": ("context_tokens",),
162
+ "context_limit": ("context_limit",),
163
+ }
164
+ for key, candidates in aliases.items():
165
+ value = next(
166
+ (event.data[name] for name in candidates if name in event.data),
167
+ None,
168
+ )
169
+ if isinstance(value, int) and value >= 0:
170
+ setattr(self, key, value)
171
+ elif event.kind == "turn_completed":
172
+ self.phase = TurnPhase.COMPLETED
173
+ elif event.kind == "turn_failed":
174
+ self.phase = TurnPhase.FAILED
175
+ elif event.kind == "turn_cancelled":
176
+ self.phase = TurnPhase.CANCELLED
177
+ return True
178
+
179
+
180
+ class WelcomeBanner(Static):
181
+ """Compact dcode-style product and version banner."""
182
+
183
+ DEFAULT_CSS = """
184
+ WelcomeBanner {
185
+ height: auto;
186
+ border: solid $primary;
187
+ padding: 0 2;
188
+ margin-bottom: 1;
189
+ }
190
+ """
191
+
192
+ def __init__(self, workspace: Path) -> None:
193
+ self.workspace = workspace
194
+ super().__init__(self._content())
195
+
196
+ def _content(self) -> Content:
197
+ return Content.assemble(
198
+ ("◆ ", "bold $primary"),
199
+ ("CopperPilot", "bold"),
200
+ (f" v{__version__}", "dim"),
201
+ "\n\n",
202
+ ("directory: ", "dim"),
203
+ (_home_path(self.workspace), "$primary"),
204
+ )
205
+
206
+ def update_workspace(self, workspace: Path) -> None:
207
+ self.workspace = workspace
208
+ self.update(self._content())
209
+
210
+
211
+ class LoadingWidget(Static):
212
+ """Animated dcode-style turn activity that pauses during approval."""
213
+
214
+ DEFAULT_CSS = """
215
+ LoadingWidget { height: auto; padding: 0 1; margin-bottom: 1; }
216
+ LoadingWidget .loading-container { height: auto; width: 100%; }
217
+ LoadingWidget .loading-spinner,
218
+ LoadingWidget .loading-status { width: auto; color: $primary; }
219
+ LoadingWidget .loading-hint { width: auto; color: $text-muted; margin-left: 1; }
220
+ """
221
+
222
+ def __init__(self, status: str = "Thinking") -> None:
223
+ super().__init__()
224
+ self.status_text = status
225
+ self.started_at = monotonic()
226
+ self.paused_at: float | None = None
227
+ self.paused_total = 0.0
228
+ self.position = 0
229
+ self.timer = None
230
+
231
+ def compose(self) -> ComposeResult:
232
+ with Horizontal(classes="loading-container"):
233
+ yield Static(SPINNER_FRAMES[0], classes="loading-spinner")
234
+ yield Static(f" {self.status_text}... ", classes="loading-status")
235
+ yield Static("(0s, esc to interrupt)", classes="loading-hint")
236
+
237
+ def on_mount(self) -> None:
238
+ self.timer = self.set_interval(0.1, self._tick)
239
+
240
+ def on_unmount(self) -> None:
241
+ self.stop()
242
+
243
+ def _tick(self) -> None:
244
+ if self.paused_at is not None:
245
+ return
246
+ self.position = (self.position + 1) % len(SPINNER_FRAMES)
247
+ self.query_one(".loading-spinner", Static).update(SPINNER_FRAMES[self.position])
248
+ elapsed = int(monotonic() - self.started_at - self.paused_total)
249
+ self.query_one(".loading-hint", Static).update(f"({elapsed}s, esc to interrupt)")
250
+
251
+ def set_status(self, status: str) -> None:
252
+ self.status_text = status
253
+ if self.is_mounted:
254
+ self.query_one(".loading-status", Static).update(f" {status}... ")
255
+
256
+ def pause(self, status: str = "Awaiting decision") -> None:
257
+ if self.paused_at is None:
258
+ self.paused_at = monotonic()
259
+ self.set_status(status)
260
+ if self.is_mounted:
261
+ elapsed = int(self.paused_at - self.started_at - self.paused_total)
262
+ self.query_one(".loading-spinner", Static).update("Ⅱ")
263
+ self.query_one(".loading-hint", Static).update(f"(paused at {elapsed}s)")
264
+
265
+ def resume(self) -> None:
266
+ if self.paused_at is None:
267
+ return
268
+ self.paused_total += monotonic() - self.paused_at
269
+ self.paused_at = None
270
+ self.set_status("Thinking")
271
+
272
+ def stop(self) -> None:
273
+ if self.timer is not None:
274
+ self.timer.stop()
275
+ self.timer = None
276
+
277
+
278
+ class StatusBar(Vertical):
279
+ """Compact dcode-style status bar for values CopperPilot can report truthfully."""
280
+
281
+ DEFAULT_CSS = """
282
+ StatusBar { height: 1; dock: bottom; background: $background; }
283
+ StatusBar > Horizontal { height: 1; width: 1fr; }
284
+ StatusBar .status-approval { width: auto; padding: 0 1; margin-right: 1; }
285
+ StatusBar .status-approval.manual { background: $warning; color: $background; }
286
+ StatusBar .status-approval.auto { background: $success; color: $background; }
287
+ StatusBar .status-approval.yolo { background: $error; color: white; text-style: bold; }
288
+ StatusBar .status-cwd { width: auto; max-width: 55%; color: $text-muted; }
289
+ StatusBar .status-branch { width: auto; color: $text-muted; padding-left: 1; }
290
+ StatusBar .status-message { width: 1fr; color: $text-muted; }
291
+ """
292
+
293
+ def __init__(self, workspace: Path, approval_mode: str = "manual") -> None:
294
+ super().__init__(id="status-bar")
295
+ self.workspace = workspace
296
+ self.approval_mode = approval_mode
297
+
298
+ def compose(self) -> ComposeResult:
299
+ with Horizontal():
300
+ yield Static(self.approval_mode, classes=f"status-approval {self.approval_mode}")
301
+ yield Static(_home_path(self.workspace), classes="status-cwd")
302
+ yield Static(_git_branch(self.workspace), classes="status-branch")
303
+ yield Static("", classes="status-message")
304
+
305
+ def on_resize(self, event: events.Resize) -> None:
306
+ self.query_one(".status-cwd", Static).display = event.size.width >= 70
307
+
308
+ def set_workspace(self, workspace: Path) -> None:
309
+ self.workspace = workspace
310
+ if self.is_mounted:
311
+ self.query_one(".status-cwd", Static).update(_home_path(workspace))
312
+ self.query_one(".status-branch", Static).update(_git_branch(workspace))
313
+
314
+ def set_approval_mode(self, mode: str) -> None:
315
+ self.approval_mode = mode if mode in {"manual", "auto", "yolo"} else "manual"
316
+ if not self.is_mounted:
317
+ return
318
+ widget = self.query_one(".status-approval", Static)
319
+ widget.remove_class("manual", "auto", "yolo")
320
+ widget.add_class(self.approval_mode)
321
+ widget.update("YOLO" if self.approval_mode == "yolo" else self.approval_mode)
322
+
323
+ def set_status(self, status: str) -> None:
324
+ if self.is_mounted:
325
+ self.query_one(".status-message", Static).update(Content(status))