From 53ff8810cfeda4c51b26ab8ef47a95f8a305d667 Mon Sep 17 00:00:00 2001 From: chrisnestrud Date: Sat, 20 Dec 2025 18:02:53 -0600 Subject: [PATCH 01/14] Update copypaste.md with docs for `cp:model` --- aider/website/docs/usage/copypaste.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/aider/website/docs/usage/copypaste.md b/aider/website/docs/usage/copypaste.md index c1864d50706..2f214a597c6 100644 --- a/aider/website/docs/usage/copypaste.md +++ b/aider/website/docs/usage/copypaste.md @@ -90,6 +90,21 @@ and aider will apply the LLMs changes to your local files. - Aider will automatically select the best edit format for this copy/paste functionality. Depending on the LLM you have aider use, it will be either `editor-whole` or `editor-diff`. +### No API access? Use `cp:model` + +If your only access to an LLM is via a web chat (no API keys, no local models), you can run aider with a model name prefixed by cp:. This performs the entire workflow via copy/paste without making any API calls. + +#### What cp: does + +- Activates CopyPasteCoder, which never sends requests to any LLM API. +- Uses the same copy/paste workflow described above + +#### Token and cost tracking + +- Aider uses the text after cp: as the "model name" for local token counting and cost estimation. +- If the label matches a known model in aider's pricing tables, aider will estimate tokens/costs using that model's rates; otherwise, costs may show as unknown or zero. +- With flat-rate web chat plans, you can treat any "estimated cost" displayed by aider as your "savings" versus if you had called an API model. + ## Terms of service Be sure to review the Terms Of Service of any LLM web chat service you use with From 1421ddd6f4088cfaabbf5dae655a8940fda0eafc Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 21 Dec 2025 09:20:42 -0500 Subject: [PATCH 02/14] Bump Version --- aider/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aider/__init__.py b/aider/__init__.py index 935725a74c0..d9364cda02e 100644 --- a/aider/__init__.py +++ b/aider/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.90.5.dev" +__version__ = "0.90.6.dev" safe_version = __version__ try: From 4b9b2779c6e09f229a406696fb14874720e7baf2 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 21 Dec 2025 09:47:30 -0500 Subject: [PATCH 03/14] #296: Assistant messages always need some type of content --- aider/coders/agent_coder.py | 2 +- aider/coders/base_coder.py | 6 +++++- aider/sendchat.py | 8 ++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/aider/coders/agent_coder.py b/aider/coders/agent_coder.py index e717bed6ccb..e68fc865812 100644 --- a/aider/coders/agent_coder.py +++ b/aider/coders/agent_coder.py @@ -1202,7 +1202,7 @@ async def reply_completed(self): self.tool_usage_history = [] if self.files_edited_by_tools: _ = await self.auto_commit(self.files_edited_by_tools) - return True + return False # Since we are no longer suppressing, the partial_response_content IS the final content. # We might want to update it to the processed_content (without tool calls) if we don't diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 47c7d765130..fb1a4cb2df2 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -2845,7 +2845,11 @@ def add_assistant_reply_to_cur_messages(self): msg["reasoning_content"] = self.partial_response_reasoning_content # Only add a message if it's not empty. - if msg is not None: + if msg is not None and ( + msg.get("content", None) + or msg.get("tool_calls", None) + or msg.get("function_call", None) + ): self.cur_messages.append(msg) def get_file_mentions(self, content, ignore_current=False): diff --git a/aider/sendchat.py b/aider/sendchat.py index 7060c28b21e..dcb1f2d8661 100644 --- a/aider/sendchat.py +++ b/aider/sendchat.py @@ -160,6 +160,14 @@ def ensure_alternating_roles(messages): msg = messages[i] role = msg.get("role") + if ( + role == "assistant" + and not msg.get("content", None) + and not msg.get("tool_calls", None) + and not msg.get("function_call", None) + ): + msg["content"] = "(empty response)" + # Handle tool call sequences atomically if role == "assistant" and "tool_calls" in msg and msg["tool_calls"]: # Start of tool sequence - collect all related messages From 0ea74b0b2e1eb1a2cfe34f062805253435c21f61 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 21 Dec 2025 10:22:36 -0500 Subject: [PATCH 04/14] #297: Update MANIFEST.in for global dev installation with UV --- MANIFEST.in | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 9ab273215b7..c3ebc1dde4d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,10 @@ # This needs to sync with aider/help_pats.py +include requirements/requirements.in +include requirements/requirements-dev.in +include requirements/requirements-help.in +include requirements/requirements-playwright.in + global-exclude .DS_Store recursive-exclude aider/website/examples * From 1d369171d8277583a3600bf97d262bf159da257d Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 21 Dec 2025 12:58:10 -0500 Subject: [PATCH 05/14] Update mcp docs --- aider/website/docs/config/mcp.md | 68 ++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/aider/website/docs/config/mcp.md b/aider/website/docs/config/mcp.md index 35f17f142c2..b23b23c70b7 100644 --- a/aider/website/docs/config/mcp.md +++ b/aider/website/docs/config/mcp.md @@ -120,3 +120,71 @@ If you encounter issues with MCP servers: 3. Verify that your JSON or YAML configuration is valid For more information about specific MCP servers and their capabilities, refer to their respective documentation. + +## Common MCP Servers + +Here are some commonly used MCP servers that can enhance aider's capabilities: + +### Context7 + +Context7 MCP provides up-to-date, version-specific documentation and code examples directly from the source into your LLM prompts, eliminating outdated information and hallucinations. It offers a streamlined integration experience with built-in caching mechanisms and is optimized for explorative agentic workflows. + +```yaml +mcp-servers: + mcpServers: + context7: + transport: http + url: https://mcp.context7.com/mcp +``` + +### DeepWiki + +DeepWiki MCP is an unofficial server that crawls Deepwiki URLs, converts pages to Markdown, and returns them as a single document or a list. It features domain safety, HTML sanitization, and link rewriting to provide clean, structured documentation from Deepwiki repositories. + +```yaml +mcp-servers: + mcpServers: + deepwiki: + transport: http + url: https://mcp.deepwiki.com/mcp +``` + +### Serena + +Serena MCP provides LSP support for the current project, offering code analysis, symbol navigation, and project-specific tooling. It runs as a local stdio server and provides context-aware development assistance directly within the IDE environment. + +```yaml +mcp-servers: + mcpServers: + serena: + transport: stdio + command: uvx + args: [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide", + "--project", + "{project path}" + ] +``` + +### Chrome DevTools + +Chrome DevTools MCP provides browser automation and debugging capabilities through Chrome's DevTools Protocol, enabling web page interaction, network monitoring, and performance analysis. It connects to a running Chrome instance and offers tools for web development testing and automation. Note: the configuration below requires you to start chrome with remote debugging enabled before +starting the coding agent. + +```yaml +mcp-servers: + mcpServers: + chrome-devtools: + transport: stdio + command: npx + args: [ + "chrome-devtools-mcp@latest", + "--browser-url", + "http://127.0.0.1:9222" + ] +``` From f3cb76c9f930dfafa6a640a7ba8feef1323e298a Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 21 Dec 2025 13:05:08 -0500 Subject: [PATCH 06/14] #300: Fix include_context_blocks typo --- aider/coders/agent_coder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aider/coders/agent_coder.py b/aider/coders/agent_coder.py index e68fc865812..44175c63446 100644 --- a/aider/coders/agent_coder.py +++ b/aider/coders/agent_coder.py @@ -287,7 +287,7 @@ def _get_agent_config(self): config["tools_excludelist"] = [] if "include_context_blocks" in config: - self.allowed_context_blocks = set(config["context_blocks"]) + self.allowed_context_blocks = set(config["include_context_blocks"]) else: self.allowed_context_blocks = { "context_summary", From 8eb1abf31f78dabe0efc6fa25b8a03fefa624419 Mon Sep 17 00:00:00 2001 From: Dhiraj Bhakta K Date: Mon, 22 Dec 2025 00:32:52 +0530 Subject: [PATCH 07/14] #301 TUI model warnings fix --- aider/io.py | 1 + aider/main.py | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/aider/io.py b/aider/io.py index b2d5d811197..4ec36d0f87c 100644 --- a/aider/io.py +++ b/aider/io.py @@ -753,6 +753,7 @@ async def recreate_input(self, future=None): await asyncio.sleep(0) else: self.input_task = asyncio.create_task(self.get_input(None, [], [], [])) + await asyncio.sleep(0) async def get_input( self, diff --git a/aider/main.py b/aider/main.py index 03ea584a6e9..81aa7197ffc 100644 --- a/aider/main.py +++ b/aider/main.py @@ -762,6 +762,7 @@ def get_io(pretty): # TUI mode - create TUI-specific IO output_queue = None input_queue = None + _console_io = get_io(args.pretty) if args.tui or (args.tui is None and not args.linear_output): try: from aider.tui import create_tui_io @@ -776,7 +777,7 @@ def get_io(pretty): print(f"Import error: {e}") sys.exit(1) else: - io = get_io(args.pretty) + io = _console_io # Only do CLI-specific initialization if not in TUI mode if not args.tui: @@ -1258,37 +1259,38 @@ def apply_model_overrides(model_name): repomap_in_memory=args.map_memory_cache, linear_output=args.linear_output, ) + _console_coder = coder.clone(io=_console_io) if args.show_model_warnings: - problem = await models.sanity_check_models(io, main_model) + problem = await models.sanity_check_models(_console_io, main_model) if problem: - io.tool_output("You can skip this check with --no-show-model-warnings") + _console_io.tool_output("You can skip this check with --no-show-model-warnings") try: - await io.offer_url( + await _console_io.offer_url( urls.model_warnings, "Open documentation url for more info?", acknowledge=True, ) - io.tool_output() + _console_io.tool_output() except KeyboardInterrupt: return await graceful_exit(coder, 1) if args.git: - git_root = await setup_git(git_root, io) + git_root = await setup_git(git_root, _console_io) if args.gitignore: - await check_gitignore(git_root, io) + await check_gitignore(git_root, _console_io) except UnknownEditFormat as err: - io.tool_error(str(err)) - await io.offer_url( + _console_io.tool_error(str(err)) + await _console_io.offer_url( urls.edit_formats, "Open documentation about edit formats?", acknowledge=True ) return await graceful_exit(None, 1) except ValueError as err: - io.tool_error(str(err)) + _console_io.tool_error(str(err)) return await graceful_exit(None, 1) @@ -1437,11 +1439,9 @@ def apply_model_overrides(model_name): except Exception: # Don't show errors for auto-load to avoid interrupting the user experience pass - # TUI mode - launch Textual interface if args.tui: from aider.tui import launch_tui - return_code = await launch_tui(coder, output_queue, input_queue, args) return await graceful_exit(coder, return_code) From 955c3b9d719af397d152cb39331d20a2e1ecc26e Mon Sep 17 00:00:00 2001 From: Dhiraj Bhakta K Date: Mon, 22 Dec 2025 00:55:54 +0530 Subject: [PATCH 08/14] Avoid Bottom status bar 'Thinking...' during /exit --- aider/tui/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aider/tui/app.py b/aider/tui/app.py index 8cbca5bd286..b5dea720623 100644 --- a/aider/tui/app.py +++ b/aider/tui/app.py @@ -450,7 +450,8 @@ def on_input_area_submit(self, message: InputArea.Submit): # Update footer to show processing footer = self.query_one(AiderFooter) - footer.start_spinner("Thinking...") + if not user_input.startswith("/"): + footer.start_spinner("Thinking...") self.update_key_hints(generating=True) From aa97a5e913d27842d6f1e0bfbc9edc18f89674c8 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 21 Dec 2025 14:55:58 -0500 Subject: [PATCH 09/14] #298: Fix and simplify voice mode --- aider/commands.py | 9 +- aider/tui/app.py | 5 + aider/voice.py | 263 +++++++++++++--------------------------------- 3 files changed, 86 insertions(+), 191 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 2d7f104918c..6014e9f882f 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1649,7 +1649,7 @@ def get_help_md(self): res += "\n" return res - def cmd_voice(self, args): + async def cmd_voice(self, args): "Record and transcribe voice input" if not self.voice: @@ -1667,7 +1667,8 @@ def cmd_voice(self, args): return try: - text = self.voice.record_and_transcribe(None, language=self.voice_language) + self.coder.io.update_spinner("Recording...") + text = await self.voice.record_and_transcribe(None, language=self.voice_language) except litellm.OpenAIError as err: self.io.tool_error(f"Unable to use OpenAI whisper model: {err}") return @@ -1675,6 +1676,10 @@ def cmd_voice(self, args): if text: self.io.placeholder = text + if self.coder.tui and self.coder.tui(): + self.coder.tui().set_input_value(text) + self.coder.tui().refresh() + def cmd_paste(self, args): """Paste image/text from the clipboard into the chat.\ Optionally provide a name for the image.""" diff --git a/aider/tui/app.py b/aider/tui/app.py index 8cbca5bd286..b4201275144 100644 --- a/aider/tui/app.py +++ b/aider/tui/app.py @@ -456,6 +456,11 @@ def on_input_area_submit(self, message: InputArea.Submit): self.input_queue.put({"text": user_input}) + def set_input_value(self, text) -> None: + """Find the input widget and set focus to it.""" + input_area = self.query_one("#input", InputArea) + input_area.value = text + def action_focus_input(self) -> None: """Find the input widget and set focus to it.""" input_area = self.query_one("#input", InputArea) diff --git a/aider/voice.py b/aider/voice.py index 63b9108d7bd..a6230f949b6 100644 --- a/aider/voice.py +++ b/aider/voice.py @@ -1,205 +1,90 @@ -import math +import asyncio import os -import queue +import sys import tempfile -import time -import warnings - -from prompt_toolkit.shortcuts import prompt - -from aider.llm import litellm - -from .dump import dump # noqa: F401 - -warnings.filterwarnings( - "ignore", message="Couldn't find ffmpeg or avconv - defaulting to ffmpeg, but may not work" -) -warnings.filterwarnings("ignore", category=SyntaxWarning) - - -try: - from pydub import AudioSegment # noqa - from pydub.exceptions import CouldntDecodeError, CouldntEncodeError # noqa - - PYDUB_AVAILABLE = True -except (ModuleNotFoundError, ImportError) as e: - if "audioop" in str(e) or "pyaudioop" in str(e): - # Handle missing audioop/pyaudioop dependency gracefully - PYDUB_AVAILABLE = False - AudioSegment = None - CouldntDecodeError = Exception - CouldntEncodeError = Exception - else: - raise - -try: - import soundfile as sf -except (OSError, ModuleNotFoundError): - sf = None - - -class SoundDeviceError(Exception): - pass +from concurrent.futures import ProcessPoolExecutor class Voice: - max_rms = 0 - min_rms = 1e5 - pct = 0 - - threshold = 0.15 - def __init__(self, audio_format="wav", device_name=None): - if sf is None: - raise SoundDeviceError - try: - print("Initializing sound device...") - import sounddevice as sd - - self.sd = sd - - devices = sd.query_devices() - - if device_name: - # Find the device with matching name - device_id = None - for i, device in enumerate(devices): - if device_name in device["name"]: - device_id = i - break - if device_id is None: - available_inputs = [d["name"] for d in devices if d["max_input_channels"] > 0] - raise ValueError( - f"Device '{device_name}' not found. Available input devices:" - f" {available_inputs}" - ) - - print(f"Using input device: {device_name} (ID: {device_id})") - - self.device_id = device_id - else: - self.device_id = None - - except (OSError, ModuleNotFoundError): - raise SoundDeviceError - if audio_format not in ["wav", "mp3", "webm"]: - raise ValueError(f"Unsupported audio format: {audio_format}") self.audio_format = audio_format + self.device_name = device_name + self._executor = ProcessPoolExecutor(max_workers=1) - def callback(self, indata, frames, time, status): - """This is called (from a separate thread) for each audio block.""" - import numpy as np - - rms = np.sqrt(np.mean(indata**2)) - self.max_rms = max(self.max_rms, rms) - self.min_rms = min(self.min_rms, rms) - - rng = self.max_rms - self.min_rms - if rng > 0.001: - self.pct = (rms - self.min_rms) / rng - else: - self.pct = 0.5 - - self.q.put(indata.copy()) - - def get_prompt(self): - num = 10 - if math.isnan(self.pct) or self.pct < self.threshold: - cnt = 0 - else: - cnt = int(self.pct * 10) - - bar = "░" * cnt + "█" * (num - cnt) - bar = bar[:num] + async def record_and_transcribe(self, history=None, language=None): + loop = asyncio.get_running_loop() + stdin_fd = sys.stdin.fileno() - dur = time.time() - self.start_time - return f"Recording, press ENTER when done... {dur:.1f}sec {bar}" - - def record_and_transcribe(self, history=None, language=None): try: - return self.raw_record_and_transcribe(history, language) - except KeyboardInterrupt: - return - except SoundDeviceError as e: - print(f"Error: {e}") - print("Please ensure you have a working audio input device connected and try again.") - return + return await loop.run_in_executor( + self._executor, + _run_record_process, + stdin_fd, + self.audio_format, + self.device_name, + history, + language, + ) + except Exception as e: + print(f"Error in transcription: {e}") + return None - def raw_record_and_transcribe(self, history, language): - self.q = queue.Queue() - temp_wav = tempfile.mktemp(suffix=".wav") +def _run_record_process(stdin_fd, audio_format, device_name, history, language): + import queue - try: - sample_rate = int(self.sd.query_devices(self.device_id, "input")["default_samplerate"]) - except (TypeError, ValueError): - sample_rate = 16000 # fallback to 16kHz if unable to query device - except self.sd.PortAudioError: - raise SoundDeviceError( - "No audio input device detected. Please check your audio settings and try again." + import sounddevice as sd + import soundfile as sf + + from aider.llm import litellm + + # Re-link terminal input + sys.stdin = os.fdopen(os.dup(stdin_fd)) + + q = queue.Queue() + + def callback(indata, frames, time, status): + q.put(indata.copy()) + + # 1. Securely create the temporary file + # delete=False is required so we can close the handle and let 'soundfile' open it again + with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp_file: + temp_path = tmp_file.name + + try: + # Device Setup + device_id = None + if device_name: + for i, d in enumerate(sd.query_devices()): + if device_name in d["name"]: + device_id = i + break + + info = sd.query_devices(device_id, "input") + sample_rate = int(info["default_samplerate"]) + + # Recording + with sd.InputStream( + samplerate=sample_rate, channels=1, callback=callback, device=device_id + ): + print("\nRecording... Press ENTER to stop.") + sys.stdin.readline() + + # 2. Write buffered audio using the named path + with sf.SoundFile(temp_path, mode="w", samplerate=sample_rate, channels=1) as file: + while not q.empty(): + file.write(q.get()) + + # 3. Transcription + with open(temp_path, "rb") as fh: + print("\nTranscribing...") + transcript = litellm.transcription( + model="whisper-1", file=fh, prompt=history, language=language ) - self.start_time = time.time() + return transcript.text - try: - with self.sd.InputStream( - samplerate=sample_rate, channels=1, callback=self.callback, device=self.device_id - ): - prompt(self.get_prompt, refresh_interval=0.1) - except self.sd.PortAudioError as err: - raise SoundDeviceError(f"Error accessing audio input device: {err}") - - with sf.SoundFile(temp_wav, mode="x", samplerate=sample_rate, channels=1) as file: - while not self.q.empty(): - file.write(self.q.get()) - - use_audio_format = self.audio_format - - # Check file size and offer to convert to mp3 if too large - file_size = os.path.getsize(temp_wav) - if file_size > 24.9 * 1024 * 1024 and self.audio_format == "wav": - print("\nWarning: {temp_wav} is too large, switching to mp3 format.") - use_audio_format = "mp3" - - filename = temp_wav - if use_audio_format != "wav": - try: - if not PYDUB_AVAILABLE: - print( - f"Warning: pydub not available, cannot convert to {use_audio_format}. Using" - " original WAV file." - ) - else: - new_filename = tempfile.mktemp(suffix=f".{use_audio_format}") - audio = AudioSegment.from_wav(temp_wav) - audio.export(new_filename, format=use_audio_format) - os.remove(temp_wav) - filename = new_filename - except (CouldntDecodeError, CouldntEncodeError) as e: - print(f"Error converting audio: {e}") - except (OSError, FileNotFoundError) as e: - print(f"File system error during conversion: {e}") - except Exception as e: - print(f"Unexpected error during audio conversion: {e}") - - with open(filename, "rb") as fh: - try: - transcript = litellm.transcription( - model="whisper-1", file=fh, prompt=history, language=language - ) - except Exception as err: - print(f"Unable to transcribe {filename}: {err}") - return - - if filename != temp_wav: - os.remove(filename) - - text = transcript.text - return text - - -if __name__ == "__main__": - api_key = os.getenv("OPENAI_API_KEY") - if not api_key: - raise ValueError("Please set the OPENAI_API_KEY environment variable.") - print(Voice().record_and_transcribe()) + finally: + # 4. Manual cleanup since delete=False was used + if os.path.exists(temp_path): + os.remove(temp_path) From 323276f2f1650a4771ed76a82adfee31a0386093 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 21 Dec 2025 15:17:53 -0500 Subject: [PATCH 10/14] Fix test_voice.py --- tests/basic/test_voice.py | 261 +++++++++++++++++++++++++++++--------- 1 file changed, 200 insertions(+), 61 deletions(-) diff --git a/tests/basic/test_voice.py b/tests/basic/test_voice.py index e1c4b1c3a59..02b4ec4cb17 100644 --- a/tests/basic/test_voice.py +++ b/tests/basic/test_voice.py @@ -1,99 +1,238 @@ -import os -import queue -from unittest.mock import MagicMock, patch +import asyncio +from unittest.mock import MagicMock, mock_open, patch -import numpy as np import pytest -from aider.voice import SoundDeviceError, Voice +from aider.voice import Voice -# Mock the entire sounddevice module @pytest.fixture def mock_sounddevice(): mock_sd = MagicMock() mock_sd.query_devices.return_value = [ - {"name": "test_device", "max_input_channels": 2}, - {"name": "another_device", "max_input_channels": 1}, + {"name": "test_device", "max_input_channels": 2, "default_samplerate": 44100}, + {"name": "another_device", "max_input_channels": 1, "default_samplerate": 48000}, ] - with patch.dict("sys.modules", {"sounddevice": mock_sd}): - yield mock_sd + return mock_sd @pytest.fixture def mock_soundfile(): - with patch("aider.voice.sf") as mock_sf: - yield mock_sf + mock_sf = MagicMock() + mock_sf.SoundFile = MagicMock() + return mock_sf -def test_voice_init_default_device(mock_sounddevice): +@pytest.fixture +def mock_litellm(): + mock_llm = MagicMock() + mock_llm.transcription = MagicMock(return_value=MagicMock(text="Test transcription")) + return mock_llm + + +@pytest.mark.asyncio +async def test_voice_init_default(): + """Test Voice initialization with default parameters.""" voice = Voice() - assert voice.device_id is None assert voice.audio_format == "wav" - assert voice.sd == mock_sounddevice + assert voice.device_name is None + assert voice._executor is not None -def test_voice_init_specific_device(mock_sounddevice): - voice = Voice(device_name="test_device") - assert voice.device_id == 0 - assert voice.sd == mock_sounddevice +@pytest.mark.asyncio +async def test_voice_init_with_device(): + """Test Voice initialization with specific device name.""" + voice = Voice(device_name="test_device", audio_format="mp3") + assert voice.device_name == "test_device" + assert voice.audio_format == "mp3" -def test_voice_init_invalid_device(mock_sounddevice): - with pytest.raises(ValueError) as exc: - Voice(device_name="nonexistent_device") - assert "Device" in str(exc.value) - assert "not found" in str(exc.value) +@pytest.mark.asyncio +async def test_record_and_transcribe_success(): + """Test successful recording and transcription.""" + voice = Voice() + + # Mock the executor's run_in_executor to return a successful transcription + mock_future = asyncio.Future() + mock_future.set_result("Test transcription result") + with ( + patch.object(asyncio, "get_running_loop") as mock_loop, + patch("sys.stdin.fileno", return_value=42), + ): + mock_loop.return_value.run_in_executor = MagicMock(return_value=mock_future) -def test_voice_init_invalid_format(mock_sounddevice): - with patch("aider.voice.sf", MagicMock()): # Need to mock sf to avoid SoundDeviceError - with pytest.raises(ValueError) as exc: - Voice(audio_format="invalid") - assert "Unsupported audio format" in str(exc.value) + result = await voice.record_and_transcribe(history="Previous context", language="en") + # Verify the executor was called with correct arguments + mock_loop.return_value.run_in_executor.assert_called_once() + call_args = mock_loop.return_value.run_in_executor.call_args + assert call_args[0][0] == voice._executor # executor + assert call_args[0][1].__name__ == "_run_record_process" # function + assert call_args[0][2] == 42 # stdin_fd + assert call_args[0][3] == "wav" # audio_format + assert call_args[0][4] is None # device_name + assert call_args[0][5] == "Previous context" # history + assert call_args[0][6] == "en" # language -def test_callback_processing(mock_sounddevice, mock_soundfile): + assert result == "Test transcription result" + + +@pytest.mark.asyncio +async def test_record_and_transcribe_exception(): + """Test that exceptions in transcription are caught and return None.""" voice = Voice() - voice.q = queue.Queue() - # Test with silence (low amplitude) - test_data = np.zeros((1000, 1)) - voice.callback(test_data, None, None, None) - assert voice.pct == 0.5 # When range is too small (<=0.001), pct is set to 0.5 + # Mock the executor's run_in_executor to raise an exception + mock_future = asyncio.Future() + mock_future.set_exception(Exception("Test error")) - # Test with loud signal (high amplitude) - test_data = np.ones((1000, 1)) - voice.callback(test_data, None, None, None) - assert voice.pct > 0.9 + with ( + patch.object(asyncio, "get_running_loop") as mock_loop, + patch("sys.stdin.fileno", return_value=42), + ): + mock_loop.return_value.run_in_executor = MagicMock(return_value=mock_future) - # Verify data is queued - assert not voice.q.empty() + result = await voice.record_and_transcribe() + assert result is None -def test_get_prompt(mock_sounddevice, mock_soundfile): - voice = Voice() - voice.start_time = os.times().elapsed - voice.pct = 0.5 # 50% volume level - prompt = voice.get_prompt() - assert "Recording" in prompt - assert "sec" in prompt - assert "█" in prompt # Should contain some filled blocks - assert "░" in prompt # Should contain some empty blocks +@pytest.mark.asyncio +async def test_record_and_transcribe_with_device(): + """Test recording with specific device name.""" + voice = Voice(device_name="test_device") + mock_future = asyncio.Future() + mock_future.set_result("Test transcription") -def test_record_and_transcribe_keyboard_interrupt(mock_sounddevice, mock_soundfile): - voice = Voice() - with patch.object(voice, "raw_record_and_transcribe", side_effect=KeyboardInterrupt()): - result = voice.record_and_transcribe() - assert result is None + with ( + patch.object(asyncio, "get_running_loop") as mock_loop, + patch("sys.stdin.fileno", return_value=42), + ): + mock_loop.return_value.run_in_executor = MagicMock(return_value=mock_future) + result = await voice.record_and_transcribe() -def test_record_and_transcribe_device_error(mock_sounddevice, mock_soundfile): - voice = Voice() - with patch.object( - voice, "raw_record_and_transcribe", side_effect=SoundDeviceError("Test error") + call_args = mock_loop.return_value.run_in_executor.call_args + assert call_args[0][4] == "test_device" # device_name should be passed + assert result == "Test transcription" + + +def test_run_record_process_device_selection(): + """Test device selection logic in _run_record_process.""" + stdin_fd = 42 # Mocked file descriptor + audio_format = "wav" + device_name = "test_device" + history = "test history" + language = "en" + + # Mock dependencies + mock_sd = MagicMock() + mock_sf = MagicMock() + mock_sf.SoundFile = MagicMock() + mock_litellm = MagicMock() + mock_litellm.transcription = MagicMock(return_value=MagicMock(text="Test transcription")) + + with ( + patch.dict("sys.modules", {"sounddevice": mock_sd, "soundfile": mock_sf}), + patch("aider.llm.litellm", mock_litellm), + patch("tempfile.NamedTemporaryFile") as mock_tempfile, + patch("builtins.open", mock_open()), + patch("os.remove"), + patch("os.path.exists", return_value=True), + patch("os.dup"), + patch("os.fdopen"), ): - result = voice.record_and_transcribe() - assert result is None + # Setup mocks + # Mock query_devices to handle both calls: + # 1. sd.query_devices() - returns list of devices + # 2. sd.query_devices(device_id, "input") - returns device info dict + def query_devices_side_effect(device_id=None, kind=None): + if device_id is None and kind is None: + return [ + {"name": "test_device", "default_samplerate": 44100}, + {"name": "other_device", "default_samplerate": 48000}, + ] + elif device_id == 0 and kind == "input": + return {"default_samplerate": 44100} + elif device_id is None and kind == "input": + return {"default_samplerate": 44100} + else: + return {"default_samplerate": 44100} + + mock_sd.query_devices.side_effect = query_devices_side_effect + + mock_temp_file = MagicMock() + mock_temp_file.name = "/tmp/test.wav" + mock_tempfile.return_value.__enter__.return_value = mock_temp_file + + mock_sf.SoundFile.return_value.__enter__.return_value.write = MagicMock() + + # Mock stdin.readline to simulate user pressing ENTER + with patch("sys.stdin.readline", return_value=""): + # Call the function + from aider.voice import _run_record_process + + result = _run_record_process(stdin_fd, audio_format, device_name, history, language) + + # Verify device was found + mock_sd.query_devices.assert_called() + # Should try to find device with name containing "test_device" + assert result == "Test transcription" + + +def test_run_record_process_no_device_found(): + """Test _run_record_process when specified device is not found.""" + stdin_fd = 42 # Mocked file descriptor + audio_format = "wav" + device_name = "nonexistent_device" + + mock_sd = MagicMock() + mock_sf = MagicMock() + mock_sf.SoundFile = MagicMock() + + with ( + patch.dict("sys.modules", {"sounddevice": mock_sd, "soundfile": mock_sf}), + patch("tempfile.NamedTemporaryFile") as mock_tempfile, + patch("builtins.open", mock_open()), + patch("os.remove"), + patch("os.path.exists", return_value=True), + patch("os.dup"), + patch("os.fdopen"), + ): + # Setup mocks - device not found + # Mock query_devices to handle both calls: + # 1. sd.query_devices() - returns list of devices + # 2. sd.query_devices(device_id, "input") - returns device info dict + def query_devices_side_effect(device_id=None, kind=None): + if device_id is None and kind is None: + return [ + {"name": "test_device", "default_samplerate": 44100}, + ] + elif device_id is None and kind == "input": + return {"default_samplerate": 44100} + else: + return {"default_samplerate": 44100} + + mock_sd.query_devices.side_effect = query_devices_side_effect + + mock_temp_file = MagicMock() + mock_temp_file.name = "/tmp/test.wav" + mock_tempfile.return_value.__enter__.return_value = mock_temp_file + + mock_sf.SoundFile.return_value.__enter__.return_value.write = MagicMock() + + # Mock litellm + mock_litellm = MagicMock() + mock_litellm.transcription = MagicMock(return_value=MagicMock(text="Test transcription")) + + with patch("aider.llm.litellm", mock_litellm): + # Mock stdin.readline to simulate user pressing ENTER + with patch("sys.stdin.readline", return_value=""): + from aider.voice import _run_record_process + + result = _run_record_process(stdin_fd, audio_format, device_name, None, None) + + # Should still work with device_id=None + assert result == "Test transcription" From 79db0f9c6bc5d646ea88e2a7f0fcf755ea5d6fce Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 21 Dec 2025 16:54:04 -0500 Subject: [PATCH 11/14] Change Thinking.. to Processing... for agnosticism --- aider/tui/app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/aider/tui/app.py b/aider/tui/app.py index 12c51727141..c16e34b94aa 100644 --- a/aider/tui/app.py +++ b/aider/tui/app.py @@ -450,8 +450,7 @@ def on_input_area_submit(self, message: InputArea.Submit): # Update footer to show processing footer = self.query_one(AiderFooter) - if not user_input.startswith("/"): - footer.start_spinner("Thinking...") + footer.start_spinner("Processing...") self.update_key_hints(generating=True) From e99e313c4a8d5c9db914f3458dcc5a13a3f94de5 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 21 Dec 2025 17:20:46 -0500 Subject: [PATCH 12/14] Fix formatting, make naming more specific, help garbage collection along --- aider/main.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/aider/main.py b/aider/main.py index 81aa7197ffc..4f0719e4449 100644 --- a/aider/main.py +++ b/aider/main.py @@ -762,7 +762,7 @@ def get_io(pretty): # TUI mode - create TUI-specific IO output_queue = None input_queue = None - _console_io = get_io(args.pretty) + pre_init_io = get_io(args.pretty) if args.tui or (args.tui is None and not args.linear_output): try: from aider.tui import create_tui_io @@ -777,7 +777,7 @@ def get_io(pretty): print(f"Import error: {e}") sys.exit(1) else: - io = _console_io + io = pre_init_io # Only do CLI-specific initialization if not in TUI mode if not args.tui: @@ -1259,38 +1259,37 @@ def apply_model_overrides(model_name): repomap_in_memory=args.map_memory_cache, linear_output=args.linear_output, ) - _console_coder = coder.clone(io=_console_io) if args.show_model_warnings: - problem = await models.sanity_check_models(_console_io, main_model) + problem = await models.sanity_check_models(pre_init_io, main_model) if problem: - _console_io.tool_output("You can skip this check with --no-show-model-warnings") + pre_init_io.tool_output("You can skip this check with --no-show-model-warnings") try: - await _console_io.offer_url( + await pre_init_io.offer_url( urls.model_warnings, "Open documentation url for more info?", acknowledge=True, ) - _console_io.tool_output() + pre_init_io.tool_output() except KeyboardInterrupt: return await graceful_exit(coder, 1) if args.git: - git_root = await setup_git(git_root, _console_io) + git_root = await setup_git(git_root, pre_init_io) if args.gitignore: - await check_gitignore(git_root, _console_io) + await check_gitignore(git_root, pre_init_io) except UnknownEditFormat as err: - _console_io.tool_error(str(err)) - await _console_io.offer_url( + pre_init_io.tool_error(str(err)) + await pre_init_io.offer_url( urls.edit_formats, "Open documentation about edit formats?", acknowledge=True ) return await graceful_exit(None, 1) except ValueError as err: - _console_io.tool_error(str(err)) + pre_init_io.tool_error(str(err)) return await graceful_exit(None, 1) @@ -1442,6 +1441,8 @@ def apply_model_overrides(model_name): # TUI mode - launch Textual interface if args.tui: from aider.tui import launch_tui + + del pre_init_io return_code = await launch_tui(coder, output_queue, input_queue, args) return await graceful_exit(coder, return_code) From d322d5739e9729f5b627911d0f214f4391b1abf2 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 21 Dec 2025 20:23:50 -0500 Subject: [PATCH 13/14] Get file watcher working with TUI --- aider/commands.py | 30 ++++++++++++++++++++---------- aider/tui/io.py | 14 ++++++++++++++ aider/watch.py | 3 +++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 6014e9f882f..9bc17b4ba16 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1606,19 +1606,29 @@ async def _generic_chat_command(self, args, edit_format, placeholder=None): from aider.coders.base_coder import Coder + user_msg = args + original_main_model = self.coder.main_model original_edit_format = self.coder.edit_format + kwargs = { + "io": self.coder.io, + "from_coder": self.coder, + "edit_format": edit_format, + "summarize_from_coder": False, + "num_cache_warming_pings": 0, + "aider_commit_hashes": self.coder.aider_commit_hashes, + "args": self.coder.args, + } + + kwargs["mcp_servers"] = [] # Empty to skip initialization + + coder = await Coder.create(**kwargs) + # Transfer MCP state to avoid re-initialization + coder.mcp_servers = self.coder.mcp_servers + coder.mcp_tools = self.coder.mcp_tools + # Transfer TUI app weak reference + coder.tui = self.coder.tui - coder = await Coder.create( - io=self.io, - from_coder=self.coder, - edit_format=edit_format, - summarize_from_coder=False, - num_cache_warming_pings=0, - aider_commit_hashes=self.coder.aider_commit_hashes, - ) - - user_msg = args await coder.generate(user_message=user_msg, preproc=False) self.coder.aider_commit_hashes = coder.aider_commit_hashes diff --git a/aider/tui/io.py b/aider/tui/io.py index cf06f45012e..fb2620677b8 100644 --- a/aider/tui/io.py +++ b/aider/tui/io.py @@ -254,6 +254,9 @@ def stop_spinner(self): } ) + def interrupt_input(self): + self.interrupted = True + async def get_input( self, root, @@ -278,6 +281,8 @@ async def get_input( Returns: User input string """ + self.interrupted = False + # Signal TUI that we're ready for input command_names = commands.get_commands() if commands else [] @@ -308,6 +313,15 @@ async def get_input( # Wait for input from TUI (blocking in async context) # We need to poll the queue since it's not async while True: + if hasattr(self, "file_watcher") and self.file_watcher: + if not self.file_watcher.is_running: + self.file_watcher.start() + + # Check if we were interrupted by a file change + if self.interrupted: + cmd = self.file_watcher.process_changes() + return cmd + try: # Non-blocking get with timeout import queue diff --git a/aider/watch.py b/aider/watch.py index ec2b98ff905..0698119f885 100644 --- a/aider/watch.py +++ b/aider/watch.py @@ -79,6 +79,7 @@ def __init__(self, coder, gitignores=None, verbose=False, root=None): self.watcher_thread = None self.changed_files = set() self.gitignores = gitignores + self.is_running = False self.gitignore_spec = load_gitignores( [Path(g) for g in self.gitignores] if self.gitignores else [] @@ -165,6 +166,7 @@ def start(self): self.stop_event = threading.Event() self.changed_files = set() + self.is_running = True self.watcher_thread = threading.Thread(target=self.watch_files, daemon=True) self.watcher_thread.start() @@ -247,6 +249,7 @@ def process_changes(self): for ln, comment in zip(line_nums, comments): res += f" Line {ln}: {comment}\n" + self.is_running = False return res def get_ai_comments(self, filepath): From 2256f8952175fa289d5ec76a08203f57bcc9f8fd Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 21 Dec 2025 20:24:15 -0500 Subject: [PATCH 14/14] Don't reinitialize mcp tools in architect coder sub agent call --- aider/coders/architect_coder.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/aider/coders/architect_coder.py b/aider/coders/architect_coder.py index 41553f7c9e7..bc85dbf15ff 100644 --- a/aider/coders/architect_coder.py +++ b/aider/coders/architect_coder.py @@ -42,6 +42,14 @@ async def reply_completed(self): kwargs["cache_prompts"] = False kwargs["num_cache_warming_pings"] = 0 kwargs["summarize_from_coder"] = False + kwargs["mcp_servers"] = [] # Empty to skip initialization + + coder = await Coder.create(**kwargs) + # Transfer MCP state to avoid re-initialization + coder.mcp_servers = self.mcp_servers + coder.mcp_tools = self.mcp_tools + # Transfer TUI app weak reference + coder.tui = self.tui new_kwargs = dict(io=self.io, from_coder=self) new_kwargs.update(kwargs)