add /editor command

Opens an editor for constructing a user prompt, using the currently defined chat mode.

The editor is determined as follows:

Look for the following environment variables, in order:

1. AIDER_EDITOR
2. VISUAL
3. EDITOR

If none of these are defined, use the following defaults:

Windows: notepad
OS X: vim
*nix: vi

If an editor is not found, a RuntimeError is raised.

Any arguments passed after the /editor command are inserted as content.

The temporary file used for editing has an .md extension, which can be leveraged for syntax highlighting.

NOTE: The editor used MUST block the process until the editor is closed -- the default editors all do this.
This commit is contained in:
Chad Phillips 2024-11-03 20:17:23 -05:00
parent 0022c1a67e
commit d8e9da35d6
3 changed files with 162 additions and 6 deletions

View file

@ -1357,6 +1357,13 @@ class Commands:
report_github_issue(issue_text, title=title, confirm=False)
def cmd_editor(self, initial_content=""):
"Open an editor to write a prompt"
from aider.editor import pipe_editor
user_input = pipe_editor(initial_content, suffix="md")
self.io.display_user_input(user_input)
self._generic_chat_command(user_input, self.coder.edit_format)
def expand_subdir(file_path):
if file_path.is_file():

146
aider/editor.py Normal file
View file

@ -0,0 +1,146 @@
"""
Editor module for handling system text editor interactions.
This module provides functionality to:
- Discover and launch the system's configured text editor
- Create and manage temporary files for editing
- Handle editor preferences from environment variables
- Support cross-platform editor operations
"""
import os
import tempfile
import subprocess
import platform
import shlex
from rich.console import Console
SYSTEM = platform.system()
DEFAULT_EDITOR_NIX = "vi"
DEFAULT_EDITOR_OS_X = "vim"
DEFAULT_EDITOR_WINDOWS = "notepad"
console = Console()
def print_status_message(success: bool, message: str, style: str | None = None) -> None:
"""
Print a status message with appropriate styling.
:param success: Whether the operation was successful
:param message: The message to display
:param style: Optional style override. If None, uses green for success and red for failure
"""
if style is None:
style = "bold green" if success else "bold red"
console.print(message, style=style)
print("")
def write_temp_file(input_data: str = "", suffix: str | None = None, prefix: str | None = None, dir: str | None = None) -> str:
"""
Create a temporary file with the given input data.
:param input_data: Content to write to the temporary file
:param suffix: Optional file extension (without the dot)
:param prefix: Optional prefix for the temporary filename
:param dir: Optional directory to create the file in
:return: Path to the created temporary file
:raises: OSError if file creation or writing fails
"""
kwargs = {"prefix": prefix, "dir": dir}
if suffix:
kwargs["suffix"] = f".{suffix}"
fd, filepath = tempfile.mkstemp(**kwargs)
try:
with os.fdopen(fd, 'w') as f:
f.write(input_data)
except Exception:
os.close(fd)
raise
return filepath
def get_environment_editor(default: str | None = None) -> str | None:
"""
Fetches the preferred editor from the environment variables.
This function checks the following environment variables in order to
determine the user's preferred editor:
- AIDER_EDITOR
- VISUAL
- EDITOR
:param default: The default editor to return if no environment variable is set.
:type default: str or None
:return: The preferred editor as specified by environment variables or the default value.
:rtype: str or None
"""
editor = os.environ.get("AIDER_EDITOR", os.environ.get("VISUAL", os.environ.get("EDITOR", default)))
return editor
def discover_editor() -> list[str]:
"""
Discovers and returns the appropriate editor command as a list of arguments.
Handles cases where the editor command includes arguments, including quoted arguments
with spaces (e.g. 'vim -c "set noswapfile"').
:return: A list of command parts ready for subprocess execution
:rtype: list[str]
"""
if SYSTEM == "Windows":
default_editor = DEFAULT_EDITOR_WINDOWS
elif SYSTEM == "Darwin":
default_editor = DEFAULT_EDITOR_OS_X
else:
default_editor = DEFAULT_EDITOR_NIX
editor = get_environment_editor(default_editor)
try:
return shlex.split(editor)
except ValueError as e:
raise RuntimeError(f"Invalid editor command format '{editor}': {e}")
def file_editor(filepath: str) -> None:
"""
Open the specified file in the system's configured editor.
This function blocks until the editor is closed.
:param filepath: Path to the file to edit
:type filepath: str
:raises RuntimeError: If the editor command is invalid
"""
command_parts = discover_editor()
command_parts.append(filepath)
subprocess.call(command_parts)
def pipe_editor(input_data: str = "", suffix: str | None = None) -> str:
"""
Opens the system editor with optional input data and returns the edited content.
This function creates a temporary file with the provided input data, opens it in
the system editor, waits for the user to make changes and close the editor, then
reads and returns the modified content. The temporary file is deleted afterwards.
:param input_data: Initial content to populate the editor with
:type input_data: str
:param suffix: Optional file extension for the temporary file (e.g. '.txt', '.md')
:type suffix: str or None
:return: The edited content after the editor is closed
:rtype: str
"""
filepath = write_temp_file(input_data, suffix)
file_editor(filepath)
with open(filepath, "r") as f:
output_data = f.read()
try:
os.remove(filepath)
except PermissionError:
print_status_message(False, f"WARNING: Unable to delete temporary file {filepath!r}. You may need to delete it manually.")
return output_data

View file

@ -457,14 +457,17 @@ class InputOutput:
log_file.write(f"{role.upper()} {timestamp}\n")
log_file.write(content + "\n")
def display_user_input(self, inp):
if self.pretty and self.user_input_color:
style = dict(style=self.user_input_color)
else:
style = dict()
self.console.print(Text(inp), **style)
def user_input(self, inp, log_only=True):
if not log_only:
if self.pretty and self.user_input_color:
style = dict(style=self.user_input_color)
else:
style = dict()
self.console.print(Text(inp), **style)
self.display_user_input(inp)
prefix = "####"
if inp: