From 6ecbb74ad5eb9e69bab1b74be5aac954a559367d Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 15 Apr 2026 10:58:28 -0700 Subject: [PATCH 01/15] fix: Improve robustness of Ctrl+C interruption Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/base_coder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index c8462207b9a..c9716290104 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2,6 +2,7 @@ import asyncio import base64 +import asyncio import hashlib import json import locale From 7868d98e0d09d8303f0a09a6ac7c9af5d8ca19ed Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 15 Apr 2026 11:20:16 -0700 Subject: [PATCH 02/15] fix: Improve interrupt handling for LLM requests Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/io.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cecli/io.py b/cecli/io.py index 8f572b7e856..a0be32117d7 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -758,6 +758,9 @@ def rule(self): print() def interrupt_input(self): + if self.output_task and not self.output_task.done(): + self.output_task.cancel() + if self.prompt_session and self.prompt_session.app: # Store any partial input before interrupting self.placeholder = self.prompt_session.app.current_buffer.text From 16af1869ca91ffb43268857441e3085c7793aa3e Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 15 Apr 2026 14:27:08 -0700 Subject: [PATCH 03/15] fix: Improve robustness of LLM request interruption Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/base_coder.py | 41 ++++++++++++++++++++++++++++---------- cecli/io.py | 4 ++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index c9716290104..ff21e256d71 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -329,6 +329,7 @@ def __init__( uuid="", ): # initialize from args.map_cache_dir + self.interrupt_event = asyncio.Event() self.uuid = generate_unique_id() if uuid: self.uuid = uuid @@ -1735,6 +1736,7 @@ def keyboard_interrupt(self): Console().show_cursor(True) self.io.tool_warning("\n\n^C KeyboardInterrupt") + self.interrupt_event.set() self.last_keyboard_interrupt = time.time() @@ -2285,7 +2287,7 @@ async def send_message(self, inp): self.io.tool_output(f"Retrying in {retry_delay:.1f} seconds...") await asyncio.sleep(retry_delay) continue - except KeyboardInterrupt: + except (KeyboardInterrupt, asyncio.CancelledError): interrupted = True break except FinishReasonLength: @@ -3040,6 +3042,7 @@ async def check_for_file_mentions(self, content): return prompts.added_files.format(fnames=", ".join(added_fnames)) async def send(self, messages, model=None, functions=None, tools=None): + self.interrupt_event.clear() self.got_reasoning_content = False self.ended_reasoning_content = False @@ -3059,15 +3062,33 @@ async def send(self, messages, model=None, functions=None, tools=None): self.token_profiler.start() try: - hash_object, completion = await model.send_completion( - messages, - functions, - self.stream, - self.temperature, - # This could include any tools, but for now it is just MCP tools - tools=tools, - override_kwargs=self.model_kwargs, + completion_task = asyncio.create_task( + model.send_completion( + messages, + functions, + self.stream, + self.temperature, + # This could include any tools, but for now it is just MCP tools + tools=tools, + override_kwargs=self.model_kwargs, + ) + ) + interrupt_task = asyncio.create_task(self.interrupt_event.wait()) + + done, pending = await asyncio.wait( + {completion_task, interrupt_task}, + return_when=asyncio.FIRST_COMPLETED, ) + + if interrupt_task in done: + completion_task.cancel() + try: + await completion_task + except asyncio.CancelledError: + pass + raise KeyboardInterrupt + + hash_object, completion = completion_task.result() self.chat_completion_call_hashes.append(hash_object.hexdigest()) if not isinstance(completion, ModelResponse): @@ -3090,7 +3111,7 @@ async def send(self, messages, model=None, functions=None, tools=None): self.token_profiler.on_error() self.calculate_and_show_tokens_and_cost(messages, completion) raise - except KeyboardInterrupt as kbi: + except (KeyboardInterrupt, asyncio.CancelledError) as kbi: self.keyboard_interrupt() raise kbi finally: diff --git a/cecli/io.py b/cecli/io.py index a0be32117d7..bae86cdc8b0 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -758,6 +758,10 @@ def rule(self): print() def interrupt_input(self): + if self.coder: + coder = self.coder() + if coder and hasattr(coder, "interrupt_event"): + coder.interrupt_event.set() if self.output_task and not self.output_task.done(): self.output_task.cancel() From 36f43c89329b06923db02682b8e691318741d1e8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 15 Apr 2026 15:10:44 -0700 Subject: [PATCH 04/15] fix: Reduce excessive notifications during agent and interactive modes Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 5 +++-- cecli/tools/command_interactive.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 342a183eb1a..4a85d328428 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -829,11 +829,12 @@ async def reply_completed(self): ) self.io.tool_output(waiting_msg) await asyncio.sleep(command_timeout / 2) - return True + return False # Check for recently finished commands that need reflection if recently_finished_commands and not self.agent_finished: - return True # Retrigger reflection to process recently finished command outputs + self.reflected_message = "Background command finished, processing output." + return False # Retrigger reflection to process recently finished command outputs # 3. If no content and no tools, we might be done or just empty response if (not content or not content.strip()) and not tool_calls_found: diff --git a/cecli/tools/command_interactive.py b/cecli/tools/command_interactive.py index 45d3251bdcb..5343207f949 100644 --- a/cecli/tools/command_interactive.py +++ b/cecli/tools/command_interactive.py @@ -72,6 +72,7 @@ def _run_interactive(): else: coder.io.tool_output(">>> You may need to interact with the command below <<<") coder.io.tool_output(" \n") + coder.io.bell_on_next_input = False await coder.io.stop_input_task() await asyncio.sleep(1) exit_status, combined_output = _run_interactive() From a49ddb6d3a599054d502650ff4c9ccf4288a1fca Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 15 Apr 2026 16:10:34 -0700 Subject: [PATCH 05/15] chore: Remove unused ring_bell call Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/coders/base_coder.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 6e41c80a4e9..86a91699ff9 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2771,7 +2771,6 @@ async def process_tool_calls(self, tool_call_response): def _print_tool_call_info(self, server_tool_calls): """Print information about an MCP tool call.""" - self.io.ring_bell() # self.io.tool_output("Preparing to run MCP tools", bold=False) for server, tool_calls in server_tool_calls.items(): From 15cb6de545ec847fa419e3a50c0e173773f61b67 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 15 Apr 2026 16:14:17 -0700 Subject: [PATCH 06/15] refactor: Make ls.py tool cross-platform and default path to current directory Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/tools/ls.py | 79 +++++++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/cecli/tools/ls.py b/cecli/tools/ls.py index 200816db435..44d1e60caa8 100644 --- a/cecli/tools/ls.py +++ b/cecli/tools/ls.py @@ -8,67 +8,80 @@ class Tool(BaseTool): SCHEMA = { "type": "function", "function": { - "name": "Ls", - "description": "List files in a directory.", + "name": "ls", + "description": "List files in a directory. Paths are relative to the project root.", "parameters": { "type": "object", "properties": { - "directory": { + "path": { "type": "string", - "description": "The directory to list.", - }, + "description": ( + "The path of the directory to list, relative to the project root. " + "Defaults to the project root." + ), + "default": ".", + } }, - "required": ["directory"], + "required": [], }, }, } @classmethod - def execute(cls, coder, dir_path=None, directory=None, **kwargs): - # Handle both positional and keyword arguments for backward compatibility - if dir_path is None and directory is not None: - dir_path = directory - elif dir_path is None: - return "Error: Missing directory parameter" + def execute(cls, coder, path=None, directory=None, **kwargs): """ List files in directory and optionally add some to context. This provides information about the structure of the codebase, similar to how a developer would explore directories. """ + # Handle both positional and keyword arguments for backward compatibility + dir_path = path or directory or "." + try: - # Make the path relative to root if it's absolute - if dir_path.startswith("/"): - rel_dir = os.path.relpath(dir_path, coder.root) - else: - rel_dir = dir_path + # Create an absolute path from the provided relative path + abs_path = os.path.abspath(os.path.join(coder.root, dir_path)) - # Get absolute path - abs_dir = coder.abs_root_path(rel_dir) + # Security check: ensure the resolved path is within the project root + if not abs_path.startswith(os.path.abspath(coder.root)): + coder.io.tool_error( + f"Error: Path '{dir_path}' attempts to access files outside the project" + " root." + ) + return "Error: Path is outside the project root." # Check if path exists - if not os.path.exists(abs_dir): - coder.io.tool_output(f"⚠️ Directory '{dir_path}' not found") + if not os.path.exists(abs_path): + coder.io.tool_output(f"⚠️ Path '{dir_path}' not found") return "Directory not found" # Get directory contents contents = [] - try: - with os.scandir(abs_dir) as entries: - for entry in entries: - if entry.is_file() and not entry.name.startswith("."): - rel_path = os.path.join(rel_dir, entry.name) - contents.append(rel_path) - except NotADirectoryError: - # If it's a file, just return the file - contents = [rel_dir] + if os.path.isdir(abs_path): + # It's a directory, list its contents + try: + with os.scandir(abs_path) as entries: + for entry in entries: + if entry.is_file() and not entry.name.startswith("."): + rel_path = os.path.relpath(entry.path, coder.root) + contents.append(rel_path) + except OSError as e: + coder.io.tool_error(f"Error listing directory '{dir_path}': {e}") + return f"Error: {e}" + elif os.path.isfile(abs_path): + # It's a file, just return its relative path + contents.append(os.path.relpath(abs_path, coder.root)) if contents: coder.io.tool_output(f"📋 Listed {len(contents)} file(s) in '{dir_path}'") - if len(contents) > 10: - return f"Found {len(contents)} files: {', '.join(contents[:10])}..." + sorted_contents = sorted(contents) + if len(sorted_contents) > 10: + return ( + f"Found {len(sorted_contents)} files:" + f" {', '.join(sorted_contents[:10])}..." + ) else: - return f"Found {len(contents)} files: {', '.join(contents)}" + return f"Found {len(sorted_contents)} files: {', '.join(sorted_contents)}" else: coder.io.tool_output(f"📋 No files found in '{dir_path}'") return "No files found in directory" From 15baf5a3e54fa9612ad98fb7dd56ca04008ef5bd Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 15 Apr 2026 16:23:28 -0700 Subject: [PATCH 07/15] refactor: Improve tool call confirmation logic Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/coders/base_coder.py | 16 ++++++++++++---- cecli/tools/command.py | 2 +- cecli/tools/command_interactive.py | 1 + 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 86a91699ff9..568f99ae621 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -365,6 +365,7 @@ def __init__( self.context_compaction_max_tokens = context_compaction_max_tokens self.context_compaction_summary_tokens = context_compaction_summary_tokens + self.globally_approved_tool_calls = False self.max_reflections = ( 3 if self.edit_format == "agent" else nested.getter(self.args, "max_reflections", 3) ) @@ -2747,11 +2748,18 @@ async def process_tool_calls(self, tool_call_response): self._print_tool_call_info(server_tool_calls=tool_groups) # 4. Ask for user confirmation - if not await self.io.confirm_ask("Run tools?", group_response="Run MCP Tools"): - return False + try: + self.globally_approved_tool_calls = False + if not await self.io.confirm_ask("Run tools?", group_response="Run MCP Tools"): + return False - # 5. Execute tools - tool_responses_by_server = await self._execute_tool_groups(tool_groups) + if self.io.group_responses.get("Run MCP Tools"): + self.globally_approved_tool_calls = True + + # 5. Execute tools + tool_responses_by_server = await self._execute_tool_groups(tool_groups) + finally: + self.globally_approved_tool_calls = False # 6. Add responses to conversation (re-prefixing if necessary) tool_responses = [] diff --git a/cecli/tools/command.py b/cecli/tools/command.py index e6cd80bb8e9..d2a1fd33c02 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -128,7 +128,7 @@ async def execute( @classmethod async def _get_confirmation(cls, coder, command_string, background): """Get user confirmation for command execution.""" - if coder.skip_cli_confirmations: + if coder.skip_cli_confirmations or getattr(coder, "globally_approved_tool_calls", False): return True command_string = coder.format_command_with_prefix(command_string) diff --git a/cecli/tools/command_interactive.py b/cecli/tools/command_interactive.py index 5343207f949..39ec75755f0 100644 --- a/cecli/tools/command_interactive.py +++ b/cecli/tools/command_interactive.py @@ -37,6 +37,7 @@ async def execute(cls, coder, command_string, **kwargs): confirmed = ( True if coder.skip_cli_confirmations + or getattr(coder, "globally_approved_tool_calls", False) else await coder.io.confirm_ask( "Allow execution of this command?", subject=command_string, From 241adef412ef44a6518f441bbd81866ac04aa141 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 12:53:38 -0700 Subject: [PATCH 08/15] fix: Make tool execution interruptible Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/base_coder.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index ff21e256d71..cc138cb7a7c 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2751,7 +2751,31 @@ async def process_tool_calls(self, tool_call_response): return False # 5. Execute tools - tool_responses_by_server = await self._execute_tool_groups(tool_groups) + tool_execution_task = asyncio.create_task(self._execute_tool_groups(tool_groups)) + interrupt_task = asyncio.create_task(self.interrupt_event.wait()) + + tool_responses_by_server = {} + try: + done, pending = await asyncio.wait( + {tool_execution_task, interrupt_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + if interrupt_task in done: + tool_execution_task.cancel() + try: + await tool_execution_task + except asyncio.CancelledError: + pass + self.io.tool_warning("Tool execution interrupted.") + return False + + if tool_execution_task in done: + tool_responses_by_server = tool_execution_task.result() + + except asyncio.CancelledError: + self.io.tool_warning("Tool execution cancelled.") + return False # 6. Add responses to conversation (re-prefixing if necessary) tool_responses = [] From dd9134649a7326e2eaedd56e405d35ee4cfe1c2c Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 14:32:32 -0700 Subject: [PATCH 09/15] fix: Prevent notifications during prompt processing Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/io.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/cecli/io.py b/cecli/io.py index c3f207bade8..771acd35f45 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -385,6 +385,7 @@ def __init__( self.verbose = verbose self.profile_start_time = None self.profile_last_time = None + self.is_processing_prompt = False # Variables used to interface with base_coder self.coder = None @@ -814,9 +815,11 @@ async def get_input( abs_read_only_stubs_fnames=None, edit_format=None, ): - self.rule() + self.is_processing_prompt = True + try: + self.rule() - rel_fnames = list(rel_fnames) + rel_fnames = list(rel_fnames) show = "" if rel_fnames: rel_read_only_fnames = [ @@ -1073,8 +1076,10 @@ def get_continuation(width, line_number, is_soft_wrap): inp = line break - self.user_input(inp) - return inp + self.user_input(inp) + return inp + finally: + self.is_processing_prompt = False async def stop_input_task(self): if self.input_task: @@ -1717,9 +1722,13 @@ def get_default_notification_command(self): return None # Unknown system def _send_notification(self): + if self.is_processing_prompt: + return if self.notifications_command: try: - result = subprocess.run(self.notifications_command, shell=True, capture_output=True) + result = subprocess.run( + self.notifications_command, shell=True, capture_output=True + ) if result.returncode != 0 and result.stderr: error_msg = result.stderr.decode("utf-8", errors="replace") self.tool_warning(f"Failed to run notifications command: {error_msg}") @@ -1730,7 +1739,7 @@ def _send_notification(self): def notify_user_input_required(self): """Send a notification that user input is required.""" - if self.notifications: + if self.notifications and not self.is_processing_prompt: self._send_notification() def ring_bell(self): From 7095fa744d16787470bb4a914d8ccbe40bf33aee Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 14:45:49 -0700 Subject: [PATCH 10/15] fix: Prevent notifications during prompt processing Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/io.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cecli/io.py b/cecli/io.py index 771acd35f45..551165077d6 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1076,8 +1076,10 @@ def get_continuation(width, line_number, is_soft_wrap): inp = line break - self.user_input(inp) - return inp + self.user_input(inp) + return inp + finally: + self.is_processing_prompt = False finally: self.is_processing_prompt = False From ca460c29302ac57281680ba8cecfd9c5a6f0faae Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 14:48:42 -0700 Subject: [PATCH 11/15] fix: Correct indentation and logic in get_input method Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/io.py | 451 ++++++++++++++++++++++++++-------------------------- 1 file changed, 225 insertions(+), 226 deletions(-) diff --git a/cecli/io.py b/cecli/io.py index 551165077d6..d0105f8f6e4 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -820,268 +820,267 @@ async def get_input( self.rule() rel_fnames = list(rel_fnames) - show = "" - if rel_fnames: - rel_read_only_fnames = [ - get_rel_fname(fname, root) for fname in abs_read_only_fnames or [] - ] - rel_read_only_stubs_fnames = [ - get_rel_fname(fname, root) for fname in abs_read_only_stubs_fnames or [] - ] - show = self.format_files_for_input( - rel_fnames, rel_read_only_fnames, rel_read_only_stubs_fnames - ) - - prompt_prefix = "" - - if edit_format: - prompt_prefix += edit_format - if self.multiline_mode: - prompt_prefix += (" " if edit_format else "") + "multi" - prompt_prefix += "> " - - show += prompt_prefix - self.prompt_prefix = prompt_prefix - - inp = "" - multiline_input = False + show = "" + if rel_fnames: + rel_read_only_fnames = [ + get_rel_fname(fname, root) for fname in abs_read_only_fnames or [] + ] + rel_read_only_stubs_fnames = [ + get_rel_fname(fname, root) for fname in abs_read_only_stubs_fnames or [] + ] + show = self.format_files_for_input( + rel_fnames, rel_read_only_fnames, rel_read_only_stubs_fnames + ) - style = self._get_style() + prompt_prefix = "" - completer_instance = ThreadedCompleter( - AutoCompleter( - root, - rel_fnames, - addable_rel_fnames, - commands, - self.encoding, - abs_read_only_fnames=(abs_read_only_fnames or set()) - | (abs_read_only_stubs_fnames or set()), + if edit_format: + prompt_prefix += edit_format + if self.multiline_mode: + prompt_prefix += (" " if edit_format else "") + "multi" + prompt_prefix += "> " + + show += prompt_prefix + self.prompt_prefix = prompt_prefix + + inp = "" + multiline_input = False + + style = self._get_style() + + completer_instance = ThreadedCompleter( + AutoCompleter( + root, + rel_fnames, + addable_rel_fnames, + commands, + self.encoding, + abs_read_only_fnames=(abs_read_only_fnames or set()) + | (abs_read_only_stubs_fnames or set()), + ) ) - ) - - def suspend_to_bg(event): - """Suspend currently running application.""" - event.app.suspend_to_background() - - kb = KeyBindings() - @kb.add(Keys.ControlZ, filter=Condition(lambda: hasattr(signal, "SIGTSTP"))) - def _(event): - "Suspend to background with ctrl-z" - suspend_to_bg(event) + def suspend_to_bg(event): + """Suspend currently running application.""" + event.app.suspend_to_background() - @kb.add("c-space") - def _(event): - "Ignore Ctrl when pressing space bar" - event.current_buffer.insert_text(" ") + kb = KeyBindings() - @kb.add("c-up") - def _(event): - "Navigate backward through history" - event.current_buffer.history_backward() + @kb.add(Keys.ControlZ, filter=Condition(lambda: hasattr(signal, "SIGTSTP"))) + def _(event): + "Suspend to background with ctrl-z" + suspend_to_bg(event) - @kb.add("c-down") - def _(event): - "Navigate forward through history" - event.current_buffer.history_forward() + @kb.add("c-space") + def _(event): + "Ignore Ctrl when pressing space bar" + event.current_buffer.insert_text(" ") - @kb.add("c-x", "c-e") - def _(event): - "Edit current input in external editor (like Bash)" - buffer = event.current_buffer - current_text = buffer.text + @kb.add("c-up") + def _(event): + "Navigate backward through history" + event.current_buffer.history_backward() - # Open the editor with the current text - edited_text = pipe_editor(input_data=current_text, suffix="md") + @kb.add("c-down") + def _(event): + "Navigate forward through history" + event.current_buffer.history_forward() - # Replace the buffer with the edited text, strip any trailing newlines - buffer.text = edited_text.rstrip("\n") + @kb.add("c-x", "c-e") + def _(event): + "Edit current input in external editor (like Bash)" + buffer = event.current_buffer + current_text = buffer.text - # Move cursor to the end of the text - buffer.cursor_position = len(buffer.text) + # Open the editor with the current text + edited_text = pipe_editor(input_data=current_text, suffix="md") - @kb.add("c-t", filter=Condition(lambda: self.fzf_available)) - def _(event): - "Fuzzy find files to add to the chat" - buffer = event.current_buffer - if not buffer.text.strip().startswith("/add "): - return - - files = run_fzf(addable_rel_fnames, multi=True) - if files: - buffer.text = "/add " + " ".join(files) - buffer.cursor_position = len(buffer.text) + # Replace the buffer with the edited text, strip any trailing newlines + buffer.text = edited_text.rstrip("\n") - @kb.add("c-r", filter=Condition(lambda: self.fzf_available)) - def _(event): - "Fuzzy search in history and paste it in the prompt" - buffer = event.current_buffer - history_lines = self.get_input_history() - selected_lines = run_fzf(history_lines) - if selected_lines: - buffer.text = "".join(selected_lines) + # Move cursor to the end of the text buffer.cursor_position = len(buffer.text) - @kb.add("enter", eager=True, filter=~is_searching) - def _(event): - "Handle Enter key press" - if self.multiline_mode and not ( - self.editingmode == EditingMode.VI - and event.app.vi_state.input_mode == InputMode.NAVIGATION - ): - # In multiline mode and if not in vi-mode or vi navigation/normal mode, - # Enter adds a newline - event.current_buffer.insert_text("\n") - else: - # In normal mode, Enter submits - event.current_buffer.validate_and_handle() - - @kb.add("escape", "enter", eager=True, filter=~is_searching) # This is Alt+Enter - def _(event): - "Handle Alt+Enter key press" - if self.multiline_mode: - # In multiline mode, Alt+Enter submits - event.current_buffer.validate_and_handle() - else: - # In normal mode, Alt+Enter adds a newline - event.current_buffer.insert_text("\n") - - while True: - if multiline_input: - show = self.prompt_prefix - - try: - self.interrupted = False - if not multiline_input: - if self.file_watcher: - self.file_watcher.start() - if self.clipboard_watcher: - self.clipboard_watcher.start() + @kb.add("c-t", filter=Condition(lambda: self.fzf_available)) + def _(event): + "Fuzzy find files to add to the chat" + buffer = event.current_buffer + if not buffer.text.strip().startswith("/add "): + return + + files = run_fzf(addable_rel_fnames, multi=True) + if files: + buffer.text = "/add " + " ".join(files) + buffer.cursor_position = len(buffer.text) + + @kb.add("c-r", filter=Condition(lambda: self.fzf_available)) + def _(event): + "Fuzzy search in history and paste it in the prompt" + buffer = event.current_buffer + history_lines = self.get_input_history() + selected_lines = run_fzf(history_lines) + if selected_lines: + buffer.text = "".join(selected_lines) + buffer.cursor_position = len(buffer.text) + + @kb.add("enter", eager=True, filter=~is_searching) + def _(event): + "Handle Enter key press" + if self.multiline_mode and not ( + self.editingmode == EditingMode.VI + and event.app.vi_state.input_mode == InputMode.NAVIGATION + ): + # In multiline mode and if not in vi-mode or vi navigation/normal mode, + # Enter adds a newline + event.current_buffer.insert_text("\n") + else: + # In normal mode, Enter submits + event.current_buffer.validate_and_handle() + + @kb.add("escape", "enter", eager=True, filter=~is_searching) # This is Alt+Enter + def _(event): + "Handle Alt+Enter key press" + if self.multiline_mode: + # In multiline mode, Alt+Enter submits + event.current_buffer.validate_and_handle() + else: + # In normal mode, Alt+Enter adds a newline + event.current_buffer.insert_text("\n") - if self.prompt_session: - # Use placeholder if set, then clear it - default = self.placeholder or "" - self.placeholder = None + while True: + if multiline_input: + show = self.prompt_prefix - def get_continuation(width, line_number, is_soft_wrap): - return self.prompt_prefix + try: + self.interrupted = False + if not multiline_input: + if self.file_watcher: + self.file_watcher.start() + if self.clipboard_watcher: + self.clipboard_watcher.start() + + if self.prompt_session: + # Use placeholder if set, then clear it + default = self.placeholder or "" + self.placeholder = None + + def get_continuation(width, line_number, is_soft_wrap): + return self.prompt_prefix + + line = await self.prompt_session.prompt_async( + show, + default=default, + completer=completer_instance, + reserve_space_for_menu=4, + complete_style=CompleteStyle.MULTI_COLUMN, + style=style, + key_bindings=kb, + complete_while_typing=True, + prompt_continuation=get_continuation, + ) + else: + try: + self.interruptible_input = InterruptibleInput() + except RuntimeError: + # Fallback to non-interruptible input (Windows ...) + line = await asyncio.get_event_loop().run_in_executor(None, input, show) + + if self.interruptible_input: + try: + line = await asyncio.get_event_loop().run_in_executor( + None, self.interruptible_input.input, show + ) + except InterruptedError: + self.interrupted = True + line = "" + finally: + self.interruptible_input.close() + self.interruptible_input = None + + # Check if we were interrupted by a file change + if self.interrupted: + line = line or "" + if self.file_watcher: + cmd = self.file_watcher.process_changes() + return cmd + + except EOFError: + coder = self.get_coder() + + if coder: + await coder.commands.execute("exit", "") + return "" + else: + raise SystemExit - line = await self.prompt_session.prompt_async( - show, - default=default, - completer=completer_instance, - reserve_space_for_menu=4, - complete_style=CompleteStyle.MULTI_COLUMN, - style=style, - key_bindings=kb, - complete_while_typing=True, - prompt_continuation=get_continuation, - ) - else: + except KeyboardInterrupt: + self.console.print() + return "" + except UnicodeEncodeError as err: + self.tool_error(str(err)) + return "" + except Exception as err: try: - self.interruptible_input = InterruptibleInput() - except RuntimeError: - # Fallback to non-interruptible input (Windows ...) - line = await asyncio.get_event_loop().run_in_executor(None, input, show) + self.prompt_session.app.exit() + except Exception: + pass - if self.interruptible_input: - try: - line = await asyncio.get_event_loop().run_in_executor( - None, self.interruptible_input.input, show - ) - except InterruptedError: - self.interrupted = True - line = "" - finally: - self.interruptible_input.close() - self.interruptible_input = None - - # Check if we were interrupted by a file change - if self.interrupted: - line = line or "" - if self.file_watcher: - cmd = self.file_watcher.process_changes() - return cmd + import traceback - except EOFError: - coder = self.get_coder() - - if coder: - await coder.commands.execute("exit", "") + self.tool_error(str(err)) + self.tool_error(traceback.format_exc()) return "" - else: - raise SystemExit - - except KeyboardInterrupt: - self.console.print() - return "" - except UnicodeEncodeError as err: - self.tool_error(str(err)) - return "" - except Exception as err: - try: - self.prompt_session.app.exit() - except Exception: - pass + finally: + if self.file_watcher: + self.file_watcher.stop() + if self.clipboard_watcher: + self.clipboard_watcher.stop() - import traceback + line = line or "" - self.tool_error(str(err)) - self.tool_error(traceback.format_exc()) - return "" - finally: - if self.file_watcher: - self.file_watcher.stop() - if self.clipboard_watcher: - self.clipboard_watcher.stop() - - line = line or "" - - if line.strip("\r\n") and not multiline_input: - stripped = line.strip("\r\n") - if stripped == "{": - multiline_input = True - multiline_tag = None - inp += "" - elif stripped[0] == "{": - # Extract tag if it exists (only alphanumeric chars) - tag = "".join(c for c in stripped[1:] if c.isalnum()) - if stripped == "{" + tag: + if line.strip("\r\n") and not multiline_input: + stripped = line.strip("\r\n") + if stripped == "{": multiline_input = True - multiline_tag = tag + multiline_tag = None inp += "" + elif stripped[0] == "{": + # Extract tag if it exists (only alphanumeric chars) + tag = "".join(c for c in stripped[1:] if c.isalnum()) + if stripped == "{" + tag: + multiline_input = True + multiline_tag = tag + inp += "" + else: + inp = line + break else: inp = line break - else: - inp = line - break - continue - elif multiline_input and line.strip(): - if multiline_tag: - # Check if line is exactly "tag}" - if line.strip("\r\n") == f"{multiline_tag}}}": + continue + elif multiline_input and line.strip(): + if multiline_tag: + # Check if line is exactly "tag}" + if line.strip("\r\n") == f"{multiline_tag}}}": + break + else: + inp += line + "\n" + # Check if line is exactly "}" + elif line.strip("\r\n") == "}": break else: inp += line + "\n" - # Check if line is exactly "}" - elif line.strip("\r\n") == "}": - break - else: + elif multiline_input: inp += line + "\n" - elif multiline_input: - inp += line + "\n" - else: - inp = line - break + else: + inp = line + break - self.user_input(inp) - return inp - finally: - self.is_processing_prompt = False + self.user_input(inp) + return inp finally: self.is_processing_prompt = False + self.is_processing_prompt = False async def stop_input_task(self): if self.input_task: From b261f2e623bdd4b13eef69871a20dd3dffacafe0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 15:33:32 -0700 Subject: [PATCH 12/15] fix: Set is_processing_prompt flag in Coder methods Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/base_coder.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 5953db85b6c..e703a99f2e5 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1278,7 +1278,11 @@ async def _run_linear(self, with_message=None, preproc=True): try: if with_message: self.io.user_input(with_message) - await self.run_one(with_message, preproc) + self.io.is_processing_prompt = True + try: + await self.run_one(with_message, preproc) + finally: + self.io.is_processing_prompt = False return self.partial_response_content user_message = None @@ -1340,7 +1344,11 @@ async def _run_parallel(self, with_message=None, preproc=True): try: if with_message: self.io.user_input(with_message) - await self.run_one(with_message, preproc) + self.io.is_processing_prompt = True + try: + await self.run_one(with_message, preproc) + finally: + self.io.is_processing_prompt = False return self.partial_response_content # Initialize state for task coordination @@ -1534,7 +1542,11 @@ async def generate(self, user_message, preproc): self.compact_context_completed = True self.run_one_completed = False - await self.run_one(user_message, preproc) + self.io.is_processing_prompt = True + try: + await self.run_one(user_message, preproc) + finally: + self.io.is_processing_prompt = False self.show_undo_hint() except asyncio.CancelledError: # Don't show undo hint if cancelled From 9be97c301f9444a48fd3f77f651ef3622a835e1b Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 18:14:40 -0700 Subject: [PATCH 13/15] fix: Prevent notifications during prompt processing Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/io.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cecli/io.py b/cecli/io.py index d0105f8f6e4..c8735cb7bd9 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1740,7 +1740,9 @@ def _send_notification(self): def notify_user_input_required(self): """Send a notification that user input is required.""" - if self.notifications and not self.is_processing_prompt: + if self.is_processing_prompt: + return + if self.notifications: self._send_notification() def ring_bell(self): From 7c09b82f2b1902ea54e3df2eac83da1f3633c4f3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 18:19:29 -0700 Subject: [PATCH 14/15] feat: Add test for notification suppression during prompt processing Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- tests/basic/test_io.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/basic/test_io.py b/tests/basic/test_io.py index cd838cbfbb7..876a2b96bc2 100644 --- a/tests/basic/test_io.py +++ b/tests/basic/test_io.py @@ -648,3 +648,40 @@ def test_format_files_for_input_pretty_true_mixed_files( args_ed, _ = mock_columns.call_args_list[2] renderables_ed = args_ed[0] assert renderables_ed == ["Editable:", "edit1.txt", "edit[markup].txt"] +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + +from cecli.io import InputOutput + + +@pytest.mark.asyncio +async def test_notification_suppressed_during_processing(): + """ + Verify that notifications are not sent when a prompt is being processed. + """ + # Initialize InputOutput with notifications enabled + io = InputOutput(notifications=True) + io.is_processing_prompt = False # Start in idle state + + with patch.object(io, "_send_notification") as mock_send_notification: + # 1. Test when idle: notification should be sent + io.notify_user_input_required() + mock_send_notification.assert_called_once() + + # Reset mock for the next check + mock_send_notification.reset_mock() + + # 2. Test when processing: notification should be suppressed + io.is_processing_prompt = True + io.notify_user_input_required() + mock_send_notification.assert_not_called() + + # Reset mock for the next check + mock_send_notification.reset_mock() + + # 3. Test after processing: notification should be sent again + io.is_processing_prompt = False + io.notify_user_input_required() + mock_send_notification.assert_called_once() From 69b49034ca3c264f8f9b2896621688f3777d3170 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 21 Apr 2026 18:11:30 -0700 Subject: [PATCH 15/15] cli-12: fixed formatting for pipeline --- cecli/coders/base_coder.py | 1 - cecli/io.py | 4 +--- cecli/tools/ls.py | 6 ++---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 2c098fbb4f8..fb10f29a057 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2772,7 +2772,6 @@ async def process_tool_calls(self, tool_call_response): finally: self.globally_approved_tool_calls = False - # 6. Add responses to conversation (re-prefixing if necessary) tool_responses = [] for server, server_responses in tool_responses_by_server.items(): diff --git a/cecli/io.py b/cecli/io.py index c8735cb7bd9..2303161fe9e 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1727,9 +1727,7 @@ def _send_notification(self): return if self.notifications_command: try: - result = subprocess.run( - self.notifications_command, shell=True, capture_output=True - ) + result = subprocess.run(self.notifications_command, shell=True, capture_output=True) if result.returncode != 0 and result.stderr: error_msg = result.stderr.decode("utf-8", errors="replace") self.tool_warning(f"Failed to run notifications command: {error_msg}") diff --git a/cecli/tools/ls.py b/cecli/tools/ls.py index f88eeeb8fef..443f74acd67 100644 --- a/cecli/tools/ls.py +++ b/cecli/tools/ls.py @@ -48,8 +48,7 @@ def execute(cls, coder, path=None, directory=None, **kwargs): # Security check: ensure the resolved path is within the project root if not abs_path.startswith(os.path.abspath(coder.root)): coder.io.tool_error( - f"Error: Path '{dir_path}' attempts to access files outside the project" - " root." + f"Error: Path '{dir_path}' attempts to access files outside the project root." ) return "Error: Path is outside the project root." @@ -80,8 +79,7 @@ def execute(cls, coder, path=None, directory=None, **kwargs): sorted_contents = sorted(contents) if len(sorted_contents) > 10: return ( - f"Found {len(sorted_contents)} files:" - f" {', '.join(sorted_contents[:10])}..." + f"Found {len(sorted_contents)} files: {', '.join(sorted_contents[:10])}..." ) else: return f"Found {len(sorted_contents)} files: {', '.join(sorted_contents)}"