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,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))
|