mirror of
https://github.com/Aider-AI/aider.git
synced 2025-06-06 20:54:59 +00:00
Merge branch 'main' into mixpanel
This commit is contained in:
commit
068fb38a5d
181 changed files with 141428 additions and 1961 deletions
|
@ -1,19 +1,24 @@
|
|||
import glob
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import OrderedDict
|
||||
from os.path import expanduser
|
||||
from pathlib import Path
|
||||
|
||||
import git
|
||||
import pyperclip
|
||||
from PIL import Image, ImageGrab
|
||||
from rich.text import Text
|
||||
from prompt_toolkit.completion import Completion, PathCompleter
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
from aider import models, prompts, voice
|
||||
from aider.format_settings import format_settings
|
||||
from aider.help import Help, install_help_extra
|
||||
from aider.llm import litellm
|
||||
from aider.repo import ANY_GIT_ERROR
|
||||
from aider.run_cmd import run_cmd
|
||||
from aider.scrape import Scraper, install_playwright
|
||||
from aider.utils import is_image_file
|
||||
|
||||
|
@ -29,9 +34,24 @@ class Commands:
|
|||
voice = None
|
||||
scraper = None
|
||||
|
||||
def __init__(self, io, coder, voice_language=None, verify_ssl=True):
|
||||
def clone(self):
|
||||
return Commands(
|
||||
self.io,
|
||||
None,
|
||||
voice_language=self.voice_language,
|
||||
verify_ssl=self.verify_ssl,
|
||||
args=self.args,
|
||||
parser=self.parser,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, io, coder, voice_language=None, verify_ssl=True, args=None, parser=None, verbose=False
|
||||
):
|
||||
self.io = io
|
||||
self.coder = coder
|
||||
self.parser = parser
|
||||
self.args = args
|
||||
self.verbose = verbose
|
||||
|
||||
self.verify_ssl = verify_ssl
|
||||
if voice_language == "auto":
|
||||
|
@ -119,8 +139,8 @@ class Commands:
|
|||
else:
|
||||
self.io.tool_output("Please provide a partial model name to search for.")
|
||||
|
||||
def cmd_web(self, args, paginate=True):
|
||||
"Scrape a webpage, convert to markdown and add to the chat"
|
||||
def cmd_web(self, args):
|
||||
"Scrape a webpage, convert to markdown and send in a message"
|
||||
|
||||
url = args.strip()
|
||||
if not url:
|
||||
|
@ -131,7 +151,7 @@ class Commands:
|
|||
if not self.scraper:
|
||||
res = install_playwright(self.io)
|
||||
if not res:
|
||||
self.io.tool_error("Unable to initialize playwright.")
|
||||
self.io.tool_warning("Unable to initialize playwright.")
|
||||
|
||||
self.scraper = Scraper(
|
||||
print_error=self.io.tool_error, playwright_available=res, verify_ssl=self.verify_ssl
|
||||
|
@ -142,19 +162,24 @@ class Commands:
|
|||
|
||||
self.io.tool_output("... done.")
|
||||
|
||||
if paginate:
|
||||
with self.io.console.pager():
|
||||
self.io.console.print(Text(content))
|
||||
|
||||
return content
|
||||
|
||||
def is_command(self, inp):
|
||||
return inp[0] in "/!"
|
||||
|
||||
def get_raw_completions(self, cmd):
|
||||
assert cmd.startswith("/")
|
||||
cmd = cmd[1:]
|
||||
cmd = cmd.replace("-", "_")
|
||||
|
||||
raw_completer = getattr(self, f"completions_raw_{cmd}", None)
|
||||
return raw_completer
|
||||
|
||||
def get_completions(self, cmd):
|
||||
assert cmd.startswith("/")
|
||||
cmd = cmd[1:]
|
||||
|
||||
cmd = cmd.replace("-", "_")
|
||||
fun = getattr(self, f"completions_{cmd}", None)
|
||||
if not fun:
|
||||
return
|
||||
|
@ -175,10 +200,14 @@ class Commands:
|
|||
cmd_name = cmd_name.replace("-", "_")
|
||||
cmd_method_name = f"cmd_{cmd_name}"
|
||||
cmd_method = getattr(self, cmd_method_name, None)
|
||||
if cmd_method:
|
||||
return cmd_method(args)
|
||||
else:
|
||||
if not cmd_method:
|
||||
self.io.tool_output(f"Error: Command {cmd_name} not found.")
|
||||
return
|
||||
|
||||
try:
|
||||
return cmd_method(args)
|
||||
except ANY_GIT_ERROR as err:
|
||||
self.io.tool_error(f"Unable to complete {cmd_name}: {err}")
|
||||
|
||||
def matching_commands(self, inp):
|
||||
words = inp.strip().split()
|
||||
|
@ -186,7 +215,7 @@ class Commands:
|
|||
return
|
||||
|
||||
first_word = words[0]
|
||||
rest_inp = inp[len(words[0]) :]
|
||||
rest_inp = inp[len(words[0]) :].strip()
|
||||
|
||||
all_commands = self.get_commands()
|
||||
matching_commands = [cmd for cmd in all_commands if cmd.startswith(first_word)]
|
||||
|
@ -219,20 +248,25 @@ class Commands:
|
|||
|
||||
def cmd_commit(self, args=None):
|
||||
"Commit edits to the repo made outside the chat (commit message optional)"
|
||||
try:
|
||||
self.raw_cmd_commit(args)
|
||||
except ANY_GIT_ERROR as err:
|
||||
self.io.tool_error(f"Unable to complete commit: {err}")
|
||||
|
||||
def raw_cmd_commit(self, args=None):
|
||||
if not self.coder.repo:
|
||||
self.io.tool_error("No git repository found.")
|
||||
return
|
||||
|
||||
if not self.coder.repo.is_dirty():
|
||||
self.io.tool_error("No more changes to commit.")
|
||||
self.io.tool_warning("No more changes to commit.")
|
||||
return
|
||||
|
||||
commit_message = args.strip() if args else None
|
||||
self.coder.repo.commit(message=commit_message)
|
||||
|
||||
def cmd_lint(self, args="", fnames=None):
|
||||
"Lint and fix provided files or in-chat files if none provided"
|
||||
"Lint and fix in-chat files or all dirty files if none in chat"
|
||||
|
||||
if not self.coder.repo:
|
||||
self.io.tool_error("No git repository found.")
|
||||
|
@ -246,7 +280,7 @@ class Commands:
|
|||
fnames = self.coder.repo.get_dirty_files()
|
||||
|
||||
if not fnames:
|
||||
self.io.tool_error("No dirty files to lint.")
|
||||
self.io.tool_warning("No dirty files to lint.")
|
||||
return
|
||||
|
||||
fnames = [self.coder.abs_root_path(fname) for fname in fnames]
|
||||
|
@ -257,18 +291,18 @@ class Commands:
|
|||
errors = self.coder.linter.lint(fname)
|
||||
except FileNotFoundError as err:
|
||||
self.io.tool_error(f"Unable to lint {fname}")
|
||||
self.io.tool_error(str(err))
|
||||
self.io.tool_output(str(err))
|
||||
continue
|
||||
|
||||
if not errors:
|
||||
continue
|
||||
|
||||
self.io.tool_error(errors)
|
||||
self.io.tool_output(errors)
|
||||
if not self.io.confirm_ask(f"Fix lint errors in {fname}?", default="y"):
|
||||
continue
|
||||
|
||||
# Commit everything before we start fixing lint errors
|
||||
if self.coder.repo.is_dirty():
|
||||
if self.coder.repo.is_dirty() and self.coder.dirty_commits:
|
||||
self.cmd_commit("")
|
||||
|
||||
if not lint_coder:
|
||||
|
@ -283,15 +317,28 @@ class Commands:
|
|||
lint_coder.run(errors)
|
||||
lint_coder.abs_fnames = set()
|
||||
|
||||
if lint_coder and self.coder.repo.is_dirty():
|
||||
if lint_coder and self.coder.repo.is_dirty() and self.coder.auto_commits:
|
||||
self.cmd_commit("")
|
||||
|
||||
def cmd_clear(self, args):
|
||||
"Clear the chat history"
|
||||
|
||||
self._clear_chat_history()
|
||||
|
||||
def _drop_all_files(self):
|
||||
self.coder.abs_fnames = set()
|
||||
self.coder.abs_read_only_fnames = set()
|
||||
|
||||
def _clear_chat_history(self):
|
||||
self.coder.done_messages = []
|
||||
self.coder.cur_messages = []
|
||||
|
||||
def cmd_reset(self, args):
|
||||
"Drop all files and clear the chat history"
|
||||
self._drop_all_files()
|
||||
self._clear_chat_history()
|
||||
self.io.tool_output("All files dropped and chat history cleared.")
|
||||
|
||||
def cmd_tokens(self, args):
|
||||
"Report on the number of tokens used by the current chat context"
|
||||
|
||||
|
@ -398,15 +445,37 @@ class Commands:
|
|||
|
||||
def cmd_undo(self, args):
|
||||
"Undo the last git commit if it was done by aider"
|
||||
try:
|
||||
self.raw_cmd_undo(args)
|
||||
except ANY_GIT_ERROR as err:
|
||||
self.io.tool_error(f"Unable to complete undo: {err}")
|
||||
|
||||
def raw_cmd_undo(self, args):
|
||||
if not self.coder.repo:
|
||||
self.io.tool_error("No git repository found.")
|
||||
return
|
||||
|
||||
last_commit = self.coder.repo.repo.head.commit
|
||||
if not last_commit.parents:
|
||||
last_commit = self.coder.repo.get_head_commit()
|
||||
if not last_commit or not last_commit.parents:
|
||||
self.io.tool_error("This is the first commit in the repository. Cannot undo.")
|
||||
return
|
||||
|
||||
last_commit_hash = self.coder.repo.get_head_commit_sha(short=True)
|
||||
last_commit_message = self.coder.repo.get_head_commit_message("(unknown)").strip()
|
||||
if last_commit_hash not in self.coder.aider_commit_hashes:
|
||||
self.io.tool_error("The last commit was not made by aider in this chat session.")
|
||||
self.io.tool_output(
|
||||
"You could try `/git reset --hard HEAD^` but be aware that this is a destructive"
|
||||
" command!"
|
||||
)
|
||||
return
|
||||
|
||||
if len(last_commit.parents) > 1:
|
||||
self.io.tool_error(
|
||||
f"The last commit {last_commit.hexsha} has more than 1 parent, can't undo."
|
||||
)
|
||||
return
|
||||
|
||||
prev_commit = last_commit.parents[0]
|
||||
changed_files_last_commit = [item.a_path for item in last_commit.diff(prev_commit)]
|
||||
|
||||
|
@ -432,7 +501,7 @@ class Commands:
|
|||
try:
|
||||
remote_head = self.coder.repo.repo.git.rev_parse(f"origin/{current_branch}")
|
||||
has_origin = True
|
||||
except git.exc.GitCommandError:
|
||||
except ANY_GIT_ERROR:
|
||||
has_origin = False
|
||||
|
||||
if has_origin:
|
||||
|
@ -443,19 +512,25 @@ class Commands:
|
|||
)
|
||||
return
|
||||
|
||||
last_commit_hash = self.coder.repo.repo.head.commit.hexsha[:7]
|
||||
last_commit_message = self.coder.repo.repo.head.commit.message.strip()
|
||||
if last_commit_hash not in self.coder.aider_commit_hashes:
|
||||
self.io.tool_error("The last commit was not made by aider in this chat session.")
|
||||
self.io.tool_error(
|
||||
"You could try `/git reset --hard HEAD^` but be aware that this is a destructive"
|
||||
" command!"
|
||||
)
|
||||
return
|
||||
|
||||
# Reset only the files which are part of `last_commit`
|
||||
restored = set()
|
||||
unrestored = set()
|
||||
for file_path in changed_files_last_commit:
|
||||
self.coder.repo.repo.git.checkout("HEAD~1", file_path)
|
||||
try:
|
||||
self.coder.repo.repo.git.checkout("HEAD~1", file_path)
|
||||
restored.add(file_path)
|
||||
except ANY_GIT_ERROR:
|
||||
unrestored.add(file_path)
|
||||
|
||||
if unrestored:
|
||||
self.io.tool_error(f"Error restoring {file_path}, aborting undo.")
|
||||
self.io.tool_output("Restored files:")
|
||||
for file in restored:
|
||||
self.io.tool_output(f" {file}")
|
||||
self.io.tool_output("Unable to restore files:")
|
||||
for file in unrestored:
|
||||
self.io.tool_output(f" {file}")
|
||||
return
|
||||
|
||||
# Move the HEAD back before the latest commit
|
||||
self.coder.repo.repo.git.reset("--soft", "HEAD~1")
|
||||
|
@ -463,8 +538,8 @@ class Commands:
|
|||
self.io.tool_output(f"Removed: {last_commit_hash} {last_commit_message}")
|
||||
|
||||
# Get the current HEAD after undo
|
||||
current_head_hash = self.coder.repo.repo.head.commit.hexsha[:7]
|
||||
current_head_message = self.coder.repo.repo.head.commit.message.strip()
|
||||
current_head_hash = self.coder.repo.get_head_commit_sha(short=True)
|
||||
current_head_message = self.coder.repo.get_head_commit_message("(unknown)").strip()
|
||||
self.io.tool_output(f"Now at: {current_head_hash} {current_head_message}")
|
||||
|
||||
if self.coder.main_model.send_undo_reply:
|
||||
|
@ -472,11 +547,17 @@ class Commands:
|
|||
|
||||
def cmd_diff(self, args=""):
|
||||
"Display the diff of changes since the last message"
|
||||
try:
|
||||
self.raw_cmd_diff(args)
|
||||
except ANY_GIT_ERROR as err:
|
||||
self.io.tool_error(f"Unable to complete diff: {err}")
|
||||
|
||||
def raw_cmd_diff(self, args=""):
|
||||
if not self.coder.repo:
|
||||
self.io.tool_error("No git repository found.")
|
||||
return
|
||||
|
||||
current_head = self.coder.repo.get_head()
|
||||
current_head = self.coder.repo.get_head_commit_sha()
|
||||
if current_head is None:
|
||||
self.io.tool_error("Unable to get current commit. The repository might be empty.")
|
||||
return
|
||||
|
@ -487,7 +568,7 @@ class Commands:
|
|||
commit_before_message = self.coder.commit_before_message[-2]
|
||||
|
||||
if not commit_before_message or commit_before_message == current_head:
|
||||
self.io.tool_error("No changes to display since the last message.")
|
||||
self.io.tool_warning("No changes to display since the last message.")
|
||||
return
|
||||
|
||||
self.io.tool_output(f"Diff since {commit_before_message[:7]}...")
|
||||
|
@ -498,16 +579,69 @@ class Commands:
|
|||
"HEAD",
|
||||
)
|
||||
|
||||
# don't use io.tool_output() because we don't want to log or further colorize
|
||||
print(diff)
|
||||
self.io.print(diff)
|
||||
|
||||
def quote_fname(self, fname):
|
||||
if " " in fname and '"' not in fname:
|
||||
fname = f'"{fname}"'
|
||||
return fname
|
||||
|
||||
def completions_read(self):
|
||||
return self.completions_add()
|
||||
def completions_raw_read_only(self, document, complete_event):
|
||||
# Get the text before the cursor
|
||||
text = document.text_before_cursor
|
||||
|
||||
# Skip the first word and the space after it
|
||||
after_command = text.split()[-1]
|
||||
|
||||
# Create a new Document object with the text after the command
|
||||
new_document = Document(after_command, cursor_position=len(after_command))
|
||||
|
||||
def get_paths():
|
||||
return [self.coder.root] if self.coder.root else None
|
||||
|
||||
path_completer = PathCompleter(
|
||||
get_paths=get_paths,
|
||||
only_directories=False,
|
||||
expanduser=True,
|
||||
)
|
||||
|
||||
# Adjust the start_position to replace all of 'after_command'
|
||||
adjusted_start_position = -len(after_command)
|
||||
|
||||
# Collect all completions
|
||||
all_completions = []
|
||||
|
||||
# Iterate over the completions and modify them
|
||||
for completion in path_completer.get_completions(new_document, complete_event):
|
||||
quoted_text = self.quote_fname(after_command + completion.text)
|
||||
all_completions.append(
|
||||
Completion(
|
||||
text=quoted_text,
|
||||
start_position=adjusted_start_position,
|
||||
display=completion.display,
|
||||
style=completion.style,
|
||||
selected_style=completion.selected_style,
|
||||
)
|
||||
)
|
||||
|
||||
# Add completions from the 'add' command
|
||||
add_completions = self.completions_add()
|
||||
for completion in add_completions:
|
||||
if after_command in completion:
|
||||
all_completions.append(
|
||||
Completion(
|
||||
text=completion,
|
||||
start_position=adjusted_start_position,
|
||||
display=completion,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort all completions based on their text
|
||||
sorted_completions = sorted(all_completions, key=lambda c: c.text)
|
||||
|
||||
# Yield the sorted completions
|
||||
for completion in sorted_completions:
|
||||
yield completion
|
||||
|
||||
def completions_add(self):
|
||||
files = set(self.coder.get_all_relative_files())
|
||||
|
@ -516,12 +650,17 @@ class Commands:
|
|||
return files
|
||||
|
||||
def glob_filtered_to_repo(self, pattern):
|
||||
if not pattern.strip():
|
||||
return []
|
||||
try:
|
||||
if os.path.isabs(pattern):
|
||||
# Handle absolute paths
|
||||
raw_matched_files = [Path(pattern)]
|
||||
else:
|
||||
raw_matched_files = list(Path(self.coder.root).glob(pattern))
|
||||
try:
|
||||
raw_matched_files = list(Path(self.coder.root).glob(pattern))
|
||||
except (IndexError, AttributeError):
|
||||
raw_matched_files = []
|
||||
except ValueError as err:
|
||||
self.io.tool_error(f"Error matching {pattern}: {err}")
|
||||
raw_matched_files = []
|
||||
|
@ -531,9 +670,9 @@ class Commands:
|
|||
matched_files += expand_subdir(fn)
|
||||
|
||||
matched_files = [
|
||||
str(Path(fn).relative_to(self.coder.root))
|
||||
fn.relative_to(self.coder.root)
|
||||
for fn in matched_files
|
||||
if Path(fn).is_relative_to(self.coder.root)
|
||||
if fn.is_relative_to(self.coder.root)
|
||||
]
|
||||
|
||||
# if repo, filter against it
|
||||
|
@ -545,9 +684,7 @@ class Commands:
|
|||
return res
|
||||
|
||||
def cmd_add(self, args):
|
||||
"Add files to the chat so GPT can edit them or review them in detail"
|
||||
|
||||
added_fnames = []
|
||||
"Add files to the chat so aider can edit them or review them in detail"
|
||||
|
||||
all_matched_files = set()
|
||||
|
||||
|
@ -559,7 +696,7 @@ class Commands:
|
|||
fname = Path(self.coder.root) / word
|
||||
|
||||
if self.coder.repo and self.coder.repo.ignored_file(fname):
|
||||
self.io.tool_error(f"Skipping {fname} that matches aiderignore spec.")
|
||||
self.io.tool_warning(f"Skipping {fname} due to aiderignore or --subtree-only.")
|
||||
continue
|
||||
|
||||
if fname.exists():
|
||||
|
@ -574,17 +711,25 @@ class Commands:
|
|||
all_matched_files.update(matched_files)
|
||||
continue
|
||||
|
||||
if self.io.confirm_ask(f"No files matched '{word}'. Do you want to create {fname}?"):
|
||||
if "*" in str(fname) or "?" in str(fname):
|
||||
self.io.tool_error(f"Cannot create file with wildcard characters: {fname}")
|
||||
else:
|
||||
try:
|
||||
fname.touch()
|
||||
all_matched_files.add(str(fname))
|
||||
except OSError as e:
|
||||
self.io.tool_error(f"Error creating file {fname}: {e}")
|
||||
if "*" in str(fname) or "?" in str(fname):
|
||||
self.io.tool_error(
|
||||
f"No match, and cannot create file with wildcard characters: {fname}"
|
||||
)
|
||||
continue
|
||||
|
||||
for matched_file in all_matched_files:
|
||||
if fname.exists() and fname.is_dir() and self.coder.repo:
|
||||
self.io.tool_error(f"Directory {fname} is not in git.")
|
||||
self.io.tool_output(f"You can add to git with: /git add {fname}")
|
||||
continue
|
||||
|
||||
if self.io.confirm_ask(f"No files matched '{word}'. Do you want to create {fname}?"):
|
||||
try:
|
||||
fname.touch()
|
||||
all_matched_files.add(str(fname))
|
||||
except OSError as e:
|
||||
self.io.tool_error(f"Error creating file {fname}: {e}")
|
||||
|
||||
for matched_file in sorted(all_matched_files):
|
||||
abs_file_path = self.coder.abs_root_path(matched_file)
|
||||
|
||||
if not abs_file_path.startswith(self.coder.root) and not is_image_file(matched_file):
|
||||
|
@ -594,7 +739,8 @@ class Commands:
|
|||
continue
|
||||
|
||||
if abs_file_path in self.coder.abs_fnames:
|
||||
self.io.tool_error(f"{matched_file} is already in the chat")
|
||||
self.io.tool_error(f"{matched_file} is already in the chat as an editable file")
|
||||
continue
|
||||
elif abs_file_path in self.coder.abs_read_only_fnames:
|
||||
if self.coder.repo and self.coder.repo.path_in_repo(matched_file):
|
||||
self.coder.abs_read_only_fnames.remove(abs_file_path)
|
||||
|
@ -602,17 +748,17 @@ class Commands:
|
|||
self.io.tool_output(
|
||||
f"Moved {matched_file} from read-only to editable files in the chat"
|
||||
)
|
||||
added_fnames.append(matched_file)
|
||||
else:
|
||||
self.io.tool_error(
|
||||
f"Cannot add {matched_file} as it's not part of the repository"
|
||||
)
|
||||
else:
|
||||
if is_image_file(matched_file) and not self.coder.main_model.accepts_images:
|
||||
if is_image_file(matched_file) and not self.coder.main_model.info.get(
|
||||
"supports_vision"
|
||||
):
|
||||
self.io.tool_error(
|
||||
f"Cannot add image file {matched_file} as the"
|
||||
f" {self.coder.main_model.name} does not support image.\nYou can run `aider"
|
||||
" --4-turbo-vision` to use GPT-4 Turbo with Vision."
|
||||
f" {self.coder.main_model.name} does not support images."
|
||||
)
|
||||
continue
|
||||
content = self.io.read_text(abs_file_path)
|
||||
|
@ -622,7 +768,6 @@ class Commands:
|
|||
self.coder.abs_fnames.add(abs_file_path)
|
||||
self.io.tool_output(f"Added {matched_file} to the chat")
|
||||
self.coder.check_added_files()
|
||||
added_fnames.append(matched_file)
|
||||
|
||||
def completions_drop(self):
|
||||
files = self.coder.get_inchat_relative_files()
|
||||
|
@ -636,24 +781,26 @@ class Commands:
|
|||
|
||||
if not args.strip():
|
||||
self.io.tool_output("Dropping all files from the chat session.")
|
||||
self.coder.abs_fnames = set()
|
||||
self.coder.abs_read_only_fnames = set()
|
||||
self._drop_all_files()
|
||||
return
|
||||
|
||||
filenames = parse_quoted_filenames(args)
|
||||
for word in filenames:
|
||||
# Expand tilde in the path
|
||||
expanded_word = os.path.expanduser(word)
|
||||
|
||||
# Handle read-only files separately, without glob_filtered_to_repo
|
||||
read_only_matched = [f for f in self.coder.abs_read_only_fnames if word in f]
|
||||
read_only_matched = [f for f in self.coder.abs_read_only_fnames if expanded_word in f]
|
||||
|
||||
if read_only_matched:
|
||||
for matched_file in read_only_matched:
|
||||
self.coder.abs_read_only_fnames.remove(matched_file)
|
||||
self.io.tool_output(f"Removed read-only file {matched_file} from the chat")
|
||||
|
||||
matched_files = self.glob_filtered_to_repo(word)
|
||||
matched_files = self.glob_filtered_to_repo(expanded_word)
|
||||
|
||||
if not matched_files:
|
||||
matched_files.append(word)
|
||||
matched_files.append(expanded_word)
|
||||
|
||||
for matched_file in matched_files:
|
||||
abs_fname = self.coder.abs_root_path(matched_file)
|
||||
|
@ -662,7 +809,7 @@ class Commands:
|
|||
self.io.tool_output(f"Removed {matched_file} from the chat")
|
||||
|
||||
def cmd_git(self, args):
|
||||
"Run a git command"
|
||||
"Run a git command (output excluded from chat)"
|
||||
combined_output = None
|
||||
try:
|
||||
args = "git " + args
|
||||
|
@ -692,45 +839,39 @@ class Commands:
|
|||
if not args and self.coder.test_cmd:
|
||||
args = self.coder.test_cmd
|
||||
|
||||
if not args:
|
||||
return
|
||||
|
||||
if not callable(args):
|
||||
if type(args) is not str:
|
||||
raise ValueError(repr(args))
|
||||
return self.cmd_run(args, True)
|
||||
|
||||
errors = args()
|
||||
if not errors:
|
||||
return
|
||||
|
||||
self.io.tool_error(errors, strip=False)
|
||||
self.io.tool_output(errors)
|
||||
return errors
|
||||
|
||||
def cmd_run(self, args, add_on_nonzero_exit=False):
|
||||
"Run a shell command and optionally add the output to the chat (alias: !)"
|
||||
combined_output = None
|
||||
exit_status, combined_output = run_cmd(
|
||||
args, verbose=self.verbose, error_print=self.io.tool_error
|
||||
)
|
||||
instructions = None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
shell=True,
|
||||
encoding=self.io.encoding,
|
||||
errors="replace",
|
||||
)
|
||||
combined_output = result.stdout
|
||||
except Exception as e:
|
||||
self.io.tool_error(f"Error running command: {e}")
|
||||
|
||||
if combined_output is None:
|
||||
return
|
||||
|
||||
self.io.tool_output(combined_output)
|
||||
|
||||
if add_on_nonzero_exit:
|
||||
add = result.returncode != 0
|
||||
add = exit_status != 0
|
||||
else:
|
||||
self.io.tool_output()
|
||||
response = self.io.prompt_ask(
|
||||
"Add the output to the chat?\n(Y/n/instructions)", default=""
|
||||
"Add the output to the chat?\n(Y)es/(n)o/message with instructions:",
|
||||
).strip()
|
||||
self.io.tool_output()
|
||||
|
||||
if response.lower() in ["yes", "y"]:
|
||||
add = True
|
||||
|
@ -739,6 +880,9 @@ class Commands:
|
|||
else:
|
||||
add = True
|
||||
instructions = response
|
||||
if response.strip():
|
||||
self.io.user_input(response, log_only=True)
|
||||
self.io.add_to_input_history(response)
|
||||
|
||||
if add:
|
||||
for line in combined_output.splitlines():
|
||||
|
@ -868,14 +1012,6 @@ class Commands:
|
|||
show_announcements=False,
|
||||
)
|
||||
|
||||
def clone(self):
|
||||
return Commands(
|
||||
self.io,
|
||||
None,
|
||||
voice_language=self.voice_language,
|
||||
verify_ssl=self.verify_ssl,
|
||||
)
|
||||
|
||||
def cmd_ask(self, args):
|
||||
"Ask questions about the code base without editing any files"
|
||||
return self._generic_chat_command(args, "ask")
|
||||
|
@ -884,6 +1020,10 @@ class Commands:
|
|||
"Ask for changes to your code"
|
||||
return self._generic_chat_command(args, self.coder.main_model.edit_format)
|
||||
|
||||
def cmd_architect(self, args):
|
||||
"Enter architect mode to discuss high-level design and architecture"
|
||||
return self._generic_chat_command(args, "architect")
|
||||
|
||||
def _generic_chat_command(self, args, edit_format):
|
||||
if not args.strip():
|
||||
self.io.tool_error(f"Please provide a question or topic for the {edit_format} chat.")
|
||||
|
@ -936,7 +1076,7 @@ class Commands:
|
|||
self.io.tool_error("To use /voice you must provide an OpenAI API key.")
|
||||
return
|
||||
try:
|
||||
self.voice = voice.Voice()
|
||||
self.voice = voice.Voice(audio_format=self.args.voice_format)
|
||||
except voice.SoundDeviceError:
|
||||
self.io.tool_error(
|
||||
"Unable to import `sounddevice` and/or `soundfile`, is portaudio installed?"
|
||||
|
@ -968,14 +1108,15 @@ class Commands:
|
|||
|
||||
if text:
|
||||
self.io.add_to_input_history(text)
|
||||
print()
|
||||
self.io.print()
|
||||
self.io.user_input(text, log_only=False)
|
||||
print()
|
||||
self.io.print()
|
||||
|
||||
return text
|
||||
|
||||
def cmd_clipboard(self, args):
|
||||
"Add image/text from the clipboard to the chat (optionally provide a name for the image)"
|
||||
def cmd_paste(self, args):
|
||||
"""Paste image/text from the clipboard into the chat.\
|
||||
Optionally provide a name for the image."""
|
||||
try:
|
||||
# Check for image first
|
||||
image = ImageGrab.grabclipboard()
|
||||
|
@ -1023,25 +1164,72 @@ class Commands:
|
|||
except Exception as e:
|
||||
self.io.tool_error(f"Error processing clipboard content: {e}")
|
||||
|
||||
def cmd_read(self, args):
|
||||
"Add a file to the chat that is for reference, not to be edited"
|
||||
def cmd_read_only(self, args):
|
||||
"Add files to the chat that are for reference, not to be edited"
|
||||
if not args.strip():
|
||||
self.io.tool_error("Please provide a filename to read.")
|
||||
self.io.tool_error("Please provide filenames or directories to read.")
|
||||
return
|
||||
|
||||
filename = args.strip()
|
||||
abs_path = os.path.abspath(filename)
|
||||
filenames = parse_quoted_filenames(args)
|
||||
all_paths = []
|
||||
|
||||
if not os.path.exists(abs_path):
|
||||
self.io.tool_error(f"File not found: {abs_path}")
|
||||
# First collect all expanded paths
|
||||
for pattern in filenames:
|
||||
expanded_pattern = expanduser(pattern)
|
||||
if os.path.isabs(expanded_pattern):
|
||||
# For absolute paths, glob it
|
||||
matches = list(glob.glob(expanded_pattern))
|
||||
else:
|
||||
# For relative paths and globs, use glob from the root directory
|
||||
matches = list(Path(self.coder.root).glob(expanded_pattern))
|
||||
|
||||
if not matches:
|
||||
self.io.tool_error(f"No matches found for: {pattern}")
|
||||
else:
|
||||
all_paths.extend(matches)
|
||||
|
||||
# Then process them in sorted order
|
||||
for path in sorted(all_paths):
|
||||
abs_path = self.coder.abs_root_path(path)
|
||||
if os.path.isfile(abs_path):
|
||||
self._add_read_only_file(abs_path, path)
|
||||
elif os.path.isdir(abs_path):
|
||||
self._add_read_only_directory(abs_path, path)
|
||||
else:
|
||||
self.io.tool_error(f"Not a file or directory: {abs_path}")
|
||||
|
||||
def _add_read_only_file(self, abs_path, original_name):
|
||||
if abs_path in self.coder.abs_read_only_fnames:
|
||||
self.io.tool_error(f"{original_name} is already in the chat as a read-only file")
|
||||
return
|
||||
elif abs_path in self.coder.abs_fnames:
|
||||
self.coder.abs_fnames.remove(abs_path)
|
||||
self.coder.abs_read_only_fnames.add(abs_path)
|
||||
self.io.tool_output(
|
||||
f"Moved {original_name} from editable to read-only files in the chat"
|
||||
)
|
||||
else:
|
||||
self.coder.abs_read_only_fnames.add(abs_path)
|
||||
self.io.tool_output(f"Added {original_name} to read-only files.")
|
||||
|
||||
if not os.path.isfile(abs_path):
|
||||
self.io.tool_error(f"Not a file: {abs_path}")
|
||||
return
|
||||
def _add_read_only_directory(self, abs_path, original_name):
|
||||
added_files = 0
|
||||
for root, _, files in os.walk(abs_path):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
if (
|
||||
file_path not in self.coder.abs_fnames
|
||||
and file_path not in self.coder.abs_read_only_fnames
|
||||
):
|
||||
self.coder.abs_read_only_fnames.add(file_path)
|
||||
added_files += 1
|
||||
|
||||
self.coder.abs_read_only_fnames.add(abs_path)
|
||||
self.io.tool_output(f"Added {abs_path} to read-only files.")
|
||||
if added_files > 0:
|
||||
self.io.tool_output(
|
||||
f"Added {added_files} files from directory {original_name} to read-only files."
|
||||
)
|
||||
else:
|
||||
self.io.tool_output(f"No new files added from directory {original_name}.")
|
||||
|
||||
def cmd_map(self, args):
|
||||
"Print out the current repository map"
|
||||
|
@ -1051,9 +1239,118 @@ class Commands:
|
|||
else:
|
||||
self.io.tool_output("No repository map available.")
|
||||
|
||||
def cmd_map_refresh(self, args):
|
||||
"Force a refresh of the repository map"
|
||||
repo_map = self.coder.get_repo_map(force_refresh=True)
|
||||
if repo_map:
|
||||
self.io.tool_output("The repo map has been refreshed, use /map to view it.")
|
||||
|
||||
def cmd_settings(self, args):
|
||||
"Print out the current settings"
|
||||
settings = format_settings(self.parser, self.args)
|
||||
announcements = "\n".join(self.coder.get_announcements())
|
||||
output = f"{announcements}\n{settings}"
|
||||
self.io.tool_output(output)
|
||||
|
||||
def completions_raw_load(self, document, complete_event):
|
||||
return self.completions_raw_read_only(document, complete_event)
|
||||
|
||||
def cmd_load(self, args):
|
||||
"Load and execute commands from a file"
|
||||
if not args.strip():
|
||||
self.io.tool_error("Please provide a filename containing commands to load.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(args.strip(), "r", encoding=self.io.encoding, errors="replace") as f:
|
||||
commands = f.readlines()
|
||||
except FileNotFoundError:
|
||||
self.io.tool_error(f"File not found: {args}")
|
||||
return
|
||||
except Exception as e:
|
||||
self.io.tool_error(f"Error reading file: {e}")
|
||||
return
|
||||
|
||||
for cmd in commands:
|
||||
cmd = cmd.strip()
|
||||
if not cmd or cmd.startswith("#"):
|
||||
continue
|
||||
|
||||
self.io.tool_output(f"\nExecuting: {cmd}")
|
||||
self.run(cmd)
|
||||
|
||||
def completions_raw_save(self, document, complete_event):
|
||||
return self.completions_raw_read_only(document, complete_event)
|
||||
|
||||
def cmd_save(self, args):
|
||||
"Save commands to a file that can reconstruct the current chat session's files"
|
||||
if not args.strip():
|
||||
self.io.tool_error("Please provide a filename to save the commands to.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(args.strip(), "w", encoding=self.io.encoding) as f:
|
||||
# Write commands to add editable files
|
||||
for fname in sorted(self.coder.abs_fnames):
|
||||
rel_fname = self.coder.get_rel_fname(fname)
|
||||
f.write(f"/add {rel_fname}\n")
|
||||
|
||||
# Write commands to add read-only files
|
||||
for fname in sorted(self.coder.abs_read_only_fnames):
|
||||
# Use absolute path for files outside repo root, relative path for files inside
|
||||
if Path(fname).is_relative_to(self.coder.root):
|
||||
rel_fname = self.coder.get_rel_fname(fname)
|
||||
f.write(f"/read-only {rel_fname}\n")
|
||||
else:
|
||||
f.write(f"/read-only {fname}\n")
|
||||
|
||||
self.io.tool_output(f"Saved commands to {args.strip()}")
|
||||
except Exception as e:
|
||||
self.io.tool_error(f"Error saving commands to file: {e}")
|
||||
|
||||
def cmd_copy(self, args):
|
||||
"Copy the last assistant message to the clipboard"
|
||||
all_messages = self.coder.done_messages + self.coder.cur_messages
|
||||
assistant_messages = [msg for msg in reversed(all_messages) if msg["role"] == "assistant"]
|
||||
|
||||
if not assistant_messages:
|
||||
self.io.tool_error("No assistant messages found to copy.")
|
||||
return
|
||||
|
||||
last_assistant_message = assistant_messages[0]["content"]
|
||||
|
||||
try:
|
||||
pyperclip.copy(last_assistant_message)
|
||||
preview = (
|
||||
last_assistant_message[:50] + "..."
|
||||
if len(last_assistant_message) > 50
|
||||
else last_assistant_message
|
||||
)
|
||||
self.io.tool_output(f"Copied last assistant message to clipboard. Preview: {preview}")
|
||||
except pyperclip.PyperclipException as e:
|
||||
self.io.tool_error(f"Failed to copy to clipboard: {str(e)}")
|
||||
self.io.tool_output(
|
||||
"You may need to install xclip or xsel on Linux, or pbcopy on macOS."
|
||||
)
|
||||
except Exception as e:
|
||||
self.io.tool_error(f"An unexpected error occurred while copying to clipboard: {str(e)}")
|
||||
|
||||
def cmd_report(self, args):
|
||||
"Report a problem by opening a GitHub Issue"
|
||||
from aider.report import report_github_issue
|
||||
|
||||
announcements = "\n".join(self.coder.get_announcements())
|
||||
issue_text = announcements
|
||||
|
||||
if args.strip():
|
||||
title = args.strip()
|
||||
else:
|
||||
title = None
|
||||
|
||||
report_github_issue(issue_text, title=title, confirm=False)
|
||||
|
||||
|
||||
def expand_subdir(file_path):
|
||||
file_path = Path(file_path)
|
||||
if file_path.is_file():
|
||||
yield file_path
|
||||
return
|
||||
|
@ -1061,7 +1358,7 @@ def expand_subdir(file_path):
|
|||
if file_path.is_dir():
|
||||
for file in file_path.rglob("*"):
|
||||
if file.is_file():
|
||||
yield str(file)
|
||||
yield file
|
||||
|
||||
|
||||
def parse_quoted_filenames(args):
|
||||
|
@ -1071,11 +1368,7 @@ def parse_quoted_filenames(args):
|
|||
|
||||
|
||||
def get_help_md():
|
||||
from aider.coders import Coder
|
||||
from aider.models import Model
|
||||
|
||||
coder = Coder(Model("gpt-3.5-turbo"), None)
|
||||
md = coder.commands.get_help_md()
|
||||
md = Commands(None, None).get_help_md()
|
||||
return md
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue