Skip to content

Commit 175db1a

Browse files
authored
Merge pull request #276 from chrisnestrud/copy-paste-no-api
Add copy-paste mode with cp: prefix for web UI interaction
2 parents 3303a17 + c482ce5 commit 175db1a

11 files changed

Lines changed: 644 additions & 81 deletions

File tree

aider/coders/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .ask_coder import AskCoder
44
from .base_coder import Coder
55
from .context_coder import ContextCoder
6+
from .copypaste_coder import CopyPasteCoder
67
from .editblock_coder import EditBlockCoder
78
from .editblock_fenced_coder import EditBlockFencedCoder
89
from .editor_diff_fenced_coder import EditorDiffFencedCoder
@@ -33,4 +34,5 @@
3334
EditorDiffFencedCoder,
3435
ContextCoder,
3536
AgentCoder,
37+
CopyPasteCoder,
3638
]

aider/coders/base_coder.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ async def create(
172172
if from_coder:
173173
main_model = from_coder.main_model
174174
else:
175-
main_model = models.Model(models.DEFAULT_MODEL_NAME)
175+
main_model = models.Model(models.DEFAULT_MODEL_NAME, io=io)
176176

177177
if edit_format == "code":
178178
edit_format = None
@@ -229,6 +229,14 @@ async def create(
229229
kwargs = use_kwargs
230230
from_coder.ok_to_warm_cache = False
231231

232+
if getattr(main_model, "copy_paste_mode", False) and getattr(
233+
main_model, "copy_paste_transport", "api"
234+
) == "clipboard":
235+
res = coders.CopyPasteCoder(main_model, io, args=args, **kwargs)
236+
await res.initialize_mcp_tools()
237+
res.original_kwargs = dict(kwargs)
238+
return res
239+
232240
for coder in coders.__all__:
233241
if hasattr(coder, "edit_format") and coder.edit_format == edit_format:
234242
res = coder(main_model, io, args=args, **kwargs)
@@ -379,6 +387,9 @@ def __init__(
379387
self.io = io
380388
self.io.coder = weakref.ref(self)
381389

390+
self.manual_copy_paste = getattr(main_model, "copy_paste_transport", "api") == "clipboard"
391+
self.copy_paste_mode = getattr(main_model, "copy_paste_mode", False) or auto_copy_context
392+
382393
self.shell_commands = []
383394
self.partial_response_tool_calls = []
384395

@@ -399,7 +410,7 @@ def __init__(
399410
self.main_model.reasoning_tag if self.main_model.reasoning_tag else REASONING_TAG
400411
)
401412

402-
self.stream = stream and main_model.streaming
413+
self.stream = stream and main_model.streaming and not self.manual_copy_paste
403414

404415
if cache_prompts and self.main_model.cache_control:
405416
self.add_cache_headers = True
@@ -581,6 +592,8 @@ def get_announcements(self):
581592
output += ", prompt cache"
582593
if main_model.info.get("supports_assistant_prefill"):
583594
output += ", infinite output"
595+
if self.copy_paste_mode:
596+
output += ", copy/paste mode"
584597

585598
lines.append(output)
586599

aider/coders/copypaste_coder.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import hashlib
2+
import json
3+
import math
4+
import time
5+
import uuid
6+
7+
from aider.exceptions import LiteLLMExceptions
8+
from aider.llm import litellm
9+
10+
from .base_coder import Coder
11+
12+
13+
class CopyPasteCoder(Coder):
14+
"""Coder implementation that performs clipboard-driven interactions.
15+
16+
This coder swaps the transport mechanism (clipboard vs API) but must remain compatible with the
17+
base ``Coder`` interface. In particular, many base methods assume ``self.gpt_prompts`` exists.
18+
19+
We therefore mirror the prompt pack from the coder that matches the currently selected
20+
``edit_format``.
21+
"""
22+
23+
def __init__(self, *args, **kwargs):
24+
super().__init__(*args, **kwargs)
25+
26+
# Ensure CopyPasteCoder always has a prompt pack.
27+
# We mirror prompts from the coder that matches the active edit format.
28+
self._init_prompts_from_selected_edit_format()
29+
30+
def _init_prompts_from_selected_edit_format(self):
31+
"""Initialize ``self.gpt_prompts`` based on the currently selected edit format.
32+
33+
This prevents AttributeError crashes when base ``Coder`` code assumes ``self.gpt_prompts``
34+
exists (eg during message formatting, announcements, cancellation/cleanup paths, etc).
35+
"""
36+
# Determine the selected edit_format the same way Coder.create() does.
37+
selected_edit_format = None
38+
if getattr(self, "args", None) is not None and getattr(self.args, "edit_format", None):
39+
selected_edit_format = self.args.edit_format
40+
else:
41+
selected_edit_format = getattr(self.main_model, "edit_format", None)
42+
43+
# "code" is treated like None in Coder.create()
44+
if selected_edit_format == "code":
45+
selected_edit_format = None
46+
47+
# If no edit format is selected, fall back to model default.
48+
if selected_edit_format is None:
49+
selected_edit_format = getattr(self.main_model, "edit_format", None)
50+
51+
# Find the coder class that would have been selected for this edit_format.
52+
try:
53+
import aider.coders as coders
54+
except Exception:
55+
coders = None
56+
57+
target_coder_class = None
58+
if coders is not None:
59+
for coder_cls in getattr(coders, "__all__", []):
60+
if hasattr(coder_cls, "edit_format") and coder_cls.edit_format == selected_edit_format:
61+
target_coder_class = coder_cls
62+
break
63+
64+
# Mirror prompt pack + edit_format where available.
65+
if target_coder_class is not None and hasattr(target_coder_class, "gpt_prompts"):
66+
self.gpt_prompts = target_coder_class.gpt_prompts
67+
# Keep announcements/formatting consistent with the selected coder.
68+
self.edit_format = getattr(target_coder_class, "edit_format", self.edit_format)
69+
return
70+
71+
# Last-resort fallback: avoid crashing if we can't determine the prompts.
72+
# Prefer keeping any existing gpt_prompts (if one was set elsewhere).
73+
if not hasattr(self, "gpt_prompts"):
74+
self.gpt_prompts = None
75+
76+
async def send(self, messages, model=None, functions=None, tools=None):
77+
model = model or self.main_model
78+
79+
if getattr(model, "copy_paste_transport", "api") == "api":
80+
async for chunk in super().send(messages, model=model, functions=functions, tools=tools):
81+
yield chunk
82+
return
83+
84+
if functions:
85+
self.io.tool_warning("copy/paste mode ignores function call requests.")
86+
if tools:
87+
self.io.tool_warning("copy/paste mode ignores tool call requests.")
88+
89+
self.io.reset_streaming_response()
90+
91+
# Base Coder methods (eg show_send_output/preprocess_response) expect these streaming
92+
# attributes to always exist, even when we bypass the normal API streaming path.
93+
self.partial_response_content = ""
94+
self.partial_response_function_call = None
95+
# preprocess_response() does len(self.partial_response_tool_calls), so it must not be None.
96+
self.partial_response_tool_calls = []
97+
98+
try:
99+
hash_object, completion = self.copy_paste_completion(messages, model)
100+
self.chat_completion_call_hashes.append(hash_object.hexdigest())
101+
self.show_send_output(completion)
102+
self.calculate_and_show_tokens_and_cost(messages, completion)
103+
finally:
104+
self.preprocess_response()
105+
106+
if self.partial_response_content:
107+
self.io.ai_output(self.partial_response_content)
108+
109+
def copy_paste_completion(self, messages, model):
110+
try:
111+
from aider.helpers import copypaste
112+
except ImportError: # pragma: no cover - import error path
113+
self.io.tool_error("copy/paste mode requires the pyperclip package.")
114+
self.io.tool_output("Install it with: pip install pyperclip")
115+
raise
116+
117+
def content_to_text(content):
118+
"""Extract text from the various content formats Aider/LLMs can produce."""
119+
if not content:
120+
return ""
121+
if isinstance(content, str):
122+
return content
123+
if isinstance(content, list):
124+
parts = []
125+
for part in content:
126+
if isinstance(part, dict):
127+
text = part.get("text")
128+
if isinstance(text, str):
129+
parts.append(text)
130+
elif isinstance(part, str):
131+
parts.append(part)
132+
return "".join(parts)
133+
if isinstance(content, dict):
134+
text = content.get("text")
135+
if isinstance(text, str):
136+
return text
137+
return ""
138+
return str(content)
139+
140+
lines = []
141+
for message in messages:
142+
text_content = content_to_text(message.get("content"))
143+
if not text_content:
144+
continue
145+
role = message.get("role")
146+
if role:
147+
lines.append(f"{role.upper()}:\n{text_content}")
148+
else:
149+
lines.append(text_content)
150+
151+
prompt_text = "\n\n".join(lines).strip()
152+
153+
try:
154+
copypaste.copy_to_clipboard(prompt_text)
155+
except copypaste.ClipboardError as err: # pragma: no cover - clipboard error path
156+
self.io.tool_error(f"Unable to copy prompt to clipboard: {err}")
157+
raise
158+
159+
self.io.tool_output("Request copied to clipboard.")
160+
self.io.tool_output("Paste it into your LLM interface, then copy the reply back.")
161+
self.io.tool_output("Waiting for clipboard updates (Ctrl+C to cancel)...")
162+
163+
try:
164+
last_value = copypaste.read_clipboard()
165+
except copypaste.ClipboardError as err: # pragma: no cover - clipboard error path
166+
self.io.tool_error(f"Unable to read clipboard: {err}")
167+
raise
168+
169+
try:
170+
response_text = copypaste.wait_for_clipboard_change(initial=last_value)
171+
except copypaste.ClipboardError as err: # pragma: no cover - clipboard error path
172+
self.io.tool_error(f"Unable to read clipboard: {err}")
173+
raise
174+
175+
# Estimate tokens locally using the model's tokenizer; fallback to heuristic.
176+
def _safe_token_count(text):
177+
"""Return token count via the model tokenizer, falling back to a heuristic."""
178+
if not text:
179+
return 0
180+
try:
181+
count = model.token_count(text)
182+
if isinstance(count, int) and count >= 0:
183+
return count
184+
except Exception as ex:
185+
# Try to map known LiteLLM exceptions to user-friendly messages, then fall back.
186+
try:
187+
ex_info = LiteLLMExceptions().get_ex_info(ex)
188+
if ex_info and ex_info.description:
189+
self.io.tool_warning(
190+
f"Token count failed: {ex_info.description} Falling back to heuristic."
191+
)
192+
except Exception:
193+
# Avoid masking the original issue during error mapping.
194+
pass
195+
return int(math.ceil(len(text) / 4))
196+
197+
prompt_tokens = _safe_token_count(prompt_text)
198+
completion_tokens = _safe_token_count(response_text)
199+
total_tokens = prompt_tokens + completion_tokens
200+
201+
completion = litellm.ModelResponse(
202+
id=f"chatcmpl-{uuid.uuid4()}",
203+
choices=[
204+
litellm.Choices(
205+
index=0,
206+
finish_reason="stop",
207+
message=litellm.Message(role="assistant", content=response_text),
208+
)
209+
],
210+
created=int(time.time()),
211+
model=model.name,
212+
usage={
213+
"prompt_tokens": prompt_tokens,
214+
"completion_tokens": completion_tokens,
215+
"total_tokens": total_tokens,
216+
},
217+
)
218+
219+
kwargs = dict(model=model.name, messages=messages, stream=False)
220+
hash_object = hashlib.sha1(json.dumps(kwargs, sort_keys=True).encode()) # nosec B324
221+
return hash_object, completion

aider/commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ async def cmd_model(self, args):
100100
model_name,
101101
editor_model=self.coder.main_model.editor_model.name,
102102
weak_model=self.coder.main_model.weak_model.name,
103+
io=self.io,
103104
)
104105
await models.sanity_check_models(self.io, model)
105106

@@ -172,6 +173,7 @@ async def cmd_weak_model(self, args):
172173
self.coder.main_model.name,
173174
editor_model=self.coder.main_model.editor_model.name,
174175
weak_model=model_name,
176+
io=self.io,
175177
)
176178
await models.sanity_check_models(self.io, model)
177179
raise SwitchCoder(main_model=model)

aider/copypaste.py

Lines changed: 0 additions & 72 deletions
This file was deleted.

0 commit comments

Comments
 (0)