From 6ecbb74ad5eb9e69bab1b74be5aac954a559367d Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 15 Apr 2026 10:58:28 -0700 Subject: [PATCH 01/18] 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/18] 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/18] 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 241adef412ef44a6518f441bbd81866ac04aa141 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 12:53:38 -0700 Subject: [PATCH 04/18] 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 ecf67f2bb900a54bd874106be2cea356031285e1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 20 Apr 2026 18:09:45 -0700 Subject: [PATCH 05/18] fix: Improve robustness of Ctrl+C interruption during tool calls Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/base_coder.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index cc138cb7a7c..49a4a53fce6 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2652,11 +2652,29 @@ async def _execute_mcp_tools(self, server, tool_calls): all_results_content.append("Tool Request Aborted.") continue - call_result = await experimental_mcp_client.call_openai_tool( - session=session, - openai_tool=new_tool_call, + tool_call_task = asyncio.create_task( + experimental_mcp_client.call_openai_tool( + session=session, + openai_tool=new_tool_call, + ) + ) + interrupt_task = asyncio.create_task(self.interrupt_event.wait()) + + done, pending = await asyncio.wait( + {tool_call_task, interrupt_task}, + return_when=asyncio.FIRST_COMPLETED, ) + if interrupt_task in done: + tool_call_task.cancel() + try: + await tool_call_task + except asyncio.CancelledError: + pass + raise KeyboardInterrupt("Tool call interrupted") + + call_result = tool_call_task.result() + content_parts = [] if call_result.content: for item in call_result.content: @@ -2701,6 +2719,9 @@ async def _execute_mcp_tools(self, server, tool_calls): } ) + except KeyboardInterrupt: + self.io.tool_warning(f"Tool call {tool_call.function.name} interrupted.") + raise except Exception as e: tool_error = f"Error executing tool call {tool_call.function.name}: \n{e}" self.io.tool_warning( @@ -2751,6 +2772,7 @@ async def process_tool_calls(self, tool_call_response): return False # 5. Execute tools + self.interrupt_event.clear() tool_execution_task = asyncio.create_task(self._execute_tool_groups(tool_groups)) interrupt_task = asyncio.create_task(self.interrupt_event.wait()) From 076b87542e3bb8f2ef44b3127c87adfdda34b97e Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Apr 2026 17:15:22 -0700 Subject: [PATCH 06/18] fix: Make LLM retries and tool execution interruptible Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 26 ++++++++++++++++++++++++-- cecli/coders/base_coder.py | 15 ++++++++++++++- cecli/tui/app.py | 5 ++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 86916dd8ac3..6401d2d4bba 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -303,8 +303,30 @@ async def _execute_local_tool_calls(self, tool_calls_list): else: all_results_content.append(f"Error: Unknown tool name '{tool_name}'") if tasks: - task_results = await asyncio.gather(*tasks) - all_results_content.extend(str(res) for res in task_results) + gather_task = asyncio.create_task(asyncio.gather(*tasks, return_exceptions=True)) + interrupt_task = asyncio.create_task(self.interrupt_event.wait()) + + done, pending = await asyncio.wait( + {gather_task, interrupt_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + if interrupt_task in done: + gather_task.cancel() + try: + await gather_task + except asyncio.CancelledError: + pass + self.io.tool_warning("Tool execution interrupted.") + # Append a message indicating interruption + all_results_content.append("Tool execution interrupted by user.") + else: + task_results = gather_task.result() + for res in task_results: + if isinstance(res, Exception): + all_results_content.append(f"Error in tool execution: {res}") + else: + all_results_content.append(str(res)) if not await HookIntegration.call_post_tool_hooks( self, tool_name, args_string, "\n\n".join(all_results_content) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 49a4a53fce6..f95da528d62 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2285,7 +2285,20 @@ async def send_message(self, inp): self.io.tool_error(err_msg) self.io.tool_output(f"Retrying in {retry_delay:.1f} seconds...") - await asyncio.sleep(retry_delay) + + sleep_task = asyncio.create_task(asyncio.sleep(retry_delay)) + interrupt_task = asyncio.create_task(self.interrupt_event.wait()) + + done, pending = await asyncio.wait( + {sleep_task, interrupt_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + if interrupt_task in done: + sleep_task.cancel() + interrupted = True + break + continue except (KeyboardInterrupt, asyncio.CancelledError): interrupted = True diff --git a/cecli/tui/app.py b/cecli/tui/app.py index e92feec1653..13047fe60a9 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -105,7 +105,10 @@ def __init__(self, coder_worker, output_queue, input_queue, args): show=True, ) self.bind( - self._encode_keys(self.get_keys_for("cancel")), "noop", description="Cancel", show=True + self._encode_keys(self.get_keys_for("cancel")), + "interrupt", + description="Cancel", + show=True, ) self.bind( self._encode_keys(self.get_keys_for("editor")), From 602db8ed163d41f4ef380209dd25f70e6a1d1c87 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Apr 2026 17:44:37 -0700 Subject: [PATCH 07/18] fix: Make LLM retries interruptible with Ctrl+C Co-authored-by: cecli (openai/gemini_cli/gemini-2.5-pro) --- cecli/coders/base_coder.py | 1 + cecli/models.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index f95da528d62..8bb08be0837 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -3130,6 +3130,7 @@ async def send(self, messages, model=None, functions=None, tools=None): # This could include any tools, but for now it is just MCP tools tools=tools, override_kwargs=self.model_kwargs, + interrupt_event=self.interrupt_event, ) ) interrupt_task = asyncio.create_task(self.interrupt_event.wait()) diff --git a/cecli/models.py b/cecli/models.py index 022b1723a9d..081a09538b8 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -974,6 +974,7 @@ async def send_completion( min_wait=0, max_wait=2, override_kwargs={}, + interrupt_event=None, ): if os.environ.get("CECLI_SANITY_CHECK_TURNS"): sanity_check_messages(messages) @@ -1113,7 +1114,16 @@ async def send_completion( return hash_object, self.model_error_response() print(f"Retrying in {retry_delay:.1f} seconds...") - await asyncio.sleep(retry_delay) + if interrupt_event: + try: + await asyncio.wait_for(interrupt_event.wait(), timeout=retry_delay) + # if we get here, the event was set + raise KeyboardInterrupt("Interrupted during retry sleep") + except asyncio.TimeoutError: + # sleep finished without interruption + pass + else: + await asyncio.sleep(retry_delay) continue async def simple_send_with_retries(self, messages, max_tokens=None): From cae6a5bf333d1afba3631c4fb44366e02471aa44 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Apr 2026 23:38:26 -0700 Subject: [PATCH 08/18] fix: Make MCP server load/remove commands interruptible Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/commands/load_mcp.py | 48 ++++++++++++++++++++++------------- cecli/commands/remove_mcp.py | 49 ++++++++++++++++++++++++------------ 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/cecli/commands/load_mcp.py b/cecli/commands/load_mcp.py index ad19ebc0b62..a00a723c4e7 100644 --- a/cecli/commands/load_mcp.py +++ b/cecli/commands/load_mcp.py @@ -26,27 +26,39 @@ async def execute(cls, io, coder, args, **kwargs): io, cls.NORM_NAME, "", f"MCP server {server_name} does not exist." ) - did_connect = await coder.mcp_manager.connect_server(server.name) + import asyncio + coder.interrupt_event.clear() + connect_task = asyncio.create_task(coder.mcp_manager.connect_server(server.name)) + interrupt_task = asyncio.create_task(coder.interrupt_event.wait()) + + done, pending = await asyncio.wait( + {connect_task, interrupt_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + if interrupt_task in done: + connect_task.cancel() + try: + await connect_task + except asyncio.CancelledError: + pass + io.tool_warning("MCP connection interrupted.") + return + + did_connect = connect_task.result() if not did_connect: - return format_command_result(io, cls.NORM_NAME, f"Unable to load server: {server_name}") + return format_command_result(io, cls.NORM_NAME, "", f"Unable to load server: {server_name}") - try: - if did_connect: - return format_command_result(io, cls.NORM_NAME, f"Loaded server: {server_name}") - else: - return format_command_result( - io, cls.NORM_NAME, "", f"Unable to Load server: {server_name}" - ) - finally: - from . import SwitchCoderSignal - - raise SwitchCoderSignal( - edit_format=coder.edit_format, - summarize_from_coder=False, - from_coder=coder, - show_announcements=True, - ) + io.tool_output(f"Loaded server: {server_name}") + + from . import SwitchCoderSignal + raise SwitchCoderSignal( + edit_format=coder.edit_format, + summarize_from_coder=False, + from_coder=coder, + show_announcements=True, + ) @classmethod def get_completions(cls, io, coder, args) -> List[str]: diff --git a/cecli/commands/remove_mcp.py b/cecli/commands/remove_mcp.py index 9350a9670d8..24d76429970 100644 --- a/cecli/commands/remove_mcp.py +++ b/cecli/commands/remove_mcp.py @@ -20,26 +20,43 @@ async def execute(cls, io, coder, args, **kwargs): ) server_name = args.strip() - was_disconnected = await coder.mcp_manager.disconnect_server(server_name) + import asyncio + coder.interrupt_event.clear() + disconnect_task = asyncio.create_task(coder.mcp_manager.disconnect_server(server_name)) + interrupt_task = asyncio.create_task(coder.interrupt_event.wait()) - try: - if was_disconnected: - return format_command_result(io, cls.NORM_NAME, f"Removed server: {server_name}") - else: - return format_command_result( - io, cls.NORM_NAME, "", f"Unable to remove server: {server_name}" - ) - finally: - from . import SwitchCoderSignal + done, pending = await asyncio.wait( + {disconnect_task, interrupt_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + if interrupt_task in done: + disconnect_task.cancel() + try: + await disconnect_task + except asyncio.CancelledError: + pass + io.tool_warning("MCP disconnection interrupted.") + return + + was_disconnected = disconnect_task.result() - raise SwitchCoderSignal( - edit_format=coder.edit_format, - summarize_from_coder=False, - from_coder=coder, - show_announcements=True, - mcp_manager=coder.mcp_manager, + if not was_disconnected: + return format_command_result( + io, cls.NORM_NAME, "", f"Unable to remove server: {server_name}" ) + io.tool_output(f"Removed server: {server_name}") + + from . import SwitchCoderSignal + raise SwitchCoderSignal( + edit_format=coder.edit_format, + summarize_from_coder=False, + from_coder=coder, + show_announcements=True, + mcp_manager=coder.mcp_manager, + ) + @classmethod def get_completions(cls, io, coder, args) -> List[str]: """Get completion options for remove-mcp command.""" From 17b5ff5e6a040bf4c1556bb78dbc332873c0dffd Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Apr 2026 21:43:54 -0700 Subject: [PATCH 09/18] fix: Correctly handle asyncio gather for interruptible tool execution Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 4d4765e2014..f7b3cb075f9 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -301,27 +301,25 @@ async def _execute_local_tool_calls(self, tool_calls_list): else: all_results_content.append(f"Error: Unknown tool name '{tool_name}'") if tasks: - gather_task = asyncio.create_task( - asyncio.gather(*tasks, return_exceptions=True) - ) + gather_future = asyncio.gather(*tasks, return_exceptions=True) interrupt_task = asyncio.create_task(self.interrupt_event.wait()) done, pending = await asyncio.wait( - {gather_task, interrupt_task}, + {gather_future, interrupt_task}, return_when=asyncio.FIRST_COMPLETED, ) if interrupt_task in done: - gather_task.cancel() + gather_future.cancel() try: - await gather_task + await gather_future except asyncio.CancelledError: pass self.io.tool_warning("Tool execution interrupted.") # Append a message indicating interruption all_results_content.append("Tool execution interrupted by user.") else: - task_results = gather_task.result() + task_results = gather_future.result() for res in task_results: if isinstance(res, Exception): all_results_content.append(f"Error in tool execution: {res}") From 9a9da53dc3003156e48cd87aff66240a03946ddb Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Apr 2026 16:54:21 -0700 Subject: [PATCH 10/18] fix: Improve interrupt handling for MCP tool calls Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 19 ++++++++++++++++++- cecli/tools/command.py | 9 +++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 03b554b76e5..b9103f0d6eb 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -415,7 +415,24 @@ async def _exec_async(): """) return f"Error executing tool call {tool_name}: {e}" - return await _exec_async() + exec_future = asyncio.create_task(_exec_async()) + interrupt_task = asyncio.create_task(self.interrupt_event.wait()) + + done, pending = await asyncio.wait( + {exec_future, interrupt_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + if interrupt_task in done: + exec_future.cancel() + try: + await exec_future + except asyncio.CancelledError: + pass + return "Tool execution interrupted by user." + else: + interrupt_task.cancel() + return await exec_future def _calculate_context_block_tokens(self, force=False): """ diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 28c1bec9ba6..4bf1ec941c4 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -228,6 +228,15 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal start_time = time.time() while True: + if coder.interrupt_event.is_set(): + process.terminate() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + BackgroundCommandManager.stop_background_command(command_key) + return "Command execution interrupted by user." + # Check if process has completed exit_code = process.poll() if exit_code is not None: From 821717a5ecff06e12f5e2d08e784fb41cdf66819 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Apr 2026 18:35:29 -0700 Subject: [PATCH 11/18] fix: Improve Ctrl+C interruption of MCP tool calls Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/coders/base_coder.py | 5 ----- cecli/main.py | 3 +++ 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index f72e368e552..040b1fb5857 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1370,11 +1370,6 @@ async def _run_parallel(self, with_message=None, preproc=True): except (SwitchCoderSignal, SystemExit): # Re-raise SwitchCoder to be handled by outer try block raise - except KeyboardInterrupt: - # Handle keyboard interrupt gracefully - self.io.set_placeholder("") - self.io.stop_spinner() - self.keyboard_interrupt() finally: # Signal tasks to stop self.input_running = False diff --git a/cecli/main.py b/cecli/main.py index bf8b89fa99d..d0c8a2a31c2 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -1247,6 +1247,9 @@ def get_io(pretty): if switch.kwargs.get("show_announcements") is False: coder.suppress_announcements_for_next_prompt = True + except KeyboardInterrupt: + coder.keyboard_interrupt() + continue except SystemExit: sys.settrace(None) await coder.auto_save_session(force=True) From 4c436a3cd7d95c9e7f90482a9bf41f542c825b87 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 29 Apr 2026 19:02:29 -0700 Subject: [PATCH 12/18] cli-9: fix black --- cecli/commands/load_mcp.py | 4 +--- cecli/commands/remove_mcp.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/cecli/commands/load_mcp.py b/cecli/commands/load_mcp.py index 276c7bfeef5..b3df2a6cca8 100644 --- a/cecli/commands/load_mcp.py +++ b/cecli/commands/load_mcp.py @@ -57,9 +57,7 @@ async def execute(cls, io, coder, args, **kwargs): server_name = server.name coder.interrupt_event.clear() - connect_task = asyncio.create_task( - coder.mcp_manager.connect_server(server_name) - ) + connect_task = asyncio.create_task(coder.mcp_manager.connect_server(server_name)) interrupt_task = asyncio.create_task(coder.interrupt_event.wait()) done, pending = await asyncio.wait( diff --git a/cecli/commands/remove_mcp.py b/cecli/commands/remove_mcp.py index 098ebdb2a19..86800bbc0c6 100644 --- a/cecli/commands/remove_mcp.py +++ b/cecli/commands/remove_mcp.py @@ -47,9 +47,7 @@ async def execute(cls, io, coder, args, **kwargs): coder.interrupt_event.clear() - disconnect_task = asyncio.create_task( - coder.mcp_manager.disconnect_server(server_name) - ) + disconnect_task = asyncio.create_task(coder.mcp_manager.disconnect_server(server_name)) interrupt_task = asyncio.create_task(coder.interrupt_event.wait()) done, pending = await asyncio.wait( From cca51d434a003d4ee8ee5da46c4f13f6bc313dc3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 30 Apr 2026 13:08:48 -0700 Subject: [PATCH 13/18] refactor: Improve interrupt handling with interruptible wrapper Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 40 ++++++++-------------------------- cecli/coders/base_coder.py | 3 +++ cecli/helpers/coroutines.py | 43 ++++++++++++++++++++++++++++++++++--- 3 files changed, 52 insertions(+), 34 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index b9103f0d6eb..9d7773b7d8a 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -16,6 +16,7 @@ from cecli.helpers import nested, responses from cecli.helpers.background_commands import BackgroundCommandManager from cecli.helpers.conversation import ConversationService, MessageTag +from cecli.helpers.coroutines import interruptible from cecli.helpers.similarity import ( cosine_similarity, create_bigram_vector, @@ -301,25 +302,15 @@ async def _execute_local_tool_calls(self, tool_calls_list): else: all_results_content.append(f"Error: Unknown tool name '{tool_name}'") if tasks: - gather_future = asyncio.gather(*tasks, return_exceptions=True) - interrupt_task = asyncio.create_task(self.interrupt_event.wait()) - - done, pending = await asyncio.wait( - {gather_future, interrupt_task}, - return_when=asyncio.FIRST_COMPLETED, + gather_coro = asyncio.gather(*tasks, return_exceptions=True) + task_results, interrupted = await interruptible( + gather_coro, self.interrupt_event ) - if interrupt_task in done: - gather_future.cancel() - try: - await gather_future - except asyncio.CancelledError: - pass + if interrupted: self.io.tool_warning("Tool execution interrupted.") - # Append a message indicating interruption all_results_content.append("Tool execution interrupted by user.") - else: - task_results = gather_future.result() + elif task_results: for res in task_results: if isinstance(res, Exception): all_results_content.append(f"Error in tool execution: {res}") @@ -415,24 +406,11 @@ async def _exec_async(): """) return f"Error executing tool call {tool_name}: {e}" - exec_future = asyncio.create_task(_exec_async()) - interrupt_task = asyncio.create_task(self.interrupt_event.wait()) - - done, pending = await asyncio.wait( - {exec_future, interrupt_task}, - return_when=asyncio.FIRST_COMPLETED, - ) + result, interrupted = await interruptible(_exec_async(), self.interrupt_event) - if interrupt_task in done: - exec_future.cancel() - try: - await exec_future - except asyncio.CancelledError: - pass + if interrupted: return "Tool execution interrupted by user." - else: - interrupt_task.cancel() - return await exec_future + return result def _calculate_context_block_tokens(self, force=False): """ diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 040b1fb5857..85f38cd4860 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2722,6 +2722,9 @@ async def _execute_mcp_tools(self, server, tool_calls): tool_responses.append( {"role": "tool", "tool_call_id": tool_call.id, "content": connection_error} ) + except asyncio.CancelledError: + # Re-raise CancelledError to ensure the task cancellation propagates + raise except Exception as e: connection_error = f"Could not connect to server {server.name}\n{e}" self.io.tool_warning(connection_error) diff --git a/cecli/helpers/coroutines.py b/cecli/helpers/coroutines.py index 77cee82b162..07f1a669d5a 100644 --- a/cecli/helpers/coroutines.py +++ b/cecli/helpers/coroutines.py @@ -1,8 +1,45 @@ -import asyncio # noqa: F401 +import asyncio -def is_active(coroutine): - if not coroutine or coroutine.done() or coroutine.cancelled(): +def is_active(task): + if not task or task.done() or task.cancelled(): return False return True + + +async def interruptible(coroutine, interrupt_event): + """ + Runs a coroutine and allows it to be interrupted by an asyncio.Event. + + Args: + coroutine: The coroutine to run. + interrupt_event: The asyncio.Event that signals an interruption. + + Returns: + A tuple of (result, interrupted). + - If not interrupted: (coroutine_result, False) + - If interrupted: (None, True) + """ + main_task = asyncio.create_task(coroutine) + interrupt_task = asyncio.create_task(interrupt_event.wait()) + + done, pending = await asyncio.wait( + {main_task, interrupt_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + for task in pending: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass # Expected + + if interrupt_task in done: + return None, True + + try: + return main_task.result(), False + except asyncio.CancelledError: + return None, True From f786255ad4e29b8a40af831c751da2c87fec4d13 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 30 Apr 2026 13:14:26 -0700 Subject: [PATCH 14/18] fix: Remove KeyboardInterrupt handler from _run_linear Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/coders/base_coder.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 85f38cd4860..d43631b2c19 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1449,10 +1449,6 @@ async def input_task(self, preproc): await asyncio.sleep(0.1) # Small yield to prevent tight loop - except KeyboardInterrupt: - self.io.set_placeholder("") - self.keyboard_interrupt() - await self.io.stop_task_streams() except (SwitchCoderSignal, SystemExit): raise except Exception as e: From a76ae8246c8f361e4e7da469aa88bc21e193c4a9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 30 Apr 2026 13:54:12 -0700 Subject: [PATCH 15/18] refactor: Use interruptible wrapper in _execute_mcp_tools Co-authored-by: cecli (openai/gemini_cli_local/gemini-2.5-pro) --- cecli/coders/agent_coder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 9d7773b7d8a..12586e28400 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -28,6 +28,7 @@ from cecli.mcp import LocalServer, McpServerManager from cecli.tools.utils.base_tool import BaseTool from cecli.tools.utils.registry import ToolRegistry +from cecli.helpers.coroutines import interruptible from cecli.utils import copy_tool_call, tool_call_to_dict from .base_coder import Coder From 879fd8f9712053a959f6809ed867bd73c1cb6767 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 30 Apr 2026 19:17:54 -0700 Subject: [PATCH 16/18] cli-9: used a coroutine --- cecli/coders/base_coder.py | 97 ++++++++++-------------------------- cecli/commands/load_mcp.py | 19 ++----- cecli/commands/remove_mcp.py | 19 ++----- cecli/models.py | 12 ++--- 4 files changed, 38 insertions(+), 109 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index d43631b2c19..b4de5ab2b8c 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1730,8 +1730,6 @@ def keyboard_interrupt(self): Console().show_cursor(True) self.io.tool_warning("\n\n^C KeyboardInterrupt") - self.interrupt_event.set() - self.interrupt_event.set() self.last_keyboard_interrupt = time.time() @@ -2253,16 +2251,10 @@ async def send_message(self, inp): self.io.tool_output(f"Retrying in {retry_delay:.1f} seconds...") - sleep_task = asyncio.create_task(asyncio.sleep(retry_delay)) - interrupt_task = asyncio.create_task(self.interrupt_event.wait()) - - done, pending = await asyncio.wait( - {sleep_task, interrupt_task}, - return_when=asyncio.FIRST_COMPLETED, + _res, interrupted_sleep = await coroutines.interruptible( + asyncio.sleep(retry_delay), self.interrupt_event ) - - if interrupt_task in done: - sleep_task.cancel() + if interrupted_sleep: interrupted = True break @@ -2632,29 +2624,19 @@ async def _execute_mcp_tools(self, server, tool_calls): all_results_content.append("Tool Request Aborted.") continue - tool_call_task = asyncio.create_task( - experimental_mcp_client.call_openai_tool( + async def do_tool_call(): + return await experimental_mcp_client.call_openai_tool( session=session, openai_tool=new_tool_call, ) - ) - interrupt_task = asyncio.create_task(self.interrupt_event.wait()) - done, pending = await asyncio.wait( - {tool_call_task, interrupt_task}, - return_when=asyncio.FIRST_COMPLETED, + call_result, interrupted = await coroutines.interruptible( + do_tool_call(), self.interrupt_event ) - if interrupt_task in done: - tool_call_task.cancel() - try: - await tool_call_task - except asyncio.CancelledError: - pass + if interrupted: raise KeyboardInterrupt("Tool call interrupted") - call_result = tool_call_task.result() - content_parts = [] if call_result.content: for item in call_result.content: @@ -2756,30 +2738,13 @@ async def process_tool_calls(self, tool_call_response): # 5. Execute tools self.interrupt_event.clear() - 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() + tool_responses_by_server, interrupted = await coroutines.interruptible( + self._execute_tool_groups(tool_groups), self.interrupt_event + ) - except asyncio.CancelledError: - self.io.tool_warning("Tool execution cancelled.") + if interrupted: + self.io.tool_warning("Tool execution interrupted.") return False # 6. Add responses to conversation (re-prefixing if necessary) @@ -3092,34 +3057,22 @@ async def send(self, messages, model=None, functions=None, tools=None): self.token_profiler.start() try: - 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.copy(), - interrupt_event=self.interrupt_event, - ) + completion_coro = 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.copy(), + interrupt_event=self.interrupt_event, ) - interrupt_task = asyncio.create_task(self.interrupt_event.wait()) - done, pending = await asyncio.wait( - {completion_task, interrupt_task}, - return_when=asyncio.FIRST_COMPLETED, + (hash_object, completion), interrupted = await coroutines.interruptible( + completion_coro, self.interrupt_event ) - - if interrupt_task in done: - completion_task.cancel() - try: - await completion_task - except asyncio.CancelledError: - pass + if interrupted: raise KeyboardInterrupt - - hash_object, completion = completion_task.result() self.chat_completion_call_hashes.append(hash_object.hexdigest()) if not isinstance(completion, ModelResponse): diff --git a/cecli/commands/load_mcp.py b/cecli/commands/load_mcp.py index b3df2a6cca8..964fc87776c 100644 --- a/cecli/commands/load_mcp.py +++ b/cecli/commands/load_mcp.py @@ -57,27 +57,16 @@ async def execute(cls, io, coder, args, **kwargs): server_name = server.name coder.interrupt_event.clear() - connect_task = asyncio.create_task(coder.mcp_manager.connect_server(server_name)) - interrupt_task = asyncio.create_task(coder.interrupt_event.wait()) - - done, pending = await asyncio.wait( - {connect_task, interrupt_task}, - return_when=asyncio.FIRST_COMPLETED, + did_connect, interrupted = await coder.coroutines.interruptible( + coder.mcp_manager.connect_server(server_name), + coder.interrupt_event, ) - if interrupt_task in done: - connect_task.cancel() - try: - await connect_task - except asyncio.CancelledError: - pass - + if interrupted: io.tool_warning(f"MCP connection interrupted: {server_name}") results.append(f"Interrupted: {server_name}") continue - did_connect = connect_task.result() - if did_connect: results.append(f"Loaded server: {server_name}") else: diff --git a/cecli/commands/remove_mcp.py b/cecli/commands/remove_mcp.py index 86800bbc0c6..5228f26e286 100644 --- a/cecli/commands/remove_mcp.py +++ b/cecli/commands/remove_mcp.py @@ -47,27 +47,16 @@ async def execute(cls, io, coder, args, **kwargs): coder.interrupt_event.clear() - disconnect_task = asyncio.create_task(coder.mcp_manager.disconnect_server(server_name)) - interrupt_task = asyncio.create_task(coder.interrupt_event.wait()) - - done, pending = await asyncio.wait( - {disconnect_task, interrupt_task}, - return_when=asyncio.FIRST_COMPLETED, + was_disconnected, interrupted = await coder.coroutines.interruptible( + coder.mcp_manager.disconnect_server(server_name), + coder.interrupt_event, ) - if interrupt_task in done: - disconnect_task.cancel() - try: - await disconnect_task - except asyncio.CancelledError: - pass - + if interrupted: io.tool_warning(f"MCP disconnection interrupted: {server_name}") results.append(f"Interrupted: {server_name}") continue - was_disconnected = disconnect_task.result() - if was_disconnected: results.append(f"Removed server: {server_name}") else: diff --git a/cecli/models.py b/cecli/models.py index 0a48ae1304b..495895bda12 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -19,7 +19,7 @@ from cecli import __version__ from cecli.dump import dump from cecli.exceptions import LiteLLMExceptions -from cecli.helpers import nested +from cecli.helpers import coroutines, nested from cecli.helpers.file_searcher import generate_search_path_list, handle_core_files from cecli.helpers.model_providers import ModelProviderManager from cecli.helpers.nested import deep_merge @@ -1292,13 +1292,11 @@ async def send_completion( print(f"Retrying in {retry_delay:.1f} seconds...") if interrupt_event: - try: - await asyncio.wait_for(interrupt_event.wait(), timeout=retry_delay) - # if we get here, the event was set + _res, interrupted = await coroutines.interruptible( + asyncio.sleep(retry_delay), interrupt_event + ) + if interrupted: raise KeyboardInterrupt("Interrupted during retry sleep") - except asyncio.TimeoutError: - # sleep finished without interruption - pass else: await asyncio.sleep(retry_delay) continue From 4ac203684d276eb1d376bea9693206f3696932e9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 30 Apr 2026 19:27:49 -0700 Subject: [PATCH 17/18] cli-9: used a coroutine --- cecli/coders/agent_coder.py | 2 +- cecli/commands/load_mcp.py | 1 - cecli/commands/remove_mcp.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 12586e28400..9f111693900 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -16,7 +16,7 @@ from cecli.helpers import nested, responses from cecli.helpers.background_commands import BackgroundCommandManager from cecli.helpers.conversation import ConversationService, MessageTag -from cecli.helpers.coroutines import interruptible +from cecli.helpers.coroutines import interruptible # isort:skip from cecli.helpers.similarity import ( cosine_similarity, create_bigram_vector, diff --git a/cecli/commands/load_mcp.py b/cecli/commands/load_mcp.py index 964fc87776c..302d568640f 100644 --- a/cecli/commands/load_mcp.py +++ b/cecli/commands/load_mcp.py @@ -20,7 +20,6 @@ async def execute(cls, io, coder, args, **kwargs): ) server_names = args.strip().split() - import asyncio results = [] diff --git a/cecli/commands/remove_mcp.py b/cecli/commands/remove_mcp.py index 5228f26e286..ad212da4051 100644 --- a/cecli/commands/remove_mcp.py +++ b/cecli/commands/remove_mcp.py @@ -20,7 +20,6 @@ async def execute(cls, io, coder, args, **kwargs): ) server_names = args.strip().split() - import asyncio results = [] servers_to_disconnect = [] From 60e47d01e4f057f05b6ba098c03001e8d54e65a2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 1 May 2026 11:42:01 -0700 Subject: [PATCH 18/18] cli-9: fix formatting --- cecli/coders/agent_coder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 9f111693900..21cbe00aa61 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -28,11 +28,12 @@ from cecli.mcp import LocalServer, McpServerManager from cecli.tools.utils.base_tool import BaseTool from cecli.tools.utils.registry import ToolRegistry -from cecli.helpers.coroutines import interruptible from cecli.utils import copy_tool_call, tool_call_to_dict from .base_coder import Coder +from cecli.helpers.coroutines import interruptible # isort:skip + class AgentCoder(Coder): """Mode where the LLM autonomously manages which files are in context."""