From 187929531351c5387a1d9b189fbb9ef80eced95a Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 24 Dec 2025 09:33:17 -0500 Subject: [PATCH 01/13] Bump Version --- aider/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aider/__init__.py b/aider/__init__.py index 1195d736485..15229337b59 100644 --- a/aider/__init__.py +++ b/aider/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.91.0.dev" +__version__ = "0.91.1.dev" safe_version = __version__ try: From d09c3535c3d7cc2f4f659d8fc34b1810c04f6f27 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 24 Dec 2025 09:33:56 -0500 Subject: [PATCH 02/13] #314: Don't try to call command methods directly, use new commands.py infrastructure to do so --- aider/io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aider/io.py b/aider/io.py index 052ccb02379..f21ef2953a5 100644 --- a/aider/io.py +++ b/aider/io.py @@ -939,7 +939,7 @@ def get_continuation(width, line_number, is_soft_wrap): coder = self.get_coder() if coder: - await coder.commands.cmd_exit(None) + await coder.commands.do_run("exit", "") else: raise SystemExit From f8990d21a0d051325f7b82e2e272aaef491186f6 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 25 Dec 2025 13:26:42 -0500 Subject: [PATCH 03/13] Fix tool call output formatting --- aider/coders/agent_coder.py | 2 +- aider/tui/widgets/output.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/aider/coders/agent_coder.py b/aider/coders/agent_coder.py index 44175c63446..7cb60a16eaf 100644 --- a/aider/coders/agent_coder.py +++ b/aider/coders/agent_coder.py @@ -374,7 +374,7 @@ async def initialize_mcp_tools(self): if not local_tools: return - local_server_config = {"name": "local_tools"} + local_server_config = {"name": "Local"} local_server = LocalServer(local_server_config) if not self.mcp_servers: diff --git a/aider/tui/widgets/output.py b/aider/tui/widgets/output.py index 0b422baa67d..8a35ee6e4c0 100644 --- a/aider/tui/widgets/output.py +++ b/aider/tui/widgets/output.py @@ -144,28 +144,29 @@ def add_tool_call(self, lines: list): if not lines: return + self.set_last_write_type("tool_call") for i, line in enumerate(lines): # Strip Rich markup clean_line = line.replace("[bright_cyan]", "").replace("[/bright_cyan]", "") - content = Text() if i == 0: # First line: reformat "Tool Call: server • function" to "Tool Call · server · function" clean_line = clean_line.replace("Tool Call:", "Tool Call •") - content.append(clean_line, style="dim bright_cyan") # $accent + self.output(Padding(Text(clean_line, style="dim bright_cyan"), (0, 0, 0, 2))) else: # Subsequent lines (arguments) - prefix with corner to show they belong to the call arg_string_list = re.split(r"(^\S+:)", clean_line, maxsplit=1)[1:] if len(arg_string_list) > 1: + content = Text() content.append(f"ᴸ{arg_string_list[0]}", style="dim bright_cyan") content.append(arg_string_list[1], style="dim") + self.output(Padding(content, (0, 0, 0, 2))) else: - content.append("ᴸ", style="dim bright_cyan") - content.append(clean_line, style="dim") + self.output(Padding(Text(clean_line, style="dim"), (0, 0, 0, 3))) - self.set_last_write_type("tool_call") - self.output(Padding(content, (0, 0, 0, 2))) + # self.set_last_write_type("tool_call") + # self.output(Padding(content, (0, 0, 0, 2))) def add_tool_result(self, text: str): """Add a tool result. From e343b08e80b0b9b33135cf065b593eb4698653e2 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 25 Dec 2025 15:01:43 -0500 Subject: [PATCH 04/13] Add dot identifier for assistant messages --- aider/tui/widgets/output.py | 69 ++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/aider/tui/widgets/output.py b/aider/tui/widgets/output.py index 8a35ee6e4c0..376cfb6c561 100644 --- a/aider/tui/widgets/output.py +++ b/aider/tui/widgets/output.py @@ -1,6 +1,7 @@ """Output widget for Aider TUI using Textual's RichLog widget.""" import re +import textwrap from rich.markdown import Markdown from rich.padding import Padding @@ -41,6 +42,8 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # Line buffer for streaming text to avoid word-per-line issue self._line_buffer = "" + # Track if we're on the first line of the current response + self._first_line_of_response = True # Enable markup for rich formatting self.highlight = True @@ -51,6 +54,33 @@ async def start_response(self): """Start a new LLM response section with streaming support.""" # Clear the line buffer for new response self._line_buffer = "" + # Reset first line flag + self._first_line_of_response = True + + def _wrap_text_with_prefix(self, text: str, prefix: str = "• ") -> str: + """Wrap text with prefix and proper indentation. + + Args: + text: The text to wrap + prefix: The prefix to use for the first line + + Returns: + Wrapped text with prefix and indentation + """ + if not text.strip(): + return "" + + # Get available width for wrapping + # Subtract 2 to account for potential borders or scrollbars + width = self.content_size.width - 2 if self.content_size.width else 80 + indent = " " * len(prefix) + + # Wrap the text using textwrap + wrapped_text = textwrap.fill( + text, width=width, initial_indent=prefix, subsequent_indent=indent + ) + + return wrapped_text async def stream_chunk(self, text: str): """Stream a chunk of markdown text.""" @@ -66,10 +96,21 @@ async def stream_chunk(self, text: str): # Process complete lines from buffer while "\n" in self._line_buffer: line, self._line_buffer = self._line_buffer.split("\n", 1) - # self.write(Padding(line.strip(), (0, 0, 0, 1))) if line.rstrip(): self.set_last_write_type("assistant") - self.output(line.rstrip(), render_markdown=True) + # Format with prefix on first line, proper indentation on subsequent lines + if self._first_line_of_response: + wrapped_line = self._wrap_text_with_prefix(line.rstrip(), prefix="• ") + self._first_line_of_response = False + else: + # For subsequent lines, we need to wrap with proper indentation + # but without the bullet prefix + wrapped_line = self._wrap_text_with_prefix(line.rstrip(), prefix=" ") + + # Output each wrapped line + for wrapped in wrapped_line.split("\n"): + if wrapped.strip(): + self.output(wrapped, render_markdown=True) async def end_response(self): """End the current LLM response.""" @@ -79,7 +120,16 @@ async def _stop_stream(self): """Stop the current markdown stream.""" # Flush any remaining buffer content if self._line_buffer.rstrip(): - self.output(self._line_buffer.rstrip(), render_markdown=True) + # Format remaining content based on whether it's first line or not + if self._first_line_of_response: + wrapped_line = self._wrap_text_with_prefix(self._line_buffer.rstrip(), prefix="• ") + else: + wrapped_line = self._wrap_text_with_prefix(self._line_buffer.rstrip(), prefix=" ") + + # Output each wrapped line + for wrapped in wrapped_line.split("\n"): + if wrapped.strip(): + self.output(wrapped, render_markdown=True) self._line_buffer = "" def add_user_message(self, text: str): @@ -87,7 +137,15 @@ def add_user_message(self, text: str): # User messages shown with > prefix in green color self.auto_scroll = True self.set_last_write_type("user") - self.output(f"[bold medium_spring_green]> {text}[/bold medium_spring_green]") + + # Wrap the entire user message with "> " prefix + wrapped_text = self._wrap_text_with_prefix(text, prefix="> ") + + # Output each wrapped line with green styling + for line in wrapped_text.split("\n"): + if line.strip(): + self.output(f"[bold medium_spring_green]{line}[/bold medium_spring_green]") + self.scroll_end(animate=False) def add_system_message(self, text: str, dim=True): @@ -158,8 +216,9 @@ def add_tool_call(self, lines: list): arg_string_list = re.split(r"(^\S+:)", clean_line, maxsplit=1)[1:] if len(arg_string_list) > 1: + tool_property = arg_string_list[0].replace("_", " ").title() content = Text() - content.append(f"ᴸ{arg_string_list[0]}", style="dim bright_cyan") + content.append(f"ᴸ{tool_property}", style="dim bright_cyan") content.append(arg_string_list[1], style="dim") self.output(Padding(content, (0, 0, 0, 2))) else: From 0a9949c7eb91a6067b5dedc808c2cd107e364db7 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 25 Dec 2025 15:57:06 -0500 Subject: [PATCH 05/13] Add more repo map caching for performance improvement on larger repos --- aider/repomap.py | 114 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 91 insertions(+), 23 deletions(-) diff --git a/aider/repomap.py b/aider/repomap.py index 63a596eade5..02f3bd5a2fe 100644 --- a/aider/repomap.py +++ b/aider/repomap.py @@ -15,7 +15,6 @@ from grep_ast import TreeContext, filename_to_lang from pygments.lexers import guess_lexer_for_filename from pygments.token import Token -from tqdm import tqdm from aider.dump import dump from aider.helpers.similarity import ( @@ -76,9 +75,9 @@ def __new__( SQLITE_ERRORS = (sqlite3.OperationalError, sqlite3.DatabaseError, OSError) -CACHE_VERSION = 6 +CACHE_VERSION = 7 if USING_TSL_PACK: - CACHE_VERSION = 8 + CACHE_VERSION = 9 UPDATING_REPO_MAP_MESSAGE = "Updating repo map" @@ -349,6 +348,46 @@ def get_mtime(self, fname): except FileNotFoundError: self.io.tool_warning(f"File not found error: {fname}") + def _compute_file_summary(self, tags, rel_fname): + """Compute file-level summary from tags.""" + defines = set() + references = defaultdict(int) + imports = set() + + for tag in tags: + if tag.kind == "def": + defines.add(tag.name) + elif tag.kind == "ref": + references[tag.name] += 1 + if tag.specific_kind == "import": + imports.add(tag.name) + + return {"defines": defines, "references": dict(references), "imports": imports} + + def _get_cached_summary(self, fname, file_mtime): + """Get cached summary for a file if available and up-to-date.""" + cache_key = fname + try: + val = self.TAGS_CACHE.get(cache_key) # Issue #1308 + except SQLITE_ERRORS as e: + self.tags_cache_error(e) + val = self.TAGS_CACHE.get(cache_key) + + if val is not None and val.get("mtime") == file_mtime: + # Handle backward compatibility: old cache entries won't have "summary" + summary = val.get("summary") + if summary is None: + # Compute summary from cached data + data = val.get("data") + if data is not None: + rel_fname = self.get_rel_fname(fname) + summary = self._compute_file_summary(data, rel_fname) + # Update cache with summary for future use + val["summary"] = summary + self.TAGS_CACHE[cache_key] = val + return summary + return None + def get_tags(self, fname, rel_fname): # Check if the file is in the cache and if the modification time has not changed file_mtime = self.get_mtime(fname) @@ -385,13 +424,16 @@ def get_tags(self, fname, rel_fname): # miss! data = list(self.get_tags_raw(fname, rel_fname)) + # Compute file summary + summary = self._compute_file_summary(data, rel_fname) + # Update the cache try: - self.TAGS_CACHE[cache_key] = {"mtime": file_mtime, "data": data} + self.TAGS_CACHE[cache_key] = {"mtime": file_mtime, "data": data, "summary": summary} self.save_tags_cache() except SQLITE_ERRORS as e: self.tags_cache_error(e) - self.TAGS_CACHE[cache_key] = {"mtime": file_mtime, "data": data} + self.TAGS_CACHE[cache_key] = {"mtime": file_mtime, "data": data, "summary": summary} return data @@ -631,7 +673,7 @@ def get_ranked_tags( self.io.tool_output( "Initial repo scan can be slow in larger repos, but only happens once." ) - fnames = tqdm(fnames, desc="Scanning repo") + self.io.update_spinner("Scanning repo") showing_bar = True else: showing_bar = False @@ -685,23 +727,49 @@ def get_ranked_tags( if current_pers > 0: personalization[rel_fname] = current_pers # Assign the final calculated value - tags = list(self.get_tags(fname, rel_fname)) - - if tags is None: - continue - - for tag in tags: - if tag.kind == "def": - defines[tag.name].add(rel_fname) - key = (rel_fname, tag.name) - definitions[key].add(tag) - - elif tag.kind == "ref": - references[tag.name][rel_fname] += 1 - total_ref_count[tag.name] += 1 - - if tag.specific_kind == "import": - file_imports[rel_fname].add(tag.name) + # Get file mtime and check for cached summary + file_mtime = self.get_mtime(fname) + summary = None + if file_mtime is not None: + summary = self._get_cached_summary(fname, file_mtime) + + if summary is not None: + # Use cached summary for defines and references + for ident in summary["defines"]: + defines[ident].add(rel_fname) + for ident, count in summary["references"].items(): + references[ident][rel_fname] += count + total_ref_count[ident] += count + for imp in summary["imports"]: + file_imports[rel_fname].add(imp) + + # Still need to parse tags for definitions (Tag objects) + # But only if this file has definitions + if summary["defines"]: + tags = list(self.get_tags(fname, rel_fname)) + if tags is not None: + for tag in tags: + if tag.kind == "def": + key = (rel_fname, tag.name) + definitions[key].add(tag) + else: + # No cached summary, parse all tags + tags = list(self.get_tags(fname, rel_fname)) + if tags is None: + continue + + for tag in tags: + if tag.kind == "def": + defines[tag.name].add(rel_fname) + key = (rel_fname, tag.name) + definitions[key].add(tag) + + elif tag.kind == "ref": + references[tag.name][rel_fname] += 1 + total_ref_count[tag.name] += 1 + + if tag.specific_kind == "import": + file_imports[rel_fname].add(tag.name) self.io.profile("Process Files") From 85d394a2b3ddaf260ff26191f961b215674e74a6 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 25 Dec 2025 16:04:29 -0500 Subject: [PATCH 06/13] Add repo scanning progress message --- aider/repomap.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/aider/repomap.py b/aider/repomap.py index 02f3bd5a2fe..1d013f7e16c 100644 --- a/aider/repomap.py +++ b/aider/repomap.py @@ -678,11 +678,17 @@ def get_ranked_tags( else: showing_bar = False + num_fnames = len(fnames) + fname_index = 0 for fname in fnames: if self.verbose: self.io.tool_output(f"Processing {fname}") - if progress and not showing_bar: - self.io.update_spinner(f"{UPDATING_REPO_MAP_MESSAGE}: {fname}") + if progress: + if showing_bar: + fname_index += 1 + self.io.update_spinner(f"Scanning repo: {fname_index}/{num_fnames}") + else: + self.io.update_spinner(f"{UPDATING_REPO_MAP_MESSAGE}: {fname}") try: file_ok = Path(fname).is_file() From 748b69164994a898f7dbc4328f9576ca78179ef6 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 25 Dec 2025 16:15:55 -0500 Subject: [PATCH 07/13] Create fewer path objects during repomap calculation --- aider/repomap.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aider/repomap.py b/aider/repomap.py index 1d013f7e16c..7885979ea1b 100644 --- a/aider/repomap.py +++ b/aider/repomap.py @@ -691,7 +691,7 @@ def get_ranked_tags( self.io.update_spinner(f"{UPDATING_REPO_MAP_MESSAGE}: {fname}") try: - file_ok = Path(fname).is_file() + file_ok = os.path.isfile(fname) except OSError: file_ok = False @@ -1341,7 +1341,7 @@ def get_supported_languages_md(): for lang, ext in data: fn = get_scm_fname(lang) - repo_map = "✓" if Path(fn).exists() else "" + repo_map = "✓" if fn and os.path.exists(fn) else "" linter_support = "✓" res += f"| {lang:20} | {ext:20} | {repo_map:^8} | {linter_support:^6} |\n" @@ -1356,7 +1356,7 @@ def get_supported_languages_md(): chat_fnames = [] other_fnames = [] for fname in sys.argv[1:]: - if Path(fname).is_dir(): + if os.path.isdir(fname): chat_fnames += find_src_files(fname) else: chat_fnames.append(fname) From 18fa206b505b90f29f81392900d5385159c1f68f Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 25 Dec 2025 17:18:29 -0500 Subject: [PATCH 08/13] FIle stub view when context management enabled --- aider/coders/base_coder.py | 54 +++++++++++++------------------------- 1 file changed, 18 insertions(+), 36 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index fb1a4cb2df2..8c136ad2d29 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -799,30 +799,21 @@ def get_files_content(self, fnames=None): file_tokens = self.main_model.token_count(content) if file_tokens > self.large_file_token_threshold: - # Truncate the file content - lines = content.splitlines() - - # Keep the first and last parts of the file with a marker in between - keep_lines = ( - self.large_file_token_threshold // 40 - ) # Rough estimate of tokens per line - first_chunk = lines[: keep_lines // 2] - last_chunk = lines[-(keep_lines // 2) :] - - truncated_content = "\n".join(first_chunk) - truncated_content += ( - f"\n\n... [File truncated due to size ({file_tokens} tokens). Use" - " /context-management to toggle truncation off] ...\n\n" - ) - truncated_content += "\n".join(last_chunk) + # Instead of truncating, show the file's definitions/structure + file_stub = RepoMap.get_file_stub(fname, self.io) - # Add message about truncation + # Add message about showing definitions instead of full content self.io.tool_output( f"⚠️ '{relative_fname}' is very large ({file_tokens} tokens). " "Use /context-management to toggle truncation off if needed." ) - file_prompt += truncated_content + # Add a message in the content itself so the model knows it's truncated + truncation_note = ( + f"\n... [File content truncated due to size ({file_tokens} tokens)." + " Showing structure/definitions only.] ...\n\n" + ) + file_prompt += truncation_note + file_stub else: file_prompt += content else: @@ -876,30 +867,21 @@ def get_read_only_files_content(self): file_tokens = self.main_model.token_count(content) if file_tokens > self.large_file_token_threshold: - # Truncate the file content - lines = content.splitlines() - - # Keep the first and last parts of the file with a marker in between - keep_lines = ( - self.large_file_token_threshold // 40 - ) # Rough estimate of tokens per line - first_chunk = lines[: keep_lines // 2] - last_chunk = lines[-(keep_lines // 2) :] - - truncated_content = "\n".join(first_chunk) - truncated_content += ( - f"\n\n... [File truncated due to size ({file_tokens} tokens). Use" - " /context-management to toggle truncation off] ...\n\n" - ) - truncated_content += "\n".join(last_chunk) + # Instead of truncating, show the file's definitions/structure + file_stub = RepoMap.get_file_stub(fname, self.io) - # Add message about truncation + # Add message about showing definitions instead of full content self.io.tool_output( f"⚠️ '{relative_fname}' is very large ({file_tokens} tokens). " "Use /context-management to toggle truncation off if needed." ) - prompt += truncated_content + # Add a message in the content itself so the model knows it's truncated + truncation_note = ( + f"\n... [File content truncated due to size ({file_tokens} tokens)." + " Showing structure/definitions only.] ...\n\n" + ) + prompt += truncation_note + file_stub else: prompt += content else: From b6616b57a391b412cc947b5a35c291b16bc1ce4f Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 25 Dec 2025 18:10:29 -0500 Subject: [PATCH 09/13] #303: use asyncio queues instead of asyncio.sleep() loops --- aider/coders/base_coder.py | 32 +++++++++++++++++--------------- aider/commands.py | 7 ++++++- aider/io.py | 14 +++++++++----- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 8c136ad2d29..62c64bd35ba 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -1211,8 +1211,9 @@ def init_before_message(self): self.commit_before_message.append(self.repo.get_head_commit_sha()) async def run(self, with_message=None, preproc=True): - while self.io.confirmation_in_progress: - await asyncio.sleep(0.1) # Yield control and wait briefly + # Wait for confirmation to finish if in progress + if not self.io.confirmation_in_progress_event.is_set(): + await self.io.confirmation_in_progress_event.wait() if self.linear_output: return await self._run_linear(with_message, preproc) @@ -1235,8 +1236,9 @@ async def _run_linear(self, with_message=None, preproc=True): while True: try: - if self.commands.cmd_running: - await asyncio.sleep(0.1) + # Wait for commands to finish + if not self.commands.cmd_running_event.is_set(): + await self.commands.cmd_running_event.wait() continue if not self.suppress_announcements_for_next_prompt: @@ -1344,8 +1346,8 @@ async def input_task(self, preproc): while self.input_running: try: # Wait for commands to finish - if self.commands.cmd_running: - await asyncio.sleep(0.1) + if not self.commands.cmd_running_event.is_set(): + await self.commands.cmd_running_event.wait() continue # Wait for input task completion @@ -1354,7 +1356,7 @@ async def input_task(self, preproc): user_message = self.io.input_task.result() # Defer to confirmation handler to fix Windows event loop race. - if self.io.confirmation_in_progress: + if not self.io.confirmation_in_progress_event.is_set(): pass # Set user message for output task elif not self.io.acknowledge_confirmation(): @@ -1371,7 +1373,7 @@ async def input_task(self, preproc): # Check if we should show announcements if ( - not self.io.confirmation_in_progress + self.io.confirmation_in_progress_event.is_set() and not self.user_message and not coroutines.is_active(self.io.input_task) and (not coroutines.is_active(self.io.output_task) or not self.io.placeholder) @@ -1409,8 +1411,8 @@ async def output_task(self, preproc): while self.output_running: try: # Wait for commands to finish - if self.commands.cmd_running: - await asyncio.sleep(0.1) + if not self.commands.cmd_running_event.is_set(): + await self.commands.cmd_running_event.wait() continue # Check if we have a user message to process @@ -1512,7 +1514,7 @@ async def preproc_user_input(self, inp): inp = f"/run {inp[1:]}" if self.commands.is_run_command(inp): - self.commands.cmd_running = True + self.commands.cmd_running_event.clear() # Command is running return await self.commands.run(inp) @@ -3020,8 +3022,8 @@ async def show_send_output_stream(self, completion): print(chunk, file=f) # Check if confirmation is in progress and wait if needed - while self.io.confirmation_in_progress: - await asyncio.sleep(0.1) # Yield control and wait briefly + if not self.io.confirmation_in_progress_event.is_set(): + await self.io.confirmation_in_progress_event.wait() if isinstance(chunk, str): self.io.tool_error(chunk) @@ -3812,7 +3814,7 @@ async def run_shell_commands(self): accumulated_output = "" try: - self.commands.cmd_running = True + self.commands.cmd_running_event.clear() # Command is running for command in self.shell_commands: if command in done: @@ -3824,7 +3826,7 @@ async def run_shell_commands(self): return accumulated_output finally: - self.commands.cmd_running = False + self.commands.cmd_running_event.set() # Command finished async def handle_shell_commands(self, commands_str, group): commands = commands_str.strip().split(";") diff --git a/aider/commands.py b/aider/commands.py index 9e71ed704c6..8a10c8ca661 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1,3 +1,4 @@ +import asyncio import re import sys from pathlib import Path @@ -62,7 +63,8 @@ def __init__( # Store the original read-only filenames provided via args.read self.original_read_only_fnames = set(original_read_only_fnames or []) - self.cmd_running = False + self.cmd_running_event = asyncio.Event() + self.cmd_running_event.set() # Initially set, meaning no command is running def is_command(self, inp): return inp[0] in "/!" @@ -108,6 +110,7 @@ async def do_run(self, cmd_name, args): self.io.tool_output(f"Error: Command {cmd_name} not found.") return + self.cmd_running_event.clear() # Command is running try: return await CommandRegistry.execute( cmd_name, @@ -124,6 +127,8 @@ async def do_run(self, cmd_name, args): except Exception as e: self.io.tool_error(f"Error executing command {cmd_name}: {str(e)}") return + finally: + self.cmd_running_event.set() # Command finished def matching_commands(self, inp): words = inp.strip().split() diff --git a/aider/io.py b/aider/io.py index f21ef2953a5..2b50c9d88f5 100644 --- a/aider/io.py +++ b/aider/io.py @@ -362,7 +362,8 @@ def __init__( self.linear = False # State tracking for confirmation input - self.confirmation_in_progress = False + self.confirmation_in_progress_event = asyncio.Event() + self.confirmation_in_progress_event.set() # Initially set, meaning no confirmation in progress self.confirmation_acknowledgement = False self.confirmation_input_active = False self.saved_input_text = "" @@ -1081,7 +1082,7 @@ def user_input(self, inp, log_only=True): if ( len(inp) <= 1 - or self.confirmation_in_progress + or not self.confirmation_in_progress_event.is_set() or self.get_confirmation_acknowledgement() ): return @@ -1153,7 +1154,7 @@ async def confirm_ask( *args, **kwargs, ): - self.confirmation_in_progress = True + self.confirmation_in_progress_event.clear() # Confirmation is in progress try: return await asyncio.create_task(self._confirm_ask(*args, **kwargs)) @@ -1161,7 +1162,7 @@ async def confirm_ask( # Re-raise KeyboardInterrupt to allow it to propagate raise finally: - self.confirmation_in_progress = False + self.confirmation_in_progress_event.set() # Confirmation finished async def _confirm_ask( self, @@ -1671,7 +1672,10 @@ def toggle_multiline_mode(self): ) def append_chat_history(self, text, linebreak=False, blockquote=False, strip=True): - if self.confirmation_in_progress or self.get_confirmation_acknowledgement(): + if ( + not self.confirmation_in_progress_event.is_set() + or self.get_confirmation_acknowledgement() + ): return if blockquote: From 4d2aa6f46743e1d0721567c5dba6cfe3fbca6708 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 25 Dec 2025 20:28:34 -0500 Subject: [PATCH 10/13] Make the first tab press actually select the first menu item for the TUI completion bar --- aider/tui/widgets/completion_bar.py | 37 +++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/aider/tui/widgets/completion_bar.py b/aider/tui/widgets/completion_bar.py index a516f147f3e..e3a9ba3fd84 100644 --- a/aider/tui/widgets/completion_bar.py +++ b/aider/tui/widgets/completion_bar.py @@ -44,6 +44,10 @@ class CompletionBar(Widget, can_focus=False): text-style: bold; } + CompletionBar .completion-item.preselected { + color: $secondary; + } + CompletionBar .completion-more { width: auto; height: 1; @@ -82,6 +86,7 @@ def __init__(self, suggestions: list[str] = None, prefix: str = "", **kwargs): self.suggestions = (suggestions or [])[: self.MAX_SUGGESTIONS] self.prefix = prefix self.selected_index = 0 + self._has_cycled = False # Track if user has actively cycled through suggestions self._item_widgets: list[Static] = [] self._prefix_widget: Static | None = None self._left_more: Static | None = None @@ -150,7 +155,8 @@ def compose(self) -> ComposeResult: self._item_widgets = [] for i in range(self.WINDOW_SIZE): if i < len(self._display_names): - classes = "completion-item selected" if i == 0 else "completion-item" + selected_class = "selected" if self._has_cycled else "preselected" + classes = f"completion-item {selected_class}" if i == 0 else "completion-item" item = Static(self._display_names[i], classes=classes) else: item = Static("", classes="completion-item") @@ -173,6 +179,7 @@ def update_suggestions(self, suggestions: list[str], prefix: str = "") -> None: self.suggestions = suggestions[: self.MAX_SUGGESTIONS] self.prefix = prefix self.selected_index = 0 + self._has_cycled = False # Reset cycling flag when suggestions change # Recompute display names self._compute_display_names() @@ -267,12 +274,20 @@ def _set_selection_classes(self) -> None: for i, item in enumerate(self._item_widgets): if not item.display: item.remove_class("selected") + item.remove_class("preselected") continue # First item is always the selected one if i == 0: - item.add_class("selected") + # Use "preselected" style if we haven't cycled yet and are at index 0 + if not self._has_cycled and self.selected_index == 0: + item.add_class("preselected") + item.remove_class("selected") + else: + item.add_class("selected") + item.remove_class("preselected") else: item.remove_class("selected") + item.remove_class("preselected") def _update_selection(self) -> None: """Update visual selection state.""" @@ -284,16 +299,24 @@ def _update_selection(self) -> None: def cycle_next(self) -> None: """Cycle to next suggestion.""" if self.suggestions: - self.selected_index = (self.selected_index + 1) % len(self.suggestions) + if not self._has_cycled: + self._has_cycled = True # User has actively cycled + else: + self.selected_index = (self.selected_index + 1) % len(self.suggestions) + self._update_selection() def cycle_previous(self) -> None: - """Cycle to next suggestion.""" + """Cycle to previous suggestion.""" if self.suggestions: - if not self.selected_index: - self.selected_index = len(self.suggestions) - 1 + if not self._has_cycled: + self._has_cycled = True # User has actively cycled else: - self.selected_index = (self.selected_index - 1) % len(self.suggestions) + if not self.selected_index: + self.selected_index = len(self.suggestions) - 1 + else: + self.selected_index = (self.selected_index - 1) % len(self.suggestions) + self._update_selection() def select_current(self) -> None: From 65eb5b9d0b1386bee3e8a53a4eaef75a98e6fc11 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 26 Dec 2025 12:13:10 -0500 Subject: [PATCH 11/13] Fix dual display of announcements on commands that throw SwitchCoder --- aider/commands.py | 2 ++ aider/commands/reset.py | 4 ++-- aider/commands/utils/helpers.py | 4 ++-- aider/tui/worker.py | 4 ++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 8a10c8ca661..00ce545473e 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -129,6 +129,8 @@ async def do_run(self, cmd_name, args): return finally: self.cmd_running_event.set() # Command finished + if self.coder.tui and self.coder.tui(): + self.coder.tui().refresh() def matching_commands(self, inp): words = inp.strip().split() diff --git a/aider/commands/reset.py b/aider/commands/reset.py index fdab6a7d98e..87bf923e8fe 100644 --- a/aider/commands/reset.py +++ b/aider/commands/reset.py @@ -21,8 +21,8 @@ async def execute(cls, io, coder, args, **kwargs): # Clear TUI output if available if coder.tui and coder.tui(): coder.tui().action_clear_output() - - io.tool_output("All files dropped and chat history cleared.") + else: + io.tool_output("All files dropped and chat history cleared.") # Recalculate context block tokens after dropping all files if hasattr(coder, "use_enhanced_context") and coder.use_enhanced_context: diff --git a/aider/commands/utils/helpers.py b/aider/commands/utils/helpers.py index 8e93b6b520a..bb55e782ba9 100644 --- a/aider/commands/utils/helpers.py +++ b/aider/commands/utils/helpers.py @@ -104,10 +104,10 @@ def format_command_result(io, command_name: str, success_message: str, error: Ex Formatted result string """ if error: - io.tool_error(f"Error in {command_name}: {str(error)}") + io.tool_error(f"\nError in {command_name}: {str(error)}") return f"Error: {str(error)}" else: - io.tool_output(f"✅ {success_message}") + io.tool_output(f"\n✅ {success_message}") return f"Successfully executed {command_name}." diff --git a/aider/tui/worker.py b/aider/tui/worker.py index 8effbab3467..dcaf6f0dce0 100644 --- a/aider/tui/worker.py +++ b/aider/tui/worker.py @@ -106,6 +106,10 @@ async def _async_run(self): new_coder = await Coder.create(**kwargs) new_coder.args = self.coder.args + + if switch.kwargs.get("show_announcements") is False: + new_coder.suppress_announcements_for_next_prompt = True + # Transfer MCP state to avoid re-initialization new_coder.mcp_servers = self.coder.mcp_servers new_coder.mcp_tools = self.coder.mcp_tools From 30f9370e29848e708ba3519f5b7234dd756a48ed Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 26 Dec 2025 12:28:02 -0500 Subject: [PATCH 12/13] #318: Forward Commands class args to command runners --- aider/commands.py | 17 ++++++++++++++++- aider/commands/settings.py | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 00ce545473e..7cb8811e4a3 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -22,6 +22,8 @@ def clone(self): self.io, None, voice_language=self.voice_language, + voice_input_device=self.voice_input_device, + voice_format=self.voice_format, verify_ssl=self.verify_ssl, args=self.args, parser=self.parser, @@ -112,12 +114,25 @@ async def do_run(self, cmd_name, args): self.cmd_running_event.clear() # Command is running try: + # Generate a spreadable kwargs dict with all relevant Commands attributes + kwargs = { + "original_read_only_fnames": self.original_read_only_fnames, + "voice_language": self.voice_language, + "voice_format": self.voice_format, + "voice_input_device": self.voice_input_device, + "verify_ssl": self.verify_ssl, + "parser": self.parser, + "verbose": self.verbose, + "editor": self.editor, + "system_args": self.args, + } + return await CommandRegistry.execute( cmd_name, self.io, self.coder, args, - original_read_only_fnames=self.original_read_only_fnames, + **kwargs, ) except ANY_GIT_ERROR as err: self.io.tool_error(f"Unable to complete {cmd_name}: {err}") diff --git a/aider/commands/settings.py b/aider/commands/settings.py index eb19f589a8b..ace5230b528 100644 --- a/aider/commands/settings.py +++ b/aider/commands/settings.py @@ -13,7 +13,7 @@ class SettingsCommand(BaseCommand): async def execute(cls, io, coder, args, **kwargs): # Get parser and args from kwargs or use defaults parser = kwargs.get("parser") - cmd_args = kwargs.get("args") + cmd_args = kwargs.get("system_args") if not parser or not cmd_args: io.tool_error("Settings command requires parser and args context") From 8e4b53749a6d70c0a34c306c7ef742eb27f6e5db Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 26 Dec 2025 12:30:15 -0500 Subject: [PATCH 13/13] Fix scraper test, missing attribute --- tests/scrape/test_playwright_disable.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/scrape/test_playwright_disable.py b/tests/scrape/test_playwright_disable.py index 68d72093145..d369c608115 100644 --- a/tests/scrape/test_playwright_disable.py +++ b/tests/scrape/test_playwright_disable.py @@ -89,6 +89,7 @@ def __init__(self): self.cur_messages = [] self.main_model = type("M", (), {"edit_format": "code", "name": "dummy", "info": {}}) self.args = type("Args", (), {"disable_playwright": True})() + self.tui = None def get_rel_fname(self, fname): return fname