diff --git a/aider/args.py b/aider/args.py index 5e4b09f76..c4b9af2ce 100644 --- a/aider/args.py +++ b/aider/args.py @@ -535,6 +535,12 @@ def get_parser(default_config_files, git_root): default=False, help="Enable/disable watching files for ai coding comments (default: False)", ) + group.add_argument( + "--copy-paste", + action=argparse.BooleanOptionalAction, + default=False, + help="Enable automatic copy/paste of chat between aider and web UI (default: False)", + ) group = parser.add_argument_group("Fixing and committing") group.add_argument( "--lint", diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index a2adc04f6..6b7c971a0 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -286,6 +286,7 @@ class Coder: detect_urls=True, ignore_mentions=None, file_watcher=None, + auto_copy_context=False, ): # Fill in a dummy Analytics if needed, but it is never .enable()'d self.analytics = analytics if analytics is not None else Analytics() @@ -297,6 +298,8 @@ class Coder: self.rejected_urls = set() self.abs_root_path_cache = {} + self.auto_copy_context = auto_copy_context + self.ignore_mentions = ignore_mentions if not self.ignore_mentions: self.ignore_mentions = set() @@ -792,9 +795,9 @@ class Coder: self.io.user_input(with_message) self.run_one(with_message, preproc) return self.partial_response_content - while True: try: + self.copy_context() user_message = self.get_input() self.run_one(user_message, preproc) self.show_undo_hint() @@ -803,6 +806,10 @@ class Coder: except EOFError: return + def copy_context(self): + if self.auto_copy_context: + self.commands.cmd_copy_context() + def get_input(self): inchat_files = self.get_inchat_relative_files() read_only_files = [self.get_rel_fname(fname) for fname in self.abs_read_only_fnames] @@ -1114,7 +1121,10 @@ class Coder: # add the reminder anyway total_tokens = 0 - final = chunks.cur[-1] + if chunks.cur: + final = chunks.cur[-1] + else: + final = None max_input_tokens = self.main_model.info.get("max_input_tokens") or 0 # Add the reminder prompt if we still have room to include it. @@ -1125,7 +1135,7 @@ class Coder: ): if self.main_model.reminder == "sys": chunks.reminder = reminder_message - elif self.main_model.reminder == "user" and final["role"] == "user": + elif self.main_model.reminder == "user" and final and final["role"] == "user": # stuff it into the user message new_content = ( final["content"] diff --git a/aider/commands.py b/aider/commands.py index 5fc9161c1..017329c2b 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1399,6 +1399,41 @@ class Commands: if user_input.strip(): self.io.set_placeholder(user_input.rstrip()) + def cmd_copy_context(self, args=None): + """Copy the current chat context as markdown, suitable to paste into a web UI""" + + chunks = self.coder.format_chat_chunks() + + markdown = "" + + # Only include specified chunks in order + for messages in [chunks.repo, chunks.readonly_files, chunks.chat_files]: + for msg in messages: + # Only include user messages + if msg["role"] != "user": + continue + + content = msg["content"] + + # Handle image/multipart content + if isinstance(content, list): + for part in content: + if part.get("type") == "text": + markdown += part["text"] + "\n\n" + else: + markdown += content + "\n\n" + + markdown += """ +Just tell me how to edit the files to make the changes. +Don't give me back entire files. +Just show me the edits I need to make. + + +""" + + pyperclip.copy(markdown) + self.io.tool_output("Copied context to clipboard.") + def expand_subdir(file_path): if file_path.is_file(): diff --git a/aider/copypaste.py b/aider/copypaste.py new file mode 100644 index 000000000..9e56b15cc --- /dev/null +++ b/aider/copypaste.py @@ -0,0 +1,69 @@ +import threading +import time + +import pyperclip + + +class ClipboardWatcher: + """Watches clipboard for changes and updates IO placeholder""" + + def __init__(self, io, verbose=False): + self.io = io + self.verbose = verbose + self.stop_event = None + self.watcher_thread = None + self.last_clipboard = None + self.io.clipboard_watcher = self + + def start(self): + """Start watching clipboard for changes""" + self.stop_event = threading.Event() + self.last_clipboard = pyperclip.paste() + + def watch_clipboard(): + while not self.stop_event.is_set(): + try: + current = pyperclip.paste() + if current != self.last_clipboard: + self.last_clipboard = current + self.io.interrupt_input() + self.io.placeholder = current + time.sleep(0.5) + except Exception as e: + if self.verbose: + from aider.dump import dump + + dump(f"Clipboard watcher error: {e}") + continue + + self.watcher_thread = threading.Thread(target=watch_clipboard, daemon=True) + self.watcher_thread.start() + + def stop(self): + """Stop watching clipboard for changes""" + if self.stop_event: + self.stop_event.set() + if self.watcher_thread: + self.watcher_thread.join() + self.watcher_thread = None + self.stop_event = None + + +def main(): + """Example usage of the clipboard watcher""" + from aider.io import InputOutput + + io = InputOutput() + watcher = ClipboardWatcher(io, verbose=True) + + try: + watcher.start() + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\nStopped watching clipboard") + watcher.stop() + + +if __name__ == "__main__": + main() diff --git a/aider/io.py b/aider/io.py index abb840287..226a50c25 100644 --- a/aider/io.py +++ b/aider/io.py @@ -176,6 +176,7 @@ class AutoCompleter(Completer): class InputOutput: num_error_outputs = 0 num_user_asks = 0 + clipboard_watcher = None def __init__( self, @@ -470,8 +471,11 @@ class InputOutput: self.placeholder = None self.interrupted = False - if not multiline_input and self.file_watcher: - self.file_watcher.start() + if not multiline_input: + if self.file_watcher: + self.file_watcher.start() + if self.clipboard_watcher: + self.clipboard_watcher.start() line = self.prompt_session.prompt( show, @@ -487,8 +491,10 @@ class InputOutput: # Check if we were interrupted by a file change if self.interrupted: - cmd = self.file_watcher.process_changes() - return cmd + line = line or "" + if self.file_watcher: + cmd = self.file_watcher.process_changes() + return cmd except EOFError: return "" @@ -504,6 +510,8 @@ class InputOutput: finally: if self.file_watcher: self.file_watcher.stop() + if self.clipboard_watcher: + self.clipboard_watcher.stop() if line.strip("\r\n") and not multiline_input: stripped = line.strip("\r\n") diff --git a/aider/main.py b/aider/main.py index 2bebed34c..130f07671 100644 --- a/aider/main.py +++ b/aider/main.py @@ -20,6 +20,7 @@ from aider.args import get_parser from aider.coders import Coder from aider.coders.base_coder import UnknownEditFormat from aider.commands import Commands, SwitchCoder +from aider.copypaste import ClipboardWatcher from aider.format_settings import format_settings, scrub_sensitive_info from aider.history import ChatSummary from aider.io import InputOutput @@ -687,6 +688,10 @@ def main(argv=None, input=None, output=None, force_git_root=None, return_coder=F editor_edit_format=args.editor_edit_format, ) + if args.copy_paste and args.edit_format is None: + if main_model.edit_format in ("diff", "whole"): + main_model.edit_format = "editor-" + main_model.edit_format + if args.verbose: io.tool_output("Model metadata:") io.tool_output(json.dumps(main_model.info, indent=4)) @@ -800,6 +805,7 @@ def main(argv=None, input=None, output=None, force_git_root=None, return_coder=F suggest_shell_commands=args.suggest_shell_commands, chat_language=args.chat_language, detect_urls=args.detect_urls, + auto_copy_context=args.copy_paste, ) except UnknownEditFormat as err: io.tool_error(str(err)) @@ -825,6 +831,9 @@ def main(argv=None, input=None, output=None, force_git_root=None, return_coder=F file_watcher = FileWatcher(coder, gitignores=ignores, verbose=args.verbose) coder.file_watcher = file_watcher + if args.copy_paste: + ClipboardWatcher(coder.io, verbose=args.verbose) + coder.show_announcements() if args.show_prompts: