From 155f2d0c441893693570a1e3fa858ddd5b6b524f Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Thu, 26 Feb 2026 23:56:00 -0600 Subject: [PATCH 01/40] feat(ui): non-blocking floating input box prototype --- code_puppy/cli_runner.py | 80 +++---------------- .../command_line/prompt_toolkit_completion.py | 14 +++- 2 files changed, 23 insertions(+), 71 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 9b1d0a5e3..f41f08740 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -726,75 +726,21 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non pass if task.strip(): - # Write to the secret file for permanent history with timestamp save_command_to_history(task) + # --- PROTOTYPE BACKGROUND EXECUTION --- + async def run_agent_bg(task_text, agent): + try: + result, _ = await run_prompt_with_attachments(agent, task_text, spinner_console=None, use_spinner=False) + if result is None: return + from code_puppy.messaging import get_message_bus + from code_puppy.messaging.messages import AgentResponseMessage + get_message_bus().emit(AgentResponseMessage(content=result.output, is_markdown=True)) + except Exception as e: + pass - try: - # No need to get agent directly - use manager's run methods - - # Use our custom helper to enable attachment handling with spinner support - result, current_agent_task = await run_prompt_with_attachments( - current_agent, - task, - spinner_console=message_renderer.console, - ) - # Check if the task was cancelled (but don't show message if we just killed processes) - if result is None: - # Windows-specific: Reset terminal state after cancellation - reset_windows_terminal_ansi() - # Re-disable Ctrl+C if needed (uvx mode) - try: - from code_puppy.terminal_utils import ensure_ctrl_c_disabled - - ensure_ctrl_c_disabled() - except ImportError: - pass - # Stop wiggum mode on cancellation - from code_puppy.command_line.wiggum_state import ( - is_wiggum_active, - stop_wiggum, - ) - - if is_wiggum_active(): - stop_wiggum() - from code_puppy.messaging import emit_warning - - emit_warning("šŸ© Wiggum loop stopped due to cancellation") - continue - # Get the structured response - agent_response = result.output - - # Emit structured message for proper markdown rendering - from code_puppy.messaging import get_message_bus - from code_puppy.messaging.messages import AgentResponseMessage - - response_msg = AgentResponseMessage( - content=agent_response, - is_markdown=True, - ) - get_message_bus().emit(response_msg) - - # Update the agent's message history with the complete conversation - # including the final assistant response. The history_processors callback - # may not capture the final message, so we use result.all_messages() - # to ensure the autosave includes the complete conversation. - if hasattr(result, "all_messages"): - current_agent.set_message_history(list(result.all_messages())) - - # Ensure console output is flushed before next prompt - # This fixes the issue where prompt doesn't appear after agent response - if hasattr(display_console.file, "flush"): - display_console.file.flush() - - await asyncio.sleep( - 0.1 - ) # Brief pause to ensure all messages are rendered - - except Exception: - from code_puppy.messaging.queue_console import get_queue_console - - get_queue_console().print_exception() - + import asyncio + asyncio.create_task(run_agent_bg(task, current_agent)) + continue # Auto-save session if enabled (moved outside the try block to avoid being swallowed) from code_puppy.config import auto_save_session_if_enabled diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 29b88f3ec..b76b78579 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -12,6 +12,8 @@ from typing import Optional from prompt_toolkit import PromptSession +from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.completion import Completer, Completion, merge_completers from prompt_toolkit.filters import is_searching from prompt_toolkit.formatted_text import FormattedText @@ -546,13 +548,15 @@ def get_prompt_with_active_model(base: str = ">>> "): cwd_display = cwd return FormattedText( [ + ("class:separator", "╭─ "), ("bold", "🐶 "), ("class:puppy", f"{puppy}"), ("", " "), ("class:agent", f"[{agent_display}] "), ("class:model", model_display + " "), - ("class:cwd", "(" + str(cwd_display) + ") "), - ("class:arrow", str(base)), + ("class:cwd", "(" + str(cwd_display) + ") \n"), + ("class:separator", "╰─"), + ("class:arrow", "āÆ "), ] ) @@ -815,11 +819,13 @@ def handle_image_paste_f3(event): "agent": "bold ansibrightblue", "model": "bold ansibrightcyan", "cwd": "bold ansibrightgreen", - "arrow": "bold ansibrightblue", + "arrow": "bold ansibrightcyan", + "separator": "bold ansigray", "attachment-placeholder": "italic ansicyan", } ) - text = await session.prompt_async(prompt_str, style=style) + with patch_stdout(raw=True): + text = await session.prompt_async(prompt_str, style=style) # NOTE: We used to call update_model_in_input(text) here to handle /model and /m # commands at the prompt level, but that prevented the command handler from running # and emitting success messages. Now we let all /model commands fall through to From c64e6010d829a2480663ff868296c2baf04ad239 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 00:47:46 -0600 Subject: [PATCH 02/40] fix(ui): stable interject logic without dropping keys --- code_puppy/cli_runner.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index f41f08740..05ddfa38f 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -10,6 +10,10 @@ import argparse import asyncio + +AGENT_IS_RUNNING = False +PROMPT_QUEUE = [] + import os import sys import time @@ -739,6 +743,10 @@ async def run_agent_bg(task_text, agent): pass import asyncio + +AGENT_IS_RUNNING = False +PROMPT_QUEUE = [] + asyncio.create_task(run_agent_bg(task, current_agent)) continue # Auto-save session if enabled (moved outside the try block to avoid being swallowed) @@ -850,6 +858,10 @@ async def run_prompt_with_attachments( tuple: (result, task) where result is the agent response and task is the asyncio task """ import asyncio + +AGENT_IS_RUNNING = False +PROMPT_QUEUE = [] + import re from code_puppy.messaging import emit_system_message, emit_warning From 2ae2feeda31b384a76dd86b3c9a1a62bb47ad09b Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 00:51:08 -0600 Subject: [PATCH 03/40] fix(ui): remove bad unindented global vars --- code_puppy/cli_runner.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 05ddfa38f..bb12083f9 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -11,8 +11,7 @@ import argparse import asyncio -AGENT_IS_RUNNING = False -PROMPT_QUEUE = [] + import os import sys @@ -744,8 +743,7 @@ async def run_agent_bg(task_text, agent): import asyncio -AGENT_IS_RUNNING = False -PROMPT_QUEUE = [] + asyncio.create_task(run_agent_bg(task, current_agent)) continue @@ -859,8 +857,7 @@ async def run_prompt_with_attachments( """ import asyncio -AGENT_IS_RUNNING = False -PROMPT_QUEUE = [] + import re From 97684582e5f2ee7ec28467f1f757f898c731fba9 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 01:02:38 -0600 Subject: [PATCH 04/40] fix(ui): enforce state lock so agent cannot run twice concurrently --- code_puppy/cli_runner.py | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index bb12083f9..f83af995e 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -11,6 +11,10 @@ import argparse import asyncio +AGENT_IS_RUNNING = False +PROMPT_QUEUE = [] + + import os @@ -730,24 +734,41 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non if task.strip(): save_command_to_history(task) - # --- PROTOTYPE BACKGROUND EXECUTION --- + + global AGENT_IS_RUNNING + if AGENT_IS_RUNNING: + from code_puppy.messaging import emit_warning, emit_info + t = task.strip().lower() + if t == 'i': + emit_warning("Interject triggered (stub)") + continue + elif t == 'q': + emit_info("Queue triggered (stub)") + continue + else: + emit_warning("Agent is busy! Type 'i' to interject or 'q' to queue this prompt.") + PROMPT_QUEUE.append(task.strip()) + emit_info(f"Auto-queued: {task.strip()}") + continue + async def run_agent_bg(task_text, agent): + global AGENT_IS_RUNNING + AGENT_IS_RUNNING = True try: result, _ = await run_prompt_with_attachments(agent, task_text, spinner_console=None, use_spinner=False) if result is None: return from code_puppy.messaging import get_message_bus from code_puppy.messaging.messages import AgentResponseMessage get_message_bus().emit(AgentResponseMessage(content=result.output, is_markdown=True)) - except Exception as e: + except: pass + finally: + AGENT_IS_RUNNING = False import asyncio - - - asyncio.create_task(run_agent_bg(task, current_agent)) continue - # Auto-save session if enabled (moved outside the try block to avoid being swallowed) + # Auto-save session if enabled from code_puppy.config import auto_save_session_if_enabled auto_save_session_if_enabled() @@ -857,6 +878,10 @@ async def run_prompt_with_attachments( """ import asyncio +AGENT_IS_RUNNING = False +PROMPT_QUEUE = [] + + import re From 1dc767168551f344f804140d8c13ec604688cd88 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 01:07:42 -0600 Subject: [PATCH 05/40] fix(ui): clean up sed corruption around run_prompt_with_attachments --- code_puppy/cli_runner.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index f83af995e..62d7fae89 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -877,13 +877,6 @@ async def run_prompt_with_attachments( tuple: (result, task) where result is the agent response and task is the asyncio task """ import asyncio - -AGENT_IS_RUNNING = False -PROMPT_QUEUE = [] - - - - import re from code_puppy.messaging import emit_system_message, emit_warning From 4b6f8522e2476560f536d54afe09aec8b55ab0b5 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 01:11:47 -0600 Subject: [PATCH 06/40] fix(ui): cleanly rebuild background block without losing original features --- code_puppy/cli_runner.py | 73 ++++++++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 62d7fae89..2365f716e 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -9,14 +9,11 @@ apply_all_patches() import argparse -import asyncio AGENT_IS_RUNNING = False PROMPT_QUEUE = [] - - - +import asyncio import os import sys import time @@ -733,8 +730,10 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non pass if task.strip(): + # Write to the secret file for permanent history with timestamp save_command_to_history(task) + global AGENT_IS_RUNNING if AGENT_IS_RUNNING: from code_puppy.messaging import emit_warning, emit_info @@ -755,20 +754,74 @@ async def run_agent_bg(task_text, agent): global AGENT_IS_RUNNING AGENT_IS_RUNNING = True try: - result, _ = await run_prompt_with_attachments(agent, task_text, spinner_console=None, use_spinner=False) - if result is None: return + # No need to get agent directly - use manager's run methods + + # Use our custom helper to enable attachment handling with spinner support + result, current_agent_task = await run_prompt_with_attachments(agent, task_text, spinner_console=None, use_spinner=False) + # Check if the task was cancelled (but don't show message if we just killed processes) + if result is None: + # Windows-specific: Reset terminal state after cancellation + reset_windows_terminal_ansi() + # Re-disable Ctrl+C if needed (uvx mode) + try: + from code_puppy.terminal_utils import ensure_ctrl_c_disabled + + ensure_ctrl_c_disabled() + except ImportError: + pass + # Stop wiggum mode on cancellation + from code_puppy.command_line.wiggum_state import ( + is_wiggum_active, + stop_wiggum, + ) + + if is_wiggum_active(): + stop_wiggum() + from code_puppy.messaging import emit_warning + + emit_warning("šŸ© Wiggum loop stopped due to cancellation") + return + # Get the structured response + agent_response = result.output + + # Emit structured message for proper markdown rendering from code_puppy.messaging import get_message_bus from code_puppy.messaging.messages import AgentResponseMessage - get_message_bus().emit(AgentResponseMessage(content=result.output, is_markdown=True)) - except: - pass + + response_msg = AgentResponseMessage( + content=agent_response, + is_markdown=True, + ) + get_message_bus().emit(response_msg) + + # Update the agent's message history with the complete conversation + # including the final assistant response. The history_processors callback + # may not capture the final message, so we use result.all_messages() + # to ensure the autosave includes the complete conversation. + if hasattr(result, "all_messages"): + current_agent.set_message_history(list(result.all_messages())) + + # Ensure console output is flushed before next prompt + # This fixes the issue where prompt doesn't appear after agent response + if hasattr(display_console.file, "flush"): + display_console.file.flush() + + await asyncio.sleep( + 0.1 + ) # Brief pause to ensure all messages are rendered + + except Exception: + from code_puppy.messaging.queue_console import get_queue_console + + get_queue_console().print_exception() finally: AGENT_IS_RUNNING = False import asyncio asyncio.create_task(run_agent_bg(task, current_agent)) continue - # Auto-save session if enabled + + # Auto-save session if enabled (moved outside the try block to avoid being swallowed) from code_puppy.config import auto_save_session_if_enabled auto_save_session_if_enabled() From 0c55310f32438e60d1b9711487e6de403cf5b99c Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 01:29:38 -0600 Subject: [PATCH 07/40] feat(ui): smooth inline interjection and prompt queue handling --- code_puppy/cli_runner.py | 165 ++++++++++-------- .../command_line/prompt_toolkit_completion.py | 42 +++-- 2 files changed, 120 insertions(+), 87 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 2365f716e..24781b6a6 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -13,6 +13,7 @@ AGENT_IS_RUNNING = False PROMPT_QUEUE = [] + import asyncio import os import sys @@ -524,65 +525,73 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non current_agent = get_current_agent() user_prompt = current_agent.get_user_prompt() or "Enter your coding task:" - emit_info(f"{user_prompt}\n") + global AGENT_IS_RUNNING, PROMPT_QUEUE + if not AGENT_IS_RUNNING and PROMPT_QUEUE: + task = PROMPT_QUEUE.pop(0) + from code_puppy.messaging import emit_success + emit_success(f"šŸš€ Executing queued prompt: {task}") + import asyncio + await asyncio.sleep(0.1) + else: + emit_info(f"{user_prompt}\n") - try: - # Use prompt_toolkit for enhanced input with path completion try: - # Windows-specific: Reset terminal state before prompting - reset_windows_terminal_ansi() + # Use prompt_toolkit for enhanced input with path completion + try: + # Windows-specific: Reset terminal state before prompting + reset_windows_terminal_ansi() - # Use the async version of get_input_with_combined_completion - task = await get_input_with_combined_completion( - get_prompt_with_active_model(), history_file=COMMAND_HISTORY_FILE - ) + # Use the async version of get_input_with_combined_completion + task = await get_input_with_combined_completion( + get_prompt_with_active_model(), history_file=COMMAND_HISTORY_FILE + ) - # Windows+uvx: Re-disable Ctrl+C after prompt_toolkit - # (prompt_toolkit restores console mode which re-enables Ctrl+C) - try: - from code_puppy.terminal_utils import ensure_ctrl_c_disabled + # Windows+uvx: Re-disable Ctrl+C after prompt_toolkit + # (prompt_toolkit restores console mode which re-enables Ctrl+C) + try: + from code_puppy.terminal_utils import ensure_ctrl_c_disabled - ensure_ctrl_c_disabled() + ensure_ctrl_c_disabled() + except ImportError: + pass except ImportError: - pass - except ImportError: - # Fall back to basic input if prompt_toolkit is not available - task = input(">>> ") - - except KeyboardInterrupt: - # Handle Ctrl+C - cancel input and continue - # Windows-specific: Reset terminal state after interrupt to prevent - # the terminal from becoming unresponsive (can't type characters) - reset_windows_terminal_full() - # Stop wiggum mode on Ctrl+C - from code_puppy.command_line.wiggum_state import ( - is_wiggum_active, - stop_wiggum, - ) - from code_puppy.messaging import emit_warning + # Fall back to basic input if prompt_toolkit is not available + task = input(">>> ") - if is_wiggum_active(): - stop_wiggum() - emit_warning("\nšŸ© Wiggum loop stopped!") - else: - emit_warning("\nInput cancelled") - continue - except EOFError: - # Handle Ctrl+D - exit the application - from code_puppy.messaging import emit_success + except KeyboardInterrupt: + # Handle Ctrl+C - cancel input and continue + # Windows-specific: Reset terminal state after interrupt to prevent + # the terminal from becoming unresponsive (can't type characters) + reset_windows_terminal_full() + # Stop wiggum mode on Ctrl+C + from code_puppy.command_line.wiggum_state import ( + is_wiggum_active, + stop_wiggum, + ) + from code_puppy.messaging import emit_warning - emit_success("\nGoodbye! (Ctrl+D)") + if is_wiggum_active(): + stop_wiggum() + emit_warning("\nšŸ© Wiggum loop stopped!") + else: + emit_warning("\nInput cancelled") + continue + except EOFError: + # Handle Ctrl+D - exit the application + from code_puppy.messaging import emit_success - # Cancel any running agent task for clean shutdown - if current_agent_task and not current_agent_task.done(): - emit_info("Cancelling running agent task...") - current_agent_task.cancel() - try: - await current_agent_task - except asyncio.CancelledError: - pass # Expected when cancelling + emit_success("\nGoodbye! (Ctrl+D)") - break + # Cancel any running agent task for clean shutdown + if current_agent_task and not current_agent_task.done(): + emit_info("Cancelling running agent task...") + current_agent_task.cancel() + try: + await current_agent_task + except asyncio.CancelledError: + pass # Expected when cancelling + + break # Check for exit commands (plain text or command form) if task.strip().lower() in ["exit", "quit"] or task.strip().lower() in [ @@ -733,21 +742,27 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non # Write to the secret file for permanent history with timestamp save_command_to_history(task) - - global AGENT_IS_RUNNING if AGENT_IS_RUNNING: from code_puppy.messaging import emit_warning, emit_info - t = task.strip().lower() - if t == 'i': - emit_warning("Interject triggered (stub)") - continue - elif t == 'q': - emit_info("Queue triggered (stub)") + from code_puppy.command_line.prompt_toolkit_completion import get_input_with_combined_completion + from prompt_toolkit.formatted_text import FormattedText + + try: + action = await get_input_with_combined_completion( + FormattedText([("bold ansiyellow", "press [i]nterject or [q]ueue: ")]), + history_file=None + ) + except (KeyboardInterrupt, EOFError): continue - else: - emit_warning("Agent is busy! Type 'i' to interject or 'q' to queue this prompt.") + + a = action.strip().lower() + if a == 'i': + emit_warning(f"Interjecting with: {task.strip()} (stub)") + elif a == 'q': PROMPT_QUEUE.append(task.strip()) - emit_info(f"Auto-queued: {task.strip()}") + emit_info(f"Queued (position {len(PROMPT_QUEUE)}): {task.strip()}") + else: + emit_warning("Cancelled action.") continue async def run_agent_bg(task_text, agent): @@ -755,9 +770,13 @@ async def run_agent_bg(task_text, agent): AGENT_IS_RUNNING = True try: # No need to get agent directly - use manager's run methods - + # Use our custom helper to enable attachment handling with spinner support - result, current_agent_task = await run_prompt_with_attachments(agent, task_text, spinner_console=None, use_spinner=False) + result, current_agent_task = await run_prompt_with_attachments( + current_agent, + task, + spinner_console=message_renderer.console, + ) # Check if the task was cancelled (but don't show message if we just killed processes) if result is None: # Windows-specific: Reset terminal state after cancellation @@ -765,7 +784,7 @@ async def run_agent_bg(task_text, agent): # Re-disable Ctrl+C if needed (uvx mode) try: from code_puppy.terminal_utils import ensure_ctrl_c_disabled - + ensure_ctrl_c_disabled() except ImportError: pass @@ -774,45 +793,45 @@ async def run_agent_bg(task_text, agent): is_wiggum_active, stop_wiggum, ) - + if is_wiggum_active(): stop_wiggum() from code_puppy.messaging import emit_warning - + emit_warning("šŸ© Wiggum loop stopped due to cancellation") - return + return # Get the structured response agent_response = result.output - + # Emit structured message for proper markdown rendering from code_puppy.messaging import get_message_bus from code_puppy.messaging.messages import AgentResponseMessage - + response_msg = AgentResponseMessage( content=agent_response, is_markdown=True, ) get_message_bus().emit(response_msg) - + # Update the agent's message history with the complete conversation # including the final assistant response. The history_processors callback # may not capture the final message, so we use result.all_messages() # to ensure the autosave includes the complete conversation. if hasattr(result, "all_messages"): current_agent.set_message_history(list(result.all_messages())) - + # Ensure console output is flushed before next prompt # This fixes the issue where prompt doesn't appear after agent response if hasattr(display_console.file, "flush"): display_console.file.flush() - + await asyncio.sleep( 0.1 ) # Brief pause to ensure all messages are rendered - + except Exception: from code_puppy.messaging.queue_console import get_queue_console - + get_queue_console().print_exception() finally: AGENT_IS_RUNNING = False diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index b76b78579..5c699aa20 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -546,19 +546,32 @@ def get_prompt_with_active_model(base: str = ">>> "): cwd_display = "~" + cwd[len(home) :] else: cwd_display = cwd - return FormattedText( - [ - ("class:separator", "╭─ "), - ("bold", "🐶 "), - ("class:puppy", f"{puppy}"), - ("", " "), - ("class:agent", f"[{agent_display}] "), - ("class:model", model_display + " "), - ("class:cwd", "(" + str(cwd_display) + ") \n"), - ("class:separator", "╰─"), - ("class:arrow", "āÆ "), - ] - ) + + # Fetch queued prompts if any + from code_puppy.cli_runner import PROMPT_QUEUE + + parts = [] + if PROMPT_QUEUE: + show_prompts = PROMPT_QUEUE[:3] + for idx, qp in enumerate(show_prompts): + trunc_qp = qp if len(qp) <= 60 else qp[:58] + ".." + parts.append(("class:queue-item", f" [{idx+1}] {trunc_qp}\n")) + if len(PROMPT_QUEUE) > 3: + parts.append(("class:queue-item", f" ... and {len(PROMPT_QUEUE)-3} more\n")) + + parts.extend([ + ("class:separator", "╭─ "), + ("bold", "🐶 "), + ("class:puppy", f"{puppy}"), + ("", " "), + ("class:agent", f"[{agent_display}] "), + ("class:model", model_display + " "), + ("class:cwd", "(" + str(cwd_display) + ") \n"), + ("class:separator", "╰─"), + ("class:arrow", "āÆ "), + ]) + + return FormattedText(parts) async def get_input_with_combined_completion( @@ -822,9 +835,10 @@ def handle_image_paste_f3(event): "arrow": "bold ansibrightcyan", "separator": "bold ansigray", "attachment-placeholder": "italic ansicyan", + "queue-item": "italic ansiyellow", } ) - with patch_stdout(raw=True): + with patch_stdout(): text = await session.prompt_async(prompt_str, style=style) # NOTE: We used to call update_model_in_input(text) here to handle /model and /m # commands at the prompt level, but that prevented the command handler from running From d6a8f1964cf68f9aa895958017e473c534525f07 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 01:39:40 -0600 Subject: [PATCH 08/40] fix(ui): revert to raw=True for patch_stdout to fix terminal ANSI wrapping regression --- code_puppy/command_line/prompt_toolkit_completion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 5c699aa20..0efde5cdb 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -838,7 +838,7 @@ def handle_image_paste_f3(event): "queue-item": "italic ansiyellow", } ) - with patch_stdout(): + with patch_stdout(raw=True): text = await session.prompt_async(prompt_str, style=style) # NOTE: We used to call update_model_in_input(text) here to handle /model and /m # commands at the prompt level, but that prevented the command handler from running From 1d0dfa634ce9eacdc812d709a53b5a14fdc11e3f Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 01:56:59 -0600 Subject: [PATCH 09/40] feat(ui): improve prompt layout, clean cancellation handling, full width separators --- code_puppy/cli_runner.py | 18 ++++++++++--- .../command_line/prompt_toolkit_completion.py | 26 ++++++++++++++++--- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 24781b6a6..1d760e1af 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -12,6 +12,7 @@ AGENT_IS_RUNNING = False PROMPT_QUEUE = [] +BG_AGENT_TASK = None import asyncio @@ -739,6 +740,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non pass if task.strip(): + global BG_AGENT_TASK # Write to the secret file for permanent history with timestamp save_command_to_history(task) @@ -748,8 +750,9 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non from prompt_toolkit.formatted_text import FormattedText try: + from code_puppy.command_line.prompt_toolkit_completion import get_prompt_with_active_model action = await get_input_with_combined_completion( - FormattedText([("bold ansiyellow", "press [i]nterject or [q]ueue: ")]), + get_prompt_with_active_model(is_interject=True), history_file=None ) except (KeyboardInterrupt, EOFError): @@ -757,7 +760,16 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non a = action.strip().lower() if a == 'i': - emit_warning(f"Interjecting with: {task.strip()} (stub)") + emit_warning(f"Interjecting with: {task.strip()}") + if BG_AGENT_TASK and not BG_AGENT_TASK.done(): + BG_AGENT_TASK.cancel() + from code_puppy.command_line.wiggum_state import is_wiggum_active, stop_wiggum + from code_puppy.tools.command_runner import kill_all_running_shell_processes + kill_all_running_shell_processes() + if is_wiggum_active(): + stop_wiggum() + # Add current task as the first thing to run next + PROMPT_QUEUE.insert(0, task.strip()) elif a == 'q': PROMPT_QUEUE.append(task.strip()) emit_info(f"Queued (position {len(PROMPT_QUEUE)}): {task.strip()}") @@ -837,7 +849,7 @@ async def run_agent_bg(task_text, agent): AGENT_IS_RUNNING = False import asyncio - asyncio.create_task(run_agent_bg(task, current_agent)) + BG_AGENT_TASK = asyncio.create_task(run_agent_bg(task, current_agent)) continue # Auto-save session if enabled (moved outside the try block to avoid being swallowed) diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 0efde5cdb..727f504cb 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -514,7 +514,7 @@ def get_completions(self, document, complete_event): ) -def get_prompt_with_active_model(base: str = ">>> "): +def get_prompt_with_active_model(base: str = ">>> ", is_interject: bool = False): from code_puppy.agents.agent_manager import get_current_agent puppy = get_puppy_name() @@ -550,11 +550,20 @@ def get_prompt_with_active_model(base: str = ">>> "): # Fetch queued prompts if any from code_puppy.cli_runner import PROMPT_QUEUE + # We add a visual top border using terminal width + import shutil + term_width = shutil.get_terminal_size().columns + sep_line = "─" * term_width + parts = [] + + # Optional newline to isolate from previous output + parts.append(("class:separator", f"\n{sep_line}\n")) + if PROMPT_QUEUE: show_prompts = PROMPT_QUEUE[:3] for idx, qp in enumerate(show_prompts): - trunc_qp = qp if len(qp) <= 60 else qp[:58] + ".." + trunc_qp = qp if len(qp) <= term_width - 10 else qp[:term_width - 12] + ".." parts.append(("class:queue-item", f" [{idx+1}] {trunc_qp}\n")) if len(PROMPT_QUEUE) > 3: parts.append(("class:queue-item", f" ... and {len(PROMPT_QUEUE)-3} more\n")) @@ -567,10 +576,19 @@ def get_prompt_with_active_model(base: str = ">>> "): ("class:agent", f"[{agent_display}] "), ("class:model", model_display + " "), ("class:cwd", "(" + str(cwd_display) + ") \n"), - ("class:separator", "╰─"), - ("class:arrow", "āÆ "), ]) + if is_interject: + parts.extend([ + ("class:separator", "╰─"), + ("class:queue-item", "[i]nterject or [q]ueue: "), + ]) + else: + parts.extend([ + ("class:separator", "╰─"), + ("class:arrow", "āÆ "), + ]) + return FormattedText(parts) From 9b1d969b3d951b707f514866c45bf3bc98ba4ca4 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 02:51:08 -0600 Subject: [PATCH 10/40] feat: Add smooth interject functionality with single-key execution --- code_puppy/cli_runner.py | 15 +++--- .../command_line/prompt_toolkit_completion.py | 54 +++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 1d760e1af..cad58d7e3 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -750,11 +750,10 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non from prompt_toolkit.formatted_text import FormattedText try: - from code_puppy.command_line.prompt_toolkit_completion import get_prompt_with_active_model - action = await get_input_with_combined_completion( - get_prompt_with_active_model(is_interject=True), - history_file=None - ) + from code_puppy.command_line.prompt_toolkit_completion import get_interject_action + action = await get_interject_action() + if not action: + continue except (KeyboardInterrupt, EOFError): continue @@ -769,7 +768,11 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non if is_wiggum_active(): stop_wiggum() # Add current task as the first thing to run next - PROMPT_QUEUE.insert(0, task.strip()) + AGENT_IS_RUNNING = False + # We put it at the front of the queue, but setting AGENT_IS_RUNNING=False forces immediate execution without rendering! + PROMPT_QUEUE.insert(0, f"{task.strip()}") + import asyncio + await asyncio.sleep(0.1) # Give the cancel event a tiny bit of time to settle elif a == 'q': PROMPT_QUEUE.append(task.strip()) emit_info(f"Queued (position {len(PROMPT_QUEUE)}): {task.strip()}") diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 727f504cb..0ad1702e6 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -865,6 +865,60 @@ def handle_image_paste_f3(event): return text + +async def get_interject_action() -> str: + from prompt_toolkit import PromptSession + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.patch_stdout import patch_stdout + + bindings = KeyBindings() + result = "" + + @bindings.add('i') + @bindings.add('I') + def _(event): + nonlocal result + result = 'i' + event.app.exit(result='i') + + @bindings.add('q') + @bindings.add('Q') + def _(event): + nonlocal result + result = 'q' + event.app.exit(result='q') + + @bindings.add('c-c') + def _(event): + raise KeyboardInterrupt() + + @bindings.add('c-d') + def _(event): + raise EOFError() + + @bindings.add('') + def _(event): + # Ignore other keys to simulate a single-character menu + pass + + prompt_text = get_prompt_with_active_model(is_interject=True) + session = PromptSession(message=prompt_text, key_bindings=bindings) + + with patch_stdout(raw=True): + # We catch the result of app.exit(result=...) via session.prompt_async() + try: + # We don't actually want them to type anything, just press a key + # session.prompt_async returns the text typed if they press enter, + # but our keybindings will exit early with the bound result. + action = await session.prompt_async() + if result: + return result + return action + except (KeyboardInterrupt, EOFError): + raise + + + if __name__ == "__main__": print("Type '@' for path-completion or '/model' to pick a model. Ctrl+D to exit.") From dec161be4c0957b10a2c7e6e5f1cad77fe903615 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 03:17:12 -0600 Subject: [PATCH 11/40] fix: Disable CPR to prevent terminal output corruption during interjections, and eliminate noise logs for a truly smooth UX --- code_puppy/agents/base_agent.py | 2 +- code_puppy/cli_runner.py | 8 ++++++-- .../command_line/prompt_toolkit_completion.py | 13 ++++++++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/code_puppy/agents/base_agent.py b/code_puppy/agents/base_agent.py index 86ff147de..908d4db52 100644 --- a/code_puppy/agents/base_agent.py +++ b/code_puppy/agents/base_agent.py @@ -1949,7 +1949,7 @@ async def run_agent_task(): "Try disabling any malfunctioning MCP servers", group_id=group_id ) except* asyncio.exceptions.CancelledError: - emit_info("Cancelled") + # We don't print "Cancelled" here anymore so smooth interjections stay clean. if get_use_dbos(): await DBOS.cancel_workflow_async(group_id) except* InterruptedError as ie: diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index cad58d7e3..3f4023b91 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -529,8 +529,12 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non global AGENT_IS_RUNNING, PROMPT_QUEUE if not AGENT_IS_RUNNING and PROMPT_QUEUE: task = PROMPT_QUEUE.pop(0) - from code_puppy.messaging import emit_success - emit_success(f"šŸš€ Executing queued prompt: {task}") + if task.startswith("[INTERJECT] "): + task = task[len("[INTERJECT] "):] + # We do not print anything here for smooth interject, it just runs seamlessly! + else: + from code_puppy.messaging import emit_success + emit_success(f"šŸš€ Executing queued prompt: {task}") import asyncio await asyncio.sleep(0.1) else: diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 0ad1702e6..6493549e5 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -829,12 +829,18 @@ def handle_image_paste_f3(event): event.app.current_buffer.insert_text("[āŒ clipboard error] ") event.app.output.bell() + from prompt_toolkit.output.defaults import create_output + import sys + out = create_output(stdout=sys.stdout) + if hasattr(out, 'enable_cpr'): + out.enable_cpr = False session = PromptSession( completer=completer, history=history, complete_while_typing=True, key_bindings=bindings, input_processors=[AttachmentPlaceholderProcessor()], + output=out ) # If they pass a string, backward-compat: convert it to formatted_text if isinstance(prompt_str, str): @@ -902,7 +908,12 @@ def _(event): pass prompt_text = get_prompt_with_active_model(is_interject=True) - session = PromptSession(message=prompt_text, key_bindings=bindings) + from prompt_toolkit.output.defaults import create_output + import sys + out = create_output(stdout=sys.stdout) + if hasattr(out, 'enable_cpr'): + out.enable_cpr = False + session = PromptSession(message=prompt_text, key_bindings=bindings, output=out) with patch_stdout(raw=True): # We catch the result of app.exit(result=...) via session.prompt_async() From 59b6aab69c78cabea538d24eea80083f042f92a9 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 03:21:02 -0600 Subject: [PATCH 12/40] feat: Add semantic framing prefix and suffix to interjected messages --- code_puppy/cli_runner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 3f4023b91..58e3eeab8 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -530,7 +530,8 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non if not AGENT_IS_RUNNING and PROMPT_QUEUE: task = PROMPT_QUEUE.pop(0) if task.startswith("[INTERJECT] "): - task = task[len("[INTERJECT] "):] + user_text = task[len("[INTERJECT] "):] + task = f"[user interjects]: {user_text} - please continue with that in mind" # We do not print anything here for smooth interject, it just runs seamlessly! else: from code_puppy.messaging import emit_success From 06795e3de5fbf7d9c2bf4953b1ea97af25c28528 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 03:33:44 -0600 Subject: [PATCH 13/40] fix: Erase interject prompt when done and prevent redundant queued execution logs --- code_puppy/cli_runner.py | 2 +- code_puppy/command_line/prompt_toolkit_completion.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 58e3eeab8..1e722f92c 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -775,7 +775,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non # Add current task as the first thing to run next AGENT_IS_RUNNING = False # We put it at the front of the queue, but setting AGENT_IS_RUNNING=False forces immediate execution without rendering! - PROMPT_QUEUE.insert(0, f"{task.strip()}") + PROMPT_QUEUE.insert(0, f"[INTERJECT] {task.strip()}") import asyncio await asyncio.sleep(0.1) # Give the cancel event a tiny bit of time to settle elif a == 'q': diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 6493549e5..dadf032ea 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -913,7 +913,7 @@ def _(event): out = create_output(stdout=sys.stdout) if hasattr(out, 'enable_cpr'): out.enable_cpr = False - session = PromptSession(message=prompt_text, key_bindings=bindings, output=out) + session = PromptSession(message=prompt_text, key_bindings=bindings, output=out, erase_when_done=True) with patch_stdout(raw=True): # We catch the result of app.exit(result=...) via session.prompt_async() From 44923e673ec24b8a9ffd79d81c593c2cd0546978 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 03:50:25 -0600 Subject: [PATCH 14/40] fix: Polish interjection UI (erase intermediate input, match spacing, bold red text) --- code_puppy/cli_runner.py | 7 +++-- .../command_line/prompt_toolkit_completion.py | 28 ++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 1e722f92c..f5be776de 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -549,7 +549,9 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non # Use the async version of get_input_with_combined_completion task = await get_input_with_combined_completion( - get_prompt_with_active_model(), history_file=COMMAND_HISTORY_FILE + get_prompt_with_active_model(), + history_file=COMMAND_HISTORY_FILE, + erase_when_done=AGENT_IS_RUNNING ) # Windows+uvx: Re-disable Ctrl+C after prompt_toolkit @@ -764,7 +766,8 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non a = action.strip().lower() if a == 'i': - emit_warning(f"Interjecting with: {task.strip()}") + from code_puppy.messaging import get_console + get_console().print(f"[bold bright_red]Interjecting with:[/bold bright_red] [red]{task.strip()}[/red]") if BG_AGENT_TASK and not BG_AGENT_TASK.done(): BG_AGENT_TASK.cancel() from code_puppy.command_line.wiggum_state import is_wiggum_active, stop_wiggum diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index dadf032ea..e1c09e016 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -593,7 +593,7 @@ def get_prompt_with_active_model(base: str = ">>> ", is_interject: bool = False) async def get_input_with_combined_completion( - prompt_str=">>> ", history_file: Optional[str] = None + prompt_str=">>> ", history_file: Optional[str] = None, erase_when_done: bool = False ) -> str: # Use SafeFileHistory to handle encoding errors gracefully on Windows history = SafeFileHistory(history_file) if history_file else None @@ -840,7 +840,8 @@ def handle_image_paste_f3(event): complete_while_typing=True, key_bindings=bindings, input_processors=[AttachmentPlaceholderProcessor()], - output=out + output=out, + erase_when_done=erase_when_done ) # If they pass a string, backward-compat: convert it to formatted_text if isinstance(prompt_str, str): @@ -913,7 +914,26 @@ def _(event): out = create_output(stdout=sys.stdout) if hasattr(out, 'enable_cpr'): out.enable_cpr = False - session = PromptSession(message=prompt_text, key_bindings=bindings, output=out, erase_when_done=True) + session = PromptSession( + message=prompt_text, + key_bindings=bindings, + output=out, + erase_when_done=True, + reserve_space_for_menu=8 + ) + + from prompt_toolkit.styles import Style + style = Style.from_dict({ + "puppy": "bold ansibrightcyan", + "owner": "bold ansibrightblue", + "agent": "bold ansibrightblue", + "model": "bold ansibrightcyan", + "cwd": "bold ansibrightgreen", + "arrow": "bold ansibrightcyan", + "separator": "bold ansigray", + "attachment-placeholder": "italic ansicyan", + "queue-item": "italic ansiyellow", + }) with patch_stdout(raw=True): # We catch the result of app.exit(result=...) via session.prompt_async() @@ -921,7 +941,7 @@ def _(event): # We don't actually want them to type anything, just press a key # session.prompt_async returns the text typed if they press enter, # but our keybindings will exit early with the bound result. - action = await session.prompt_async() + action = await session.prompt_async(style=style) if result: return result return action From c17e19155104a26f4859f56c70e4f29ecf5cd9ea Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 04:00:16 -0600 Subject: [PATCH 15/40] fix: Resolve missing get_console import in CLI runner causing crash on interject --- code_puppy/cli_runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index f5be776de..b3d0aeea2 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -766,8 +766,8 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non a = action.strip().lower() if a == 'i': - from code_puppy.messaging import get_console - get_console().print(f"[bold bright_red]Interjecting with:[/bold bright_red] [red]{task.strip()}[/red]") + from rich.console import Console + Console().print(f"[bold bright_red]Interjecting with:[/bold bright_red] [red]{task.strip()}[/red]") if BG_AGENT_TASK and not BG_AGENT_TASK.done(): BG_AGENT_TASK.cancel() from code_puppy.command_line.wiggum_state import is_wiggum_active, stop_wiggum From 1b27e9bade3056ad700b75f4caa58f95898343d1 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 09:54:37 -0600 Subject: [PATCH 16/40] fix: Polish interject UI - remove ghost prompt, fix spacing, align bottom --- code_puppy/cli_runner.py | 5 +++-- code_puppy/command_line/prompt_toolkit_completion.py | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index b3d0aeea2..e40603bea 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -539,7 +539,8 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non import asyncio await asyncio.sleep(0.1) else: - emit_info(f"{user_prompt}\n") + if not AGENT_IS_RUNNING: + emit_info(f"{user_prompt}\n") try: # Use prompt_toolkit for enhanced input with path completion @@ -767,7 +768,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non a = action.strip().lower() if a == 'i': from rich.console import Console - Console().print(f"[bold bright_red]Interjecting with:[/bold bright_red] [red]{task.strip()}[/red]") + Console().print(f"\n[bold bright_red]Interjecting with:[/bold bright_red] [red]{task.strip()}[/red]") if BG_AGENT_TASK and not BG_AGENT_TASK.done(): BG_AGENT_TASK.cancel() from code_puppy.command_line.wiggum_state import is_wiggum_active, stop_wiggum diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index e1c09e016..44c5e1872 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -918,8 +918,7 @@ def _(event): message=prompt_text, key_bindings=bindings, output=out, - erase_when_done=True, - reserve_space_for_menu=8 + erase_when_done=True ) from prompt_toolkit.styles import Style From 527230f0aa4a2305b92b7a3f619b0198147afc27 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 10:18:32 -0600 Subject: [PATCH 17/40] fix: Disable redundant user prompt emission and compact prompt spacing --- code_puppy/cli_runner.py | 4 ++-- code_puppy/command_line/prompt_toolkit_completion.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index e40603bea..ee12cb6a9 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -539,8 +539,8 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non import asyncio await asyncio.sleep(0.1) else: - if not AGENT_IS_RUNNING: - emit_info(f"{user_prompt}\n") + # if not AGENT_IS_RUNNING: + # emit_info(f"{user_prompt}\n") try: # Use prompt_toolkit for enhanced input with path completion diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 44c5e1872..26e1275b8 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -558,7 +558,7 @@ def get_prompt_with_active_model(base: str = ">>> ", is_interject: bool = False) parts = [] # Optional newline to isolate from previous output - parts.append(("class:separator", f"\n{sep_line}\n")) + parts.append(("class:separator", f"{sep_line}\n")) if PROMPT_QUEUE: show_prompts = PROMPT_QUEUE[:3] From 9488524f4af5a7b11c71aa9f33888ade96fcde25 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 13:21:57 -0600 Subject: [PATCH 18/40] Revert "fix: Disable redundant user prompt emission and compact prompt spacing" This reverts commit 527230f0aa4a2305b92b7a3f619b0198147afc27. --- code_puppy/cli_runner.py | 4 ++-- code_puppy/command_line/prompt_toolkit_completion.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index ee12cb6a9..e40603bea 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -539,8 +539,8 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non import asyncio await asyncio.sleep(0.1) else: - # if not AGENT_IS_RUNNING: - # emit_info(f"{user_prompt}\n") + if not AGENT_IS_RUNNING: + emit_info(f"{user_prompt}\n") try: # Use prompt_toolkit for enhanced input with path completion diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 26e1275b8..44c5e1872 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -558,7 +558,7 @@ def get_prompt_with_active_model(base: str = ">>> ", is_interject: bool = False) parts = [] # Optional newline to isolate from previous output - parts.append(("class:separator", f"{sep_line}\n")) + parts.append(("class:separator", f"\n{sep_line}\n")) if PROMPT_QUEUE: show_prompts = PROMPT_QUEUE[:3] From ee4f630273e7958b5066cfaa664b3d160d32f838 Mon Sep 17 00:00:00 2001 From: nate0-1999 Date: Fri, 27 Feb 2026 14:03:23 -0600 Subject: [PATCH 19/40] fix: Align interject prompt with main prompt and pause spinner during input --- code_puppy/command_line/prompt_toolkit_completion.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 44c5e1872..6614c5839 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -579,9 +579,11 @@ def get_prompt_with_active_model(base: str = ">>> ", is_interject: bool = False) ]) if is_interject: + # Add hint above the prompt line to keep the cursor position consistent + parts.append(("class:queue-item", " [i]nterject or [q]ueue\n")) parts.extend([ ("class:separator", "╰─"), - ("class:queue-item", "[i]nterject or [q]ueue: "), + ("class:arrow", "āÆ "), ]) else: parts.extend([ @@ -877,6 +879,7 @@ async def get_interject_action() -> str: from prompt_toolkit import PromptSession from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.patch_stdout import patch_stdout + from code_puppy.messaging.spinner import pause_all_spinners, resume_all_spinners bindings = KeyBindings() result = "" @@ -937,6 +940,9 @@ def _(event): with patch_stdout(raw=True): # We catch the result of app.exit(result=...) via session.prompt_async() try: + # Pause spinners to prevent jitter and allow clean input + pause_all_spinners() + # We don't actually want them to type anything, just press a key # session.prompt_async returns the text typed if they press enter, # but our keybindings will exit early with the bound result. @@ -946,6 +952,9 @@ def _(event): return action except (KeyboardInterrupt, EOFError): raise + finally: + # Resume spinners when done + resume_all_spinners() From c8044744128a7f6ef65917b348bec3507e01474c Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 4 Mar 2026 01:30:10 -0600 Subject: [PATCH 20/40] revert latest interject visibility tweak Back out the most recent interject banner rendering experiment while keeping the safer queue/interject runtime path intact. This isolates the shell/queue freeze and missing-visible-interject regressions for validation against capture artifacts. Made-with: Cursor --- code_puppy/cli_runner.py | 283 +++++++++++++++++++++++++++++++-------- 1 file changed, 226 insertions(+), 57 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index e40603bea..8447c4119 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -9,10 +9,12 @@ apply_all_patches() import argparse +from dataclasses import dataclass, field AGENT_IS_RUNNING = False PROMPT_QUEUE = [] BG_AGENT_TASK = None +MAX_PROMPT_QUEUE = 25 import asyncio @@ -54,10 +56,70 @@ ) from code_puppy.tools.common import console from code_puppy.version_checker import default_version_mismatch_behavior +from code_puppy.debug_capture import ( + get_active_capture, + log_event, + set_active_capture, + start_capture_session, +) plugins.load_plugin_callbacks() +@dataclass +class PromptRuntimeState: + """Single source of truth for prompt run state and queue.""" + + queue: list[str] = field(default_factory=lambda: PROMPT_QUEUE) + running: bool = False + cancelling: bool = False + bg_task: asyncio.Task | None = None + + def mark_running(self, task: asyncio.Task) -> None: + self.running = True + self.cancelling = False + self.bg_task = task + _sync_runtime_globals(self) + + def mark_idle(self) -> None: + self.running = False + self.cancelling = False + self.bg_task = None + _sync_runtime_globals(self) + + def enqueue(self, prompt: str) -> tuple[bool, int]: + if len(self.queue) >= MAX_PROMPT_QUEUE: + return False, len(self.queue) + self.queue.append(prompt) + _sync_runtime_globals(self) + return True, len(self.queue) + + def enqueue_front(self, prompt: str) -> tuple[bool, int]: + if len(self.queue) >= MAX_PROMPT_QUEUE: + return False, len(self.queue) + self.queue.insert(0, prompt) + _sync_runtime_globals(self) + return True, len(self.queue) + + def dequeue(self) -> str | None: + if not self.queue: + return None + value = self.queue.pop(0) + _sync_runtime_globals(self) + return value + + +RUNTIME_STATE = PromptRuntimeState() + + +def _sync_runtime_globals(state: PromptRuntimeState) -> None: + """Keep module globals updated for existing imports/usages.""" + global AGENT_IS_RUNNING, BG_AGENT_TASK, PROMPT_QUEUE + AGENT_IS_RUNNING = state.running + BG_AGENT_TASK = state.bg_task + PROMPT_QUEUE = state.queue + + async def main(): """Main async entry point for Code Puppy CLI.""" parser = argparse.ArgumentParser(description="Code Puppy - A code generation agent") @@ -92,6 +154,11 @@ async def main(): type=str, help="Specify which model to use (e.g., --model gpt-5)", ) + parser.add_argument( + "--debug-capture", + action="store_true", + help="Write timestamped interactive terminal capture artifacts", + ) parser.add_argument( "command", nargs="*", help="Run a single command (deprecated, use -p instead)" ) @@ -99,21 +166,27 @@ async def main(): from code_puppy.messaging import ( RichConsoleRenderer, - SynchronousInteractiveRenderer, get_global_queue, get_message_bus, ) + from code_puppy.messaging.legacy_bridge import LegacyQueueToBusBridge + + capture_session = None + if args.debug_capture: + capture_session = start_capture_session() + set_active_capture(capture_session) + log_event("debug_capture_enabled", session_dir=str(capture_session.session_dir)) - # Create a shared console for both renderers + # Create one shared console to avoid multi-renderer race conditions. display_console = Console() - # Legacy renderer for backward compatibility (emits via get_global_queue) + # Bridge legacy queue emitters into the structured bus. message_queue = get_global_queue() - message_renderer = SynchronousInteractiveRenderer(message_queue, display_console) - message_renderer.start() - - # New MessageBus renderer for structured messages (tools emit here) message_bus = get_message_bus() + legacy_bridge = LegacyQueueToBusBridge(message_queue, message_bus) + legacy_bridge.start() + + # Single UI renderer in interactive mode. bus_renderer = RichConsoleRenderer(message_bus, display_console) bus_renderer.start() @@ -334,15 +407,18 @@ def _uvx_protective_sigint_handler(_sig, _frame): prompt_only_mode = False if prompt_only_mode: - await execute_single_prompt(initial_command, message_renderer) + await execute_single_prompt(initial_command, bus_renderer) else: # Default to interactive mode (no args = same as -i) - await interactive_mode(message_renderer, initial_command=initial_command) + await interactive_mode(bus_renderer, initial_command=initial_command) finally: - if message_renderer: - message_renderer.stop() if bus_renderer: bus_renderer.stop() + if legacy_bridge: + legacy_bridge.stop() + if capture_session: + capture_session.stop(exit_reason="shutdown") + set_active_capture(None) await callbacks.on_shutdown() if get_use_dbos(): DBOS.destroy() @@ -352,12 +428,14 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non """Run the agent in interactive mode.""" from code_puppy.command_line.command_handler import handle_command + RUNTIME_STATE.mark_idle() display_console = message_renderer.console from code_puppy.messaging import emit_info, emit_system_message emit_system_message( "Type '/exit', '/quit', or press Ctrl+D to exit the interactive mode." ) + log_event("interactive_mode_start") emit_system_message("Type 'clear' to reset the conversation history.") emit_system_message("Type /help to view all commands") emit_system_message( @@ -518,6 +596,63 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non # Track the current agent task for cancellation on quit current_agent_task = None + async def cancel_active_run(reason: str) -> None: + """Aggressively stop shell + agent execution and wait for cancellation.""" + from code_puppy.tools.command_runner import ( + get_running_shell_process_count, + kill_all_running_shell_processes, + ) + + if RUNTIME_STATE.bg_task is None or RUNTIME_STATE.bg_task.done(): + RUNTIME_STATE.mark_idle() + return + + RUNTIME_STATE.cancelling = True + log_event("cancel_start", reason=reason) + + # First kill nested shell activity, repeating briefly if needed. + for _ in range(3): + kill_all_running_shell_processes() + if get_running_shell_process_count() == 0: + break + await asyncio.sleep(0.15) + + # Then cancel the active background agent task and await completion. + RUNTIME_STATE.bg_task.cancel() + try: + await asyncio.wait_for(RUNTIME_STATE.bg_task, timeout=6.0) + except asyncio.CancelledError: + pass + except TimeoutError: + pass + except Exception: + pass + finally: + RUNTIME_STATE.mark_idle() + log_event("cancel_done", reason=reason) + + def _prepare_queued_task(raw_task: str) -> tuple[str, str]: + """Normalize queued payload into executable task text and kind.""" + if raw_task.startswith("[INTERJECT] "): + user_text = raw_task[len("[INTERJECT] "):] + task_text = f"[user interjects]: {user_text} - please continue with that in mind" + log_event( + "dequeued_interject", + text=user_text, + remaining=len(RUNTIME_STATE.queue), + ) + return task_text, "interject" + + from code_puppy.messaging import emit_success + + emit_success(f"šŸš€ Executing queued prompt: {raw_task}") + log_event( + "dequeued_prompt", + text=raw_task, + remaining=len(RUNTIME_STATE.queue), + ) + return raw_task, "queued" + while True: from code_puppy.agents.agent_manager import get_current_agent from code_puppy.messaging import emit_info @@ -526,20 +661,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non current_agent = get_current_agent() user_prompt = current_agent.get_user_prompt() or "Enter your coding task:" - global AGENT_IS_RUNNING, PROMPT_QUEUE - if not AGENT_IS_RUNNING and PROMPT_QUEUE: - task = PROMPT_QUEUE.pop(0) - if task.startswith("[INTERJECT] "): - user_text = task[len("[INTERJECT] "):] - task = f"[user interjects]: {user_text} - please continue with that in mind" - # We do not print anything here for smooth interject, it just runs seamlessly! - else: - from code_puppy.messaging import emit_success - emit_success(f"šŸš€ Executing queued prompt: {task}") - import asyncio - await asyncio.sleep(0.1) + if not RUNTIME_STATE.running and RUNTIME_STATE.queue: + raw_task = RUNTIME_STATE.dequeue() + if raw_task is None: + continue + task, _ = _prepare_queued_task(raw_task) else: - if not AGENT_IS_RUNNING: + if not RUNTIME_STATE.running: emit_info(f"{user_prompt}\n") try: @@ -550,10 +678,11 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non # Use the async version of get_input_with_combined_completion task = await get_input_with_combined_completion( - get_prompt_with_active_model(), + get_prompt_with_active_model, history_file=COMMAND_HISTORY_FILE, - erase_when_done=AGENT_IS_RUNNING + erase_when_done=RUNTIME_STATE.running, ) + log_event("input_received", text=task) # Windows+uvx: Re-disable Ctrl+C after prompt_toolkit # (prompt_toolkit restores console mode which re-enables Ctrl+C) @@ -748,15 +877,12 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non pass if task.strip(): - global BG_AGENT_TASK # Write to the secret file for permanent history with timestamp save_command_to_history(task) - if AGENT_IS_RUNNING: + if RUNTIME_STATE.running: from code_puppy.messaging import emit_warning, emit_info - from code_puppy.command_line.prompt_toolkit_completion import get_input_with_combined_completion - from prompt_toolkit.formatted_text import FormattedText - + try: from code_puppy.command_line.prompt_toolkit_completion import get_interject_action action = await get_interject_action() @@ -766,39 +892,57 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non continue a = action.strip().lower() - if a == 'i': - from rich.console import Console - Console().print(f"\n[bold bright_red]Interjecting with:[/bold bright_red] [red]{task.strip()}[/red]") - if BG_AGENT_TASK and not BG_AGENT_TASK.done(): - BG_AGENT_TASK.cancel() - from code_puppy.command_line.wiggum_state import is_wiggum_active, stop_wiggum - from code_puppy.tools.command_runner import kill_all_running_shell_processes - kill_all_running_shell_processes() - if is_wiggum_active(): - stop_wiggum() - # Add current task as the first thing to run next - AGENT_IS_RUNNING = False - # We put it at the front of the queue, but setting AGENT_IS_RUNNING=False forces immediate execution without rendering! - PROMPT_QUEUE.insert(0, f"[INTERJECT] {task.strip()}") - import asyncio - await asyncio.sleep(0.1) # Give the cancel event a tiny bit of time to settle - elif a == 'q': - PROMPT_QUEUE.append(task.strip()) - emit_info(f"Queued (position {len(PROMPT_QUEUE)}): {task.strip()}") + log_event("interject_choice", action=a, prompt=task.strip()) + if a == "i": + from code_puppy.messaging import MessageLevel, TextMessage, get_message_bus + + get_message_bus().emit( + TextMessage( + level=MessageLevel.ERROR, + text=f"Interjecting with: {task.strip()}", + ) + ) + log_event("interject_banner", text=task.strip()) + await cancel_active_run("interject") + ok, _ = RUNTIME_STATE.enqueue_front(f"[INTERJECT] {task.strip()}") + if not ok: + emit_warning("Queue full (25). Cannot interject right now.") + log_event("queue_reject", prompt=task.strip(), reason="full_interject") + else: + log_event( + "queued_interject", + text=task.strip(), + position=1, + size=len(RUNTIME_STATE.queue), + ) + elif a == "q": + ok, position = RUNTIME_STATE.enqueue(task.strip()) + if not ok: + emit_warning("Queue full (25). Prompt was not queued.") + log_event("queue_reject", prompt=task.strip(), reason="full") + else: + emit_info(f"Queued (position {position}): {task.strip()}") + log_event( + "queued_prompt", + text=task.strip(), + position=position, + size=len(RUNTIME_STATE.queue), + ) else: emit_warning("Cancelled action.") continue async def run_agent_bg(task_text, agent): - global AGENT_IS_RUNNING - AGENT_IS_RUNNING = True + RUNTIME_STATE.running = True + _sync_runtime_globals(RUNTIME_STATE) + log_event("agent_start", prompt=task_text) try: # No need to get agent directly - use manager's run methods # Use our custom helper to enable attachment handling with spinner support result, current_agent_task = await run_prompt_with_attachments( - current_agent, - task, + agent, + task_text, spinner_console=message_renderer.console, ) # Check if the task was cancelled (but don't show message if we just killed processes) @@ -858,10 +1002,35 @@ async def run_agent_bg(task_text, agent): get_queue_console().print_exception() finally: - AGENT_IS_RUNNING = False + was_cancelling = RUNTIME_STATE.cancelling + RUNTIME_STATE.mark_idle() + log_event("agent_end", prompt=task_text) + # During explicit interject cancellation we requeue at the + # front immediately after cancel_active_run() returns. + # Auto-draining here can race and start a queued prompt + # before the interject gets enqueued/dequeued. + if was_cancelling: + log_event( + "queue_autodrain_skipped", + reason="cancelling", + remaining=len(RUNTIME_STATE.queue), + ) + return + + if RUNTIME_STATE.queue: + next_raw = RUNTIME_STATE.dequeue() + if next_raw: + next_task, _ = _prepare_queued_task(next_raw) + from code_puppy.agents.agent_manager import get_current_agent + + log_event("queue_autodrain_triggered", remaining=len(RUNTIME_STATE.queue)) + RUNTIME_STATE.mark_running( + asyncio.create_task( + run_agent_bg(next_task, get_current_agent()) + ) + ) - import asyncio - BG_AGENT_TASK = asyncio.create_task(run_agent_bg(task, current_agent)) + RUNTIME_STATE.mark_running(asyncio.create_task(run_agent_bg(task, current_agent))) continue # Auto-save session if enabled (moved outside the try block to avoid being swallowed) From faebf284107a19f55bfa996631d4e5290ddc9383 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 5 Mar 2026 12:04:48 -0600 Subject: [PATCH 21/40] sometime the queue doesn't trigger and the interject printing has a big of cleaning up and it's still laggy but great improvement. Made-with: Cursor --- code_puppy/agents/event_stream_handler.py | 20 +- code_puppy/cli_runner.py | 630 ++++++++++++------ .../command_line/prompt_toolkit_completion.py | 80 ++- .../messaging/spinner/console_spinner.py | 21 +- 4 files changed, 525 insertions(+), 226 deletions(-) diff --git a/code_puppy/agents/event_stream_handler.py b/code_puppy/agents/event_stream_handler.py index c21aff552..fb150fc8d 100644 --- a/code_puppy/agents/event_stream_handler.py +++ b/code_puppy/agents/event_stream_handler.py @@ -119,6 +119,7 @@ async def event_stream_handler( token_count: dict[int, int] = {} # Track token count per text/tool part tool_names: dict[int, str] = {} # Track tool name per tool part index did_stream_anything = False # Track if we streamed any content + spinner_paused = False # Termflow streaming state for text parts termflow_parsers: dict[int, TermflowParser] = {} @@ -127,10 +128,12 @@ async def event_stream_handler( async def _print_thinking_banner() -> None: """Print the THINKING banner with spinner pause and line clear.""" - nonlocal did_stream_anything + nonlocal did_stream_anything, spinner_paused - pause_all_spinners() - await asyncio.sleep(0.1) # Delay to let spinner fully clear + if not spinner_paused: + pause_all_spinners() + spinner_paused = True + await asyncio.sleep(0.02) # Clear line and print newline before banner console.print(" " * 50, end="\r") console.print() # Newline before banner @@ -146,10 +149,12 @@ async def _print_thinking_banner() -> None: async def _print_response_banner() -> None: """Print the AGENT RESPONSE banner with spinner pause and line clear.""" - nonlocal did_stream_anything + nonlocal did_stream_anything, spinner_paused - pause_all_spinners() - await asyncio.sleep(0.1) # Delay to let spinner fully clear + if not spinner_paused: + pause_all_spinners() + spinner_paused = True + await asyncio.sleep(0.02) # Clear line and print newline before banner console.print(" " * 50, end="\r") console.print() # Newline before banner @@ -344,5 +349,8 @@ async def _print_response_banner() -> None: next_kind = getattr(event, "next_part_kind", None) if next_kind not in ("text", "thinking", "tool-call"): resume_all_spinners() + spinner_paused = False # Spinner is resumed in PartEndEvent when appropriate (based on next_part_kind) + if spinner_paused: + resume_all_spinners() diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 8447c4119..64d7d34df 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -10,6 +10,8 @@ import argparse from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable, Literal AGENT_IS_RUNNING = False PROMPT_QUEUE = [] @@ -56,21 +58,51 @@ ) from code_puppy.tools.common import console from code_puppy.version_checker import default_version_mismatch_behavior -from code_puppy.debug_capture import ( - get_active_capture, - log_event, - set_active_capture, - start_capture_session, -) +try: + from code_puppy.debug_capture import ( + get_active_capture, + log_event, + set_active_capture, + start_capture_session, + ) +except ImportError: + # Keep CLI usable in checkouts that don't include debug_capture. + def get_active_capture(): + return None + + def log_event(*args, **kwargs): + return None + + def set_active_capture(*args, **kwargs): + return None + + def start_capture_session(): + return None plugins.load_plugin_callbacks() +@dataclass +class QueuedPrompt: + """Normalized queued prompt payload.""" + + kind: Literal["queued", "interject"] + text: str + created_at: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + + def preview_text(self) -> str: + if self.kind == "interject": + return f"[INTERJECT] {self.text}" + return self.text + + @dataclass class PromptRuntimeState: """Single source of truth for prompt run state and queue.""" - queue: list[str] = field(default_factory=lambda: PROMPT_QUEUE) + queue: list[QueuedPrompt] = field(default_factory=list) running: bool = False cancelling: bool = False bg_task: asyncio.Task | None = None @@ -87,27 +119,35 @@ def mark_idle(self) -> None: self.bg_task = None _sync_runtime_globals(self) - def enqueue(self, prompt: str) -> tuple[bool, int]: - if len(self.queue) >= MAX_PROMPT_QUEUE: - return False, len(self.queue) - self.queue.append(prompt) + def _can_enqueue(self) -> bool: + return len(self.queue) < MAX_PROMPT_QUEUE + + def request_queue(self, prompt: str) -> tuple[bool, int, QueuedPrompt | None]: + if not self._can_enqueue(): + return False, len(self.queue), None + item = QueuedPrompt(kind="queued", text=prompt) + self.queue.append(item) _sync_runtime_globals(self) - return True, len(self.queue) + return True, len(self.queue), item - def enqueue_front(self, prompt: str) -> tuple[bool, int]: - if len(self.queue) >= MAX_PROMPT_QUEUE: - return False, len(self.queue) - self.queue.insert(0, prompt) + def request_interject(self, prompt: str) -> tuple[bool, int, QueuedPrompt | None]: + if not self._can_enqueue(): + return False, len(self.queue), None + item = QueuedPrompt(kind="interject", text=prompt) + self.queue.insert(0, item) _sync_runtime_globals(self) - return True, len(self.queue) + return True, len(self.queue), item - def dequeue(self) -> str | None: + def dequeue(self) -> QueuedPrompt | None: if not self.queue: return None value = self.queue.pop(0) _sync_runtime_globals(self) return value + def prompt_queue_preview(self) -> list[str]: + return [item.preview_text() for item in self.queue] + RUNTIME_STATE = PromptRuntimeState() @@ -117,7 +157,123 @@ def _sync_runtime_globals(state: PromptRuntimeState) -> None: global AGENT_IS_RUNNING, BG_AGENT_TASK, PROMPT_QUEUE AGENT_IS_RUNNING = state.running BG_AGENT_TASK = state.bg_task - PROMPT_QUEUE = state.queue + PROMPT_QUEUE = state.prompt_queue_preview() + + +def emit_interject_queue_lifecycle( + runtime_state: PromptRuntimeState, + action: str, + *, + item: QueuedPrompt | None = None, + reason: str | None = None, + position: int | None = None, + level: str = "info", +) -> dict[str, Any]: + """Emit interject/queue lifecycle to UI, debug log, and frontend emitter.""" + payload: dict[str, Any] = { + "action": action, + "kind": item.kind if item else None, + "text": item.text if item else None, + "reason": reason, + "position": position, + "queue_size": len(runtime_state.queue), + "running": runtime_state.running, + } + try: + from code_puppy.plugins.frontend_emitter.emitter import emit_event + + emit_event("interject_queue", payload) + except Exception: + pass + + log_event("interject_queue", **payload) + + try: + from code_puppy.messaging import MessageLevel, TextMessage, get_message_bus + + if item and item.kind == "interject": + default_text = f"[interject] {action}: {item.text}" + elif item: + default_text = f"[queue] {action}: {item.text}" + else: + default_text = f"[queue] {action}" + + text = default_text if reason is None else f"{default_text} ({reason})" + if position is not None: + text = f"{text} [position {position}]" + + level_map = { + "error": MessageLevel.ERROR, + "warning": MessageLevel.WARNING, + "success": MessageLevel.SUCCESS, + "info": MessageLevel.INFO, + } + get_message_bus().emit( + TextMessage(level=level_map.get(level, MessageLevel.INFO), text=text) + ) + except Exception: + pass + return payload + + +async def start_next_queued_if_idle( + runtime_state: PromptRuntimeState, + queue_start_lock: asyncio.Lock, + run_agent_factory: Callable[[QueuedPrompt], asyncio.Task], + *, + origin: str, +) -> bool: + """Start exactly one queued task if we're idle.""" + async with queue_start_lock: + if runtime_state.running: + active_task = runtime_state.bg_task + if active_task is None or active_task.done(): + runtime_state.mark_idle() + log_event( + "queue_autodrain_reconciled", + origin=origin, + had_task=active_task is not None, + task_done=active_task.done() if active_task is not None else None, + ) + else: + log_event("queue_autodrain_noop", origin=origin, reason="running") + return False + + next_item = runtime_state.dequeue() + if next_item is None: + log_event("queue_autodrain_noop", origin=origin, reason="empty") + return False + + task = run_agent_factory(next_item) + runtime_state.mark_running(task) + log_event( + "queue_autodrain_triggered", + origin=origin, + remaining=len(runtime_state.queue), + kind=next_item.kind, + text=next_item.text, + ) + return True + + +async def kick_queue_after_cancel_boundary( + runtime_state: PromptRuntimeState, + queue_start_lock: asyncio.Lock, + run_agent_factory: Callable[[QueuedPrompt], asyncio.Task], + *, + origin: str, +) -> bool: + """Deferred queue kick for cancellation boundaries. + + This runs on the next event-loop turn so interject enqueue can complete first. + """ + await asyncio.sleep(0) + return await start_next_queued_if_idle( + runtime_state, + queue_start_lock, + run_agent_factory, + origin=origin, + ) async def main(): @@ -169,13 +325,30 @@ async def main(): get_global_queue, get_message_bus, ) - from code_puppy.messaging.legacy_bridge import LegacyQueueToBusBridge + try: + from code_puppy.messaging.legacy_bridge import LegacyQueueToBusBridge + except ImportError: + class LegacyQueueToBusBridge: # type: ignore[no-redef] + """No-op fallback when legacy bridge module is unavailable.""" + + def __init__(self, *_args, **_kwargs): + pass + + def start(self): + return None capture_session = None if args.debug_capture: capture_session = start_capture_session() - set_active_capture(capture_session) - log_event("debug_capture_enabled", session_dir=str(capture_session.session_dir)) + if capture_session is not None: + set_active_capture(capture_session) + log_event( + "debug_capture_enabled", session_dir=str(capture_session.session_dir) + ) + else: + print( + "Warning: --debug-capture requested but debug_capture module is unavailable." + ) # Create one shared console to avoid multi-renderer race conditions. display_console = Console() @@ -595,6 +768,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non # Track the current agent task for cancellation on quit current_agent_task = None + queue_start_lock = asyncio.Lock() async def cancel_active_run(reason: str) -> None: """Aggressively stop shell + agent execution and wait for cancellation.""" @@ -631,27 +805,146 @@ async def cancel_active_run(reason: str) -> None: RUNTIME_STATE.mark_idle() log_event("cancel_done", reason=reason) - def _prepare_queued_task(raw_task: str) -> tuple[str, str]: + def _prepare_queued_task(item: QueuedPrompt) -> tuple[str, str]: """Normalize queued payload into executable task text and kind.""" - if raw_task.startswith("[INTERJECT] "): - user_text = raw_task[len("[INTERJECT] "):] - task_text = f"[user interjects]: {user_text} - please continue with that in mind" - log_event( - "dequeued_interject", - text=user_text, - remaining=len(RUNTIME_STATE.queue), + if item.kind == "interject": + task_text = ( + f"[user interjects]: {item.text} - please continue with that in mind" + ) + emit_interject_queue_lifecycle( + RUNTIME_STATE, "dequeued", item=item, level="warning" ) return task_text, "interject" from code_puppy.messaging import emit_success - emit_success(f"šŸš€ Executing queued prompt: {raw_task}") - log_event( - "dequeued_prompt", - text=raw_task, - remaining=len(RUNTIME_STATE.queue), + emit_success(f"šŸš€ Executing queued prompt: {item.text}") + emit_interject_queue_lifecycle( + RUNTIME_STATE, "dequeued", item=item, level="success" + ) + return item.text, "queued" + + async def run_agent_bg( + task_text, agent, source_item: QueuedPrompt | None = None + ): + RUNTIME_STATE.running = True + _sync_runtime_globals(RUNTIME_STATE) + try: + log_event("agent_start", prompt=task_text) + if source_item: + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "started", + item=source_item, + level="warning" if source_item.kind == "interject" else "success", + ) + result, current_agent_task = await run_prompt_with_attachments( + agent, + task_text, + spinner_console=message_renderer.console, + ) + if result is None: + reset_windows_terminal_ansi() + try: + from code_puppy.terminal_utils import ensure_ctrl_c_disabled + + ensure_ctrl_c_disabled() + except ImportError: + pass + from code_puppy.command_line.wiggum_state import ( + is_wiggum_active, + stop_wiggum, + ) + + if is_wiggum_active(): + stop_wiggum() + from code_puppy.messaging import emit_warning + + emit_warning("šŸ© Wiggum loop stopped due to cancellation") + if source_item: + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "cancelled", + item=source_item, + reason="run_cancelled", + level="warning", + ) + return + agent_response = result.output + + from code_puppy.messaging import get_message_bus + from code_puppy.messaging.messages import AgentResponseMessage + + response_msg = AgentResponseMessage( + content=agent_response, + is_markdown=True, + ) + get_message_bus().emit(response_msg) + + if hasattr(result, "all_messages"): + agent.set_message_history(list(result.all_messages())) + + if hasattr(display_console.file, "flush"): + display_console.file.flush() + + await asyncio.sleep(0.1) + if source_item: + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "completed", + item=source_item, + level="success", + ) + + except Exception: + from code_puppy.messaging.queue_console import get_queue_console + + get_queue_console().print_exception() + if source_item: + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "failed", + item=source_item, + reason="exception", + level="error", + ) + finally: + was_cancelling = RUNTIME_STATE.cancelling + RUNTIME_STATE.mark_idle() + log_event("agent_end", prompt=task_text) + if was_cancelling: + log_event( + "queue_autodrain_skipped", + reason="cancelling", + remaining=len(RUNTIME_STATE.queue), + ) + asyncio.create_task( + kick_queue_after_cancel_boundary( + RUNTIME_STATE, + queue_start_lock, + _queue_run_factory, + origin="cancel_boundary_fallback", + ) + ) + return + await start_next_queued_if_idle( + RUNTIME_STATE, + queue_start_lock, + _queue_run_factory, + origin="run_complete", + ) + + def _queue_run_factory(next_item: QueuedPrompt) -> asyncio.Task: + next_task, _ = _prepare_queued_task(next_item) + from code_puppy.agents.agent_manager import get_current_agent + + return asyncio.create_task( + run_agent_bg( + next_task, + get_current_agent(), + source_item=next_item, + ) ) - return raw_task, "queued" while True: from code_puppy.agents.agent_manager import get_current_agent @@ -660,76 +953,79 @@ def _prepare_queued_task(raw_task: str) -> tuple[str, str]: # Get the custom prompt from the current agent, or use default current_agent = get_current_agent() user_prompt = current_agent.get_user_prompt() or "Enter your coding task:" - if not RUNTIME_STATE.running and RUNTIME_STATE.queue: - raw_task = RUNTIME_STATE.dequeue() - if raw_task is None: + started = await start_next_queued_if_idle( + RUNTIME_STATE, + queue_start_lock, + _queue_run_factory, + origin="loop_idle_check", + ) + if started: continue - task, _ = _prepare_queued_task(raw_task) - else: - if not RUNTIME_STATE.running: - emit_info(f"{user_prompt}\n") + if not RUNTIME_STATE.running: + emit_info(f"{user_prompt}\n") + + try: + # Use prompt_toolkit for enhanced input with path completion try: - # Use prompt_toolkit for enhanced input with path completion - try: - # Windows-specific: Reset terminal state before prompting - reset_windows_terminal_ansi() - - # Use the async version of get_input_with_combined_completion - task = await get_input_with_combined_completion( - get_prompt_with_active_model, - history_file=COMMAND_HISTORY_FILE, - erase_when_done=RUNTIME_STATE.running, - ) - log_event("input_received", text=task) + # Windows-specific: Reset terminal state before prompting + reset_windows_terminal_ansi() + + # Use the async version of get_input_with_combined_completion + task = await get_input_with_combined_completion( + get_prompt_with_active_model, + history_file=COMMAND_HISTORY_FILE, + erase_when_done=RUNTIME_STATE.running, + ) + log_event("input_received", text=task) - # Windows+uvx: Re-disable Ctrl+C after prompt_toolkit - # (prompt_toolkit restores console mode which re-enables Ctrl+C) - try: - from code_puppy.terminal_utils import ensure_ctrl_c_disabled + # Windows+uvx: Re-disable Ctrl+C after prompt_toolkit + # (prompt_toolkit restores console mode which re-enables Ctrl+C) + try: + from code_puppy.terminal_utils import ensure_ctrl_c_disabled - ensure_ctrl_c_disabled() - except ImportError: - pass + ensure_ctrl_c_disabled() except ImportError: - # Fall back to basic input if prompt_toolkit is not available - task = input(">>> ") - - except KeyboardInterrupt: - # Handle Ctrl+C - cancel input and continue - # Windows-specific: Reset terminal state after interrupt to prevent - # the terminal from becoming unresponsive (can't type characters) - reset_windows_terminal_full() - # Stop wiggum mode on Ctrl+C - from code_puppy.command_line.wiggum_state import ( - is_wiggum_active, - stop_wiggum, - ) - from code_puppy.messaging import emit_warning + pass + except ImportError: + # Fall back to basic input if prompt_toolkit is not available + task = input(">>> ") + + except KeyboardInterrupt: + # Handle Ctrl+C - cancel input and continue + # Windows-specific: Reset terminal state after interrupt to prevent + # the terminal from becoming unresponsive (can't type characters) + reset_windows_terminal_full() + # Stop wiggum mode on Ctrl+C + from code_puppy.command_line.wiggum_state import ( + is_wiggum_active, + stop_wiggum, + ) + from code_puppy.messaging import emit_warning - if is_wiggum_active(): - stop_wiggum() - emit_warning("\nšŸ© Wiggum loop stopped!") - else: - emit_warning("\nInput cancelled") - continue - except EOFError: - # Handle Ctrl+D - exit the application - from code_puppy.messaging import emit_success + if is_wiggum_active(): + stop_wiggum() + emit_warning("\nšŸ© Wiggum loop stopped!") + else: + emit_warning("\nInput cancelled") + continue + except EOFError: + # Handle Ctrl+D - exit the application + from code_puppy.messaging import emit_success - emit_success("\nGoodbye! (Ctrl+D)") + emit_success("\nGoodbye! (Ctrl+D)") - # Cancel any running agent task for clean shutdown - if current_agent_task and not current_agent_task.done(): - emit_info("Cancelling running agent task...") - current_agent_task.cancel() - try: - await current_agent_task - except asyncio.CancelledError: - pass # Expected when cancelling + # Cancel any running agent task for clean shutdown + if current_agent_task and not current_agent_task.done(): + emit_info("Cancelling running agent task...") + current_agent_task.cancel() + try: + await current_agent_task + except asyncio.CancelledError: + pass # Expected when cancelling - break + break # Check for exit commands (plain text or command form) if task.strip().lower() in ["exit", "quit"] or task.strip().lower() in [ @@ -894,34 +1190,60 @@ def _prepare_queued_task(raw_task: str) -> tuple[str, str]: a = action.strip().lower() log_event("interject_choice", action=a, prompt=task.strip()) if a == "i": - from code_puppy.messaging import MessageLevel, TextMessage, get_message_bus - - get_message_bus().emit( - TextMessage( - level=MessageLevel.ERROR, - text=f"Interjecting with: {task.strip()}", - ) - ) log_event("interject_banner", text=task.strip()) await cancel_active_run("interject") - ok, _ = RUNTIME_STATE.enqueue_front(f"[INTERJECT] {task.strip()}") + ok, position, item = RUNTIME_STATE.request_interject(task.strip()) if not ok: emit_warning("Queue full (25). Cannot interject right now.") + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "rejected", + reason="full_interject", + level="error", + ) log_event("queue_reject", prompt=task.strip(), reason="full_interject") else: + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "queued", + item=item, + position=position, + level="warning", + ) log_event( "queued_interject", text=task.strip(), position=1, size=len(RUNTIME_STATE.queue), ) + await start_next_queued_if_idle( + RUNTIME_STATE, + queue_start_lock, + _queue_run_factory, + origin="interject_enqueued", + ) + log_event( + "interject_queue_kick_attempted", + remaining=len(RUNTIME_STATE.queue), + running=RUNTIME_STATE.running, + ) elif a == "q": - ok, position = RUNTIME_STATE.enqueue(task.strip()) + ok, position, item = RUNTIME_STATE.request_queue(task.strip()) if not ok: emit_warning("Queue full (25). Prompt was not queued.") + emit_interject_queue_lifecycle( + RUNTIME_STATE, "rejected", reason="full", level="error" + ) log_event("queue_reject", prompt=task.strip(), reason="full") else: emit_info(f"Queued (position {position}): {task.strip()}") + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "queued", + item=item, + position=position, + level="info", + ) log_event( "queued_prompt", text=task.strip(), @@ -932,104 +1254,6 @@ def _prepare_queued_task(raw_task: str) -> tuple[str, str]: emit_warning("Cancelled action.") continue - async def run_agent_bg(task_text, agent): - RUNTIME_STATE.running = True - _sync_runtime_globals(RUNTIME_STATE) - log_event("agent_start", prompt=task_text) - try: - # No need to get agent directly - use manager's run methods - - # Use our custom helper to enable attachment handling with spinner support - result, current_agent_task = await run_prompt_with_attachments( - agent, - task_text, - spinner_console=message_renderer.console, - ) - # Check if the task was cancelled (but don't show message if we just killed processes) - if result is None: - # Windows-specific: Reset terminal state after cancellation - reset_windows_terminal_ansi() - # Re-disable Ctrl+C if needed (uvx mode) - try: - from code_puppy.terminal_utils import ensure_ctrl_c_disabled - - ensure_ctrl_c_disabled() - except ImportError: - pass - # Stop wiggum mode on cancellation - from code_puppy.command_line.wiggum_state import ( - is_wiggum_active, - stop_wiggum, - ) - - if is_wiggum_active(): - stop_wiggum() - from code_puppy.messaging import emit_warning - - emit_warning("šŸ© Wiggum loop stopped due to cancellation") - return - # Get the structured response - agent_response = result.output - - # Emit structured message for proper markdown rendering - from code_puppy.messaging import get_message_bus - from code_puppy.messaging.messages import AgentResponseMessage - - response_msg = AgentResponseMessage( - content=agent_response, - is_markdown=True, - ) - get_message_bus().emit(response_msg) - - # Update the agent's message history with the complete conversation - # including the final assistant response. The history_processors callback - # may not capture the final message, so we use result.all_messages() - # to ensure the autosave includes the complete conversation. - if hasattr(result, "all_messages"): - current_agent.set_message_history(list(result.all_messages())) - - # Ensure console output is flushed before next prompt - # This fixes the issue where prompt doesn't appear after agent response - if hasattr(display_console.file, "flush"): - display_console.file.flush() - - await asyncio.sleep( - 0.1 - ) # Brief pause to ensure all messages are rendered - - except Exception: - from code_puppy.messaging.queue_console import get_queue_console - - get_queue_console().print_exception() - finally: - was_cancelling = RUNTIME_STATE.cancelling - RUNTIME_STATE.mark_idle() - log_event("agent_end", prompt=task_text) - # During explicit interject cancellation we requeue at the - # front immediately after cancel_active_run() returns. - # Auto-draining here can race and start a queued prompt - # before the interject gets enqueued/dequeued. - if was_cancelling: - log_event( - "queue_autodrain_skipped", - reason="cancelling", - remaining=len(RUNTIME_STATE.queue), - ) - return - - if RUNTIME_STATE.queue: - next_raw = RUNTIME_STATE.dequeue() - if next_raw: - next_task, _ = _prepare_queued_task(next_raw) - from code_puppy.agents.agent_manager import get_current_agent - - log_event("queue_autodrain_triggered", remaining=len(RUNTIME_STATE.queue)) - RUNTIME_STATE.mark_running( - asyncio.create_task( - run_agent_bg(next_task, get_current_agent()) - ) - ) - RUNTIME_STATE.mark_running(asyncio.create_task(run_agent_bg(task, current_agent))) continue diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 6614c5839..0bf9cdd56 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -51,6 +51,37 @@ get_value, ) +_QUEUE_VIEW_OFFSET = 0 + + +def _truncate_queue_line(text: str, max_len: int) -> str: + if max_len <= 2: + return ".." + if len(text) <= max_len: + return text + return text[: max_len - 2] + ".." + + +def _get_queue_preview( + prompts: list[str], term_width: int, max_visible: int = 3 +) -> tuple[list[str], int]: + """Return visible queue lines and hidden count from current offset.""" + global _QUEUE_VIEW_OFFSET + if not prompts: + _QUEUE_VIEW_OFFSET = 0 + return [], 0 + + max_start = max(0, len(prompts) - max_visible) + _QUEUE_VIEW_OFFSET = max(0, min(_QUEUE_VIEW_OFFSET, max_start)) + start = _QUEUE_VIEW_OFFSET + visible = prompts[start : start + max_visible] + lines: list[str] = [] + line_room = max(8, term_width - 8) + for idx, prompt in enumerate(visible, start=start + 1): + lines.append(f" [{idx}] {_truncate_queue_line(prompt, line_room)}") + hidden = max(0, len(prompts) - (start + len(visible))) + return lines, hidden + def _sanitize_for_encoding(text: str) -> str: """Remove or replace characters that can't be safely encoded. @@ -561,12 +592,11 @@ def get_prompt_with_active_model(base: str = ">>> ", is_interject: bool = False) parts.append(("class:separator", f"\n{sep_line}\n")) if PROMPT_QUEUE: - show_prompts = PROMPT_QUEUE[:3] - for idx, qp in enumerate(show_prompts): - trunc_qp = qp if len(qp) <= term_width - 10 else qp[:term_width - 12] + ".." - parts.append(("class:queue-item", f" [{idx+1}] {trunc_qp}\n")) - if len(PROMPT_QUEUE) > 3: - parts.append(("class:queue-item", f" ... and {len(PROMPT_QUEUE)-3} more\n")) + preview_lines, hidden = _get_queue_preview(PROMPT_QUEUE, term_width=term_width) + for line in preview_lines: + parts.append(("class:queue-item", f"{line}\n")) + if hidden: + parts.append(("class:queue-item", f" ... and {hidden} more\n")) parts.extend([ ("class:separator", "╭─ "), @@ -711,6 +741,26 @@ def handle_delete_with_completion(event): if text.startswith("/"): buffer.start_completion(select_first=False) + @bindings.add("c-up", eager=True) + def handle_queue_scroll_up(event): + from code_puppy.cli_runner import PROMPT_QUEUE + + global _QUEUE_VIEW_OFFSET + if len(PROMPT_QUEUE) <= 3: + return + _QUEUE_VIEW_OFFSET = max(0, _QUEUE_VIEW_OFFSET - 1) + event.app.invalidate() + + @bindings.add("c-down", eager=True) + def handle_queue_scroll_down(event): + from code_puppy.cli_runner import PROMPT_QUEUE + + global _QUEUE_VIEW_OFFSET + if len(PROMPT_QUEUE) <= 3: + return + _QUEUE_VIEW_OFFSET = min(len(PROMPT_QUEUE) - 3, _QUEUE_VIEW_OFFSET + 1) + event.app.invalidate() + # Handle bracketed paste - smart detection for text vs images. # Most terminals (Windows included!) send Ctrl+V through bracketed paste. # - If there's meaningful text content → paste as text (drag-and-drop file paths, copied text) @@ -911,6 +961,24 @@ def _(event): # Ignore other keys to simulate a single-character menu pass + @bindings.add("c-up") + def _(event): + from code_puppy.cli_runner import PROMPT_QUEUE + + global _QUEUE_VIEW_OFFSET + if len(PROMPT_QUEUE) > 3: + _QUEUE_VIEW_OFFSET = max(0, _QUEUE_VIEW_OFFSET - 1) + event.app.invalidate() + + @bindings.add("c-down") + def _(event): + from code_puppy.cli_runner import PROMPT_QUEUE + + global _QUEUE_VIEW_OFFSET + if len(PROMPT_QUEUE) > 3: + _QUEUE_VIEW_OFFSET = min(len(PROMPT_QUEUE) - 3, _QUEUE_VIEW_OFFSET + 1) + event.app.invalidate() + prompt_text = get_prompt_with_active_model(is_interject=True) from prompt_toolkit.output.defaults import create_output import sys diff --git a/code_puppy/messaging/spinner/console_spinner.py b/code_puppy/messaging/spinner/console_spinner.py index 114c41800..c3335e44c 100644 --- a/code_puppy/messaging/spinner/console_spinner.py +++ b/code_puppy/messaging/spinner/console_spinner.py @@ -29,6 +29,7 @@ def __init__(self, console=None): self._stop_event = threading.Event() self._paused = False self._live = None + self._last_render_at = 0.0 # Register this spinner for global management from . import register_spinner @@ -44,14 +45,11 @@ def start(self): if self._thread and self._thread.is_alive(): return - # Print blank line before spinner for visual separation from content - self.console.print() - # Create a Live display for the spinner self._live = Live( self._generate_spinner_panel(), console=self.console, - refresh_per_second=20, + refresh_per_second=10, transient=True, # Clear the spinner line when stopped (no puppy litter!) auto_refresh=False, # Don't auto-refresh to avoid wiping out user input ) @@ -151,9 +149,13 @@ def _update_spinner(self): # Update the live display only if not paused and not awaiting input if self._live and not self._paused and not awaiting_input: - # Manually refresh instead of auto-refresh to avoid wiping input - self._live.update(self._generate_spinner_panel()) - self._live.refresh() + # Throttle refresh to reduce redraw churn/flicker. + now = time.time() + if now - self._last_render_at >= 0.09: + # Manually refresh instead of auto-refresh to avoid wiping input + self._live.update(self._generate_spinner_panel()) + self._live.refresh() + self._last_render_at = now # Short sleep to control animation speed time.sleep(0.05) @@ -202,13 +204,10 @@ def resume(self): sys.stdout.write("\x1b[K") # Clear to end of line sys.stdout.flush() - # Print blank line before spinner for visual separation - self.console.print() - self._live = Live( self._generate_spinner_panel(), console=self.console, - refresh_per_second=20, + refresh_per_second=10, transient=True, # Clear spinner line when stopped auto_refresh=False, ) From ff116b2ecd93143145b3a99b9466816728b2418c Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Thu, 5 Mar 2026 18:28:31 -0600 Subject: [PATCH 22/40] Restore shell streaming during foreground commands --- code_puppy/cli_runner.py | 800 +++++++++--------- .../command_line/prompt_toolkit_completion.py | 204 ++++- code_puppy/tools/command_runner.py | 67 +- tests/test_cli_runner_full_coverage.py | 461 +++++++++- tests/test_prompt_toolkit_completion.py | 167 ++++ .../test_command_runner_full_coverage.py | 183 +++- 6 files changed, 1446 insertions(+), 436 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 64d7d34df..df2d99ed2 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -337,6 +337,9 @@ def __init__(self, *_args, **_kwargs): def start(self): return None + def stop(self): + return None + capture_session = None if args.debug_capture: capture_session = start_capture_session() @@ -766,9 +769,8 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non emit_warning(f"Tutorial auto-start failed: {e}") - # Track the current agent task for cancellation on quit - current_agent_task = None queue_start_lock = asyncio.Lock() + shutdown_requested = False async def cancel_active_run(reason: str) -> None: """Aggressively stop shell + agent execution and wait for cancellation.""" @@ -805,24 +807,224 @@ async def cancel_active_run(reason: str) -> None: RUNTIME_STATE.mark_idle() log_event("cancel_done", reason=reason) - def _prepare_queued_task(item: QueuedPrompt) -> tuple[str, str]: - """Normalize queued payload into executable task text and kind.""" - if item.kind == "interject": - task_text = ( - f"[user interjects]: {item.text} - please continue with that in mind" - ) - emit_interject_queue_lifecycle( - RUNTIME_STATE, "dequeued", item=item, level="warning" - ) - return task_text, "interject" + async def shutdown_interactive_session(message: str, *, reason: str) -> None: + """Exit interactive mode and cancel active work if needed.""" + nonlocal shutdown_requested + from code_puppy.messaging import emit_info, emit_success + + shutdown_requested = True + emit_success(message) + if RUNTIME_STATE.bg_task is not None and not RUNTIME_STATE.bg_task.done(): + emit_info("Cancelling running agent task...") + await cancel_active_run(reason) + + async def restore_autosave_state() -> None: + """Handle the /autosave_load command.""" + try: + # Check if we're in a real interactive terminal + # (not pexpect/tests) - interactive picker requires proper TTY + use_interactive_picker = sys.stdin.isatty() and sys.stdout.isatty() + + # Allow environment variable override for tests + if os.getenv("CODE_PUPPY_NO_TUI") == "1": + use_interactive_picker = False + + if use_interactive_picker: + # Use interactive picker for terminal sessions + from code_puppy.agents.agent_manager import get_current_agent + from code_puppy.command_line.autosave_menu import ( + interactive_autosave_picker, + ) + from code_puppy.config import ( + set_current_autosave_from_session_name, + ) + from code_puppy.messaging import emit_error, emit_success, emit_warning + from code_puppy.session_storage import ( + load_session, + restore_autosave_interactively, + ) + + chosen_session = await interactive_autosave_picker() + + if not chosen_session: + emit_warning("Autosave load cancelled") + return - from code_puppy.messaging import emit_success + # Load the session + base_dir = Path(AUTOSAVE_DIR) + history = load_session(chosen_session, base_dir) - emit_success(f"šŸš€ Executing queued prompt: {item.text}") + agent = get_current_agent() + agent.set_message_history(history) + + # Set current autosave session + set_current_autosave_from_session_name(chosen_session) + + total_tokens = sum( + agent.estimate_tokens_for_message(msg) for msg in history + ) + session_path = base_dir / f"{chosen_session}.pkl" + + emit_success( + f"āœ… Autosave loaded: {len(history)} messages ({total_tokens} tokens)\n" + f"šŸ“ From: {session_path}" + ) + + # Display recent message history for context + from code_puppy.command_line.autosave_menu import ( + display_resumed_history, + ) + + display_resumed_history(history) + else: + # Fall back to old text-based picker for tests/non-TTY environments + from code_puppy.session_storage import restore_autosave_interactively + + await restore_autosave_interactively(Path(AUTOSAVE_DIR)) + + except Exception as e: + from code_puppy.messaging import emit_error + + emit_error(f"Failed to load autosave: {e}") + + async def clear_conversation_history() -> None: + """Reset the current session history and clipboard attachments.""" + from code_puppy.agents.agent_manager import get_current_agent + from code_puppy.command_line.clipboard import get_clipboard_manager + from code_puppy.messaging import emit_info, emit_system_message, emit_warning + + agent = get_current_agent() + new_session_id = finalize_autosave_session() + agent.clear_message_history() + emit_warning("Conversation history cleared!") + emit_system_message("The agent will not remember previous interactions.") + emit_info(f"Auto-save session rotated to: {new_session_id}") + + clipboard_manager = get_clipboard_manager() + clipboard_count = clipboard_manager.get_pending_count() + clipboard_manager.clear_pending() + if clipboard_count > 0: + emit_info(f"Cleared {clipboard_count} pending clipboard image(s)") + + def is_exit_text(text: str) -> bool: + """Check if text should terminate interactive mode.""" + return text.strip().lower() in {"exit", "quit", "/exit", "/quit"} + + def queue_level(item: QueuedPrompt) -> str: + """Return the lifecycle level for a queued item.""" + return "warning" if item.kind == "interject" else "success" + + def emit_queue_dispatch(item: QueuedPrompt) -> None: + """Emit UI markers before a queued/interjected item is dispatched.""" + if item.kind == "queued": + from code_puppy.messaging import emit_success + + emit_success(f"šŸš€ Executing queued prompt: {item.text}") emit_interject_queue_lifecycle( - RUNTIME_STATE, "dequeued", item=item, level="success" + RUNTIME_STATE, + "dequeued", + item=item, + level=queue_level(item), + ) + + def complete_queue_item(item: QueuedPrompt, reason: str) -> None: + """Mark a queued item as handled without launching the agent.""" + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "completed", + item=item, + reason=reason, + level=queue_level(item), + ) + + async def handle_live_running_submission(task_text: str) -> str: + """Handle input that arrives while an agent run is active.""" + from code_puppy.command_line.prompt_toolkit_completion import ( + get_interject_action, ) - return item.text, "queued" + from code_puppy.messaging import emit_info, emit_warning + + save_command_to_history(task_text) + + try: + action = await get_interject_action() + if not action: + return "consumed" + except (KeyboardInterrupt, EOFError): + return "consumed" + + selected = action.strip().lower() + log_event("interject_choice", action=selected, prompt=task_text.strip()) + + if selected == "i": + log_event("interject_banner", text=task_text.strip()) + await cancel_active_run("interject") + ok, position, item = RUNTIME_STATE.request_interject(task_text.strip()) + if not ok: + emit_warning("Queue full (25). Cannot interject right now.") + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "rejected", + reason="full_interject", + level="error", + ) + log_event("queue_reject", prompt=task_text.strip(), reason="full_interject") + return "consumed" + + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "queued", + item=item, + position=position, + level="warning", + ) + log_event( + "queued_interject", + text=task_text.strip(), + position=1, + size=len(RUNTIME_STATE.queue), + ) + handled = await drain_pending_work_if_idle(origin="interject_enqueued") + log_event( + "interject_queue_kick_attempted", + remaining=len(RUNTIME_STATE.queue), + running=RUNTIME_STATE.running, + handled=handled, + ) + return "consumed" + + if selected == "q": + ok, position, item = RUNTIME_STATE.request_queue(task_text.strip()) + if not ok: + emit_warning("Queue full (25). Prompt was not queued.") + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "rejected", + reason="full", + level="error", + ) + log_event("queue_reject", prompt=task_text.strip(), reason="full") + return "consumed" + + emit_info(f"Queued (position {position}): {task_text.strip()}") + emit_interject_queue_lifecycle( + RUNTIME_STATE, + "queued", + item=item, + position=position, + level="info", + ) + log_event( + "queued_prompt", + text=task_text.strip(), + position=position, + size=len(RUNTIME_STATE.queue), + ) + await drain_pending_work_if_idle(origin="queue_enqueued") + return "consumed" + + emit_warning("Cancelled action.") + return "consumed" async def run_agent_bg( task_text, agent, source_item: QueuedPrompt | None = None @@ -838,7 +1040,7 @@ async def run_agent_bg( item=source_item, level="warning" if source_item.kind == "interject" else "success", ) - result, current_agent_task = await run_prompt_with_attachments( + result, _ = await run_prompt_with_attachments( agent, task_text, spinner_console=message_renderer.console, @@ -913,38 +1115,217 @@ async def run_agent_bg( RUNTIME_STATE.mark_idle() log_event("agent_end", prompt=task_text) if was_cancelling: + if shutdown_requested: + log_event( + "queue_autodrain_skipped", + reason="shutdown_requested", + remaining=len(RUNTIME_STATE.queue), + ) + return log_event( "queue_autodrain_skipped", reason="cancelling", remaining=len(RUNTIME_STATE.queue), ) asyncio.create_task( - kick_queue_after_cancel_boundary( - RUNTIME_STATE, - queue_start_lock, - _queue_run_factory, + kick_drain_after_cancel_boundary( origin="cancel_boundary_fallback", ) ) return - await start_next_queued_if_idle( - RUNTIME_STATE, - queue_start_lock, - _queue_run_factory, - origin="run_complete", - ) + await drain_pending_work_if_idle(origin="run_complete") + + async def dispatch_submission( + task_text: str, + *, + source_item: QueuedPrompt | None = None, + save_history: bool = True, + allow_command_dispatch: bool = True, + ) -> str: + """Normalize a submitted prompt into exit, command handling, or agent work.""" + raw_task = task_text + stripped_task = raw_task.strip() + if not stripped_task: + if source_item: + complete_queue_item(source_item, "empty") + return "noop" + + if source_item is None and is_exit_text(stripped_task): + await shutdown_interactive_session("Goodbye!", reason="user_exit") + return "exit" + + if source_item is None and RUNTIME_STATE.running: + return await handle_live_running_submission(raw_task) + + if source_item: + emit_queue_dispatch(source_item) + + if allow_command_dispatch and stripped_task.lower() in {"clear", "/clear"}: + await clear_conversation_history() + if source_item: + complete_queue_item(source_item, "clear") + return "consumed" + + candidate_task = raw_task + if allow_command_dispatch: + processed_for_commands = parse_prompt_attachments(raw_task) + cleaned_for_commands = (processed_for_commands.prompt or "").strip() + + if source_item and is_exit_text(cleaned_for_commands or stripped_task): + from code_puppy.messaging import emit_warning + + emit_warning("Skipping queued exit command. Use /exit directly.") + complete_queue_item(source_item, "exit_skipped") + return "consumed" + + if cleaned_for_commands.startswith("/"): + try: + command_result = handle_command(cleaned_for_commands) + except Exception as e: + from code_puppy.messaging import emit_error + + emit_error(f"Command error: {e}") + if source_item: + complete_queue_item(source_item, "command_error") + return "consumed" + + if command_result is True: + if source_item: + complete_queue_item(source_item, "command_consumed") + return "consumed" + + if isinstance(command_result, str): + if command_result == "__AUTOSAVE_LOAD__": + await restore_autosave_state() + if source_item: + complete_queue_item(source_item, "autosave_load") + return "consumed" + candidate_task = command_result + + candidate_task = candidate_task.strip() + if not candidate_task: + if source_item: + complete_queue_item(source_item, "empty") + return "noop" + + if save_history: + save_command_to_history(raw_task) - def _queue_run_factory(next_item: QueuedPrompt) -> asyncio.Task: - next_task, _ = _prepare_queued_task(next_item) from code_puppy.agents.agent_manager import get_current_agent - return asyncio.create_task( - run_agent_bg( - next_task, - get_current_agent(), - source_item=next_item, + RUNTIME_STATE.mark_running( + asyncio.create_task( + run_agent_bg( + candidate_task, + get_current_agent(), + source_item=source_item, + ) ) ) + return "launched" + + async def dispatch_wiggum_if_idle() -> str: + """Start the next wiggum loop iteration when no queued work exists.""" + from code_puppy.command_line.wiggum_state import ( + get_wiggum_prompt, + increment_wiggum_count, + is_wiggum_active, + stop_wiggum, + ) + from code_puppy.messaging import emit_system_message, emit_warning + + if not is_wiggum_active(): + return "noop" + + wiggum_prompt = get_wiggum_prompt() + if not wiggum_prompt: + stop_wiggum() + return "consumed" + + loop_num = increment_wiggum_count() + emit_warning(f"\nšŸ© WIGGUM RELOOPING! (Loop #{loop_num})") + emit_system_message(f"Re-running prompt: {wiggum_prompt}") + + current_agent = get_current_agent() + new_session_id = finalize_autosave_session() + current_agent.clear_message_history() + emit_system_message(f"Context cleared. Session rotated to: {new_session_id}") + await asyncio.sleep(0.5) + + try: + return await dispatch_submission( + wiggum_prompt, + save_history=False, + allow_command_dispatch=False, + ) + except KeyboardInterrupt: + emit_warning("\nšŸ© Wiggum loop interrupted by Ctrl+C") + stop_wiggum() + return "consumed" + except Exception as e: + from code_puppy.messaging import emit_error + + emit_error(f"Wiggum loop error: {e}") + stop_wiggum() + return "consumed" + + async def drain_pending_work_if_idle(*, origin: str) -> bool: + """Single-flight idle drain for queued prompts and wiggum reruns.""" + handled_any = False + + async with queue_start_lock: + while True: + if RUNTIME_STATE.running: + active_task = RUNTIME_STATE.bg_task + if active_task is None or active_task.done(): + RUNTIME_STATE.mark_idle() + log_event( + "queue_autodrain_reconciled", + origin=origin, + had_task=active_task is not None, + task_done=active_task.done() if active_task is not None else None, + ) + else: + log_event("queue_autodrain_noop", origin=origin, reason="running") + return handled_any + + next_item = RUNTIME_STATE.dequeue() + if next_item is not None: + outcome = await dispatch_submission( + next_item.text + if next_item.kind == "queued" + else f"[user interjects]: {next_item.text} - please continue with that in mind", + source_item=next_item, + save_history=False, + allow_command_dispatch=next_item.kind == "queued", + ) + handled_any = True + if outcome == "launched": + log_event( + "queue_autodrain_triggered", + origin=origin, + remaining=len(RUNTIME_STATE.queue), + kind=next_item.kind, + text=next_item.text, + ) + return True + continue + + outcome = await dispatch_wiggum_if_idle() + if outcome == "launched": + log_event("queue_autodrain_triggered", origin=origin, kind="wiggum") + return True + if outcome == "consumed": + log_event("queue_autodrain_consumed", origin=origin, kind="wiggum") + return True + + log_event("queue_autodrain_noop", origin=origin, reason="empty") + return handled_any + + async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: + """Yield once before draining, so cancellation state fully settles.""" + await asyncio.sleep(0) + return await drain_pending_work_if_idle(origin=origin) while True: from code_puppy.agents.agent_manager import get_current_agent @@ -953,14 +1334,9 @@ def _queue_run_factory(next_item: QueuedPrompt) -> asyncio.Task: # Get the custom prompt from the current agent, or use default current_agent = get_current_agent() user_prompt = current_agent.get_user_prompt() or "Enter your coding task:" - if not RUNTIME_STATE.running and RUNTIME_STATE.queue: - started = await start_next_queued_if_idle( - RUNTIME_STATE, - queue_start_lock, - _queue_run_factory, - origin="loop_idle_check", - ) - if started: + if not RUNTIME_STATE.running: + handled = await drain_pending_work_if_idle(origin="loop_idle_check") + if handled: continue if not RUNTIME_STATE.running: @@ -1012,346 +1388,12 @@ def _queue_run_factory(next_item: QueuedPrompt) -> asyncio.Task: continue except EOFError: # Handle Ctrl+D - exit the application - from code_puppy.messaging import emit_success - - emit_success("\nGoodbye! (Ctrl+D)") - - # Cancel any running agent task for clean shutdown - if current_agent_task and not current_agent_task.done(): - emit_info("Cancelling running agent task...") - current_agent_task.cancel() - try: - await current_agent_task - except asyncio.CancelledError: - pass # Expected when cancelling - + await shutdown_interactive_session("\nGoodbye! (Ctrl+D)", reason="ctrl_d") break - - # Check for exit commands (plain text or command form) - if task.strip().lower() in ["exit", "quit"] or task.strip().lower() in [ - "/exit", - "/quit", - ]: - from code_puppy.messaging import emit_success - - emit_success("Goodbye!") - - # Cancel any running agent task for clean shutdown - if current_agent_task and not current_agent_task.done(): - emit_info("Cancelling running agent task...") - current_agent_task.cancel() - try: - await current_agent_task - except asyncio.CancelledError: - pass # Expected when cancelling - - # The renderer is stopped in the finally block of main(). + outcome = await dispatch_submission(task) + if outcome == "exit": break - # Check for clear command (supports both `clear` and `/clear`) - if task.strip().lower() in ("clear", "/clear"): - from code_puppy.command_line.clipboard import get_clipboard_manager - from code_puppy.messaging import ( - emit_info, - emit_system_message, - emit_warning, - ) - - agent = get_current_agent() - new_session_id = finalize_autosave_session() - agent.clear_message_history() - emit_warning("Conversation history cleared!") - emit_system_message("The agent will not remember previous interactions.") - emit_info(f"Auto-save session rotated to: {new_session_id}") - - # Also clear pending clipboard images - clipboard_manager = get_clipboard_manager() - clipboard_count = clipboard_manager.get_pending_count() - clipboard_manager.clear_pending() - if clipboard_count > 0: - emit_info(f"Cleared {clipboard_count} pending clipboard image(s)") - continue - - # Parse attachments first so leading paths aren't misread as commands - processed_for_commands = parse_prompt_attachments(task) - cleaned_for_commands = (processed_for_commands.prompt or "").strip() - - # Handle / commands based on cleaned prompt (after stripping attachments) - if cleaned_for_commands.startswith("/"): - try: - command_result = handle_command(cleaned_for_commands) - except Exception as e: - from code_puppy.messaging import emit_error - - emit_error(f"Command error: {e}") - # Continue interactive loop instead of exiting - continue - if command_result is True: - continue - elif isinstance(command_result, str): - if command_result == "__AUTOSAVE_LOAD__": - # Handle async autosave loading - try: - # Check if we're in a real interactive terminal - # (not pexpect/tests) - interactive picker requires proper TTY - use_interactive_picker = ( - sys.stdin.isatty() and sys.stdout.isatty() - ) - - # Allow environment variable override for tests - if os.getenv("CODE_PUPPY_NO_TUI") == "1": - use_interactive_picker = False - - if use_interactive_picker: - # Use interactive picker for terminal sessions - from code_puppy.agents.agent_manager import ( - get_current_agent, - ) - from code_puppy.command_line.autosave_menu import ( - interactive_autosave_picker, - ) - from code_puppy.config import ( - set_current_autosave_from_session_name, - ) - from code_puppy.messaging import ( - emit_error, - emit_success, - emit_warning, - ) - from code_puppy.session_storage import ( - load_session, - restore_autosave_interactively, - ) - - chosen_session = await interactive_autosave_picker() - - if not chosen_session: - emit_warning("Autosave load cancelled") - continue - - # Load the session - base_dir = Path(AUTOSAVE_DIR) - history = load_session(chosen_session, base_dir) - - agent = get_current_agent() - agent.set_message_history(history) - - # Set current autosave session - set_current_autosave_from_session_name(chosen_session) - - total_tokens = sum( - agent.estimate_tokens_for_message(msg) - for msg in history - ) - session_path = base_dir / f"{chosen_session}.pkl" - - emit_success( - f"āœ… Autosave loaded: {len(history)} messages ({total_tokens} tokens)\n" - f"šŸ“ From: {session_path}" - ) - - # Display recent message history for context - from code_puppy.command_line.autosave_menu import ( - display_resumed_history, - ) - - display_resumed_history(history) - else: - # Fall back to old text-based picker for tests/non-TTY environments - await restore_autosave_interactively(Path(AUTOSAVE_DIR)) - - except Exception as e: - from code_puppy.messaging import emit_error - - emit_error(f"Failed to load autosave: {e}") - continue - else: - # Command returned a prompt to execute - task = command_result - elif command_result is False: - # Command not recognized, continue with normal processing - pass - - if task.strip(): - # Write to the secret file for permanent history with timestamp - save_command_to_history(task) - - if RUNTIME_STATE.running: - from code_puppy.messaging import emit_warning, emit_info - - try: - from code_puppy.command_line.prompt_toolkit_completion import get_interject_action - action = await get_interject_action() - if not action: - continue - except (KeyboardInterrupt, EOFError): - continue - - a = action.strip().lower() - log_event("interject_choice", action=a, prompt=task.strip()) - if a == "i": - log_event("interject_banner", text=task.strip()) - await cancel_active_run("interject") - ok, position, item = RUNTIME_STATE.request_interject(task.strip()) - if not ok: - emit_warning("Queue full (25). Cannot interject right now.") - emit_interject_queue_lifecycle( - RUNTIME_STATE, - "rejected", - reason="full_interject", - level="error", - ) - log_event("queue_reject", prompt=task.strip(), reason="full_interject") - else: - emit_interject_queue_lifecycle( - RUNTIME_STATE, - "queued", - item=item, - position=position, - level="warning", - ) - log_event( - "queued_interject", - text=task.strip(), - position=1, - size=len(RUNTIME_STATE.queue), - ) - await start_next_queued_if_idle( - RUNTIME_STATE, - queue_start_lock, - _queue_run_factory, - origin="interject_enqueued", - ) - log_event( - "interject_queue_kick_attempted", - remaining=len(RUNTIME_STATE.queue), - running=RUNTIME_STATE.running, - ) - elif a == "q": - ok, position, item = RUNTIME_STATE.request_queue(task.strip()) - if not ok: - emit_warning("Queue full (25). Prompt was not queued.") - emit_interject_queue_lifecycle( - RUNTIME_STATE, "rejected", reason="full", level="error" - ) - log_event("queue_reject", prompt=task.strip(), reason="full") - else: - emit_info(f"Queued (position {position}): {task.strip()}") - emit_interject_queue_lifecycle( - RUNTIME_STATE, - "queued", - item=item, - position=position, - level="info", - ) - log_event( - "queued_prompt", - text=task.strip(), - position=position, - size=len(RUNTIME_STATE.queue), - ) - else: - emit_warning("Cancelled action.") - continue - - RUNTIME_STATE.mark_running(asyncio.create_task(run_agent_bg(task, current_agent))) - continue - - # Auto-save session if enabled (moved outside the try block to avoid being swallowed) - from code_puppy.config import auto_save_session_if_enabled - - auto_save_session_if_enabled() - - # ================================================================ - # WIGGUM LOOP: Re-run prompt if wiggum mode is active - # ================================================================ - from code_puppy.command_line.wiggum_state import ( - get_wiggum_prompt, - increment_wiggum_count, - is_wiggum_active, - stop_wiggum, - ) - - while is_wiggum_active(): - wiggum_prompt = get_wiggum_prompt() - if not wiggum_prompt: - stop_wiggum() - break - - # Increment and show debug message - loop_num = increment_wiggum_count() - from code_puppy.messaging import emit_system_message, emit_warning - - emit_warning(f"\nšŸ© WIGGUM RELOOPING! (Loop #{loop_num})") - emit_system_message(f"Re-running prompt: {wiggum_prompt}") - - # Reset context/history for fresh start - new_session_id = finalize_autosave_session() - current_agent.clear_message_history() - emit_system_message( - f"Context cleared. Session rotated to: {new_session_id}" - ) - - # Small delay to let user see the debug message - - await asyncio.sleep(0.5) - - try: - # Re-run the wiggum prompt - result, current_agent_task = await run_prompt_with_attachments( - current_agent, - wiggum_prompt, - spinner_console=message_renderer.console, - ) - - if result is None: - # Cancelled - stop wiggum mode - emit_warning("Wiggum loop cancelled by user") - stop_wiggum() - break - - # Get the structured response - agent_response = result.output - - # Emit structured message for proper markdown rendering - response_msg = AgentResponseMessage( - content=agent_response, - is_markdown=True, - ) - get_message_bus().emit(response_msg) - - # Update message history - if hasattr(result, "all_messages"): - current_agent.set_message_history(list(result.all_messages())) - - # Flush console - if hasattr(display_console.file, "flush"): - display_console.file.flush() - await asyncio.sleep(0.1) - - # Auto-save - auto_save_session_if_enabled() - - except KeyboardInterrupt: - emit_warning("\nšŸ© Wiggum loop interrupted by Ctrl+C") - stop_wiggum() - break - except Exception as e: - from code_puppy.messaging import emit_error - - emit_error(f"Wiggum loop error: {e}") - stop_wiggum() - break - - # Re-disable Ctrl+C if needed (uvx mode) - must be done after - # each iteration as various operations may restore console mode - try: - from code_puppy.terminal_utils import ensure_ctrl_c_disabled - - ensure_ctrl_c_disabled() - except ImportError: - pass - async def run_prompt_with_attachments( agent, diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 0bf9cdd56..74001e971 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -9,13 +9,13 @@ import asyncio import os import sys -from typing import Optional +from dataclasses import dataclass +from typing import Literal, Optional from prompt_toolkit import PromptSession from prompt_toolkit.patch_stdout import patch_stdout -from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.completion import Completer, Completion, merge_completers -from prompt_toolkit.filters import is_searching +from prompt_toolkit.filters import Condition, is_searching from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.history import FileHistory from prompt_toolkit.key_binding import KeyBindings @@ -54,6 +54,79 @@ _QUEUE_VIEW_OFFSET = 0 +@dataclass +class _PromptRuntimeState: + kind: Literal["main", "interject"] | None = None + session: PromptSession | None = None + app: object | None = None + shell_suspended: bool = False + + +_PROMPT_RUNTIME_STATE = _PromptRuntimeState() + + +def register_active_prompt_surface( + kind: Literal["main", "interject"], session: PromptSession +) -> None: + """Track the mounted prompt surface so shells can suspend it in-place.""" + _PROMPT_RUNTIME_STATE.kind = kind + _PROMPT_RUNTIME_STATE.session = session + _PROMPT_RUNTIME_STATE.app = getattr(session, "app", None) + _PROMPT_RUNTIME_STATE.shell_suspended = False + + +def clear_active_prompt_surface(session: PromptSession | None = None) -> None: + """Clear the tracked prompt surface if it matches the current session.""" + if session is not None and _PROMPT_RUNTIME_STATE.session is not session: + return + _PROMPT_RUNTIME_STATE.kind = None + _PROMPT_RUNTIME_STATE.session = None + _PROMPT_RUNTIME_STATE.app = None + _PROMPT_RUNTIME_STATE.shell_suspended = False + + +def get_active_prompt_surface_kind() -> Literal["main", "interject"] | None: + return _PROMPT_RUNTIME_STATE.kind + + +def has_active_prompt_surface() -> bool: + return _PROMPT_RUNTIME_STATE.session is not None + + +def is_shell_prompt_suspended() -> bool: + return ( + _PROMPT_RUNTIME_STATE.session is not None + and _PROMPT_RUNTIME_STATE.shell_suspended + ) + + +def set_shell_prompt_suspended(suspended: bool) -> None: + """Flip shell suspension on the mounted prompt and force a redraw.""" + if _PROMPT_RUNTIME_STATE.session is None: + _PROMPT_RUNTIME_STATE.shell_suspended = False + return + + _PROMPT_RUNTIME_STATE.shell_suspended = suspended + _PROMPT_RUNTIME_STATE.app = getattr( + _PROMPT_RUNTIME_STATE.session, "app", _PROMPT_RUNTIME_STATE.app + ) + + app = _PROMPT_RUNTIME_STATE.app + if app is not None: + try: + app.invalidate() + except Exception: + pass + + +def _interrupt_shell_from_prompt(label: str) -> None: + from code_puppy.messaging import emit_warning + from code_puppy.tools.command_runner import kill_all_running_shell_processes + + emit_warning(f"\nšŸ›‘ {label} detected! Interrupting shell command...") + kill_all_running_shell_processes() + + def _truncate_queue_line(text: str, max_len: int) -> str: if max_len <= 2: return ".." @@ -611,15 +684,16 @@ def get_prompt_with_active_model(base: str = ">>> ", is_interject: bool = False) if is_interject: # Add hint above the prompt line to keep the cursor position consistent parts.append(("class:queue-item", " [i]nterject or [q]ueue\n")) - parts.extend([ - ("class:separator", "╰─"), - ("class:arrow", "āÆ "), - ]) - else: - parts.extend([ - ("class:separator", "╰─"), - ("class:arrow", "āÆ "), - ]) + + if is_shell_prompt_suspended(): + parts.append( + ("class:suspension", " input suspended during shell command\n") + ) + + parts.extend([ + ("class:separator", "╰─"), + ("class:arrow", "āÆ "), + ]) return FormattedText(parts) @@ -648,6 +722,7 @@ async def get_input_with_combined_completion( ) # Add custom key bindings and multiline toggle bindings = KeyBindings() + shell_suspended_filter = Condition(is_shell_prompt_suspended) # Multiline mode state multiline = {"enabled": False} @@ -655,6 +730,9 @@ async def get_input_with_combined_completion( # Ctrl+X keybinding - exit with KeyboardInterrupt for shell command cancellation @bindings.add(Keys.ControlX) def _(event): + if is_shell_prompt_suspended(): + _interrupt_shell_from_prompt("Ctrl-X") + return try: event.app.exit(exception=KeyboardInterrupt) except Exception: @@ -662,15 +740,25 @@ def _(event): # This happens when user presses multiple exit keys in quick succession pass + @bindings.add("c-c", filter=shell_suspended_filter, eager=True) + def _(event): + _interrupt_shell_from_prompt("Ctrl-C") + # Escape keybinding - exit with KeyboardInterrupt @bindings.add(Keys.Escape) def _(event): + if is_shell_prompt_suspended(): + return try: event.app.exit(exception=KeyboardInterrupt) except Exception: # Ignore "Return value already set" errors when exit was already called pass + @bindings.add("c-d", filter=shell_suspended_filter, eager=True) + def _(event): + return + # NOTE: We intentionally do NOT override Ctrl+C here. # prompt_toolkit's default Ctrl+C handler properly resets the terminal state on Windows. # Overriding it with event.app.exit(exception=KeyboardInterrupt) can leave the terminal @@ -679,6 +767,8 @@ def _(event): # Toggle multiline with Alt+M @bindings.add(Keys.Escape, "m") def _(event): + if is_shell_prompt_suspended(): + return multiline["enabled"] = not multiline["enabled"] status = "ON" if multiline["enabled"] else "OFF" # Print status for user feedback (version-agnostic) @@ -689,6 +779,8 @@ def _(event): # Also toggle multiline with F2 (more reliable across platforms) @bindings.add("f2") def _(event): + if is_shell_prompt_suspended(): + return multiline["enabled"] = not multiline["enabled"] status = "ON" if multiline["enabled"] else "OFF" sys.stdout.write(f"[multiline] {status}\n") @@ -698,6 +790,8 @@ def _(event): # Ctrl+J (line feed) works in virtually all terminals; mark eager so it wins @bindings.add("c-j", eager=True) def _(event): + if is_shell_prompt_suspended(): + return event.app.current_buffer.insert_text("\n") # Also allow Ctrl+Enter for newline (terminal-dependent) @@ -705,6 +799,8 @@ def _(event): @bindings.add("c-enter", eager=True) def _(event): + if is_shell_prompt_suspended(): + return event.app.current_buffer.insert_text("\n") except Exception: pass @@ -712,6 +808,8 @@ def _(event): # Enter behavior depends on multiline mode @bindings.add("enter", filter=~is_searching, eager=True) def _(event): + if is_shell_prompt_suspended(): + return if multiline["enabled"]: event.app.current_buffer.insert_text("\n") else: @@ -723,6 +821,8 @@ def _(event): @bindings.add("c-h", eager=True) # Backspace (Ctrl+H) @bindings.add("backspace", eager=True) def handle_backspace_with_completion(event): + if is_shell_prompt_suspended(): + return buffer = event.app.current_buffer # Perform the deletion first buffer.delete_before_cursor(count=1) @@ -733,6 +833,8 @@ def handle_backspace_with_completion(event): @bindings.add("delete", eager=True) def handle_delete_with_completion(event): + if is_shell_prompt_suspended(): + return buffer = event.app.current_buffer # Perform the deletion first buffer.delete(count=1) @@ -768,6 +870,8 @@ def handle_queue_scroll_down(event): @bindings.add(Keys.BracketedPaste) def handle_bracketed_paste(event): """Handle bracketed paste - smart text vs image detection.""" + if is_shell_prompt_suspended(): + return pasted_data = event.data # If we have meaningful text content, paste it (don't check for images) @@ -798,6 +902,8 @@ def handle_bracketed_paste(event): @bindings.add("c-v", eager=True) def handle_smart_paste(event): """Handle Ctrl+V - auto-detect image vs text in clipboard.""" + if is_shell_prompt_suspended(): + return try: # Check for image first if has_image_in_clipboard(): @@ -865,6 +971,8 @@ def handle_smart_paste(event): @bindings.add("f3") def handle_image_paste_f3(event): """Handle F3 - paste image from clipboard (image-only, shows error if none).""" + if is_shell_prompt_suspended(): + return try: if has_image_in_clipboard(): placeholder = capture_clipboard_image_to_pending() @@ -895,6 +1003,7 @@ def handle_image_paste_f3(event): output=out, erase_when_done=erase_when_done ) + session.default_buffer.read_only = shell_suspended_filter # If they pass a string, backward-compat: convert it to formatted_text if isinstance(prompt_str, str): from prompt_toolkit.formatted_text import FormattedText @@ -913,10 +1022,15 @@ def handle_image_paste_f3(event): "separator": "bold ansigray", "attachment-placeholder": "italic ansicyan", "queue-item": "italic ansiyellow", + "suspension": "bold ansibrightred", } ) - with patch_stdout(raw=True): - text = await session.prompt_async(prompt_str, style=style) + register_active_prompt_surface("main", session) + try: + with patch_stdout(raw=True): + text = await session.prompt_async(prompt_str, style=style) + finally: + clear_active_prompt_surface(session) # NOTE: We used to call update_model_in_input(text) here to handle /model and /m # commands at the prompt level, but that prevented the command handler from running # and emitting success messages. Now we let all /model commands fall through to @@ -926,9 +1040,6 @@ def handle_image_paste_f3(event): async def get_interject_action() -> str: - from prompt_toolkit import PromptSession - from prompt_toolkit.key_binding import KeyBindings - from prompt_toolkit.patch_stdout import patch_stdout from code_puppy.messaging.spinner import pause_all_spinners, resume_all_spinners bindings = KeyBindings() @@ -938,6 +1049,8 @@ async def get_interject_action() -> str: @bindings.add('I') def _(event): nonlocal result + if is_shell_prompt_suspended(): + return result = 'i' event.app.exit(result='i') @@ -945,15 +1058,28 @@ def _(event): @bindings.add('Q') def _(event): nonlocal result + if is_shell_prompt_suspended(): + return result = 'q' event.app.exit(result='q') @bindings.add('c-c') def _(event): + if is_shell_prompt_suspended(): + _interrupt_shell_from_prompt("Ctrl-C") + return raise KeyboardInterrupt() + @bindings.add(Keys.ControlX) + def _(event): + if is_shell_prompt_suspended(): + _interrupt_shell_from_prompt("Ctrl-X") + return + @bindings.add('c-d') def _(event): + if is_shell_prompt_suspended(): + return raise EOFError() @bindings.add('') @@ -979,7 +1105,7 @@ def _(event): _QUEUE_VIEW_OFFSET = min(len(PROMPT_QUEUE) - 3, _QUEUE_VIEW_OFFSET + 1) event.app.invalidate() - prompt_text = get_prompt_with_active_model(is_interject=True) + prompt_text = lambda: get_prompt_with_active_model(is_interject=True) from prompt_toolkit.output.defaults import create_output import sys out = create_output(stdout=sys.stdout) @@ -991,6 +1117,7 @@ def _(event): output=out, erase_when_done=True ) + session.default_buffer.read_only = Condition(is_shell_prompt_suspended) from prompt_toolkit.styles import Style style = Style.from_dict({ @@ -1003,26 +1130,31 @@ def _(event): "separator": "bold ansigray", "attachment-placeholder": "italic ansicyan", "queue-item": "italic ansiyellow", + "suspension": "bold ansibrightred", }) - with patch_stdout(raw=True): - # We catch the result of app.exit(result=...) via session.prompt_async() - try: - # Pause spinners to prevent jitter and allow clean input - pause_all_spinners() - - # We don't actually want them to type anything, just press a key - # session.prompt_async returns the text typed if they press enter, - # but our keybindings will exit early with the bound result. - action = await session.prompt_async(style=style) - if result: - return result - return action - except (KeyboardInterrupt, EOFError): - raise - finally: - # Resume spinners when done - resume_all_spinners() + register_active_prompt_surface("interject", session) + try: + with patch_stdout(raw=True): + # We catch the result of app.exit(result=...) via session.prompt_async() + try: + # Pause spinners to prevent jitter and allow clean input + pause_all_spinners() + + # We don't actually want them to type anything, just press a key + # session.prompt_async returns the text typed if they press enter, + # but our keybindings will exit early with the bound result. + action = await session.prompt_async(style=style) + if result: + return result + return action + except (KeyboardInterrupt, EOFError): + raise + finally: + # Resume spinners when done + resume_all_spinners() + finally: + clear_active_prompt_surface(session) diff --git a/code_puppy/tools/command_runner.py b/code_puppy/tools/command_runner.py index aa773acea..44f3afb7f 100644 --- a/code_puppy/tools/command_runner.py +++ b/code_puppy/tools/command_runner.py @@ -285,6 +285,16 @@ def set_awaiting_user_input(awaiting=True): pass # Spinner functionality not available +def _normalize_shell_cwd(cwd: str | None) -> str | None: + """Normalize empty shell cwd values to None.""" + if cwd is None: + return None + normalized = cwd.strip() + if not normalized: + return None + return normalized + + class ShellCommandOutput(BaseModel): success: bool command: str | None @@ -338,6 +348,12 @@ def _listen_for_ctrl_x_windows( # Note: msvcrt.getwch() returns unicode string on Windows key = msvcrt.getwch() + if key in {"\x00", "\xe0"}: + # Discard the follow-up code for special keys. + if msvcrt.kbhit(): + msvcrt.getwch() + continue + # Check for Ctrl+X (\x18) or other interrupt keys # Some terminals might not send \x18, so also check for 'x' with modifier if key == "\x18": # Standard Ctrl+X @@ -347,6 +363,7 @@ def _listen_for_ctrl_x_windows( emit_warning( "Ctrl+X handler raised unexpectedly; Ctrl+C still works." ) + continue # Note: In some Windows terminals, Ctrl+X might not be captured # Users can use Ctrl+C as alternative, which is handled by signal handler except (OSError, ValueError): @@ -399,8 +416,12 @@ def _listen_for_ctrl_x_posix( emit_warning( "Ctrl+X handler raised unexpectedly; Ctrl+C still works." ) + continue finally: - termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs) + try: + termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs) + except Exception: + pass def _spawn_ctrl_x_key_listener( @@ -934,10 +955,12 @@ def nuclear_kill(proc): async def run_shell_command( context: RunContext, command: str, - cwd: str = None, + cwd: str | None = None, timeout: int = 60, background: bool = False, ) -> ShellCommandOutput: + cwd = _normalize_shell_cwd(cwd) + # Generate unique group_id for this command execution group_id = generate_group_id("shell_command", command) @@ -1163,6 +1186,8 @@ async def _execute_shell_command( Returns: ShellCommandOutput with execution results """ + cwd = _normalize_shell_cwd(cwd) + # Always emit the ShellStartMessage banner (even for sub-agents) bus = get_message_bus() bus.emit( @@ -1178,13 +1203,41 @@ async def _execute_shell_command( pause_all_spinners() - # Acquire shared keyboard context - Ctrl-X/Ctrl-C will kill ALL running commands - # This is reference-counted: listener starts on first command, stops on last - _acquire_keyboard_context() + suspend_prompt = False + release_keyboard_context = False + + try: + from code_puppy.command_line.prompt_toolkit_completion import ( + has_active_prompt_surface, + set_shell_prompt_suspended, + ) + except ImportError: + has_active_prompt_surface = lambda: False # type: ignore[assignment] + set_shell_prompt_suspended = None # type: ignore[assignment] + + if has_active_prompt_surface() and set_shell_prompt_suspended is not None: + try: + set_shell_prompt_suspended(True) + suspend_prompt = True + except Exception: + _acquire_keyboard_context() + release_keyboard_context = True + else: + # Acquire shared keyboard context - Ctrl-X/Ctrl-C will kill ALL running commands + # This is reference-counted: listener starts on first command, stops on last + _acquire_keyboard_context() + release_keyboard_context = True + try: return await _run_command_inner(command, cwd, timeout, group_id, silent=silent) finally: - _release_keyboard_context() + if suspend_prompt and set_shell_prompt_suspended is not None: + try: + set_shell_prompt_suspended(False) + except Exception: + pass + if release_keyboard_context: + _release_keyboard_context() resume_all_spinners() @@ -1319,7 +1372,7 @@ def register_agent_run_shell_command(agent): async def agent_run_shell_command( context: RunContext, command: str = "", - cwd: str = None, + cwd: str | None = None, timeout: int = 60, background: bool = False, ) -> ShellCommandOutput: diff --git a/tests/test_cli_runner_full_coverage.py b/tests/test_cli_runner_full_coverage.py index 0e2d3c985..f086becfa 100644 --- a/tests/test_cli_runner_full_coverage.py +++ b/tests/test_cli_runner_full_coverage.py @@ -518,24 +518,36 @@ async def fake_input(*a, **kw): @pytest.mark.anyio async def test_keyboard_interrupt_stops_wiggum(self): call_count = 0 + wiggum_active = {"value": False} async def fake_input(*a, **kw): nonlocal call_count call_count += 1 if call_count == 1: + wiggum_active["value"] = True raise KeyboardInterrupt return "/exit" mock_stop = MagicMock() + + def fake_wiggum_active(): + return wiggum_active["value"] + + def fake_stop_wiggum(): + wiggum_active["value"] = False + mock_stop() + await _run_interactive( _mock_renderer(), _interactive_patches(), fake_input, extra_patches={ "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( - return_value=True + side_effect=fake_wiggum_active + ), + "code_puppy.command_line.wiggum_state.stop_wiggum": MagicMock( + side_effect=fake_stop_wiggum ), - "code_puppy.command_line.wiggum_state.stop_wiggum": mock_stop, }, ) mock_stop.assert_called() @@ -704,25 +716,45 @@ async def fake_input(*a, **kw): @pytest.mark.anyio async def test_prompt_cancelled_wiggum_active(self): call_count = 0 + run_started = asyncio.Event() + wiggum_active = {"value": False} async def fake_input(*a, **kw): nonlocal call_count call_count += 1 - return "write hello" if call_count == 1 else "/exit" + if call_count == 1: + return "write hello" + await asyncio.wait_for(run_started.wait(), timeout=1) + return "/exit" mock_stop = MagicMock() + + def fake_wiggum_active(): + return wiggum_active["value"] + + def fake_stop_wiggum(): + wiggum_active["value"] = False + mock_stop() + + async def fake_run(*args, **kwargs): + wiggum_active["value"] = True + run_started.set() + return (None, MagicMock()) + await _run_interactive( _mock_renderer(), _interactive_patches(), fake_input, extra_patches={ "code_puppy.cli_runner.run_prompt_with_attachments": AsyncMock( - return_value=(None, MagicMock()) + side_effect=fake_run ), "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( - return_value=True + side_effect=fake_wiggum_active + ), + "code_puppy.command_line.wiggum_state.stop_wiggum": MagicMock( + side_effect=fake_stop_wiggum ), - "code_puppy.command_line.wiggum_state.stop_wiggum": mock_stop, "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( return_value=_mock_parse_result("write hello") ), @@ -779,6 +811,330 @@ async def fake_input(*a, **kw): }, ) + +class TestInteractiveQueueHandoff: + """Test unified queue, interject, and idle-drain behavior.""" + + @pytest.mark.anyio + async def test_queued_prompt_runs_after_current_task_finishes(self): + call_count = 0 + second_prompt_seen = asyncio.Event() + queued_prompt_started = asyncio.Event() + started_prompts = [] + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return "first task" + if call_count == 2: + second_prompt_seen.set() + return "queued task" + await queued_prompt_started.wait() + return "/exit" + + async def fake_run(*args, **kwargs): + prompt = args[1] + started_prompts.append(prompt) + if prompt == "first task": + await second_prompt_seen.wait() + await asyncio.sleep(0.05) + if prompt == "queued task": + queued_prompt_started.set() + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": AsyncMock( + return_value="q" + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + }, + ) + + assert started_prompts[:2] == ["first task", "queued task"] + + @pytest.mark.anyio + async def test_queued_prompt_starts_when_run_finishes_during_choice_menu(self): + call_count = 0 + queued_prompt_started = asyncio.Event() + started_prompts = [] + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return "first task" + if call_count == 2: + return "queued task" + await queued_prompt_started.wait() + return "/exit" + + async def fake_run(*args, **kwargs): + prompt = args[1] + started_prompts.append(prompt) + if prompt == "first task": + await asyncio.sleep(0.01) + if prompt == "queued task": + queued_prompt_started.set() + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + async def slow_queue_choice(): + await asyncio.sleep(0.05) + return "q" + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": MagicMock( + side_effect=slow_queue_choice + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + }, + ) + + assert started_prompts[:2] == ["first task", "queued task"] + + @pytest.mark.anyio + async def test_queued_command_consumes_and_drain_continues(self): + call_count = 0 + ready_for_completion = asyncio.Event() + followup_started = asyncio.Event() + started_prompts = [] + handled_commands = [] + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return "first task" + if call_count == 2: + return "/help" + if call_count == 3: + ready_for_completion.set() + return "followup task" + await followup_started.wait() + return "/exit" + + async def fake_run(*args, **kwargs): + prompt = args[1] + started_prompts.append(prompt) + if prompt == "first task": + await ready_for_completion.wait() + await asyncio.sleep(0.05) + if prompt == "followup task": + followup_started.set() + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + def fake_handle_command(command): + handled_commands.append(command) + if command == "/help": + return True + return False + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.command_line.command_handler.handle_command": MagicMock( + side_effect=fake_handle_command + ), + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": AsyncMock( + return_value="q" + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + }, + ) + + assert "/help" in handled_commands + assert started_prompts[:2] == ["first task", "followup task"] + + @pytest.mark.anyio + async def test_queued_command_returning_prompt_uses_normal_dispatch(self): + call_count = 0 + second_prompt_seen = asyncio.Event() + transformed_prompt_started = asyncio.Event() + started_prompts = [] + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return "first task" + if call_count == 2: + second_prompt_seen.set() + return "/custom" + await transformed_prompt_started.wait() + return "/exit" + + async def fake_run(*args, **kwargs): + prompt = args[1] + started_prompts.append(prompt) + if prompt == "first task": + await second_prompt_seen.wait() + await asyncio.sleep(0.05) + if prompt == "transformed prompt": + transformed_prompt_started.set() + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.command_line.command_handler.handle_command": MagicMock( + side_effect=lambda command: "transformed prompt" + if command == "/custom" + else False + ), + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": AsyncMock( + return_value="q" + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + }, + ) + + assert started_prompts[:2] == ["first task", "transformed prompt"] + + @pytest.mark.anyio + async def test_interject_runs_before_queued_prompts(self): + call_count = 0 + first_task_started = asyncio.Event() + queued_prompt_started = asyncio.Event() + started_prompts = [] + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return "first task" + if call_count == 2: + await first_task_started.wait() + return "second queued" + if call_count == 3: + return "steer now" + await queued_prompt_started.wait() + return "/exit" + + async def fake_run(*args, **kwargs): + prompt = args[1] + started_prompts.append(prompt) + if prompt == "first task": + first_task_started.set() + await asyncio.sleep(10) + if prompt == "second queued": + queued_prompt_started.set() + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": AsyncMock( + side_effect=["q", "i"] + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + "code_puppy.tools.command_runner.get_running_shell_process_count": MagicMock( + return_value=0 + ), + "code_puppy.tools.command_runner.kill_all_running_shell_processes": MagicMock(), + }, + ) + + assert started_prompts[0] == "first task" + assert started_prompts[1].startswith("[user interjects]: steer now") + assert started_prompts[2] == "second queued" + + @pytest.mark.anyio + async def test_exit_while_running_cancels_runtime_task(self): + call_count = 0 + cancelled = asyncio.Event() + first_task_started = asyncio.Event() + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return "do work" + await first_task_started.wait() + return "/exit" + + async def fake_run(*args, **kwargs): + try: + first_task_started.set() + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled.set() + raise + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + "code_puppy.tools.command_runner.get_running_shell_process_count": MagicMock( + return_value=0 + ), + "code_puppy.tools.command_runner.kill_all_running_shell_processes": MagicMock(), + }, + ) + + assert cancelled.is_set() + @pytest.mark.anyio async def test_initial_command_success(self): agent = MagicMock() @@ -1363,6 +1719,91 @@ async def fake_input(*a, **kw): ) +class TestInteractiveShellSuspension: + @pytest.mark.anyio + async def test_shell_suspension_does_not_poll_old_shell_lock_path(self): + call_count = 0 + shell_count_mock = MagicMock(return_value=0) + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + return "first task" if call_count == 1 else "/exit" + + async def fake_run(*args, **kwargs): + prompt = args[1] + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + "code_puppy.tools.command_runner.get_running_shell_process_count": shell_count_mock, + }, + ) + + assert call_count == 2 + assert shell_count_mock.call_count <= 1 + + @pytest.mark.anyio + async def test_queue_behavior_still_drains_without_shell_lock_loop(self): + call_count = 0 + queued_prompt_started = asyncio.Event() + started_prompts = [] + shell_count_mock = MagicMock(return_value=0) + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return "first task" + if call_count == 2: + return "queued after shell" + await queued_prompt_started.wait() + return "/exit" + + async def fake_run(*args, **kwargs): + prompt = args[1] + started_prompts.append(prompt) + if prompt == "queued after shell": + queued_prompt_started.set() + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": AsyncMock( + return_value="q" + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + "code_puppy.tools.command_runner.get_running_shell_process_count": shell_count_mock, + }, + ) + + assert started_prompts[:2] == ["first task", "queued after shell"] + assert shell_count_mock.call_count <= 1 + + # --------------------------------------------------------------------------- # main_entry() additional tests # --------------------------------------------------------------------------- @@ -1832,11 +2273,15 @@ def fake_import(name, *args, **kwargs): class TestMainEntryAdditional: - @patch("asyncio.run", side_effect=KeyboardInterrupt) - def test_keyboard_interrupt_stderr_output(self, mock_run): + def test_keyboard_interrupt_stderr_output(self): + def fake_asyncio_run(coro): + coro.close() + raise KeyboardInterrupt + from code_puppy.cli_runner import main_entry with ExitStack() as stack: + stack.enter_context(patch("asyncio.run", side_effect=fake_asyncio_run)) stack.enter_context(patch("code_puppy.cli_runner.reset_unix_terminal")) stack.enter_context( patch("code_puppy.cli_runner.get_use_dbos", return_value=False) diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index c9a1553d1..91b1dbc20 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -16,7 +16,15 @@ CDCompleter, FilePathCompleter, SetCompleter, + clear_active_prompt_surface, + get_active_prompt_surface_kind, + get_prompt_with_active_model, get_input_with_combined_completion, + get_interject_action, + has_active_prompt_surface, + is_shell_prompt_suspended, + register_active_prompt_surface, + set_shell_prompt_suspended, ) # Skip some path-format sensitive tests on Windows where backslashes are expected @@ -584,6 +592,165 @@ async def test_get_input_key_binding_escape(mock_prompt_session_cls): mock_event.app.exit.assert_called_once_with(exception=KeyboardInterrupt) +def test_prompt_runtime_registry_round_trip(): + session = MagicMock() + session.app = MagicMock() + + clear_active_prompt_surface() + register_active_prompt_surface("main", session) + + assert has_active_prompt_surface() is True + assert get_active_prompt_surface_kind() == "main" + assert is_shell_prompt_suspended() is False + + set_shell_prompt_suspended(True) + assert is_shell_prompt_suspended() is True + session.app.invalidate.assert_called_once() + + clear_active_prompt_surface(session) + assert has_active_prompt_surface() is False + assert get_active_prompt_surface_kind() is None + assert is_shell_prompt_suspended() is False + + +def test_get_prompt_with_active_model_shows_shell_suspension(monkeypatch): + clear_active_prompt_surface() + session = MagicMock() + session.app = MagicMock() + register_active_prompt_surface("main", session) + set_shell_prompt_suspended(True) + + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.get_puppy_name", + lambda: "Buddy", + ) + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.get_active_model", + lambda: "gpt-test", + ) + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.os.getcwd", + lambda: "/tmp/demo", + ) + + agent = MagicMock() + agent.display_name = "code-puppy" + agent.get_model_name.return_value = "gpt-test" + + with patch( + "code_puppy.agents.agent_manager.get_current_agent", + return_value=agent, + ): + with patch( + "code_puppy.cli_runner.PROMPT_QUEUE", + [], + create=True, + ): + with patch( + "shutil.get_terminal_size", return_value=os.terminal_size((80, 24)) + ): + rendered = "".join(text for _style, text in get_prompt_with_active_model()) + + assert "input suspended during shell command" in rendered + clear_active_prompt_surface() + + +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +async def test_get_input_registers_active_prompt_and_marks_buffer_read_only( + mock_prompt_session_cls, +): + session = MagicMock() + session.app = MagicMock() + session.default_buffer = MagicMock() + + async def fake_prompt_async(*args, **kwargs): + assert has_active_prompt_surface() is True + assert get_active_prompt_surface_kind() == "main" + set_shell_prompt_suspended(True) + assert session.default_buffer.read_only() is True + return "test input" + + session.prompt_async = AsyncMock(side_effect=fake_prompt_async) + mock_prompt_session_cls.return_value = session + + result = await get_input_with_combined_completion() + + assert result == "test input" + assert has_active_prompt_surface() is False + assert is_shell_prompt_suspended() is False + + +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +async def test_ctrl_x_interrupts_shell_when_prompt_is_suspended( + mock_prompt_session_cls, +): + session = MagicMock() + session.app = MagicMock() + session.default_buffer = MagicMock() + session.prompt_async = AsyncMock(return_value="done") + mock_prompt_session_cls.return_value = session + + await get_input_with_combined_completion() + + bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] + ctrl_x_handler = next( + binding.handler + for binding in bindings.bindings + if binding.keys == (Keys.ControlX,) + ) + + register_active_prompt_surface("main", session) + set_shell_prompt_suspended(True) + + mock_event = MagicMock() + mock_event.app = MagicMock() + + with patch( + "code_puppy.tools.command_runner.kill_all_running_shell_processes", + return_value=1, + ) as mock_kill: + with patch("code_puppy.messaging.emit_warning"): + ctrl_x_handler(mock_event) + + mock_kill.assert_called_once() + mock_event.app.exit.assert_not_called() + clear_active_prompt_surface() + + +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +async def test_interject_ignores_i_and_q_while_shell_is_suspended( + mock_prompt_session_cls, +): + session = MagicMock() + session.app = MagicMock() + session.default_buffer = MagicMock() + session.prompt_async = AsyncMock(return_value="") + mock_prompt_session_cls.return_value = session + + await get_interject_action() + + bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] + key_handlers = { + binding.keys: binding.handler + for binding in bindings.bindings + if binding.keys in {("i",), ("q",)} + } + + register_active_prompt_surface("interject", session) + set_shell_prompt_suspended(True) + + mock_event = MagicMock() + mock_event.app = MagicMock() + key_handlers[("i",)](mock_event) + key_handlers[("q",)](mock_event) + + mock_event.app.exit.assert_not_called() + clear_active_prompt_surface() + + @pytest.mark.asyncio async def test_attachment_placeholder_processor_renders_images(tmp_path: Path) -> None: image_path = tmp_path / "fluffy pupper.png" diff --git a/tests/tools/test_command_runner_full_coverage.py b/tests/tools/test_command_runner_full_coverage.py index 05b5a0e3d..5308acd9e 100644 --- a/tests/tools/test_command_runner_full_coverage.py +++ b/tests/tools/test_command_runner_full_coverage.py @@ -9,7 +9,7 @@ import subprocess import sys import threading -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest from pydantic_ai import RunContext @@ -238,13 +238,25 @@ def test_set_and_check(self): ) with patch("code_puppy.tools.command_runner.pause_all_spinners", create=True): - pass + set_awaiting_user_input(True) + assert is_awaiting_user_input() is True + set_awaiting_user_input(False) + assert is_awaiting_user_input() is False - set_awaiting_user_input(True) - assert is_awaiting_user_input() is True - set_awaiting_user_input(False) - assert is_awaiting_user_input() is False +# --------------------------------------------------------------------------- +# Shell lock helpers +# --------------------------------------------------------------------------- + + +class TestShellLockHelpers: + def test_normalize_shell_cwd(self): + from code_puppy.tools.command_runner import _normalize_shell_cwd + + assert _normalize_shell_cwd(None) is None + assert _normalize_shell_cwd("") is None + assert _normalize_shell_cwd(" ") is None + assert _normalize_shell_cwd(" /tmp/work ") == "/tmp/work" # --------------------------------------------------------------------------- @@ -569,6 +581,71 @@ async def test_yolo_mode_executes(self): result = await run_shell_command(ctx, "echo hi", timeout=10) assert result.success is True + @pytest.mark.asyncio + async def test_omitted_cwd_normalizes_to_none(self): + from code_puppy.tools.command_runner import run_shell_command + + ctx = MagicMock(spec=RunContext) + mock_output = MagicMock(success=True) + callback_mock = AsyncMock(return_value=[]) + + with patch("code_puppy.callbacks.on_run_shell_command", callback_mock): + with patch("code_puppy.config.get_yolo_mode", return_value=True): + with patch( + "code_puppy.tools.command_runner.is_subagent", return_value=False + ): + with patch( + "code_puppy.tools.command_runner._execute_shell_command", + new_callable=AsyncMock, + return_value=mock_output, + ) as mock_execute: + result = await run_shell_command(ctx, "echo hi", timeout=10) + + assert result.success is True + callback_mock.assert_awaited_once_with(ctx, "echo hi", None, 10) + mock_execute.assert_awaited_once_with( + command="echo hi", + cwd=None, + timeout=10, + group_id=ANY, + silent=False, + ) + + @pytest.mark.asyncio + async def test_blank_cwd_normalizes_to_none(self): + from code_puppy.tools.command_runner import run_shell_command + + ctx = MagicMock(spec=RunContext) + mock_output = MagicMock(success=True) + callback_mock = AsyncMock(return_value=[]) + + with patch("code_puppy.callbacks.on_run_shell_command", callback_mock): + with patch("code_puppy.config.get_yolo_mode", return_value=True): + with patch( + "code_puppy.tools.command_runner.is_subagent", return_value=False + ): + with patch( + "code_puppy.tools.command_runner._execute_shell_command", + new_callable=AsyncMock, + return_value=mock_output, + ) as mock_execute: + result = await run_shell_command( + ctx, + "echo hi", + cwd=" ", + timeout=10, + ) + + assert result.success is True + callback_mock.assert_awaited_once_with(ctx, "echo hi", None, 10) + mock_execute.assert_awaited_once_with( + command="echo hi", + cwd=None, + timeout=10, + group_id=ANY, + silent=False, + ) + @pytest.mark.asyncio async def test_subagent_runs_silently(self): from code_puppy.tools.command_runner import run_shell_command @@ -790,6 +867,100 @@ async def test_executes(self): assert result.success is True + @pytest.mark.asyncio + async def test_suspends_active_prompt_and_skips_keyboard_listener(self): + from code_puppy.tools.command_runner import ( + ShellCommandOutput, + _execute_shell_command, + ) + + mock_result = ShellCommandOutput( + success=True, + command="echo hi", + stdout="hi", + stderr="", + exit_code=0, + execution_time=0.1, + ) + + with patch("code_puppy.tools.command_runner.get_message_bus") as mock_bus: + mock_bus.return_value = MagicMock() + with patch("code_puppy.messaging.spinner.pause_all_spinners"): + with patch("code_puppy.messaging.spinner.resume_all_spinners"): + with patch( + "code_puppy.command_line.prompt_toolkit_completion.has_active_prompt_surface", + return_value=True, + ): + with patch( + "code_puppy.command_line.prompt_toolkit_completion.set_shell_prompt_suspended" + ) as mock_suspend: + with patch( + "code_puppy.tools.command_runner._acquire_keyboard_context" + ) as mock_acquire: + with patch( + "code_puppy.tools.command_runner._release_keyboard_context" + ) as mock_release: + with patch( + "code_puppy.tools.command_runner._run_command_inner", + new_callable=AsyncMock, + return_value=mock_result, + ): + result = await _execute_shell_command( + "echo hi", None, 10, "grp" + ) + + assert result.success is True + assert mock_suspend.call_args_list == [((True,),), ((False,),)] + mock_acquire.assert_not_called() + mock_release.assert_not_called() + + @pytest.mark.asyncio + async def test_uses_keyboard_listener_when_no_active_prompt(self): + from code_puppy.tools.command_runner import ( + ShellCommandOutput, + _execute_shell_command, + ) + + mock_result = ShellCommandOutput( + success=True, + command="echo hi", + stdout="hi", + stderr="", + exit_code=0, + execution_time=0.1, + ) + + with patch("code_puppy.tools.command_runner.get_message_bus") as mock_bus: + mock_bus.return_value = MagicMock() + with patch("code_puppy.messaging.spinner.pause_all_spinners"): + with patch("code_puppy.messaging.spinner.resume_all_spinners"): + with patch( + "code_puppy.command_line.prompt_toolkit_completion.has_active_prompt_surface", + return_value=False, + ): + with patch( + "code_puppy.command_line.prompt_toolkit_completion.set_shell_prompt_suspended" + ) as mock_suspend: + with patch( + "code_puppy.tools.command_runner._acquire_keyboard_context" + ) as mock_acquire: + with patch( + "code_puppy.tools.command_runner._release_keyboard_context" + ) as mock_release: + with patch( + "code_puppy.tools.command_runner._run_command_inner", + new_callable=AsyncMock, + return_value=mock_result, + ): + result = await _execute_shell_command( + "echo hi", None, 10, "grp" + ) + + assert result.success is True + mock_suspend.assert_not_called() + mock_acquire.assert_called_once() + mock_release.assert_called_once() + # --------------------------------------------------------------------------- # _run_command_inner exception handling From 46df8288f7b4c85e74915099cef8641f50154966 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Fri, 6 Mar 2026 14:23:40 -0600 Subject: [PATCH 23/40] Create v1 stable checkpoint --- code_puppy/cli_runner.py | 68 ++++++++++++--- .../command_line/prompt_toolkit_completion.py | 11 ++- tests/test_cli_runner_full_coverage.py | 87 +++++++++++++++++++ tests/test_prompt_toolkit_completion.py | 12 +++ 4 files changed, 163 insertions(+), 15 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index df2d99ed2..8e61e80ef 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -136,7 +136,7 @@ def request_interject(self, prompt: str) -> tuple[bool, int, QueuedPrompt | None item = QueuedPrompt(kind="interject", text=prompt) self.queue.insert(0, item) _sync_runtime_globals(self) - return True, len(self.queue), item + return True, 1, item def dequeue(self) -> QueuedPrompt | None: if not self.queue: @@ -191,16 +191,14 @@ def emit_interject_queue_lifecycle( try: from code_puppy.messaging import MessageLevel, TextMessage, get_message_bus - if item and item.kind == "interject": - default_text = f"[interject] {action}: {item.text}" - elif item: - default_text = f"[queue] {action}: {item.text}" - else: - default_text = f"[queue] {action}" - - text = default_text if reason is None else f"{default_text} ({reason})" - if position is not None: - text = f"{text} [position {position}]" + text = _format_queue_lifecycle_text( + action, + item=item, + reason=reason, + position=position, + ) + if text is None: + return payload level_map = { "error": MessageLevel.ERROR, @@ -216,6 +214,49 @@ def emit_interject_queue_lifecycle( return payload +def _format_queue_lifecycle_text( + action: str, + *, + item: QueuedPrompt | None = None, + reason: str | None = None, + position: int | None = None, +) -> str | None: + """Translate internal queue lifecycle steps into user-facing copy.""" + if action == "dequeued": + return None + + if item is None: + if action == "rejected": + return "[QUEUE] couldn't save that prompt" + return f"[QUEUE] {action}" + + if item.kind == "interject": + action_text = { + "queued": "stopping current work", + "started": "applying now", + "completed": "applied", + "cancelled": "cancelled", + "failed": "failed", + "rejected": "couldn't apply", + }.get(action, action.replace("_", " ")) + return f"[INTERJECT] {action_text}: {item.text}" + + if action == "started": + return None + + action_text = { + "queued": "saved for after this task", + "completed": "finished", + "cancelled": "cancelled", + "failed": "failed", + "rejected": "couldn't save", + }.get(action, action.replace("_", " ")) + text = f"[QUEUE] {action_text}: {item.text}" + if action == "queued" and position is not None: + text = f"{text} [position {position}]" + return text + + async def start_next_queued_if_idle( runtime_state: PromptRuntimeState, queue_start_lock: asyncio.Lock, @@ -919,7 +960,7 @@ def emit_queue_dispatch(item: QueuedPrompt) -> None: if item.kind == "queued": from code_puppy.messaging import emit_success - emit_success(f"šŸš€ Executing queued prompt: {item.text}") + emit_success(f"[QUEUE] running queued prompt: {item.text}") emit_interject_queue_lifecycle( RUNTIME_STATE, "dequeued", @@ -942,7 +983,7 @@ async def handle_live_running_submission(task_text: str) -> str: from code_puppy.command_line.prompt_toolkit_completion import ( get_interject_action, ) - from code_puppy.messaging import emit_info, emit_warning + from code_puppy.messaging import emit_warning save_command_to_history(task_text) @@ -1006,7 +1047,6 @@ async def handle_live_running_submission(task_text: str) -> str: log_event("queue_reject", prompt=task_text.strip(), reason="full") return "consumed" - emit_info(f"Queued (position {position}): {task_text.strip()}") emit_interject_queue_lifecycle( RUNTIME_STATE, "queued", diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 74001e971..19792064d 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -127,6 +127,11 @@ def _interrupt_shell_from_prompt(label: str) -> None: kill_all_running_shell_processes() +def _shell_interrupt_hint() -> str: + """Return platform-appropriate interrupt text for the shell lock banner.""" + return "control+c" if sys.platform == "darwin" else "ctrl+c" + + def _truncate_queue_line(text: str, max_len: int) -> str: if max_len <= 2: return ".." @@ -687,7 +692,11 @@ def get_prompt_with_active_model(base: str = ">>> ", is_interject: bool = False) if is_shell_prompt_suspended(): parts.append( - ("class:suspension", " input suspended during shell command\n") + ( + "class:suspension", + " input suspended during shell command, press " + f"{_shell_interrupt_hint()} to interrupt\n", + ) ) parts.extend([ diff --git a/tests/test_cli_runner_full_coverage.py b/tests/test_cli_runner_full_coverage.py index f086becfa..b8cb80e19 100644 --- a/tests/test_cli_runner_full_coverage.py +++ b/tests/test_cli_runner_full_coverage.py @@ -139,6 +139,90 @@ async def _run_interactive( await interactive_mode(renderer, initial_command=initial_command) +def test_emit_interject_queue_lifecycle_uses_friendly_interject_copy(): + from code_puppy.cli_runner import ( + PromptRuntimeState, + QueuedPrompt, + emit_interject_queue_lifecycle, + ) + + message_bus = MagicMock() + + with patch("code_puppy.messaging.get_message_bus", return_value=message_bus): + emit_interject_queue_lifecycle( + PromptRuntimeState(), + "queued", + item=QueuedPrompt(kind="interject", text="steer now"), + position=2, + level="warning", + ) + + emitted = message_bus.emit.call_args[0][0] + assert emitted.text == "[INTERJECT] stopping current work: steer now" + + +def test_emit_interject_queue_lifecycle_uses_friendly_queue_copy(): + from code_puppy.cli_runner import ( + PromptRuntimeState, + QueuedPrompt, + emit_interject_queue_lifecycle, + ) + + message_bus = MagicMock() + + with patch("code_puppy.messaging.get_message_bus", return_value=message_bus): + emit_interject_queue_lifecycle( + PromptRuntimeState(), + "queued", + item=QueuedPrompt(kind="queued", text="follow up"), + position=2, + level="info", + ) + + emitted = message_bus.emit.call_args[0][0] + assert emitted.text == "[QUEUE] saved for after this task: follow up [position 2]" + + +def test_emit_interject_queue_lifecycle_skips_dequeued_user_message(): + from code_puppy.cli_runner import ( + PromptRuntimeState, + QueuedPrompt, + emit_interject_queue_lifecycle, + ) + + message_bus = MagicMock() + + with patch("code_puppy.messaging.get_message_bus", return_value=message_bus): + emit_interject_queue_lifecycle( + PromptRuntimeState(), + "dequeued", + item=QueuedPrompt(kind="queued", text="follow up"), + level="success", + ) + + message_bus.emit.assert_not_called() + + +def test_emit_interject_queue_lifecycle_skips_started_queue_user_message(): + from code_puppy.cli_runner import ( + PromptRuntimeState, + QueuedPrompt, + emit_interject_queue_lifecycle, + ) + + message_bus = MagicMock() + + with patch("code_puppy.messaging.get_message_bus", return_value=message_bus): + emit_interject_queue_lifecycle( + PromptRuntimeState(), + "started", + item=QueuedPrompt(kind="queued", text="follow up"), + level="success", + ) + + message_bus.emit.assert_not_called() + + # --------------------------------------------------------------------------- # main() tests # --------------------------------------------------------------------------- @@ -821,6 +905,7 @@ async def test_queued_prompt_runs_after_current_task_finishes(self): second_prompt_seen = asyncio.Event() queued_prompt_started = asyncio.Event() started_prompts = [] + emit_success = MagicMock() async def fake_input(*a, **kw): nonlocal call_count @@ -860,10 +945,12 @@ async def fake_run(*args, **kwargs): "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( return_value=False ), + "code_puppy.messaging.emit_success": emit_success, }, ) assert started_prompts[:2] == ["first task", "queued task"] + emit_success.assert_any_call("[QUEUE] running queued prompt: queued task") @pytest.mark.anyio async def test_queued_prompt_starts_when_run_finishes_during_choice_menu(self): diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index 91b1dbc20..667d8f685 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -16,6 +16,7 @@ CDCompleter, FilePathCompleter, SetCompleter, + _shell_interrupt_hint, clear_active_prompt_surface, get_active_prompt_surface_kind, get_prompt_with_active_model, @@ -652,9 +653,20 @@ def test_get_prompt_with_active_model_shows_shell_suspension(monkeypatch): rendered = "".join(text for _style, text in get_prompt_with_active_model()) assert "input suspended during shell command" in rendered + assert f"press {_shell_interrupt_hint()} to interrupt" in rendered clear_active_prompt_surface() +def test_shell_interrupt_hint_uses_platform_specific_copy(monkeypatch): + import code_puppy.command_line.prompt_toolkit_completion as prompt_completion + + monkeypatch.setattr(prompt_completion.sys, "platform", "darwin") + assert prompt_completion._shell_interrupt_hint() == "control+c" + + monkeypatch.setattr(prompt_completion.sys, "platform", "win32") + assert prompt_completion._shell_interrupt_hint() == "ctrl+c" + + @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") async def test_get_input_registers_active_prompt_and_marks_buffer_read_only( From 47386152434466a634c26f0ba5e01610d3cf5682 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Tue, 10 Mar 2026 09:11:14 -0500 Subject: [PATCH 24/40] Refine interject and queue runtime --- AGENTS.md | 2 + IMPLEMENTATION_GUARDRAILS.md | 49 ++ code_puppy/agents/event_stream_handler.py | 87 +- code_puppy/cli_runner.py | 821 +++++++++--------- .../command_line/interactive_runtime.py | 304 +++++++ .../command_line/prompt_toolkit_completion.py | 495 ++++++----- code_puppy/messaging/rich_renderer.py | 125 ++- code_puppy/messaging/spinner/__init__.py | 20 + code_puppy/tools/command_runner.py | 27 +- tests/agents/test_event_stream_handler.py | 101 +++ tests/messaging/spinner/test_spinner_init.py | 34 + tests/messaging/test_rich_renderer.py | 65 ++ tests/test_cli_runner_coverage.py | 83 ++ tests/test_cli_runner_full_coverage.py | 104 +-- tests/test_prompt_toolkit_completion.py | 219 ++++- .../test_command_runner_full_coverage.py | 65 +- 16 files changed, 1751 insertions(+), 850 deletions(-) create mode 100644 IMPLEMENTATION_GUARDRAILS.md create mode 100644 code_puppy/command_line/interactive_runtime.py diff --git a/AGENTS.md b/AGENTS.md index 1f27e5e7c..f690d89e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # Contributing to Code Puppy +> Before implementing changes in this repo, read `IMPLEMENTATION_GUARDRAILS.md`. + > **Golden rule:** nearly all new functionality should be a **plugin** under `code_puppy/plugins/` > that hooks into core via `code_puppy/callbacks.py`. Don't edit `code_puppy/command_line/`. diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md new file mode 100644 index 000000000..d84e691ed --- /dev/null +++ b/IMPLEMENTATION_GUARDRAILS.md @@ -0,0 +1,49 @@ +# Implementation Guardrails + +Read this before making changes in this repo. + +## Protected Behavior + +- Keep the always-on composer fixed at the bottom. +- Keep the composer usable while the agent is running. +- Keep the composer usable while a foreground shell command is running. +- Do not reintroduce a read-only or suspended prompt during shell execution. +- `/exit` and `/quit` must bypass queue/interject handling and win immediately. +- `Ctrl+C` from the composer must interrupt foreground shell work immediately. +- Busy submit must stay inline: `Enter` stores the prompt and shows `[i]nterject or [q]ueue`. +- Do not bring back a second prompt surface for interject decisions. +- Interject must aggressively cancel current work and run before normal queued prompts. +- Queued/interjected prompts must echo into the transcript before dispatch. +- Interjected prompts must echo the user's exact text into the transcript before any apply/start lifecycle lines. +- Interject injection text must tell the agent to continue the interrupted task after acknowledging the interjection. +- Queue preview must stay above the composer, max 3 visible rows, scroll when over 3, and cap at 25 prompts. +- Queued prompt preview rows must stay one line, truncated, and never wrap. +- Keep the composer at a fixed two-line viewport with internal scrolling for long prompts. + +## Rendering Guardrails + +- Do not stream Rich/ANSI/termflow output directly into the mounted prompt surface. +- Final `AGENT RESPONSE` output must render as a proper banner above the prompt. +- Structured banners like `AGENT REASONING`, listings, and tool blocks must use the prompt-safe render path. +- Shell output with the prompt mounted must stay plain text. Do not reintroduce Rich dim styling or raw ANSI fragments there. +- Do not reintroduce the shell warning line in the prompt header if typing is still enabled. + +## Spinner And Context Rules + +- The spinner is a visual heartbeat only. It should not control real work or real redraw priority. +- Spinner redraws must stay low-priority and yield behind real prompt/state redraws. +- Token/context updates should invalidate promptly on their own and should not wait on spinner timing. +- Seed the token/context line at run start so it does not show stale data from the previous run. +- Keep the prompt-native spinner; do not bring back the old Rich live spinner for interactive runs. + +## Config And Runtime Notes + +- Use `./.cp-local/run-code-puppy-local.sh` when you need isolated local setup/auth for this repo. +- Treat `/Users/nateoswalt/code-puppy-interject-queue-v2` as the rewrite repo. +- Treat `/Users/nateoswalt/code-puppy` as the older baseline repo. + +## Pre-Implementation Check + +- Read this file. +- Check for prompt/render/runtime side effects before editing. +- If a change touches interactive runtime, prompt rendering, shell integration, queue/interject flow, or spinner behavior, run focused tests and do a real terminal smoke pass afterward. diff --git a/code_puppy/agents/event_stream_handler.py b/code_puppy/agents/event_stream_handler.py index fb150fc8d..b882a0f1a 100644 --- a/code_puppy/agents/event_stream_handler.py +++ b/code_puppy/agents/event_stream_handler.py @@ -85,6 +85,50 @@ def _should_suppress_output() -> bool: return is_subagent() and not get_subagent_verbose() +def _has_active_prompt_surface() -> bool: + """Return True when the always-on prompt surface is mounted.""" + try: + from code_puppy.command_line.interactive_runtime import ( + get_active_interactive_runtime, + ) + + runtime = get_active_interactive_runtime() + return runtime.has_prompt_surface() if runtime is not None else False + except Exception: + return False + + +async def _consume_events_without_console(events: AsyncIterable[Any]) -> None: + """Consume stream events without terminal rendering, but keep callbacks alive.""" + async for event in events: + if isinstance(event, PartStartEvent): + _fire_stream_event( + "part_start", + { + "index": event.index, + "part_type": type(event.part).__name__, + "part": event.part, + }, + ) + elif isinstance(event, PartDeltaEvent): + _fire_stream_event( + "part_delta", + { + "index": event.index, + "delta_type": type(event.delta).__name__, + "delta": event.delta, + }, + ) + elif isinstance(event, PartEndEvent): + _fire_stream_event( + "part_end", + { + "index": event.index, + "next_part_kind": getattr(event, "next_part_kind", None), + }, + ) + + async def event_stream_handler( ctx: RunContext, events: AsyncIterable[Any], @@ -104,6 +148,13 @@ async def event_stream_handler( pass # Just consume events without rendering return + # The always-on prompt surface cannot safely coexist with live terminal streaming. + # In that mode, we keep callbacks/events flowing and render the final response + # through the normal AgentResponseMessage banner path instead. + if _has_active_prompt_surface(): + await _consume_events_without_console(events) + return + from termflow import Parser as TermflowParser from termflow import Renderer as TermflowRenderer @@ -118,6 +169,7 @@ async def event_stream_handler( banner_printed: set[int] = set() # Track if banner was already printed token_count: dict[int, int] = {} # Track token count per text/tool part tool_names: dict[int, str] = {} # Track tool name per tool part index + tool_progress_announced: set[int] = set() did_stream_anything = False # Track if we streamed any content spinner_paused = False @@ -134,7 +186,6 @@ async def _print_thinking_banner() -> None: pause_all_spinners() spinner_paused = True await asyncio.sleep(0.02) - # Clear line and print newline before banner console.print(" " * 50, end="\r") console.print() # Newline before banner # Bold banner with configurable color and lightning bolt @@ -155,7 +206,6 @@ async def _print_response_banner() -> None: pause_all_spinners() spinner_paused = True await asyncio.sleep(0.02) - # Clear line and print newline before banner console.print(" " * 50, end="\r") console.print() # Newline before banner response_color = get_banner_color("agent_response") @@ -259,6 +309,7 @@ async def _print_response_banner() -> None: escaped = escape(delta.content_delta) console.print(f"[dim]{escaped}[/dim]", end="") elif isinstance(delta, ToolCallPartDelta): + prompt_surface_active = _has_active_prompt_surface() # For tool calls, estimate tokens from args_delta content # args_delta contains the streaming JSON arguments args_delta = getattr(delta, "args_delta", "") or "" @@ -280,17 +331,23 @@ async def _print_response_banner() -> None: # Use stored tool name for display tool_name = tool_names.get(event.index, "") count = token_count[event.index] - # Display with tool wrench icon and tool name - if tool_name: - console.print( - f" \U0001f527 Calling {tool_name}... {count} token(s) ", - end="\r", - ) + if prompt_surface_active: + if event.index not in tool_progress_announced: + label = tool_name or "tool" + console.print(f" \U0001f527 Calling {label}...") + tool_progress_announced.add(event.index) else: - console.print( - f" \U0001f527 Calling tool... {count} token(s) ", - end="\r", - ) + # Display with tool wrench icon and tool name + if tool_name: + console.print( + f" \U0001f527 Calling {tool_name}... {count} token(s) ", + end="\r", + ) + else: + console.print( + f" \U0001f527 Calling tool... {count} token(s) ", + end="\r", + ) # PartEndEvent - finish the streaming with a newline elif isinstance(event, PartEndEvent): @@ -327,8 +384,9 @@ async def _print_response_banner() -> None: del termflow_line_buffers[event.index] # For tool parts, clear the chunk counter line elif event.index in tool_parts: - # Clear the chunk counter line by printing spaces and returning - console.print(" " * 50, end="\r") + if not _has_active_prompt_surface(): + # Clear the chunk counter line by printing spaces and returning + console.print(" " * 50, end="\r") # For thinking parts, just print newline elif event.index in banner_printed: console.print() # Final newline after streaming @@ -336,6 +394,7 @@ async def _print_response_banner() -> None: # Clean up token count and tool names token_count.pop(event.index, None) tool_names.pop(event.index, None) + tool_progress_announced.discard(event.index) # Clean up all tracking sets streaming_parts.discard(event.index) thinking_parts.discard(event.index) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 8e61e80ef..e3179b09e 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -9,14 +9,7 @@ apply_all_patches() import argparse -from dataclasses import dataclass, field -from datetime import datetime, timezone -from typing import Any, Callable, Literal - -AGENT_IS_RUNNING = False -PROMPT_QUEUE = [] -BG_AGENT_TASK = None -MAX_PROMPT_QUEUE = 25 +from typing import Any, Literal import asyncio @@ -33,6 +26,12 @@ from code_puppy.agents import get_current_agent from code_puppy.command_line.attachments import parse_prompt_attachments from code_puppy.command_line.clipboard import get_clipboard_manager +from code_puppy.command_line.interactive_runtime import ( + PromptRuntimeState, + QueuedPrompt, + clear_active_interactive_runtime, + register_active_interactive_runtime, +) from code_puppy.config import ( AUTOSAVE_DIR, COMMAND_HISTORY_FILE, @@ -82,84 +81,6 @@ def start_capture_session(): plugins.load_plugin_callbacks() -@dataclass -class QueuedPrompt: - """Normalized queued prompt payload.""" - - kind: Literal["queued", "interject"] - text: str - created_at: str = field( - default_factory=lambda: datetime.now(timezone.utc).isoformat() - ) - - def preview_text(self) -> str: - if self.kind == "interject": - return f"[INTERJECT] {self.text}" - return self.text - - -@dataclass -class PromptRuntimeState: - """Single source of truth for prompt run state and queue.""" - - queue: list[QueuedPrompt] = field(default_factory=list) - running: bool = False - cancelling: bool = False - bg_task: asyncio.Task | None = None - - def mark_running(self, task: asyncio.Task) -> None: - self.running = True - self.cancelling = False - self.bg_task = task - _sync_runtime_globals(self) - - def mark_idle(self) -> None: - self.running = False - self.cancelling = False - self.bg_task = None - _sync_runtime_globals(self) - - def _can_enqueue(self) -> bool: - return len(self.queue) < MAX_PROMPT_QUEUE - - def request_queue(self, prompt: str) -> tuple[bool, int, QueuedPrompt | None]: - if not self._can_enqueue(): - return False, len(self.queue), None - item = QueuedPrompt(kind="queued", text=prompt) - self.queue.append(item) - _sync_runtime_globals(self) - return True, len(self.queue), item - - def request_interject(self, prompt: str) -> tuple[bool, int, QueuedPrompt | None]: - if not self._can_enqueue(): - return False, len(self.queue), None - item = QueuedPrompt(kind="interject", text=prompt) - self.queue.insert(0, item) - _sync_runtime_globals(self) - return True, 1, item - - def dequeue(self) -> QueuedPrompt | None: - if not self.queue: - return None - value = self.queue.pop(0) - _sync_runtime_globals(self) - return value - - def prompt_queue_preview(self) -> list[str]: - return [item.preview_text() for item in self.queue] - - -RUNTIME_STATE = PromptRuntimeState() - - -def _sync_runtime_globals(state: PromptRuntimeState) -> None: - """Keep module globals updated for existing imports/usages.""" - global AGENT_IS_RUNNING, BG_AGENT_TASK, PROMPT_QUEUE - AGENT_IS_RUNNING = state.running - BG_AGENT_TASK = state.bg_task - PROMPT_QUEUE = state.prompt_queue_preview() - - def emit_interject_queue_lifecycle( runtime_state: PromptRuntimeState, action: str, @@ -257,64 +178,67 @@ def _format_queue_lifecycle_text( return text -async def start_next_queued_if_idle( - runtime_state: PromptRuntimeState, - queue_start_lock: asyncio.Lock, - run_agent_factory: Callable[[QueuedPrompt], asyncio.Task], - *, - origin: str, -) -> bool: - """Start exactly one queued task if we're idle.""" - async with queue_start_lock: - if runtime_state.running: - active_task = runtime_state.bg_task - if active_task is None or active_task.done(): - runtime_state.mark_idle() - log_event( - "queue_autodrain_reconciled", - origin=origin, - had_task=active_task is not None, - task_done=active_task.done() if active_task is not None else None, - ) - else: - log_event("queue_autodrain_noop", origin=origin, reason="running") - return False - - next_item = runtime_state.dequeue() - if next_item is None: - log_event("queue_autodrain_noop", origin=origin, reason="empty") - return False - - task = run_agent_factory(next_item) - runtime_state.mark_running(task) - log_event( - "queue_autodrain_triggered", - origin=origin, - remaining=len(runtime_state.queue), - kind=next_item.kind, - text=next_item.text, +def _build_interject_submission_text(text: str) -> str: + """Wrap an interjected prompt so the agent resumes the interrupted task.""" + return ( + "user interjects - " + f"{text} - " + "please affirm you've seen this interjection, continue the interrupted task, " + "and proceed with that in mind" + ) + + +def _seed_spinner_context(agent, prompt: str, *, link_attachments: list[str]) -> None: + """Seed context usage immediately so the prompt status line is not stale.""" + from code_puppy.messaging.spinner import ( + SpinnerBase, + clear_spinner_context, + update_spinner_context, + ) + + clear_spinner_context() + + try: + history = ( + agent.get_message_history() + if hasattr(agent, "get_message_history") + else [] + ) or [] + estimate_tokens_for_message = getattr(agent, "estimate_tokens_for_message", None) + estimate_context_overhead_tokens = getattr( + agent, "estimate_context_overhead_tokens", None ) - return True + estimate_token_count = getattr(agent, "estimate_token_count", None) + get_model_context_length = getattr(agent, "get_model_context_length", None) + if not callable(estimate_token_count) or not callable(get_model_context_length): + return -async def kick_queue_after_cancel_boundary( - runtime_state: PromptRuntimeState, - queue_start_lock: asyncio.Lock, - run_agent_factory: Callable[[QueuedPrompt], asyncio.Task], - *, - origin: str, -) -> bool: - """Deferred queue kick for cancellation boundaries. + history_tokens = 0 + if callable(estimate_tokens_for_message): + history_tokens = sum( + estimate_tokens_for_message(message) for message in history + ) - This runs on the next event-loop turn so interject enqueue can complete first. - """ - await asyncio.sleep(0) - return await start_next_queued_if_idle( - runtime_state, - queue_start_lock, - run_agent_factory, - origin=origin, - ) + overhead_tokens = ( + estimate_context_overhead_tokens() + if callable(estimate_context_overhead_tokens) + else 0 + ) + prompt_tokens = estimate_token_count(prompt) if prompt else 0 + link_tokens = sum(estimate_token_count(url) for url in link_attachments) + total_tokens = max(0, history_tokens + overhead_tokens + prompt_tokens + link_tokens) + capacity = max(1, int(get_model_context_length())) + + update_spinner_context( + SpinnerBase.format_context_info( + total_tokens, + capacity, + total_tokens / capacity, + ) + ) + except Exception: + return async def main(): @@ -645,208 +569,222 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non """Run the agent in interactive mode.""" from code_puppy.command_line.command_handler import handle_command - RUNTIME_STATE.mark_idle() - display_console = message_renderer.console - from code_puppy.messaging import emit_info, emit_system_message + runtime = PromptRuntimeState() + register_active_interactive_runtime(runtime) + runtime.mark_idle() + try: + display_console = message_renderer.console + from code_puppy.messaging import emit_info, emit_system_message - emit_system_message( - "Type '/exit', '/quit', or press Ctrl+D to exit the interactive mode." - ) - log_event("interactive_mode_start") - emit_system_message("Type 'clear' to reset the conversation history.") - emit_system_message("Type /help to view all commands") - emit_system_message( - "Type @ for path completion, or /model to pick a model. Toggle multiline with Alt+M or F2; newline: Ctrl+J." - ) - emit_system_message("Paste images: Ctrl+V (even on Mac!), F3, or /paste command.") - import platform + emit_system_message( + "Type '/exit', '/quit', or press Ctrl+D to exit the interactive mode." + ) + log_event("interactive_mode_start") + emit_system_message("Type 'clear' to reset the conversation history.") + emit_system_message("Type /help to view all commands") + emit_system_message( + "Type @ for path completion, or /model to pick a model. Toggle multiline with Alt+M or F2; newline: Ctrl+J." + ) + emit_system_message( + "Paste images: Ctrl+V (even on Mac!), F3, or /paste command." + ) + import platform - if platform.system() == "Darwin": + if platform.system() == "Darwin": + emit_system_message( + "šŸ’” macOS tip: Use Ctrl+V (not Cmd+V) to paste images in terminal." + ) + cancel_key = get_cancel_agent_display_name() emit_system_message( - "šŸ’” macOS tip: Use Ctrl+V (not Cmd+V) to paste images in terminal." + f"Press {cancel_key} during processing to cancel the current task or inference. Use Ctrl+X to interrupt running shell commands." ) - cancel_key = get_cancel_agent_display_name() - emit_system_message( - f"Press {cancel_key} during processing to cancel the current task or inference. Use Ctrl+X to interrupt running shell commands." - ) - emit_system_message( - "Use /autosave_load to manually load a previous autosave session." - ) - emit_system_message( - "Use /diff to configure diff highlighting colors for file changes." - ) - emit_system_message("To re-run the tutorial, use /tutorial.") - try: - from code_puppy.command_line.motd import print_motd + emit_system_message( + "Use /autosave_load to manually load a previous autosave session." + ) + emit_system_message( + "Use /diff to configure diff highlighting colors for file changes." + ) + emit_system_message("To re-run the tutorial, use /tutorial.") + try: + from code_puppy.command_line.motd import print_motd - print_motd(console, force=False) - except Exception as e: - from code_puppy.messaging import emit_warning + print_motd(console, force=False) + except Exception as e: + from code_puppy.messaging import emit_warning - emit_warning(f"MOTD error: {e}") + emit_warning(f"MOTD error: {e}") - # Print truecolor warning LAST so it's the most visible thing on startup - # Big ugly red box should be impossible to miss! šŸ”“ - print_truecolor_warning(display_console) + # Print truecolor warning LAST so it's the most visible thing on startup + # Big ugly red box should be impossible to miss! šŸ”“ + print_truecolor_warning(display_console) - # Initialize the runtime agent manager - if initial_command: - from code_puppy.agents import get_current_agent - from code_puppy.messaging import emit_info, emit_success, emit_system_message + # Initialize the runtime agent manager + if initial_command: + from code_puppy.agents import get_current_agent + from code_puppy.messaging import emit_info, emit_success, emit_system_message - agent = get_current_agent() - emit_info(f"Processing initial command: {initial_command}") + agent = get_current_agent() + emit_info(f"Processing initial command: {initial_command}") - try: - # Check if any tool is waiting for user input before showing spinner try: - from code_puppy.tools.command_runner import is_awaiting_user_input - - awaiting_input = is_awaiting_user_input() - except ImportError: - awaiting_input = False - - # Run with or without spinner based on whether we're awaiting input - response, agent_task = await run_prompt_with_attachments( - agent, - initial_command, - spinner_console=display_console, - use_spinner=not awaiting_input, - ) - if response is not None: - agent_response = response.output - - # Update the agent's message history with the complete conversation - # including the final assistant response - if hasattr(response, "all_messages"): - agent.set_message_history(list(response.all_messages())) - - # Emit structured message for proper markdown rendering - from code_puppy.messaging import get_message_bus - from code_puppy.messaging.messages import AgentResponseMessage + # Check if any tool is waiting for user input before showing spinner + try: + from code_puppy.tools.command_runner import is_awaiting_user_input - response_msg = AgentResponseMessage( - content=agent_response, - is_markdown=True, + awaiting_input = is_awaiting_user_input() + except ImportError: + awaiting_input = False + + # Run with or without spinner based on whether we're awaiting input + response, agent_task = await run_prompt_with_attachments( + agent, + initial_command, + spinner_console=display_console, + use_spinner=not awaiting_input, ) - get_message_bus().emit(response_msg) + if response is not None: + agent_response = response.output - emit_success("🐶 Continuing in Interactive Mode") - emit_system_message( - "Your command and response are preserved in the conversation history." - ) + # Update the agent's message history with the complete conversation + # including the final assistant response + if hasattr(response, "all_messages"): + agent.set_message_history(list(response.all_messages())) - except Exception as e: - from code_puppy.messaging import emit_error + # Emit structured message for proper markdown rendering + from code_puppy.messaging import get_message_bus + from code_puppy.messaging.messages import AgentResponseMessage - emit_error(f"Error processing initial command: {str(e)}") + response_msg = AgentResponseMessage( + content=agent_response, + is_markdown=True, + ) + get_message_bus().emit(response_msg) - # Check if prompt_toolkit is installed - try: - from code_puppy.command_line.prompt_toolkit_completion import ( - get_input_with_combined_completion, - get_prompt_with_active_model, - ) - except ImportError: - from code_puppy.messaging import emit_warning + emit_success("🐶 Continuing in Interactive Mode") + emit_system_message( + "Your command and response are preserved in the conversation history." + ) - emit_warning("Warning: prompt_toolkit not installed. Installing now...") - try: - import subprocess + except Exception as e: + from code_puppy.messaging import emit_error - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "--quiet", "prompt_toolkit"] - ) - from code_puppy.messaging import emit_success + emit_error(f"Error processing initial command: {str(e)}") - emit_success("Successfully installed prompt_toolkit") + # Check if prompt_toolkit is installed + try: from code_puppy.command_line.prompt_toolkit_completion import ( - get_input_with_combined_completion, get_prompt_with_active_model, + prompt_for_submission, ) - except Exception as e: - from code_puppy.messaging import emit_error, emit_warning + except ImportError: + from code_puppy.messaging import emit_warning - emit_error(f"Error installing prompt_toolkit: {e}") - emit_warning("Falling back to basic input without tab completion") + emit_warning("Warning: prompt_toolkit not installed. Installing now...") + try: + import subprocess - # Autosave loading is now manual - use /autosave_load command + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--quiet", "prompt_toolkit"] + ) + from code_puppy.messaging import emit_success - # Auto-run tutorial on first startup - try: - from code_puppy.command_line.onboarding_wizard import should_show_onboarding + emit_success("Successfully installed prompt_toolkit") + from code_puppy.command_line.prompt_toolkit_completion import ( + get_prompt_with_active_model, + prompt_for_submission, + ) + except Exception as e: + from code_puppy.messaging import emit_error, emit_warning - if should_show_onboarding(): - import concurrent.futures + emit_error(f"Error installing prompt_toolkit: {e}") + emit_warning("Falling back to basic input without tab completion") - from code_puppy.command_line.onboarding_wizard import run_onboarding_wizard - from code_puppy.config import set_model_name - from code_puppy.messaging import emit_info + # Autosave loading is now manual - use /autosave_load command - with concurrent.futures.ThreadPoolExecutor() as executor: - future = executor.submit(lambda: asyncio.run(run_onboarding_wizard())) - result = future.result(timeout=300) + # Auto-run tutorial on first startup + try: + from code_puppy.command_line.onboarding_wizard import should_show_onboarding - if result == "chatgpt": - emit_info("šŸ” Starting ChatGPT OAuth flow...") - from code_puppy.plugins.chatgpt_oauth.oauth_flow import run_oauth_flow + if should_show_onboarding(): + import concurrent.futures - run_oauth_flow() - set_model_name("chatgpt-gpt-5.3-codex") - elif result == "claude": - emit_info("šŸ” Starting Claude Code OAuth flow...") - from code_puppy.plugins.claude_code_oauth.register_callbacks import ( - _perform_authentication, + from code_puppy.command_line.onboarding_wizard import ( + run_onboarding_wizard, ) + from code_puppy.config import set_model_name + from code_puppy.messaging import emit_info + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(lambda: asyncio.run(run_onboarding_wizard())) + result = future.result(timeout=300) + + if result == "chatgpt": + emit_info("šŸ” Starting ChatGPT OAuth flow...") + from code_puppy.plugins.chatgpt_oauth.oauth_flow import run_oauth_flow + + run_oauth_flow() + set_model_name("chatgpt-gpt-5.3-codex") + elif result == "claude": + emit_info("šŸ” Starting Claude Code OAuth flow...") + from code_puppy.plugins.claude_code_oauth.register_callbacks import ( + _perform_authentication, + ) - _perform_authentication() - set_model_name("claude-code-claude-opus-4-6") - elif result == "completed": - emit_info("šŸŽ‰ Tutorial complete! Happy coding!") - elif result == "skipped": - emit_info("ā­ļø Tutorial skipped. Run /tutorial anytime!") - except Exception as e: - from code_puppy.messaging import emit_warning + _perform_authentication() + set_model_name("claude-code-claude-opus-4-6") + elif result == "completed": + emit_info("šŸŽ‰ Tutorial complete! Happy coding!") + elif result == "skipped": + emit_info("ā­ļø Tutorial skipped. Run /tutorial anytime!") + except Exception as e: + from code_puppy.messaging import emit_warning - emit_warning(f"Tutorial auto-start failed: {e}") + emit_warning(f"Tutorial auto-start failed: {e}") - queue_start_lock = asyncio.Lock() - shutdown_requested = False + queue_start_lock = asyncio.Lock() + shutdown_requested = False - async def cancel_active_run(reason: str) -> None: - """Aggressively stop shell + agent execution and wait for cancellation.""" - from code_puppy.tools.command_runner import ( - get_running_shell_process_count, - kill_all_running_shell_processes, - ) + async def cancel_active_run(reason: str) -> None: + """Aggressively stop shell + agent execution and wait for cancellation.""" + try: + from code_puppy.tools.command_runner import ( + get_running_shell_process_count, + kill_all_running_shell_processes, + ) + except ImportError: + get_running_shell_process_count = lambda: 0 + kill_all_running_shell_processes = lambda: None - if RUNTIME_STATE.bg_task is None or RUNTIME_STATE.bg_task.done(): - RUNTIME_STATE.mark_idle() - return + if runtime.bg_task is None or runtime.bg_task.done(): + runtime.mark_idle() + return - RUNTIME_STATE.cancelling = True - log_event("cancel_start", reason=reason) + runtime.cancelling = True + log_event("cancel_start", reason=reason) - # First kill nested shell activity, repeating briefly if needed. - for _ in range(3): - kill_all_running_shell_processes() - if get_running_shell_process_count() == 0: - break - await asyncio.sleep(0.15) + # First kill nested shell activity, repeating briefly if needed. + for _ in range(3): + kill_all_running_shell_processes() + if get_running_shell_process_count() == 0: + break + await asyncio.sleep(0.15) - # Then cancel the active background agent task and await completion. - RUNTIME_STATE.bg_task.cancel() - try: - await asyncio.wait_for(RUNTIME_STATE.bg_task, timeout=6.0) - except asyncio.CancelledError: - pass - except TimeoutError: - pass - except Exception: - pass - finally: - RUNTIME_STATE.mark_idle() - log_event("cancel_done", reason=reason) + # Then cancel the active background agent task and await completion. + runtime.bg_task.cancel() + try: + await asyncio.wait_for(runtime.bg_task, timeout=6.0) + except asyncio.CancelledError: + pass + except TimeoutError: + pass + except Exception: + pass + finally: + runtime.mark_idle() + log_event("cancel_done", reason=reason) + except Exception: + clear_active_interactive_runtime(runtime) + raise async def shutdown_interactive_session(message: str, *, reason: str) -> None: """Exit interactive mode and cancel active work if needed.""" @@ -855,7 +793,7 @@ async def shutdown_interactive_session(message: str, *, reason: str) -> None: shutdown_requested = True emit_success(message) - if RUNTIME_STATE.bg_task is not None and not RUNTIME_STATE.bg_task.done(): + if runtime.running and runtime.bg_task is not None and not runtime.bg_task.done(): emit_info("Cancelling running agent task...") await cancel_active_run(reason) @@ -962,120 +900,51 @@ def emit_queue_dispatch(item: QueuedPrompt) -> None: emit_success(f"[QUEUE] running queued prompt: {item.text}") emit_interject_queue_lifecycle( - RUNTIME_STATE, + runtime, "dequeued", item=item, level=queue_level(item), ) + async def echo_dispatched_prompt(item: QueuedPrompt) -> None: + """Echo queued/interjected prompts into the transcript before launch.""" + try: + from code_puppy.command_line.prompt_toolkit_completion import ( + render_submitted_prompt_echo, + ) + except Exception: + return + + prompt_text = item.text.strip() + if not prompt_text: + return + + if runtime.has_prompt_surface(): + if await runtime.run_above_prompt_async( + lambda: render_submitted_prompt_echo(prompt_text) + ): + return + + render_submitted_prompt_echo(prompt_text) + def complete_queue_item(item: QueuedPrompt, reason: str) -> None: """Mark a queued item as handled without launching the agent.""" emit_interject_queue_lifecycle( - RUNTIME_STATE, + runtime, "completed", item=item, reason=reason, level=queue_level(item), ) - async def handle_live_running_submission(task_text: str) -> str: - """Handle input that arrives while an agent run is active.""" - from code_puppy.command_line.prompt_toolkit_completion import ( - get_interject_action, - ) - from code_puppy.messaging import emit_warning - - save_command_to_history(task_text) - - try: - action = await get_interject_action() - if not action: - return "consumed" - except (KeyboardInterrupt, EOFError): - return "consumed" - - selected = action.strip().lower() - log_event("interject_choice", action=selected, prompt=task_text.strip()) - - if selected == "i": - log_event("interject_banner", text=task_text.strip()) - await cancel_active_run("interject") - ok, position, item = RUNTIME_STATE.request_interject(task_text.strip()) - if not ok: - emit_warning("Queue full (25). Cannot interject right now.") - emit_interject_queue_lifecycle( - RUNTIME_STATE, - "rejected", - reason="full_interject", - level="error", - ) - log_event("queue_reject", prompt=task_text.strip(), reason="full_interject") - return "consumed" - - emit_interject_queue_lifecycle( - RUNTIME_STATE, - "queued", - item=item, - position=position, - level="warning", - ) - log_event( - "queued_interject", - text=task_text.strip(), - position=1, - size=len(RUNTIME_STATE.queue), - ) - handled = await drain_pending_work_if_idle(origin="interject_enqueued") - log_event( - "interject_queue_kick_attempted", - remaining=len(RUNTIME_STATE.queue), - running=RUNTIME_STATE.running, - handled=handled, - ) - return "consumed" - - if selected == "q": - ok, position, item = RUNTIME_STATE.request_queue(task_text.strip()) - if not ok: - emit_warning("Queue full (25). Prompt was not queued.") - emit_interject_queue_lifecycle( - RUNTIME_STATE, - "rejected", - reason="full", - level="error", - ) - log_event("queue_reject", prompt=task_text.strip(), reason="full") - return "consumed" - - emit_interject_queue_lifecycle( - RUNTIME_STATE, - "queued", - item=item, - position=position, - level="info", - ) - log_event( - "queued_prompt", - text=task_text.strip(), - position=position, - size=len(RUNTIME_STATE.queue), - ) - await drain_pending_work_if_idle(origin="queue_enqueued") - return "consumed" - - emit_warning("Cancelled action.") - return "consumed" - async def run_agent_bg( task_text, agent, source_item: QueuedPrompt | None = None ): - RUNTIME_STATE.running = True - _sync_runtime_globals(RUNTIME_STATE) try: log_event("agent_start", prompt=task_text) if source_item: emit_interject_queue_lifecycle( - RUNTIME_STATE, + runtime, "started", item=source_item, level="warning" if source_item.kind == "interject" else "success", @@ -1105,7 +974,7 @@ async def run_agent_bg( emit_warning("šŸ© Wiggum loop stopped due to cancellation") if source_item: emit_interject_queue_lifecycle( - RUNTIME_STATE, + runtime, "cancelled", item=source_item, reason="run_cancelled", @@ -1132,7 +1001,7 @@ async def run_agent_bg( await asyncio.sleep(0.1) if source_item: emit_interject_queue_lifecycle( - RUNTIME_STATE, + runtime, "completed", item=source_item, level="success", @@ -1144,28 +1013,28 @@ async def run_agent_bg( get_queue_console().print_exception() if source_item: emit_interject_queue_lifecycle( - RUNTIME_STATE, + runtime, "failed", item=source_item, reason="exception", level="error", ) finally: - was_cancelling = RUNTIME_STATE.cancelling - RUNTIME_STATE.mark_idle() + was_cancelling = runtime.cancelling + runtime.mark_idle() log_event("agent_end", prompt=task_text) if was_cancelling: if shutdown_requested: log_event( "queue_autodrain_skipped", reason="shutdown_requested", - remaining=len(RUNTIME_STATE.queue), + remaining=len(runtime.queue), ) return log_event( "queue_autodrain_skipped", reason="cancelling", - remaining=len(RUNTIME_STATE.queue), + remaining=len(runtime.queue), ) asyncio.create_task( kick_drain_after_cancel_boundary( @@ -1178,6 +1047,7 @@ async def run_agent_bg( async def dispatch_submission( task_text: str, *, + requested_action: Literal["submit", "queue", "interject"] = "submit", source_item: QueuedPrompt | None = None, save_history: bool = True, allow_command_dispatch: bool = True, @@ -1194,10 +1064,87 @@ async def dispatch_submission( await shutdown_interactive_session("Goodbye!", reason="user_exit") return "exit" - if source_item is None and RUNTIME_STATE.running: - return await handle_live_running_submission(raw_task) + if source_item is None and requested_action in {"queue", "interject"}: + from code_puppy.messaging import emit_warning + + save_command_to_history(raw_task) + log_event("interject_choice", action=requested_action, prompt=stripped_task) + + if requested_action == "interject": + log_event("interject_banner", text=stripped_task) + await cancel_active_run("interject") + ok, position, item = runtime.request_interject(stripped_task) + if not ok: + emit_warning("Queue full (25). Cannot interject right now.") + emit_interject_queue_lifecycle( + runtime, + "rejected", + reason="full_interject", + level="error", + ) + log_event( + "queue_reject", + prompt=stripped_task, + reason="full_interject", + ) + return "consumed" + + emit_interject_queue_lifecycle( + runtime, + "queued", + item=item, + position=position, + level="warning", + ) + log_event( + "queued_interject", + text=stripped_task, + position=1, + size=len(runtime.queue), + ) + handled = await drain_pending_work_if_idle(origin="interject_enqueued") + log_event( + "interject_queue_kick_attempted", + remaining=len(runtime.queue), + running=runtime.running, + handled=handled, + ) + return "consumed" + + ok, position, item = runtime.request_queue(stripped_task) + if not ok: + emit_warning("Queue full (25). Prompt was not queued.") + emit_interject_queue_lifecycle( + runtime, + "rejected", + reason="full", + level="error", + ) + log_event("queue_reject", prompt=stripped_task, reason="full") + return "consumed" + + emit_interject_queue_lifecycle( + runtime, + "queued", + item=item, + position=position, + level="info", + ) + log_event( + "queued_prompt", + text=stripped_task, + position=position, + size=len(runtime.queue), + ) + await drain_pending_work_if_idle(origin="queue_enqueued") + return "consumed" + + if source_item is None and runtime.running: + log_event("busy_submission_ignored", prompt=stripped_task) + return "consumed" if source_item: + await echo_dispatched_prompt(source_item) emit_queue_dispatch(source_item) if allow_command_dispatch and stripped_task.lower() in {"clear", "/clear"}: @@ -1253,7 +1200,7 @@ async def dispatch_submission( from code_puppy.agents.agent_manager import get_current_agent - RUNTIME_STATE.mark_running( + runtime.mark_running( asyncio.create_task( run_agent_bg( candidate_task, @@ -1315,10 +1262,18 @@ async def drain_pending_work_if_idle(*, origin: str) -> bool: async with queue_start_lock: while True: - if RUNTIME_STATE.running: - active_task = RUNTIME_STATE.bg_task + if runtime.has_pending_submission(): + log_event( + "queue_autodrain_noop", + origin=origin, + reason="pending_submission", + ) + return handled_any + + if runtime.running: + active_task = runtime.bg_task if active_task is None or active_task.done(): - RUNTIME_STATE.mark_idle() + runtime.mark_idle() log_event( "queue_autodrain_reconciled", origin=origin, @@ -1329,12 +1284,12 @@ async def drain_pending_work_if_idle(*, origin: str) -> bool: log_event("queue_autodrain_noop", origin=origin, reason="running") return handled_any - next_item = RUNTIME_STATE.dequeue() + next_item = runtime.dequeue() if next_item is not None: outcome = await dispatch_submission( next_item.text if next_item.kind == "queued" - else f"[user interjects]: {next_item.text} - please continue with that in mind", + else _build_interject_submission_text(next_item.text), source_item=next_item, save_history=False, allow_command_dispatch=next_item.kind == "queued", @@ -1344,7 +1299,7 @@ async def drain_pending_work_if_idle(*, origin: str) -> bool: log_event( "queue_autodrain_triggered", origin=origin, - remaining=len(RUNTIME_STATE.queue), + remaining=len(runtime.queue), kind=next_item.kind, text=next_item.text, ) @@ -1374,12 +1329,12 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: # Get the custom prompt from the current agent, or use default current_agent = get_current_agent() user_prompt = current_agent.get_user_prompt() or "Enter your coding task:" - if not RUNTIME_STATE.running: + if not runtime.running: handled = await drain_pending_work_if_idle(origin="loop_idle_check") if handled: continue - if not RUNTIME_STATE.running: + if not runtime.running and not runtime.has_pending_submission(): emit_info(f"{user_prompt}\n") try: @@ -1388,13 +1343,16 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: # Windows-specific: Reset terminal state before prompting reset_windows_terminal_ansi() - # Use the async version of get_input_with_combined_completion - task = await get_input_with_combined_completion( + submission = await prompt_for_submission( get_prompt_with_active_model, history_file=COMMAND_HISTORY_FILE, - erase_when_done=RUNTIME_STATE.running, + erase_when_done=runtime.running, + ) + log_event( + "input_received", + action=submission.action, + text=submission.text, ) - log_event("input_received", text=task) # Windows+uvx: Re-disable Ctrl+C after prompt_toolkit # (prompt_toolkit restores console mode which re-enables Ctrl+C) @@ -1406,7 +1364,11 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: pass except ImportError: # Fall back to basic input if prompt_toolkit is not available - task = input(">>> ") + from code_puppy.command_line.prompt_toolkit_completion import ( + PromptSubmission, + ) + + submission = PromptSubmission(action="submit", text=input(">>> ")) except KeyboardInterrupt: # Handle Ctrl+C - cancel input and continue @@ -1430,9 +1392,15 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: # Handle Ctrl+D - exit the application await shutdown_interactive_session("\nGoodbye! (Ctrl+D)", reason="ctrl_d") break - outcome = await dispatch_submission(task) + outcome = await dispatch_submission( + submission.text, + requested_action=submission.action, + ) if outcome == "exit": break + if outcome == "launched": + await asyncio.sleep(0) + clear_active_interactive_runtime(runtime) async def run_prompt_with_attachments( @@ -1451,6 +1419,9 @@ async def run_prompt_with_attachments( import re from code_puppy.messaging import emit_system_message, emit_warning + from code_puppy.command_line.interactive_runtime import ( + get_active_interactive_runtime, + ) processed_prompt = parse_prompt_attachments(raw_prompt) @@ -1500,6 +1471,11 @@ async def run_prompt_with_attachments( from code_puppy.agents.event_stream_handler import set_streaming_console set_streaming_console(spinner_console) + _seed_spinner_context( + agent, + cleaned_prompt, + link_attachments=link_attachments, + ) # Create the agent task first so we can track and cancel it agent_task = asyncio.create_task( @@ -1510,7 +1486,10 @@ async def run_prompt_with_attachments( ) ) - if use_spinner and spinner_console is not None: + runtime = get_active_interactive_runtime() + spinner_allowed = not (runtime is not None and runtime.running) + + if use_spinner and spinner_console is not None and spinner_allowed: from code_puppy.messaging.spinner import ConsoleSpinner with ConsoleSpinner(console=spinner_console): diff --git a/code_puppy/command_line/interactive_runtime.py b/code_puppy/command_line/interactive_runtime.py new file mode 100644 index 000000000..40b72acd4 --- /dev/null +++ b/code_puppy/command_line/interactive_runtime.py @@ -0,0 +1,304 @@ +"""Shared runtime state for interactive prompt, queue, and shell coordination.""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Callable, Literal + +MAX_PROMPT_QUEUE = 25 +PROMPT_STATUS_FRAME_INTERVAL = 0.09 +PROMPT_STATUS_BACKOFF_WINDOW = 0.045 + + +@dataclass +class QueuedPrompt: + """Normalized queued prompt payload.""" + + kind: Literal["queued", "interject"] + text: str + created_at: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + + def preview_text(self) -> str: + if self.kind == "interject": + return f"[INTERJECT] {self.text}" + return self.text + + +@dataclass +class PromptRuntimeState: + """Single source of truth for interactive prompt state.""" + + queue: list[QueuedPrompt] = field(default_factory=list) + running: bool = False + cancelling: bool = False + bg_task: asyncio.Task | None = None + shell_depth: int = 0 + queue_view_offset: int = 0 + pending_submission: str | None = None + prompt_surface_kind: Literal["main"] | None = None + prompt_session: object | None = None + prompt_status_started_at: float | None = None + prompt_status_task: asyncio.Task | None = None + last_prompt_invalidation_at: float = 0.0 + last_spinner_invalidation_at: float = 0.0 + + def mark_running(self, task: asyncio.Task) -> None: + self.running = True + self.cancelling = False + self.bg_task = task + self.prompt_status_started_at = time.monotonic() + self._ensure_prompt_status_task() + self.invalidate_prompt() + + def mark_idle(self) -> None: + self.running = False + self.cancelling = False + self.bg_task = None + self.prompt_status_started_at = None + self._stop_prompt_status_task() + self.invalidate_prompt() + + def _can_enqueue(self) -> bool: + return len(self.queue) < MAX_PROMPT_QUEUE + + def _clamp_queue_view_offset(self, *, max_visible: int = 3) -> None: + max_start = max(0, len(self.queue) - max_visible) + self.queue_view_offset = max(0, min(self.queue_view_offset, max_start)) + + def request_queue(self, prompt: str) -> tuple[bool, int, QueuedPrompt | None]: + if not self._can_enqueue(): + return False, len(self.queue), None + item = QueuedPrompt(kind="queued", text=prompt) + self.queue.append(item) + self._clamp_queue_view_offset() + self.invalidate_prompt() + return True, len(self.queue), item + + def request_interject(self, prompt: str) -> tuple[bool, int, QueuedPrompt | None]: + if not self._can_enqueue(): + return False, len(self.queue), None + item = QueuedPrompt(kind="interject", text=prompt) + self.queue.insert(0, item) + self._clamp_queue_view_offset() + self.invalidate_prompt() + return True, 1, item + + def dequeue(self) -> QueuedPrompt | None: + if not self.queue: + return None + value = self.queue.pop(0) + self._clamp_queue_view_offset() + self.invalidate_prompt() + return value + + def queue_preview_texts(self) -> list[str]: + return [item.preview_text() for item in self.queue] + + def has_pending_submission(self) -> bool: + return bool(self.pending_submission) + + def set_pending_submission(self, text: str | None) -> None: + self.pending_submission = text + self.invalidate_prompt() + + def take_pending_submission(self) -> str | None: + text = self.pending_submission + self.pending_submission = None + self.invalidate_prompt() + return text + + def has_active_shell(self) -> bool: + return self.shell_depth > 0 + + def notify_shell_started(self) -> None: + self.shell_depth += 1 + self.invalidate_prompt() + + def notify_shell_finished(self) -> None: + if self.shell_depth > 0: + self.shell_depth -= 1 + self.invalidate_prompt() + + def shift_queue_view_offset(self, delta: int, *, max_visible: int = 3) -> bool: + old_offset = self.queue_view_offset + self._clamp_queue_view_offset(max_visible=max_visible) + max_start = max(0, len(self.queue) - max_visible) + self.queue_view_offset = max(0, min(self.queue_view_offset + delta, max_start)) + changed = self.queue_view_offset != old_offset + if changed: + self.invalidate_prompt() + return changed + + def register_prompt_surface( + self, session: object, kind: Literal["main"] = "main" + ) -> None: + self.prompt_surface_kind = kind + self.prompt_session = session + self._ensure_prompt_status_task() + self.invalidate_prompt() + + def clear_prompt_surface(self, session: object | None = None) -> None: + if session is not None and self.prompt_session is not session: + return + self.prompt_surface_kind = None + self.prompt_session = None + self._stop_prompt_status_task() + + def has_prompt_surface(self) -> bool: + return self.prompt_session is not None + + def get_prompt_status_frame(self) -> str: + from code_puppy.messaging.spinner.spinner_base import SpinnerBase + + if self.prompt_status_started_at is None: + return SpinnerBase.FRAMES[0] + + elapsed = max(0.0, time.monotonic() - self.prompt_status_started_at) + frame_index = int(elapsed / PROMPT_STATUS_FRAME_INTERVAL) % len( + SpinnerBase.FRAMES + ) + return SpinnerBase.FRAMES[frame_index] + + def invalidate_prompt(self) -> None: + self._invalidate_prompt(low_priority=False) + + def invalidate_prompt_for_spinner(self) -> None: + self._invalidate_prompt(low_priority=True) + + def _invalidate_prompt(self, *, low_priority: bool) -> None: + app = getattr(self.prompt_session, "app", None) + if app is None: + return + + now = time.monotonic() + if low_priority: + if now - self.last_prompt_invalidation_at < PROMPT_STATUS_BACKOFF_WINDOW: + return + if ( + self.last_spinner_invalidation_at > 0 + and now - self.last_spinner_invalidation_at < PROMPT_STATUS_FRAME_INTERVAL + ): + return + + try: + app.invalidate() + if low_priority: + self.last_spinner_invalidation_at = now + else: + self.last_prompt_invalidation_at = now + except Exception: + pass + + def _should_refresh_prompt_status(self) -> bool: + return self.running and self.has_prompt_surface() + + def _ensure_prompt_status_task(self) -> None: + if not self._should_refresh_prompt_status(): + return + if self.prompt_status_task is not None and not self.prompt_status_task.done(): + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + self.prompt_status_task = loop.create_task(self._prompt_status_loop()) + + def _stop_prompt_status_task(self) -> None: + task = self.prompt_status_task + if task is None: + return + self.prompt_status_task = None + if not task.done(): + task.cancel() + + async def _prompt_status_loop(self) -> None: + current_task = asyncio.current_task() + try: + while self._should_refresh_prompt_status(): + self.invalidate_prompt_for_spinner() + await asyncio.sleep(PROMPT_STATUS_FRAME_INTERVAL) + except asyncio.CancelledError: + pass + finally: + if self.prompt_status_task is current_task: + self.prompt_status_task = None + self.invalidate_prompt() + + def run_above_prompt(self, func: Callable[[], None], *, timeout: float = 5.0) -> bool: + """Run a synchronous callback above the mounted prompt surface.""" + app = getattr(self.prompt_session, "app", None) + loop = getattr(app, "loop", None) + if app is None or loop is None or not loop.is_running(): + return False + + try: + current_loop = asyncio.get_running_loop() + except RuntimeError: + current_loop = None + if current_loop is loop: + return False + + from prompt_toolkit.application import run_in_terminal + + async def _runner() -> None: + await run_in_terminal(func) + + future = asyncio.run_coroutine_threadsafe(_runner(), loop) + try: + future.result(timeout=timeout) + return True + except Exception: + future.cancel() + return False + + async def run_above_prompt_async(self, func: Callable[[], None]) -> bool: + """Run a synchronous callback above the mounted prompt from async code.""" + app = getattr(self.prompt_session, "app", None) + loop = getattr(app, "loop", None) + if app is None or loop is None or not loop.is_running(): + return False + + try: + current_loop = asyncio.get_running_loop() + except RuntimeError: + return False + + from prompt_toolkit.application import run_in_terminal + + async def _runner() -> None: + await run_in_terminal(func) + + try: + if current_loop is loop: + await _runner() + return True + + future = asyncio.run_coroutine_threadsafe(_runner(), loop) + await asyncio.wrap_future(future) + return True + except Exception: + return False + + +_ACTIVE_RUNTIME: PromptRuntimeState | None = None + + +def register_active_interactive_runtime(runtime: PromptRuntimeState) -> None: + global _ACTIVE_RUNTIME + _ACTIVE_RUNTIME = runtime + + +def get_active_interactive_runtime() -> PromptRuntimeState | None: + return _ACTIVE_RUNTIME + + +def clear_active_interactive_runtime(runtime: PromptRuntimeState | None = None) -> None: + global _ACTIVE_RUNTIME + if runtime is not None and _ACTIVE_RUNTIME is not runtime: + return + _ACTIVE_RUNTIME = None diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 19792064d..ffcb8cf54 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -21,6 +21,7 @@ from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys from prompt_toolkit.layout.processors import Processor, Transformation +from prompt_toolkit.shortcuts import print_formatted_text from prompt_toolkit.styles import Style from code_puppy.command_line.attachments import ( @@ -35,6 +36,10 @@ ) from code_puppy.command_line.command_registry import get_unique_commands from code_puppy.command_line.file_path_completion import FilePathCompleter +from code_puppy.command_line.interactive_runtime import ( + PromptRuntimeState, + get_active_interactive_runtime, +) from code_puppy.command_line.load_context_completion import LoadContextCompleter from code_puppy.command_line.mcp_completion import MCPCompleter from code_puppy.command_line.model_picker_completion import ( @@ -44,79 +49,66 @@ from code_puppy.command_line.pin_command_completion import PinCompleter, UnpinCompleter from code_puppy.command_line.skills_completion import SkillsCompleter from code_puppy.command_line.utils import list_directory -from code_puppy.config import ( - COMMAND_HISTORY_FILE, - get_config_keys, - get_puppy_name, - get_value, -) - -_QUEUE_VIEW_OFFSET = 0 +from code_puppy.config import COMMAND_HISTORY_FILE, get_config_keys, get_puppy_name, get_value +from code_puppy.messaging.spinner.spinner_base import SpinnerBase -@dataclass -class _PromptRuntimeState: - kind: Literal["main", "interject"] | None = None - session: PromptSession | None = None - app: object | None = None - shell_suspended: bool = False +@dataclass(frozen=True) +class PromptSubmission: + action: Literal["submit", "queue", "interject"] + text: str -_PROMPT_RUNTIME_STATE = _PromptRuntimeState() +def _get_runtime() -> PromptRuntimeState | None: + return get_active_interactive_runtime() def register_active_prompt_surface( kind: Literal["main", "interject"], session: PromptSession ) -> None: - """Track the mounted prompt surface so shells can suspend it in-place.""" - _PROMPT_RUNTIME_STATE.kind = kind - _PROMPT_RUNTIME_STATE.session = session - _PROMPT_RUNTIME_STATE.app = getattr(session, "app", None) - _PROMPT_RUNTIME_STATE.shell_suspended = False + runtime = _get_runtime() + if runtime is None: + return + runtime.register_prompt_surface(session, kind="main") def clear_active_prompt_surface(session: PromptSession | None = None) -> None: - """Clear the tracked prompt surface if it matches the current session.""" - if session is not None and _PROMPT_RUNTIME_STATE.session is not session: + runtime = _get_runtime() + if runtime is None: return - _PROMPT_RUNTIME_STATE.kind = None - _PROMPT_RUNTIME_STATE.session = None - _PROMPT_RUNTIME_STATE.app = None - _PROMPT_RUNTIME_STATE.shell_suspended = False + runtime.clear_prompt_surface(session) def get_active_prompt_surface_kind() -> Literal["main", "interject"] | None: - return _PROMPT_RUNTIME_STATE.kind + runtime = _get_runtime() + if runtime is None: + return None + if runtime.prompt_surface_kind == "main": + return "main" + return None def has_active_prompt_surface() -> bool: - return _PROMPT_RUNTIME_STATE.session is not None + runtime = _get_runtime() + return runtime.has_prompt_surface() if runtime is not None else False def is_shell_prompt_suspended() -> bool: - return ( - _PROMPT_RUNTIME_STATE.session is not None - and _PROMPT_RUNTIME_STATE.shell_suspended - ) + runtime = _get_runtime() + return runtime.has_active_shell() if runtime is not None else False def set_shell_prompt_suspended(suspended: bool) -> None: - """Flip shell suspension on the mounted prompt and force a redraw.""" - if _PROMPT_RUNTIME_STATE.session is None: - _PROMPT_RUNTIME_STATE.shell_suspended = False + """Compatibility shim for tests that now maps to shell-active state.""" + runtime = _get_runtime() + if runtime is None: return - - _PROMPT_RUNTIME_STATE.shell_suspended = suspended - _PROMPT_RUNTIME_STATE.app = getattr( - _PROMPT_RUNTIME_STATE.session, "app", _PROMPT_RUNTIME_STATE.app - ) - - app = _PROMPT_RUNTIME_STATE.app - if app is not None: - try: - app.invalidate() - except Exception: - pass + if suspended: + if not runtime.has_active_shell(): + runtime.notify_shell_started() + return + while runtime.has_active_shell(): + runtime.notify_shell_finished() def _interrupt_shell_from_prompt(label: str) -> None: @@ -126,12 +118,6 @@ def _interrupt_shell_from_prompt(label: str) -> None: emit_warning(f"\nšŸ›‘ {label} detected! Interrupting shell command...") kill_all_running_shell_processes() - -def _shell_interrupt_hint() -> str: - """Return platform-appropriate interrupt text for the shell lock banner.""" - return "control+c" if sys.platform == "darwin" else "ctrl+c" - - def _truncate_queue_line(text: str, max_len: int) -> str: if max_len <= 2: return ".." @@ -144,14 +130,18 @@ def _get_queue_preview( prompts: list[str], term_width: int, max_visible: int = 3 ) -> tuple[list[str], int]: """Return visible queue lines and hidden count from current offset.""" - global _QUEUE_VIEW_OFFSET + runtime = _get_runtime() + queue_offset = runtime.queue_view_offset if runtime is not None else 0 if not prompts: - _QUEUE_VIEW_OFFSET = 0 + if runtime is not None: + runtime.queue_view_offset = 0 return [], 0 max_start = max(0, len(prompts) - max_visible) - _QUEUE_VIEW_OFFSET = max(0, min(_QUEUE_VIEW_OFFSET, max_start)) - start = _QUEUE_VIEW_OFFSET + queue_offset = max(0, min(queue_offset, max_start)) + if runtime is not None: + runtime.queue_view_offset = queue_offset + start = queue_offset visible = prompts[start : start + max_visible] lines: list[str] = [] line_room = max(8, term_width - 8) @@ -161,6 +151,10 @@ def _get_queue_preview( return lines, hidden +def _is_exit_text(text: str) -> bool: + return text.strip().lower() in {"exit", "quit", "/exit", "/quit"} + + def _sanitize_for_encoding(text: str) -> str: """Remove or replace characters that can't be safely encoded. @@ -624,10 +618,60 @@ def get_completions(self, document, complete_event): def get_prompt_with_active_model(base: str = ">>> ", is_interject: bool = False): + return FormattedText( + _build_prompt_parts( + is_interject=is_interject, + include_queue_preview=True, + include_pending_hint=True, + ) + ) + + +def _build_prompt_style() -> Style: + return Style.from_dict( + { + # Keys must AVOID the 'class:' prefix – that prefix is used only when + # tagging tokens in `FormattedText`. See prompt_toolkit docs. + "puppy": "bold ansibrightcyan", + "owner": "bold ansibrightblue", + "agent": "bold ansibrightblue", + "model": "bold ansibrightcyan", + "cwd": "bold ansibrightgreen", + "arrow": "bold ansibrightcyan", + "separator": "bold ansigray", + "attachment-placeholder": "italic ansicyan", + "queue-item": "italic ansiyellow", + "thinking": "bold ansibrightcyan", + "thinking-context": "bold white", + } + ) + + +def _build_prompt_status_parts(runtime: PromptRuntimeState) -> list[tuple[str, str]]: + """Build the lightweight thinking line shown above the prompt separator.""" + parts: list[tuple[str, str]] = [ + ("class:thinking", f"{get_puppy_name().title()} is thinking... "), + ("class:thinking", runtime.get_prompt_status_frame()), + ] + context_info = SpinnerBase.get_context_info() + if context_info: + parts.append(("", " ")) + parts.append(("class:thinking-context", context_info)) + parts.append(("", "\n")) + return parts + + +def _build_prompt_parts( + *, + is_interject: bool, + include_queue_preview: bool, + include_pending_hint: bool, +) -> list[tuple[str, str]]: from code_puppy.agents.agent_manager import get_current_agent puppy = get_puppy_name() global_model = get_active_model() or "(default)" + runtime = _get_runtime() # Get current agent information current_agent = get_current_agent() @@ -656,21 +700,22 @@ def get_prompt_with_active_model(base: str = ">>> ", is_interject: bool = False) else: cwd_display = cwd - # Fetch queued prompts if any - from code_puppy.cli_runner import PROMPT_QUEUE - # We add a visual top border using terminal width import shutil + term_width = shutil.get_terminal_size().columns sep_line = "─" * term_width - - parts = [] - - # Optional newline to isolate from previous output - parts.append(("class:separator", f"\n{sep_line}\n")) - - if PROMPT_QUEUE: - preview_lines, hidden = _get_queue_preview(PROMPT_QUEUE, term_width=term_width) + + parts = [("", "\n")] + + if runtime is not None and runtime.running: + parts.extend(_build_prompt_status_parts(runtime)) + + parts.append(("class:separator", f"{sep_line}\n")) + + queue_preview = runtime.queue_preview_texts() if runtime is not None else [] + if include_queue_preview and queue_preview: + preview_lines, hidden = _get_queue_preview(queue_preview, term_width=term_width) for line in preview_lines: parts.append(("class:queue-item", f"{line}\n")) if hidden: @@ -685,33 +730,61 @@ def get_prompt_with_active_model(base: str = ">>> ", is_interject: bool = False) ("class:model", model_display + " "), ("class:cwd", "(" + str(cwd_display) + ") \n"), ]) - - if is_interject: + + if include_pending_hint and ( + is_interject or (runtime is not None and runtime.has_pending_submission()) + ): # Add hint above the prompt line to keep the cursor position consistent parts.append(("class:queue-item", " [i]nterject or [q]ueue\n")) - if is_shell_prompt_suspended(): - parts.append( - ( - "class:suspension", - " input suspended during shell command, press " - f"{_shell_interrupt_hint()} to interrupt\n", - ) - ) - parts.extend([ ("class:separator", "╰─"), ("class:arrow", "āÆ "), ]) - - return FormattedText(parts) + return parts -async def get_input_with_combined_completion( + +def render_submitted_prompt_echo(text: str) -> None: + """Print a submitted prompt using the same prompt chrome as the composer.""" + echo_text = text.rstrip("\n") + if not echo_text: + return + + from prompt_toolkit.output.defaults import create_output + + out = create_output(stdout=sys.__stdout__) + if hasattr(out, "enable_cpr"): + out.enable_cpr = False + + parts = _build_prompt_parts( + is_interject=False, + include_queue_preview=False, + include_pending_hint=False, + ) + parts.append(("", echo_text)) + parts.append(("", "\n")) + formatted = FormattedText(parts) + style = _build_prompt_style() + + runtime = _get_runtime() + app = getattr(getattr(runtime, "prompt_session", None), "app", None) + if app is not None: + try: + app.print_text(formatted, style=style) + return + except Exception: + pass + + print_formatted_text(formatted, style=style, output=out) + + +async def prompt_for_submission( prompt_str=">>> ", history_file: Optional[str] = None, erase_when_done: bool = False -) -> str: +) -> PromptSubmission: # Use SafeFileHistory to handle encoding errors gracefully on Windows history = SafeFileHistory(history_file) if history_file else None + runtime = _get_runtime() completer = merge_completers( [ FilePathCompleter(symbol="@"), @@ -731,15 +804,23 @@ async def get_input_with_combined_completion( ) # Add custom key bindings and multiline toggle bindings = KeyBindings() - shell_suspended_filter = Condition(is_shell_prompt_suspended) + pending_decision_filter = Condition( + lambda: runtime is not None and runtime.has_pending_submission() + ) + shell_active_filter = Condition( + lambda: runtime is not None and runtime.has_active_shell() + ) # Multiline mode state multiline = {"enabled": False} - # Ctrl+X keybinding - exit with KeyboardInterrupt for shell command cancellation + def awaiting_decision() -> bool: + return runtime is not None and runtime.has_pending_submission() + + # Ctrl+X keybinding - exit with KeyboardInterrupt for input cancellation @bindings.add(Keys.ControlX) def _(event): - if is_shell_prompt_suspended(): + if runtime is not None and runtime.has_active_shell(): _interrupt_shell_from_prompt("Ctrl-X") return try: @@ -749,14 +830,17 @@ def _(event): # This happens when user presses multiple exit keys in quick succession pass - @bindings.add("c-c", filter=shell_suspended_filter, eager=True) + @bindings.add("c-c", filter=shell_active_filter, eager=True) def _(event): _interrupt_shell_from_prompt("Ctrl-C") # Escape keybinding - exit with KeyboardInterrupt @bindings.add(Keys.Escape) def _(event): - if is_shell_prompt_suspended(): + if awaiting_decision(): + runtime.set_pending_submission(None) + return + if runtime is not None and runtime.has_active_shell(): return try: event.app.exit(exception=KeyboardInterrupt) @@ -764,19 +848,27 @@ def _(event): # Ignore "Return value already set" errors when exit was already called pass - @bindings.add("c-d", filter=shell_suspended_filter, eager=True) - def _(event): - return - # NOTE: We intentionally do NOT override Ctrl+C here. # prompt_toolkit's default Ctrl+C handler properly resets the terminal state on Windows. # Overriding it with event.app.exit(exception=KeyboardInterrupt) can leave the terminal # in a bad state where characters cannot be typed. Let prompt_toolkit handle Ctrl+C natively. + @bindings.add("i", filter=pending_decision_filter, eager=True) + @bindings.add("I", filter=pending_decision_filter, eager=True) + def _(event): + text = runtime.take_pending_submission() or event.app.current_buffer.text + event.app.exit(result=PromptSubmission(action="interject", text=text)) + + @bindings.add("q", filter=pending_decision_filter, eager=True) + @bindings.add("Q", filter=pending_decision_filter, eager=True) + def _(event): + text = runtime.take_pending_submission() or event.app.current_buffer.text + event.app.exit(result=PromptSubmission(action="queue", text=text)) + # Toggle multiline with Alt+M @bindings.add(Keys.Escape, "m") def _(event): - if is_shell_prompt_suspended(): + if awaiting_decision(): return multiline["enabled"] = not multiline["enabled"] status = "ON" if multiline["enabled"] else "OFF" @@ -788,7 +880,7 @@ def _(event): # Also toggle multiline with F2 (more reliable across platforms) @bindings.add("f2") def _(event): - if is_shell_prompt_suspended(): + if awaiting_decision(): return multiline["enabled"] = not multiline["enabled"] status = "ON" if multiline["enabled"] else "OFF" @@ -799,7 +891,7 @@ def _(event): # Ctrl+J (line feed) works in virtually all terminals; mark eager so it wins @bindings.add("c-j", eager=True) def _(event): - if is_shell_prompt_suspended(): + if awaiting_decision(): return event.app.current_buffer.insert_text("\n") @@ -808,7 +900,7 @@ def _(event): @bindings.add("c-enter", eager=True) def _(event): - if is_shell_prompt_suspended(): + if awaiting_decision(): return event.app.current_buffer.insert_text("\n") except Exception: @@ -817,7 +909,16 @@ def _(event): # Enter behavior depends on multiline mode @bindings.add("enter", filter=~is_searching, eager=True) def _(event): - if is_shell_prompt_suspended(): + if awaiting_decision(): + return + text = event.app.current_buffer.text + if ( + runtime is not None + and runtime.running + and text.strip() + and not _is_exit_text(text) + ): + runtime.set_pending_submission(text) return if multiline["enabled"]: event.app.current_buffer.insert_text("\n") @@ -830,7 +931,7 @@ def _(event): @bindings.add("c-h", eager=True) # Backspace (Ctrl+H) @bindings.add("backspace", eager=True) def handle_backspace_with_completion(event): - if is_shell_prompt_suspended(): + if awaiting_decision(): return buffer = event.app.current_buffer # Perform the deletion first @@ -842,7 +943,7 @@ def handle_backspace_with_completion(event): @bindings.add("delete", eager=True) def handle_delete_with_completion(event): - if is_shell_prompt_suspended(): + if awaiting_decision(): return buffer = event.app.current_buffer # Perform the deletion first @@ -854,23 +955,15 @@ def handle_delete_with_completion(event): @bindings.add("c-up", eager=True) def handle_queue_scroll_up(event): - from code_puppy.cli_runner import PROMPT_QUEUE - - global _QUEUE_VIEW_OFFSET - if len(PROMPT_QUEUE) <= 3: + if runtime is None or len(runtime.queue) <= 3: return - _QUEUE_VIEW_OFFSET = max(0, _QUEUE_VIEW_OFFSET - 1) - event.app.invalidate() + runtime.shift_queue_view_offset(-1) @bindings.add("c-down", eager=True) def handle_queue_scroll_down(event): - from code_puppy.cli_runner import PROMPT_QUEUE - - global _QUEUE_VIEW_OFFSET - if len(PROMPT_QUEUE) <= 3: + if runtime is None or len(runtime.queue) <= 3: return - _QUEUE_VIEW_OFFSET = min(len(PROMPT_QUEUE) - 3, _QUEUE_VIEW_OFFSET + 1) - event.app.invalidate() + runtime.shift_queue_view_offset(1) # Handle bracketed paste - smart detection for text vs images. # Most terminals (Windows included!) send Ctrl+V through bracketed paste. @@ -879,7 +972,7 @@ def handle_queue_scroll_down(event): @bindings.add(Keys.BracketedPaste) def handle_bracketed_paste(event): """Handle bracketed paste - smart text vs image detection.""" - if is_shell_prompt_suspended(): + if awaiting_decision(): return pasted_data = event.data @@ -911,7 +1004,7 @@ def handle_bracketed_paste(event): @bindings.add("c-v", eager=True) def handle_smart_paste(event): """Handle Ctrl+V - auto-detect image vs text in clipboard.""" - if is_shell_prompt_suspended(): + if awaiting_decision(): return try: # Check for image first @@ -980,7 +1073,7 @@ def handle_smart_paste(event): @bindings.add("f3") def handle_image_paste_f3(event): """Handle F3 - paste image from clipboard (image-only, shows error if none).""" - if is_shell_prompt_suspended(): + if awaiting_decision(): return try: if has_image_in_clipboard(): @@ -998,10 +1091,15 @@ def handle_image_paste_f3(event): event.app.current_buffer.insert_text("[āŒ clipboard error] ") event.app.output.bell() + @bindings.add("", filter=pending_decision_filter, eager=True) + def _(event): + # Freeze the submitted text until the user chooses queue or interject. + return + from prompt_toolkit.output.defaults import create_output - import sys + out = create_output(stdout=sys.stdout) - if hasattr(out, 'enable_cpr'): + if hasattr(out, "enable_cpr"): out.enable_cpr = False session = PromptSession( completer=completer, @@ -1010,160 +1108,55 @@ def handle_image_paste_f3(event): key_bindings=bindings, input_processors=[AttachmentPlaceholderProcessor()], output=out, - erase_when_done=erase_when_done + erase_when_done=erase_when_done, ) - session.default_buffer.read_only = shell_suspended_filter # If they pass a string, backward-compat: convert it to formatted_text if isinstance(prompt_str, str): from prompt_toolkit.formatted_text import FormattedText prompt_str = FormattedText([(None, prompt_str)]) - style = Style.from_dict( - { - # Keys must AVOID the 'class:' prefix – that prefix is used only when - # tagging tokens in `FormattedText`. See prompt_toolkit docs. - "puppy": "bold ansibrightcyan", - "owner": "bold ansibrightblue", - "agent": "bold ansibrightblue", - "model": "bold ansibrightcyan", - "cwd": "bold ansibrightgreen", - "arrow": "bold ansibrightcyan", - "separator": "bold ansigray", - "attachment-placeholder": "italic ansicyan", - "queue-item": "italic ansiyellow", - "suspension": "bold ansibrightred", - } - ) + style = _build_prompt_style() register_active_prompt_surface("main", session) try: - with patch_stdout(raw=True): - text = await session.prompt_async(prompt_str, style=style) + with patch_stdout(): + result = await session.prompt_async(prompt_str, style=style) + except (KeyboardInterrupt, EOFError): + if runtime is not None: + runtime.set_pending_submission(None) + raise finally: clear_active_prompt_surface(session) + if isinstance(result, PromptSubmission): + return result # NOTE: We used to call update_model_in_input(text) here to handle /model and /m # commands at the prompt level, but that prevented the command handler from running # and emitting success messages. Now we let all /model commands fall through to # the command handler in main.py for consistent handling. - return text - - - -async def get_interject_action() -> str: - from code_puppy.messaging.spinner import pause_all_spinners, resume_all_spinners - - bindings = KeyBindings() - result = "" - - @bindings.add('i') - @bindings.add('I') - def _(event): - nonlocal result - if is_shell_prompt_suspended(): - return - result = 'i' - event.app.exit(result='i') - - @bindings.add('q') - @bindings.add('Q') - def _(event): - nonlocal result - if is_shell_prompt_suspended(): - return - result = 'q' - event.app.exit(result='q') - - @bindings.add('c-c') - def _(event): - if is_shell_prompt_suspended(): - _interrupt_shell_from_prompt("Ctrl-C") - return - raise KeyboardInterrupt() - - @bindings.add(Keys.ControlX) - def _(event): - if is_shell_prompt_suspended(): - _interrupt_shell_from_prompt("Ctrl-X") - return + return PromptSubmission(action="submit", text=result) - @bindings.add('c-d') - def _(event): - if is_shell_prompt_suspended(): - return - raise EOFError() - - @bindings.add('') - def _(event): - # Ignore other keys to simulate a single-character menu - pass - @bindings.add("c-up") - def _(event): - from code_puppy.cli_runner import PROMPT_QUEUE - - global _QUEUE_VIEW_OFFSET - if len(PROMPT_QUEUE) > 3: - _QUEUE_VIEW_OFFSET = max(0, _QUEUE_VIEW_OFFSET - 1) - event.app.invalidate() - - @bindings.add("c-down") - def _(event): - from code_puppy.cli_runner import PROMPT_QUEUE +async def get_input_with_combined_completion( + prompt_str=">>> ", history_file: Optional[str] = None, erase_when_done: bool = False +) -> str: + submission = await prompt_for_submission( + prompt_str=prompt_str, + history_file=history_file, + erase_when_done=erase_when_done, + ) + return submission.text - global _QUEUE_VIEW_OFFSET - if len(PROMPT_QUEUE) > 3: - _QUEUE_VIEW_OFFSET = min(len(PROMPT_QUEUE) - 3, _QUEUE_VIEW_OFFSET + 1) - event.app.invalidate() - prompt_text = lambda: get_prompt_with_active_model(is_interject=True) - from prompt_toolkit.output.defaults import create_output - import sys - out = create_output(stdout=sys.stdout) - if hasattr(out, 'enable_cpr'): - out.enable_cpr = False - session = PromptSession( - message=prompt_text, - key_bindings=bindings, - output=out, - erase_when_done=True +async def get_interject_action() -> str: + """Compatibility shim for tests; interactive_mode no longer uses this.""" + submission = await prompt_for_submission( + prompt_str=lambda: get_prompt_with_active_model(is_interject=True), + erase_when_done=True, ) - session.default_buffer.read_only = Condition(is_shell_prompt_suspended) - - from prompt_toolkit.styles import Style - style = Style.from_dict({ - "puppy": "bold ansibrightcyan", - "owner": "bold ansibrightblue", - "agent": "bold ansibrightblue", - "model": "bold ansibrightcyan", - "cwd": "bold ansibrightgreen", - "arrow": "bold ansibrightcyan", - "separator": "bold ansigray", - "attachment-placeholder": "italic ansicyan", - "queue-item": "italic ansiyellow", - "suspension": "bold ansibrightred", - }) - - register_active_prompt_surface("interject", session) - try: - with patch_stdout(raw=True): - # We catch the result of app.exit(result=...) via session.prompt_async() - try: - # Pause spinners to prevent jitter and allow clean input - pause_all_spinners() - - # We don't actually want them to type anything, just press a key - # session.prompt_async returns the text typed if they press enter, - # but our keybindings will exit early with the bound result. - action = await session.prompt_async(style=style) - if result: - return result - return action - except (KeyboardInterrupt, EOFError): - raise - finally: - # Resume spinners when done - resume_all_spinners() - finally: - clear_active_prompt_surface(session) + if submission.action == "interject": + return "i" + if submission.action == "queue": + return "q" + return "" diff --git a/code_puppy/messaging/rich_renderer.py b/code_puppy/messaging/rich_renderer.py index 6a2fc2954..205393808 100644 --- a/code_puppy/messaging/rich_renderer.py +++ b/code_puppy/messaging/rich_renderer.py @@ -7,6 +7,8 @@ only structured data with no formatting hints. """ +import sys + from typing import Dict, Optional, Protocol, runtime_checkable from rich.console import Console @@ -172,6 +174,87 @@ def _should_suppress_subagent_output(self) -> bool: """ return is_subagent() and not get_subagent_verbose() + def _get_prompt_runtime(self): + try: + from code_puppy.command_line.interactive_runtime import ( + get_active_interactive_runtime, + ) + + return get_active_interactive_runtime() + except Exception: + return None + + def _should_render_agent_response(self) -> bool: + """Render final agent responses when the interactive prompt is mounted.""" + runtime = self._get_prompt_runtime() + return runtime.has_prompt_surface() if runtime is not None else False + + def _build_prompt_safe_console(self) -> Console: + """Create a console that writes to the real terminal, not patched stdout.""" + return Console( + file=sys.__stdout__, + force_terminal=self._console.is_terminal, + width=self._console.width, + color_system=self._console.color_system, + soft_wrap=self._console.soft_wrap, + legacy_windows=self._console.legacy_windows, + ) + + def _should_render_above_prompt(self, message: AnyMessage) -> bool: + """Render styled structured output above the live prompt surface.""" + runtime = self._get_prompt_runtime() + if runtime is None or not runtime.has_prompt_surface(): + return False + + return not isinstance( + message, + ( + ConfirmationRequest, + SelectionRequest, + ShellLineMessage, + SpinnerControl, + UserInputRequest, + ), + ) + + def _render_message_with_console( + self, message: AnyMessage, console: Console + ) -> None: + """Temporarily swap consoles so direct renderers can reuse their logic.""" + original_console = self._console + self._console = console + try: + self._do_render_direct(message) + finally: + self._console = original_console + + def _render_message_above_prompt(self, message: AnyMessage) -> bool: + """Render a structured message above the live prompt.""" + runtime = self._get_prompt_runtime() + if runtime is None or not runtime.has_prompt_surface(): + return False + + console = self._build_prompt_safe_console() + return runtime.run_above_prompt( + lambda: self._render_message_with_console(message, console) + ) + + def _render_agent_response_to_console( + self, console: Console, msg: AgentResponseMessage + ) -> None: + """Render the final agent response using the supplied console.""" + banner = self._format_banner("agent_response", "AGENT RESPONSE") + console.print(f"\n{banner}\n") + + if msg.is_markdown: + console.print(Markdown(msg.content)) + else: + console.print(msg.content) + + def _render_agent_response_above_prompt(self, msg: AgentResponseMessage) -> bool: + """Render above the mounted prompt so Rich markup is not escaped.""" + return self._render_message_above_prompt(msg) + # ========================================================================= # Lifecycle (Synchronous - for compatibility with main.py) # ========================================================================= @@ -258,12 +341,8 @@ async def stop_async(self) -> None: # Main Dispatch # ========================================================================= - def _do_render(self, message: AnyMessage) -> None: - """Synchronously render a message by dispatching to the appropriate handler. - - Note: User input requests are skipped in sync mode as they require async. - """ - # Dispatch based on message type + def _do_render_direct(self, message: AnyMessage) -> None: + """Synchronously render a message without prompt-surface handoff.""" if isinstance(message, TextMessage): self._render_text(message) elif isinstance(message, FileListingMessage): @@ -283,8 +362,7 @@ def _do_render(self, message: AnyMessage) -> None: elif isinstance(message, AgentReasoningMessage): self._render_agent_reasoning(message) elif isinstance(message, AgentResponseMessage): - # Skip rendering - we now stream agent responses via event_stream_handler - pass + self._render_agent_response(message) elif isinstance(message, SubAgentInvocationMessage): self._render_subagent_invocation(message) elif isinstance(message, SubAgentResponseMessage): @@ -317,6 +395,17 @@ def _do_render(self, message: AnyMessage) -> None: # Unknown message type - render as debug self._console.print(f"[dim]Unknown message: {type(message).__name__}[/dim]") + def _do_render(self, message: AnyMessage) -> None: + """Synchronously render a message by dispatching to the appropriate handler. + + Note: User input requests are skipped in sync mode as they require async. + """ + if self._should_render_above_prompt(message): + if self._render_message_above_prompt(message): + return + + self._do_render_direct(message) + async def render(self, message: AnyMessage) -> None: """Render a message asynchronously (supports user input requests).""" # Handle async-only message types @@ -688,6 +777,15 @@ def _render_shell_line(self, msg: ShellLineMessage) -> None: from rich.text import Text + runtime = self._get_prompt_runtime() + if runtime is not None and runtime.has_prompt_surface(): + if "\r" in msg.line: + sys.stdout.write(msg.line) + else: + sys.stdout.write(msg.line + "\n") + sys.stdout.flush() + return + # Check if line contains carriage return (progress bar style output) if "\r" in msg.line: # Bypass Rich entirely - write directly to stdout so terminal interprets \r @@ -735,16 +833,7 @@ def _render_agent_reasoning(self, msg: AgentReasoningMessage) -> None: def _render_agent_response(self, msg: AgentResponseMessage) -> None: """Render agent response with header and markdown formatting.""" - # Header - banner = self._format_banner("agent_response", "AGENT RESPONSE") - self._console.print(f"\n{banner}\n") - - # Content (markdown or plain) - if msg.is_markdown: - md = Markdown(msg.content) - self._console.print(md) - else: - self._console.print(msg.content) + self._render_agent_response_to_console(self._console, msg) def _render_subagent_invocation(self, msg: SubAgentInvocationMessage) -> None: """Render sub-agent invocation header with nice formatting.""" diff --git a/code_puppy/messaging/spinner/__init__.py b/code_puppy/messaging/spinner/__init__.py index c6880a762..2fcd1a201 100644 --- a/code_puppy/messaging/spinner/__init__.py +++ b/code_puppy/messaging/spinner/__init__.py @@ -64,11 +64,31 @@ def resume_all_spinners(): def update_spinner_context(info: str) -> None: """Update the shared context information displayed beside active spinners.""" SpinnerBase.set_context_info(info) + try: + from code_puppy.command_line.interactive_runtime import ( + get_active_interactive_runtime, + ) + + runtime = get_active_interactive_runtime() + if runtime is not None: + runtime.invalidate_prompt() + except Exception: + pass def clear_spinner_context() -> None: """Clear any context information displayed beside active spinners.""" SpinnerBase.clear_context_info() + try: + from code_puppy.command_line.interactive_runtime import ( + get_active_interactive_runtime, + ) + + runtime = get_active_interactive_runtime() + if runtime is not None: + runtime.invalidate_prompt() + except Exception: + pass __all__ = [ diff --git a/code_puppy/tools/command_runner.py b/code_puppy/tools/command_runner.py index 44f3afb7f..d72ce4f10 100644 --- a/code_puppy/tools/command_runner.py +++ b/code_puppy/tools/command_runner.py @@ -1203,25 +1203,19 @@ async def _execute_shell_command( pause_all_spinners() - suspend_prompt = False + interactive_runtime = None release_keyboard_context = False try: - from code_puppy.command_line.prompt_toolkit_completion import ( - has_active_prompt_surface, - set_shell_prompt_suspended, + from code_puppy.command_line.interactive_runtime import ( + get_active_interactive_runtime, ) except ImportError: - has_active_prompt_surface = lambda: False # type: ignore[assignment] - set_shell_prompt_suspended = None # type: ignore[assignment] + get_active_interactive_runtime = lambda: None # type: ignore[assignment] - if has_active_prompt_surface() and set_shell_prompt_suspended is not None: - try: - set_shell_prompt_suspended(True) - suspend_prompt = True - except Exception: - _acquire_keyboard_context() - release_keyboard_context = True + interactive_runtime = get_active_interactive_runtime() + if interactive_runtime is not None: + interactive_runtime.notify_shell_started() else: # Acquire shared keyboard context - Ctrl-X/Ctrl-C will kill ALL running commands # This is reference-counted: listener starts on first command, stops on last @@ -1231,11 +1225,8 @@ async def _execute_shell_command( try: return await _run_command_inner(command, cwd, timeout, group_id, silent=silent) finally: - if suspend_prompt and set_shell_prompt_suspended is not None: - try: - set_shell_prompt_suspended(False) - except Exception: - pass + if interactive_runtime is not None: + interactive_runtime.notify_shell_finished() if release_keyboard_context: _release_keyboard_context() resume_all_spinners() diff --git a/tests/agents/test_event_stream_handler.py b/tests/agents/test_event_stream_handler.py index 2b9fa4222..64462758e 100644 --- a/tests/agents/test_event_stream_handler.py +++ b/tests/agents/test_event_stream_handler.py @@ -522,6 +522,107 @@ async def event_stream(): # Should have printed something with token(s) assert any("token(s)" in str(call) for call in call_args_list) + @pytest.mark.asyncio + async def test_tool_call_prompt_surface_mode_avoids_carriage_returns(self, mock_ctx): + """Prompt-surface mode should suppress terminal tool streaming entirely.""" + tool_part = ToolCallPart(tool_call_id="tool_1", tool_name="test_tool", args={}) + start_event = PartStartEvent(index=0, part=tool_part) + delta_event = PartDeltaEvent( + index=0, + delta=ToolCallPartDelta(tool_name_delta="test_tool", args_delta="{}"), + ) + end_event = PartEndEvent(index=0, part=tool_part, next_part_kind=None) + + async def event_stream(): + yield start_event + yield delta_event + yield end_event + + console = MagicMock(spec=Console) + set_streaming_console(console) + + with patch( + "code_puppy.agents.event_stream_handler._has_active_prompt_surface", + return_value=True, + ): + with patch("code_puppy.agents.event_stream_handler.pause_all_spinners"): + with patch( + "code_puppy.agents.event_stream_handler.resume_all_spinners" + ): + await event_stream_handler(mock_ctx, event_stream()) + + console.print.assert_not_called() + + @pytest.mark.asyncio + async def test_text_banner_prompt_surface_mode_skips_clear_line(self, mock_ctx): + """Prompt-surface mode should not emit response banners from the stream handler.""" + text_part = TextPart(content="hello") + start_event = PartStartEvent(index=0, part=text_part) + end_event = PartEndEvent(index=0, part=text_part, next_part_kind=None) + + async def event_stream(): + yield start_event + yield end_event + + console = MagicMock(spec=Console, width=80) + console.file = StringIO() + set_streaming_console(console) + + with patch( + "code_puppy.agents.event_stream_handler._has_active_prompt_surface", + return_value=True, + ): + with patch("code_puppy.agents.event_stream_handler.pause_all_spinners"): + with patch( + "code_puppy.agents.event_stream_handler.resume_all_spinners" + ): + with patch( + "code_puppy.agents.event_stream_handler.get_banner_color", + return_value="blue", + ): + with patch("termflow.Parser") as mock_parser_cls: + mock_parser = MagicMock() + mock_parser.parse_line.return_value = [] + mock_parser.finalize.return_value = [] + mock_parser_cls.return_value = mock_parser + + with patch("termflow.Renderer"): + await event_stream_handler(mock_ctx, event_stream()) + + console.print.assert_not_called() + + @pytest.mark.asyncio + async def test_prompt_surface_mode_streams_plain_text_response(self, mock_ctx): + """Prompt-surface mode should suppress text streaming and skip termflow.""" + text_part = TextPart(content="") + start_event = PartStartEvent(index=0, part=text_part) + delta_event = PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello")) + end_event = PartEndEvent(index=0, part=text_part, next_part_kind=None) + + async def event_stream(): + yield start_event + yield delta_event + yield end_event + + console = MagicMock(spec=Console, width=80) + console.file = StringIO() + set_streaming_console(console) + + with patch( + "code_puppy.agents.event_stream_handler._has_active_prompt_surface", + return_value=True, + ): + with patch("code_puppy.agents.event_stream_handler.pause_all_spinners"): + with patch( + "code_puppy.agents.event_stream_handler.resume_all_spinners" + ): + with patch("termflow.Parser") as mock_parser_cls: + with patch("termflow.Renderer"): + await event_stream_handler(mock_ctx, event_stream()) + + console.print.assert_not_called() + mock_parser_cls.assert_not_called() + @pytest.mark.asyncio async def test_thinking_part_without_initial_content_defers_banner(self, mock_ctx): """Test that thinking banner is deferred if no initial content.""" diff --git a/tests/messaging/spinner/test_spinner_init.py b/tests/messaging/spinner/test_spinner_init.py index 2db72814b..0f13ce1e2 100644 --- a/tests/messaging/spinner/test_spinner_init.py +++ b/tests/messaging/spinner/test_spinner_init.py @@ -8,6 +8,11 @@ import pytest +from code_puppy.command_line.interactive_runtime import ( + PromptRuntimeState, + clear_active_interactive_runtime, + register_active_interactive_runtime, +) from code_puppy.messaging.spinner import ( _active_spinners, clear_spinner_context, @@ -234,3 +239,32 @@ def test_update_context_overwrites_previous(self): update_spinner_context("Second") assert SpinnerBase.get_context_info() == "Second" + + def test_update_spinner_context_invalidates_active_prompt(self): + runtime = PromptRuntimeState() + register_active_interactive_runtime(runtime) + session = MagicMock() + session.app = MagicMock() + runtime.register_prompt_surface(session) + + try: + session.app.invalidate.reset_mock() + update_spinner_context("Tokens: 42/100 (42.0% used)") + session.app.invalidate.assert_called_once() + finally: + clear_active_interactive_runtime(runtime) + + def test_clear_spinner_context_invalidates_active_prompt(self): + runtime = PromptRuntimeState() + register_active_interactive_runtime(runtime) + session = MagicMock() + session.app = MagicMock() + runtime.register_prompt_surface(session) + + try: + update_spinner_context("Tokens: 42/100 (42.0% used)") + session.app.invalidate.reset_mock() + clear_spinner_context() + session.app.invalidate.assert_called_once() + finally: + clear_active_interactive_runtime(runtime) diff --git a/tests/messaging/test_rich_renderer.py b/tests/messaging/test_rich_renderer.py index 33955641c..386913ce7 100644 --- a/tests/messaging/test_rich_renderer.py +++ b/tests/messaging/test_rich_renderer.py @@ -374,6 +374,22 @@ def test_render_shell_line(renderer, console): assert "hello output" in out +def test_render_shell_line_with_prompt_surface_uses_plain_stdout(renderer): + msg = ShellLineMessage(line="hello output", stream="stdout") + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + mock_stdout = MagicMock() + + with ( + patch.object(renderer, "_get_prompt_runtime", return_value=runtime), + patch("sys.stdout", mock_stdout), + ): + renderer._render_shell_line(msg) + + mock_stdout.write.assert_called_once_with("hello output\n") + mock_stdout.flush.assert_called_once() + + def test_render_shell_line_with_cr(renderer, console): msg = ShellLineMessage(line="progress\r50%", stream="stdout") renderer._render_shell_line(msg) @@ -415,6 +431,55 @@ def test_render_agent_response_plain(renderer, console): renderer._render_agent_response(msg) +def test_do_render_agent_response_when_prompt_surface_active(renderer, console): + msg = AgentResponseMessage(content="plain text", is_markdown=False) + + with patch.object(renderer, "_should_render_above_prompt", return_value=False): + renderer._do_render(msg) + + out = output(console) + assert "AGENT RESPONSE" in out + + +def test_do_render_agent_response_uses_prompt_runtime(renderer): + msg = AgentResponseMessage(content="plain text", is_markdown=False) + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + runtime.run_above_prompt.return_value = True + + with ( + patch.object(renderer, "_get_prompt_runtime", return_value=runtime), + patch.object(renderer, "_render_agent_response") as mock_render, + ): + renderer._do_render(msg) + + runtime.run_above_prompt.assert_called_once() + mock_render.assert_not_called() + + +def test_do_render_file_listing_uses_prompt_runtime(renderer): + msg = FileListingMessage( + directory="/tmp", + files=[], + recursive=False, + total_size=0, + dir_count=0, + file_count=0, + ) + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + runtime.run_above_prompt.return_value = True + + with ( + patch.object(renderer, "_get_prompt_runtime", return_value=runtime), + patch.object(renderer, "_render_file_listing") as mock_render, + ): + renderer._do_render(msg) + + runtime.run_above_prompt.assert_called_once() + mock_render.assert_not_called() + + @patch("code_puppy.messaging.rich_renderer.is_subagent", return_value=False) def test_render_subagent_invocation(mock_sub, renderer, console): msg = SubAgentInvocationMessage( diff --git a/tests/test_cli_runner_coverage.py b/tests/test_cli_runner_coverage.py index 49e82e81b..319499304 100644 --- a/tests/test_cli_runner_coverage.py +++ b/tests/test_cli_runner_coverage.py @@ -8,6 +8,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from code_puppy.command_line.interactive_runtime import ( + PromptRuntimeState, + clear_active_interactive_runtime, + register_active_interactive_runtime, +) class TestRunPromptWithAttachments: @@ -169,6 +174,84 @@ async def test_clipboard_placeholder_cleaned(self): call_args = mock_agent.run_with_mcp.call_args assert "clipboard image" not in call_args[0][0] + @pytest.mark.anyio + async def test_interactive_runtime_disables_spinner(self): + from code_puppy.cli_runner import run_prompt_with_attachments + + runtime = PromptRuntimeState(running=True) + register_active_interactive_runtime(runtime) + + mock_agent = MagicMock() + mock_result = MagicMock() + mock_agent.run_with_mcp = AsyncMock(return_value=mock_result) + + try: + with ( + patch("code_puppy.cli_runner.parse_prompt_attachments") as mock_parse, + patch("code_puppy.cli_runner.get_clipboard_manager") as mock_clip, + patch("code_puppy.agents.event_stream_handler.set_streaming_console"), + patch("code_puppy.messaging.spinner.ConsoleSpinner") as mock_spinner, + ): + mock_parse.return_value = MagicMock( + prompt="do stuff", + warnings=[], + attachments=[], + link_attachments=[], + ) + clip_mgr = MagicMock() + clip_mgr.get_pending_images.return_value = [] + clip_mgr.get_pending_count.return_value = 0 + mock_clip.return_value = clip_mgr + + console = MagicMock() + result, _task = await run_prompt_with_attachments( + mock_agent, "do stuff", spinner_console=console, use_spinner=True + ) + + assert result is mock_result + mock_spinner.assert_not_called() + finally: + clear_active_interactive_runtime(runtime) + + @pytest.mark.anyio + async def test_seeds_spinner_context_before_agent_updates(self): + from code_puppy.cli_runner import run_prompt_with_attachments + + mock_agent = MagicMock() + mock_result = MagicMock() + mock_agent.run_with_mcp = AsyncMock(return_value=mock_result) + mock_agent.get_message_history.return_value = ["m1", "m2"] + mock_agent.estimate_tokens_for_message.side_effect = [100, 200] + mock_agent.estimate_context_overhead_tokens.return_value = 400 + mock_agent.estimate_token_count.side_effect = lambda text: len(text) * 10 + mock_agent.get_model_context_length.return_value = 10000 + + with ( + patch("code_puppy.cli_runner.parse_prompt_attachments") as mock_parse, + patch("code_puppy.cli_runner.get_clipboard_manager") as mock_clip, + patch("code_puppy.agents.event_stream_handler.set_streaming_console"), + patch("code_puppy.messaging.spinner.clear_spinner_context") as mock_clear, + patch("code_puppy.messaging.spinner.update_spinner_context") as mock_update, + ): + mock_parse.return_value = MagicMock( + prompt="do stuff", + warnings=[], + attachments=[], + link_attachments=[MagicMock(url_part="https://example.com")], + ) + clip_mgr = MagicMock() + clip_mgr.get_pending_images.return_value = [] + clip_mgr.get_pending_count.return_value = 0 + mock_clip.return_value = clip_mgr + + result, _task = await run_prompt_with_attachments( + mock_agent, "do stuff", use_spinner=False + ) + + assert result is mock_result + mock_clear.assert_called_once() + mock_update.assert_called_once_with("Tokens: 970/10,000 (9.7% used)") + class TestExecuteSinglePrompt: @pytest.mark.anyio diff --git a/tests/test_cli_runner_full_coverage.py b/tests/test_cli_runner_full_coverage.py index b8cb80e19..507288d14 100644 --- a/tests/test_cli_runner_full_coverage.py +++ b/tests/test_cli_runner_full_coverage.py @@ -11,6 +11,8 @@ import pytest +from code_puppy.command_line.prompt_toolkit_completion import PromptSubmission + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -45,6 +47,10 @@ def _mock_clipboard(images=None): return mgr +def _submission(text: str, action: str = "submit") -> PromptSubmission: + return PromptSubmission(action=action, text=text) + + def _apply_patches(stack, patches_dict): """Apply a dict of patches using an ExitStack.""" for target, value in patches_dict.items(): @@ -109,14 +115,28 @@ async def _run_interactive( agent = MagicMock() agent.get_user_prompt.return_value = "task:" + from code_puppy.command_line.prompt_toolkit_completion import PromptSubmission + + async def prompt_side_effect(*args, **kwargs): + if isinstance(input_fn, AsyncMock): + value = await input_fn(*args, **kwargs) + elif callable(input_fn): + value = input_fn(*args, **kwargs) + if asyncio.iscoroutine(value): + value = await value + else: + value = input_fn + + if isinstance(value, PromptSubmission): + return value + return PromptSubmission(action="submit", text=value) + with ExitStack() as stack: _apply_patches(stack, patches_dict) stack.enter_context( patch( - "code_puppy.command_line.prompt_toolkit_completion.get_input_with_combined_completion", - side_effect=input_fn - if callable(input_fn) and not isinstance(input_fn, AsyncMock) - else input_fn, + "code_puppy.command_line.prompt_toolkit_completion.prompt_for_submission", + side_effect=prompt_side_effect, ) ) stack.enter_context( @@ -906,17 +926,18 @@ async def test_queued_prompt_runs_after_current_task_finishes(self): queued_prompt_started = asyncio.Event() started_prompts = [] emit_success = MagicMock() + render_prompt_echo = MagicMock() async def fake_input(*a, **kw): nonlocal call_count call_count += 1 if call_count == 1: - return "first task" + return _submission("first task") if call_count == 2: second_prompt_seen.set() - return "queued task" + return _submission("queued task", action="queue") await queued_prompt_started.wait() - return "/exit" + return _submission("/exit") async def fake_run(*args, **kwargs): prompt = args[1] @@ -939,18 +960,17 @@ async def fake_run(*args, **kwargs): "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( side_effect=lambda text: _mock_parse_result(text) ), - "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": AsyncMock( - return_value="q" - ), "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( return_value=False ), "code_puppy.messaging.emit_success": emit_success, + "code_puppy.command_line.prompt_toolkit_completion.render_submitted_prompt_echo": render_prompt_echo, }, ) assert started_prompts[:2] == ["first task", "queued task"] emit_success.assert_any_call("[QUEUE] running queued prompt: queued task") + render_prompt_echo.assert_any_call("queued task") @pytest.mark.anyio async def test_queued_prompt_starts_when_run_finishes_during_choice_menu(self): @@ -962,11 +982,12 @@ async def fake_input(*a, **kw): nonlocal call_count call_count += 1 if call_count == 1: - return "first task" + return _submission("first task") if call_count == 2: - return "queued task" + await asyncio.sleep(0.05) + return _submission("queued task", action="queue") await queued_prompt_started.wait() - return "/exit" + return _submission("/exit") async def fake_run(*args, **kwargs): prompt = args[1] @@ -979,10 +1000,6 @@ async def fake_run(*args, **kwargs): result.all_messages.return_value = [] return result, MagicMock() - async def slow_queue_choice(): - await asyncio.sleep(0.05) - return "q" - await _run_interactive( _mock_renderer(), _interactive_patches(), @@ -992,9 +1009,6 @@ async def slow_queue_choice(): "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( side_effect=lambda text: _mock_parse_result(text) ), - "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": MagicMock( - side_effect=slow_queue_choice - ), "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( return_value=False ), @@ -1015,14 +1029,14 @@ async def fake_input(*a, **kw): nonlocal call_count call_count += 1 if call_count == 1: - return "first task" + return _submission("first task") if call_count == 2: - return "/help" + return _submission("/help", action="queue") if call_count == 3: ready_for_completion.set() - return "followup task" + return _submission("followup task", action="queue") await followup_started.wait() - return "/exit" + return _submission("/exit") async def fake_run(*args, **kwargs): prompt = args[1] @@ -1054,9 +1068,6 @@ def fake_handle_command(command): "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( side_effect=lambda text: _mock_parse_result(text) ), - "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": AsyncMock( - return_value="q" - ), "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( return_value=False ), @@ -1077,12 +1088,12 @@ async def fake_input(*a, **kw): nonlocal call_count call_count += 1 if call_count == 1: - return "first task" + return _submission("first task") if call_count == 2: second_prompt_seen.set() - return "/custom" + return _submission("/custom", action="queue") await transformed_prompt_started.wait() - return "/exit" + return _submission("/exit") async def fake_run(*args, **kwargs): prompt = args[1] @@ -1110,9 +1121,6 @@ async def fake_run(*args, **kwargs): "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( side_effect=lambda text: _mock_parse_result(text) ), - "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": AsyncMock( - return_value="q" - ), "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( return_value=False ), @@ -1127,19 +1135,20 @@ async def test_interject_runs_before_queued_prompts(self): first_task_started = asyncio.Event() queued_prompt_started = asyncio.Event() started_prompts = [] + render_prompt_echo = MagicMock() async def fake_input(*a, **kw): nonlocal call_count call_count += 1 if call_count == 1: - return "first task" + return _submission("first task") if call_count == 2: await first_task_started.wait() - return "second queued" + return _submission("second queued", action="queue") if call_count == 3: - return "steer now" + return _submission("steer now", action="interject") await queued_prompt_started.wait() - return "/exit" + return _submission("/exit") async def fake_run(*args, **kwargs): prompt = args[1] @@ -1162,9 +1171,7 @@ async def fake_run(*args, **kwargs): "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( side_effect=lambda text: _mock_parse_result(text) ), - "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": AsyncMock( - side_effect=["q", "i"] - ), + "code_puppy.command_line.prompt_toolkit_completion.render_submitted_prompt_echo": render_prompt_echo, "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( return_value=False ), @@ -1176,8 +1183,10 @@ async def fake_run(*args, **kwargs): ) assert started_prompts[0] == "first task" - assert started_prompts[1].startswith("[user interjects]: steer now") + assert started_prompts[1].startswith("user interjects - steer now") + assert "continue the interrupted task" in started_prompts[1] assert started_prompts[2] == "second queued" + render_prompt_echo.assert_any_call("steer now") @pytest.mark.anyio async def test_exit_while_running_cancels_runtime_task(self): @@ -1853,11 +1862,11 @@ async def fake_input(*a, **kw): nonlocal call_count call_count += 1 if call_count == 1: - return "first task" + return _submission("first task") if call_count == 2: - return "queued after shell" + return _submission("queued after shell", action="queue") await queued_prompt_started.wait() - return "/exit" + return _submission("/exit") async def fake_run(*args, **kwargs): prompt = args[1] @@ -1877,9 +1886,6 @@ async def fake_run(*args, **kwargs): "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( side_effect=lambda text: _mock_parse_result(text) ), - "code_puppy.command_line.prompt_toolkit_completion.get_interject_action": AsyncMock( - return_value="q" - ), "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( return_value=False ), @@ -2326,9 +2332,9 @@ def fake_import(name, *args, **kwargs): _apply_patches(stack, patches) stack.enter_context( patch( - "code_puppy.command_line.prompt_toolkit_completion.get_input_with_combined_completion", + "code_puppy.command_line.prompt_toolkit_completion.prompt_for_submission", new_callable=AsyncMock, - return_value="/exit", + return_value=_submission("/exit"), ) ) stack.enter_context( diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index 667d8f685..70387f61f 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -1,3 +1,5 @@ +import asyncio +import contextlib import os import sys from pathlib import Path @@ -15,23 +17,37 @@ AttachmentPlaceholderProcessor, CDCompleter, FilePathCompleter, + PromptSubmission, SetCompleter, - _shell_interrupt_hint, clear_active_prompt_surface, get_active_prompt_surface_kind, get_prompt_with_active_model, get_input_with_combined_completion, - get_interject_action, has_active_prompt_surface, is_shell_prompt_suspended, + prompt_for_submission, + render_submitted_prompt_echo, register_active_prompt_surface, set_shell_prompt_suspended, ) +from code_puppy.command_line.interactive_runtime import ( + PromptRuntimeState, + clear_active_interactive_runtime, + register_active_interactive_runtime, +) # Skip some path-format sensitive tests on Windows where backslashes are expected IS_WINDOWS = os.name == "nt" or sys.platform.startswith("win") +@pytest.fixture +def active_runtime(): + runtime = PromptRuntimeState() + register_active_interactive_runtime(runtime) + yield runtime + clear_active_interactive_runtime(runtime) + + def setup_files(tmp_path): d = tmp_path / "dir" d.mkdir() @@ -234,6 +250,36 @@ def test_set_completer_excludes_model_key(monkeypatch): assert completions[0].text == "api_key = test_value" +@patch("code_puppy.command_line.prompt_toolkit_completion.print_formatted_text") +@patch("prompt_toolkit.output.defaults.create_output") +def test_render_submitted_prompt_echo(mock_create_output, mock_print_formatted_text): + mock_output = MagicMock() + mock_create_output.return_value = mock_output + + render_submitted_prompt_echo("queued task") + + mock_create_output.assert_called_once() + mock_print_formatted_text.assert_called_once() + rendered = mock_print_formatted_text.call_args.args[0] + assert any("queued task" in text for _style, text in rendered) + + +@patch("code_puppy.command_line.prompt_toolkit_completion.print_formatted_text") +@patch("prompt_toolkit.output.defaults.create_output") +def test_render_submitted_prompt_echo_uses_prompt_app_when_available( + mock_create_output, mock_print_formatted_text, active_runtime +): + session = MagicMock() + session.app = MagicMock() + active_runtime.register_prompt_surface(session) + + render_submitted_prompt_echo("queued task") + + session.app.print_text.assert_called_once() + mock_create_output.assert_called_once() + mock_print_formatted_text.assert_not_called() + + def test_set_completer_excludes_puppy_token(monkeypatch): # Ensure 'puppy_token' is a config key but SetCompleter doesn't offer it monkeypatch.setattr( @@ -535,6 +581,24 @@ async def test_get_input_with_combined_completion_no_model_update( assert result == raw_input +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.patch_stdout") +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +async def test_prompt_for_submission_uses_non_raw_patch_stdout( + mock_prompt_session_cls, mock_patch_stdout +): + mock_session_instance = MagicMock() + mock_session_instance.prompt_async = AsyncMock(return_value="test input") + mock_prompt_session_cls.return_value = mock_session_instance + mock_patch_stdout.return_value.__enter__ = MagicMock() + mock_patch_stdout.return_value.__exit__ = MagicMock(return_value=False) + + result = await prompt_for_submission() + + assert result == PromptSubmission(action="submit", text="test input") + mock_patch_stdout.assert_called_once_with() + + # To test key bindings, we need to inspect the KeyBindings object passed to PromptSession # We can get it from the mock_prompt_session_cls.call_args @@ -593,7 +657,7 @@ async def test_get_input_key_binding_escape(mock_prompt_session_cls): mock_event.app.exit.assert_called_once_with(exception=KeyboardInterrupt) -def test_prompt_runtime_registry_round_trip(): +def test_prompt_runtime_registry_round_trip(active_runtime): session = MagicMock() session.app = MagicMock() @@ -604,17 +668,42 @@ def test_prompt_runtime_registry_round_trip(): assert get_active_prompt_surface_kind() == "main" assert is_shell_prompt_suspended() is False + session.app.invalidate.reset_mock() set_shell_prompt_suspended(True) assert is_shell_prompt_suspended() is True session.app.invalidate.assert_called_once() + set_shell_prompt_suspended(False) clear_active_prompt_surface(session) assert has_active_prompt_surface() is False assert get_active_prompt_surface_kind() is None assert is_shell_prompt_suspended() is False -def test_get_prompt_with_active_model_shows_shell_suspension(monkeypatch): +def test_spinner_invalidation_yields_to_recent_prompt_redraw(monkeypatch, active_runtime): + session = MagicMock() + session.app = MagicMock() + active_runtime.register_prompt_surface(session) + session.app.invalidate.reset_mock() + + samples = iter([10.0, 10.02, 10.12]) + monkeypatch.setattr( + "code_puppy.command_line.interactive_runtime.time.monotonic", + lambda: next(samples), + ) + + active_runtime.invalidate_prompt() + session.app.invalidate.assert_called_once() + + session.app.invalidate.reset_mock() + active_runtime.invalidate_prompt_for_spinner() + session.app.invalidate.assert_not_called() + + active_runtime.invalidate_prompt_for_spinner() + session.app.invalidate.assert_called_once() + + +def test_get_prompt_with_active_model_omits_shell_status(monkeypatch, active_runtime): clear_active_prompt_surface() session = MagicMock() session.app = MagicMock() @@ -643,34 +732,65 @@ def test_get_prompt_with_active_model_shows_shell_suspension(monkeypatch): return_value=agent, ): with patch( - "code_puppy.cli_runner.PROMPT_QUEUE", - [], - create=True, + "shutil.get_terminal_size", return_value=os.terminal_size((80, 24)) ): - with patch( - "shutil.get_terminal_size", return_value=os.terminal_size((80, 24)) - ): - rendered = "".join(text for _style, text in get_prompt_with_active_model()) + rendered = "".join(text for _style, text in get_prompt_with_active_model()) - assert "input suspended during shell command" in rendered - assert f"press {_shell_interrupt_hint()} to interrupt" in rendered + assert "shell running" not in rendered clear_active_prompt_surface() -def test_shell_interrupt_hint_uses_platform_specific_copy(monkeypatch): - import code_puppy.command_line.prompt_toolkit_completion as prompt_completion +def test_get_prompt_with_active_model_shows_thinking_status(monkeypatch, active_runtime): + clear_active_prompt_surface() + session = MagicMock() + session.app = MagicMock() + register_active_prompt_surface("main", session) + active_runtime.running = True + active_runtime.prompt_status_started_at = 0.0 - monkeypatch.setattr(prompt_completion.sys, "platform", "darwin") - assert prompt_completion._shell_interrupt_hint() == "control+c" + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.get_puppy_name", + lambda: "Buddy", + ) + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.get_active_model", + lambda: "gpt-test", + ) + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.os.getcwd", + lambda: "/tmp/demo", + ) + monkeypatch.setattr( + "code_puppy.command_line.interactive_runtime.time.monotonic", + lambda: 0.18, + ) - monkeypatch.setattr(prompt_completion.sys, "platform", "win32") - assert prompt_completion._shell_interrupt_hint() == "ctrl+c" + agent = MagicMock() + agent.display_name = "code-puppy" + agent.get_model_name.return_value = "gpt-test" + with ( + patch( + "code_puppy.agents.agent_manager.get_current_agent", + return_value=agent, + ), + patch( + "code_puppy.command_line.prompt_toolkit_completion.SpinnerBase.get_context_info", + return_value="Tokens: 1,650/272,000 (0.6% used)", + ), + patch("shutil.get_terminal_size", return_value=os.terminal_size((80, 24))), + ): + rendered = "".join(text for _style, text in get_prompt_with_active_model()) + assert "Buddy is thinking..." in rendered + assert "( 🐶 ) " in rendered + assert "Tokens: 1,650/272,000 (0.6% used)" in rendered + assert rendered.index("Buddy is thinking...") < rendered.index("─" * 80) + clear_active_prompt_surface() @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") -async def test_get_input_registers_active_prompt_and_marks_buffer_read_only( - mock_prompt_session_cls, +async def test_get_input_registers_active_prompt_surface( + mock_prompt_session_cls, active_runtime ): session = MagicMock() session.app = MagicMock() @@ -680,7 +800,8 @@ async def fake_prompt_async(*args, **kwargs): assert has_active_prompt_surface() is True assert get_active_prompt_surface_kind() == "main" set_shell_prompt_suspended(True) - assert session.default_buffer.read_only() is True + assert is_shell_prompt_suspended() is True + set_shell_prompt_suspended(False) return "test input" session.prompt_async = AsyncMock(side_effect=fake_prompt_async) @@ -693,10 +814,34 @@ async def fake_prompt_async(*args, **kwargs): assert is_shell_prompt_suspended() is False +@pytest.mark.asyncio +async def test_prompt_runtime_refreshes_spinner_while_running(active_runtime): + session = MagicMock() + session.app = MagicMock() + active_runtime.register_prompt_surface(session) + + worker = asyncio.create_task(asyncio.sleep(1)) + active_runtime.mark_running(worker) + session.app.invalidate.reset_mock() + + try: + await asyncio.sleep(0.12) + assert session.app.invalidate.called + assert active_runtime.prompt_status_task is not None + finally: + active_runtime.mark_idle() + worker.cancel() + with contextlib.suppress(asyncio.CancelledError): + await worker + + await asyncio.sleep(0) + assert active_runtime.prompt_status_task is None + + @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") async def test_ctrl_x_interrupts_shell_when_prompt_is_suspended( - mock_prompt_session_cls, + mock_prompt_session_cls, active_runtime ): session = MagicMock() session.app = MagicMock() @@ -733,34 +878,20 @@ async def test_ctrl_x_interrupts_shell_when_prompt_is_suspended( @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") -async def test_interject_ignores_i_and_q_while_shell_is_suspended( - mock_prompt_session_cls, +async def test_prompt_for_submission_returns_inline_queue_action( + mock_prompt_session_cls, active_runtime ): session = MagicMock() session.app = MagicMock() session.default_buffer = MagicMock() - session.prompt_async = AsyncMock(return_value="") + session.prompt_async = AsyncMock( + return_value=PromptSubmission(action="queue", text="queued task") + ) mock_prompt_session_cls.return_value = session - await get_interject_action() + result = await prompt_for_submission() - bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] - key_handlers = { - binding.keys: binding.handler - for binding in bindings.bindings - if binding.keys in {("i",), ("q",)} - } - - register_active_prompt_surface("interject", session) - set_shell_prompt_suspended(True) - - mock_event = MagicMock() - mock_event.app = MagicMock() - key_handlers[("i",)](mock_event) - key_handlers[("q",)](mock_event) - - mock_event.app.exit.assert_not_called() - clear_active_prompt_surface() + assert result == PromptSubmission(action="queue", text="queued task") @pytest.mark.asyncio diff --git a/tests/tools/test_command_runner_full_coverage.py b/tests/tools/test_command_runner_full_coverage.py index 5308acd9e..763c3e71c 100644 --- a/tests/tools/test_command_runner_full_coverage.py +++ b/tests/tools/test_command_runner_full_coverage.py @@ -868,7 +868,7 @@ async def test_executes(self): assert result.success is True @pytest.mark.asyncio - async def test_suspends_active_prompt_and_skips_keyboard_listener(self): + async def test_marks_active_runtime_shell_state_and_skips_keyboard_listener(self): from code_puppy.tools.command_runner import ( ShellCommandOutput, _execute_shell_command, @@ -883,34 +883,33 @@ async def test_suspends_active_prompt_and_skips_keyboard_listener(self): execution_time=0.1, ) + runtime = MagicMock() with patch("code_puppy.tools.command_runner.get_message_bus") as mock_bus: mock_bus.return_value = MagicMock() with patch("code_puppy.messaging.spinner.pause_all_spinners"): with patch("code_puppy.messaging.spinner.resume_all_spinners"): with patch( - "code_puppy.command_line.prompt_toolkit_completion.has_active_prompt_surface", - return_value=True, + "code_puppy.command_line.interactive_runtime.get_active_interactive_runtime", + return_value=runtime, ): with patch( - "code_puppy.command_line.prompt_toolkit_completion.set_shell_prompt_suspended" - ) as mock_suspend: + "code_puppy.tools.command_runner._acquire_keyboard_context" + ) as mock_acquire: with patch( - "code_puppy.tools.command_runner._acquire_keyboard_context" - ) as mock_acquire: + "code_puppy.tools.command_runner._release_keyboard_context" + ) as mock_release: with patch( - "code_puppy.tools.command_runner._release_keyboard_context" - ) as mock_release: - with patch( - "code_puppy.tools.command_runner._run_command_inner", - new_callable=AsyncMock, - return_value=mock_result, - ): - result = await _execute_shell_command( - "echo hi", None, 10, "grp" - ) + "code_puppy.tools.command_runner._run_command_inner", + new_callable=AsyncMock, + return_value=mock_result, + ): + result = await _execute_shell_command( + "echo hi", None, 10, "grp" + ) assert result.success is True - assert mock_suspend.call_args_list == [((True,),), ((False,),)] + runtime.notify_shell_started.assert_called_once() + runtime.notify_shell_finished.assert_called_once() mock_acquire.assert_not_called() mock_release.assert_not_called() @@ -935,29 +934,25 @@ async def test_uses_keyboard_listener_when_no_active_prompt(self): with patch("code_puppy.messaging.spinner.pause_all_spinners"): with patch("code_puppy.messaging.spinner.resume_all_spinners"): with patch( - "code_puppy.command_line.prompt_toolkit_completion.has_active_prompt_surface", - return_value=False, + "code_puppy.command_line.interactive_runtime.get_active_interactive_runtime", + return_value=None, ): with patch( - "code_puppy.command_line.prompt_toolkit_completion.set_shell_prompt_suspended" - ) as mock_suspend: + "code_puppy.tools.command_runner._acquire_keyboard_context" + ) as mock_acquire: with patch( - "code_puppy.tools.command_runner._acquire_keyboard_context" - ) as mock_acquire: + "code_puppy.tools.command_runner._release_keyboard_context" + ) as mock_release: with patch( - "code_puppy.tools.command_runner._release_keyboard_context" - ) as mock_release: - with patch( - "code_puppy.tools.command_runner._run_command_inner", - new_callable=AsyncMock, - return_value=mock_result, - ): - result = await _execute_shell_command( - "echo hi", None, 10, "grp" - ) + "code_puppy.tools.command_runner._run_command_inner", + new_callable=AsyncMock, + return_value=mock_result, + ): + result = await _execute_shell_command( + "echo hi", None, 10, "grp" + ) assert result.success is True - mock_suspend.assert_not_called() mock_acquire.assert_called_once() mock_release.assert_called_once() From 132028fbdc5546ed5f3540be00a4d2ffcabe7402 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Tue, 10 Mar 2026 09:13:48 -0500 Subject: [PATCH 25/40] Document current rewrite state --- README.md | 318 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) diff --git a/README.md b/README.md index c49fab361..cc1f35542 100644 --- a/README.md +++ b/README.md @@ -719,3 +719,321 @@ Code Puppy is designed with privacy-by-design principles. Every feature has been ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Current State + +This rewrite branch is currently in a strong interactive state for the always-on composer, inline interjects, +queued prompts, and shell-time typing. The transcript below is a representative successful run from +`/Users/nateoswalt/code-puppy-interject-queue-v2`. + +```text +───────────────────────────────────────────────────────────────────────────────────────────────────────────── +╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) +ā•°ā”€āÆ hello + + AGENT RESPONSE + +yo n8 šŸ‘‹ +Adrius reporting for tail-wagging duty šŸ¶šŸ’» + +What are we building/breaking/fixing today? + +───────────────────────────────────────────────────────────────────────────────────────────────────────────── +╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) +ā•°ā”€āÆ Could you count to 7 in as close the 35 seconds as possible with equal spacing between printing the natural numbers? + +ℹ [QUEUE] saved for after this task: Could you count to 7 in as close the 35 seconds as possible with equal +spacing between printing the natural numbers? [position 1] + + AGENT REASONING +Current reasoning: +Got it — I’ll actually execute a small Python timer that prints 1 through 7 with equal spacing and aims for +~35 seconds total. To hit ~35s from first print to last, spacing should be 35/6 ā‰ˆ 5.8333s. I’ll anchor each +print to an absolute schedule (not cumulative sleep) to minimize drift. + +Planned next steps: + + 1 Run a Python command that prints numbers 1..7 at scheduled times + 2 Return the printed output and timing summary + + + SHELL COMMAND šŸš€ $ python3 - <<'PY' +import time +import math + +start = time.perf_counter() +nums = list(range(1,8)) +interval = 35/6 # so first-to-last spans ~35s + +for i, n in enumerate(nums): + target = start + i*interval + while True: + now = time.perf_counter() + remaining = target - now + if remaining <= 0: + break + time.sleep(min(remaining, 0.01)) + t = time.perf_counter() - start + print(n, f"({t:.3f}s)", flush=True) + +end = time.perf_counter() +print(f"Total elapsed: {end-start:.3f}s") +PY +ā± Timeout: 120s +1 (0.000s) + +⚠ [QUEUE] cancelled: Could you count to 7 in as close the 35 seconds as possible with equal spacing between +printing the natural numbers? + +───────────────────────────────────────────────────────────────────────────────────────────────────────────── +╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) +ā•°ā”€āÆ please don't use any scripts that already exist + +⚠ [INTERJECT] stopping current work: please don't use any scripts that already exist +⚠ [INTERJECT] applying now: please don't use any scripts that already exist + + AGENT REASONING +Current reasoning: +Affirmed — I saw your interjection: I will not use any pre-existing script files. I’ll continue by running a +fresh inline Python command directly in the shell (no existing scripts), printing 1..7 with equal spacing +over ~35 seconds total. + +Planned next steps: + + 1 Execute an inline Python command (heredoc) that schedules prints at absolute times + 2 Report the live output and elapsed timing + +ℹ [QUEUE] saved for after this task: make it more accurate as well! [position 1] + + SHELL COMMAND šŸš€ $ python - <<'PY' +import time + +n = 7 +total = 35.0 +interval = total / (n - 1) # 35/6 +start = time.perf_counter() + +for i in range(1, n + 1): + target = start + (i - 1) * interval + now = time.perf_counter() + if target > now: + time.sleep(target - now) + t = time.perf_counter() - start + print(i, f"{t:.3f}s", flush=True) + +elapsed = time.perf_counter() - start +print(f"elapsed={elapsed:.3f}s") +PY +ā± Timeout: 50s +/bin/sh: python: command not found + + + AGENT REASONING +Current reasoning: +Quick snag: python isn’t installed in this shell (command not found). I’ll retry with python3 using the same +inline approach (still no existing scripts), then report the timing output. + +Planned next steps: + + 1 Run inline Python via python3 + 2 Return results + + + SHELL COMMAND šŸš€ $ python3 - <<'PY' +import time + +n = 7 +total = 35.0 +interval = total / (n - 1) # 35/6 +start = time.perf_counter() + +for i in range(1, n + 1): + target = start + (i - 1) * interval + now = time.perf_counter() + if target > now: + time.sleep(target - now) + t = time.perf_counter() - start + print(i, f"{t:.3f}s", flush=True) + +elapsed = time.perf_counter() - start +print(f"elapsed={elapsed:.3f}s") +PY +ā± Timeout: 50s +1 0.000s +ℹ [QUEUE] saved for after this task: RAHHHH more accuracyyyyy!!!!! [position 2] +2 5.833s +3 11.667s +ℹ [QUEUE] saved for after this task: Nice gimme a reporto please!!! [position 3] +4 17.503s +5 23.337s +6 29.172s +7 35.004s +elapsed=35.004s + + + AGENT RESPONSE + +Yep — interjection seen and respected āœ… +No existing script files were used; this was run as a fresh inline command. + +Here’s the result: + + 1 0.000s + 2 5.833s + 3 11.667s + 4 17.503s + 5 23.337s + 6 29.172s + 7 35.004s + elapsed=35.004s + +So we hit ~35 seconds total with very even spacing (target was 5.8333s steps). +That’s basically bullseye territory šŸŽÆ + +───────────────────────────────────────────────────────────────────────────────────────────────────────────── +╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) +ā•°ā”€āÆ make it more accurate as well! +āœ“ [INTERJECT] applied: please don't use any scripts that already exist + + AGENT REASONING +Current reasoning: +Love it — let’s tighten the timing. I’ll use time.perf_counter_ns() with absolute target timestamps and a +hybrid wait strategy: sleep for most of each interval, then short spin-wait for the final sub-millisecond +window. That reduces scheduler jitter and should land closer to exact targets. + +Planned next steps: + + 1 Run an inline python3 command (no existing scripts) with perf_counter_ns + sleep-then-spin + 2 Report timing deltas vs ideal schedule + + + SHELL COMMAND šŸš€ $ python3 - <<'PY' +import time + +n = 7 +total_s = 35.0 +interval_ns = int(round(total_s * 1e9 / (n - 1))) +spin_threshold_ns = 2_000_000 # 2ms spin window + +start_ns = time.perf_counter_ns() + +actual = [] +for i in range(n): + target_ns = start_ns + i * interval_ns + while True: + now_ns = time.perf_counter_ns() + remaining = target_ns - now_ns + if remaining <= 0: + break + if remaining > spin_threshold_ns: + sleep_ns = remaining - 1_000_000 + time.sleep(sleep_ns / 1e9) + else: + pass + + t_ns = time.perf_counter_ns() - start_ns + actual.append(t_ns) + print(i + 1, f"{t_ns/1e9:.6f}s", flush=True) + +elapsed_ns = time.perf_counter_ns() - start_ns +print(f"elapsed={elapsed_ns/1e9:.6f}s") +print("deltas_ms=" + ",".join(f"{(actual[i] - i*interval_ns)/1e6:+.3f}" for i in range(n))) +PY +ā± Timeout: 50s +1 0.000001s +2 5.837502s +3 11.666669s +4 17.504098s +5 23.337381s +6 29.170745s +7 35.000993s +elapsed=35.001056s +deltas_ms=+0.001,+4.169,+0.002,+4.098,+4.048,+4.079,+0.993 + + + SHELL COMMAND šŸš€ $ python3 - <<'PY' +import time + +N = 7 +TOTAL_NS = 35_000_000_000 +HEADROOM_NS = 15_000_000 # 15ms pre-spin + +start_ns = time.perf_counter_ns() +actual = [] +target_offsets = [(i * TOTAL_NS + (N - 2)//2) // (N - 1) for i in range(N)] +target_offsets[0] = 0 +target_offsets[-1] = TOTAL_NS + +for i, off in enumerate(target_offsets): + target_ns = start_ns + off + + while True: + now = time.perf_counter_ns() + rem = target_ns - now + if rem <= HEADROOM_NS: + break + time.sleep((rem - HEADROOM_NS) / 1e9) + + while time.perf_counter_ns() < target_ns: + pass + + t = time.perf_counter_ns() - start_ns + actual.append(t) + print(f"{i+1} {t/1e9:.9f}s") + +end_ns = time.perf_counter_ns() +elapsed_ns = end_ns - start_ns + +print(f"elapsed={elapsed_ns/1e9:.9f}s") +print("delta_ns=" + ",".join(str(actual[i]-target_offsets[i]) for i in range(N))) +print("max_abs_delta_ns=" + str(max(abs(actual[i]-target_offsets[i]) for i in range(N)))) +PY +ā± Timeout: 50s +1 0.000004667s +2 5.833334375s +3 11.666666833s +4 17.500000167s +5 23.333333583s +6 29.166668208s +7 35.000000208s +elapsed=35.000029125s +delta_ns=4667,1042,166,167,250,1541,208 +max_abs_delta_ns=4667 + + + AGENT RESPONSE + +RAHHHHHH we went feral on precision 😤🐶 + +New run (inline only, no script files) got: + + • max timing error: 4,667 ns (that’s ~0.0047 ms) + • final ā€œ7ā€ at: 35.000000208s (off by 208 ns from ideal target) + • elapsed line: 35.000029125s (printed after the final number, so expected tiny overhead) + +At this point we’re fighting OS scheduling physics, not code quality šŸ˜Ž + +───────────────────────────────────────────────────────────────────────────────────────────────────────────── +╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) +ā•°ā”€āÆ Nice gimme a reporto please!!! + + AGENT RESPONSE + +Yessss boss, here’s your Reportoā„¢ šŸ“‹šŸ¶ + +Objective + +Count from 1 to 7 over ~35 seconds total with equal spacing between numbers, and then push accuracy as far as +possible. + +Conclusion + +Mission absolutely smashed. +We’re in microsecond-to-nanosecond error territory, and now the limiting factor is mostly OS scheduler / +runtime overhead, not algorithm design. +āœ“ [QUEUE] finished: Nice gimme a reporto please!!! + +───────────────────────────────────────────────────────────────────────────────────────────────────────────── +╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) +ā•°ā”€āÆ +``` From 94e5f34c31b0243bbd7bec74a7c8b0d0f56340fa Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Tue, 10 Mar 2026 09:21:24 -0500 Subject: [PATCH 26/40] Remove README transcript snapshot --- README.md | 318 ------------------------------------------------------ 1 file changed, 318 deletions(-) diff --git a/README.md b/README.md index cc1f35542..c49fab361 100644 --- a/README.md +++ b/README.md @@ -719,321 +719,3 @@ Code Puppy is designed with privacy-by-design principles. Every feature has been ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## Current State - -This rewrite branch is currently in a strong interactive state for the always-on composer, inline interjects, -queued prompts, and shell-time typing. The transcript below is a representative successful run from -`/Users/nateoswalt/code-puppy-interject-queue-v2`. - -```text -───────────────────────────────────────────────────────────────────────────────────────────────────────────── -╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) -ā•°ā”€āÆ hello - - AGENT RESPONSE - -yo n8 šŸ‘‹ -Adrius reporting for tail-wagging duty šŸ¶šŸ’» - -What are we building/breaking/fixing today? - -───────────────────────────────────────────────────────────────────────────────────────────────────────────── -╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) -ā•°ā”€āÆ Could you count to 7 in as close the 35 seconds as possible with equal spacing between printing the natural numbers? - -ℹ [QUEUE] saved for after this task: Could you count to 7 in as close the 35 seconds as possible with equal -spacing between printing the natural numbers? [position 1] - - AGENT REASONING -Current reasoning: -Got it — I’ll actually execute a small Python timer that prints 1 through 7 with equal spacing and aims for -~35 seconds total. To hit ~35s from first print to last, spacing should be 35/6 ā‰ˆ 5.8333s. I’ll anchor each -print to an absolute schedule (not cumulative sleep) to minimize drift. - -Planned next steps: - - 1 Run a Python command that prints numbers 1..7 at scheduled times - 2 Return the printed output and timing summary - - - SHELL COMMAND šŸš€ $ python3 - <<'PY' -import time -import math - -start = time.perf_counter() -nums = list(range(1,8)) -interval = 35/6 # so first-to-last spans ~35s - -for i, n in enumerate(nums): - target = start + i*interval - while True: - now = time.perf_counter() - remaining = target - now - if remaining <= 0: - break - time.sleep(min(remaining, 0.01)) - t = time.perf_counter() - start - print(n, f"({t:.3f}s)", flush=True) - -end = time.perf_counter() -print(f"Total elapsed: {end-start:.3f}s") -PY -ā± Timeout: 120s -1 (0.000s) - -⚠ [QUEUE] cancelled: Could you count to 7 in as close the 35 seconds as possible with equal spacing between -printing the natural numbers? - -───────────────────────────────────────────────────────────────────────────────────────────────────────────── -╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) -ā•°ā”€āÆ please don't use any scripts that already exist - -⚠ [INTERJECT] stopping current work: please don't use any scripts that already exist -⚠ [INTERJECT] applying now: please don't use any scripts that already exist - - AGENT REASONING -Current reasoning: -Affirmed — I saw your interjection: I will not use any pre-existing script files. I’ll continue by running a -fresh inline Python command directly in the shell (no existing scripts), printing 1..7 with equal spacing -over ~35 seconds total. - -Planned next steps: - - 1 Execute an inline Python command (heredoc) that schedules prints at absolute times - 2 Report the live output and elapsed timing - -ℹ [QUEUE] saved for after this task: make it more accurate as well! [position 1] - - SHELL COMMAND šŸš€ $ python - <<'PY' -import time - -n = 7 -total = 35.0 -interval = total / (n - 1) # 35/6 -start = time.perf_counter() - -for i in range(1, n + 1): - target = start + (i - 1) * interval - now = time.perf_counter() - if target > now: - time.sleep(target - now) - t = time.perf_counter() - start - print(i, f"{t:.3f}s", flush=True) - -elapsed = time.perf_counter() - start -print(f"elapsed={elapsed:.3f}s") -PY -ā± Timeout: 50s -/bin/sh: python: command not found - - - AGENT REASONING -Current reasoning: -Quick snag: python isn’t installed in this shell (command not found). I’ll retry with python3 using the same -inline approach (still no existing scripts), then report the timing output. - -Planned next steps: - - 1 Run inline Python via python3 - 2 Return results - - - SHELL COMMAND šŸš€ $ python3 - <<'PY' -import time - -n = 7 -total = 35.0 -interval = total / (n - 1) # 35/6 -start = time.perf_counter() - -for i in range(1, n + 1): - target = start + (i - 1) * interval - now = time.perf_counter() - if target > now: - time.sleep(target - now) - t = time.perf_counter() - start - print(i, f"{t:.3f}s", flush=True) - -elapsed = time.perf_counter() - start -print(f"elapsed={elapsed:.3f}s") -PY -ā± Timeout: 50s -1 0.000s -ℹ [QUEUE] saved for after this task: RAHHHH more accuracyyyyy!!!!! [position 2] -2 5.833s -3 11.667s -ℹ [QUEUE] saved for after this task: Nice gimme a reporto please!!! [position 3] -4 17.503s -5 23.337s -6 29.172s -7 35.004s -elapsed=35.004s - - - AGENT RESPONSE - -Yep — interjection seen and respected āœ… -No existing script files were used; this was run as a fresh inline command. - -Here’s the result: - - 1 0.000s - 2 5.833s - 3 11.667s - 4 17.503s - 5 23.337s - 6 29.172s - 7 35.004s - elapsed=35.004s - -So we hit ~35 seconds total with very even spacing (target was 5.8333s steps). -That’s basically bullseye territory šŸŽÆ - -───────────────────────────────────────────────────────────────────────────────────────────────────────────── -╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) -ā•°ā”€āÆ make it more accurate as well! -āœ“ [INTERJECT] applied: please don't use any scripts that already exist - - AGENT REASONING -Current reasoning: -Love it — let’s tighten the timing. I’ll use time.perf_counter_ns() with absolute target timestamps and a -hybrid wait strategy: sleep for most of each interval, then short spin-wait for the final sub-millisecond -window. That reduces scheduler jitter and should land closer to exact targets. - -Planned next steps: - - 1 Run an inline python3 command (no existing scripts) with perf_counter_ns + sleep-then-spin - 2 Report timing deltas vs ideal schedule - - - SHELL COMMAND šŸš€ $ python3 - <<'PY' -import time - -n = 7 -total_s = 35.0 -interval_ns = int(round(total_s * 1e9 / (n - 1))) -spin_threshold_ns = 2_000_000 # 2ms spin window - -start_ns = time.perf_counter_ns() - -actual = [] -for i in range(n): - target_ns = start_ns + i * interval_ns - while True: - now_ns = time.perf_counter_ns() - remaining = target_ns - now_ns - if remaining <= 0: - break - if remaining > spin_threshold_ns: - sleep_ns = remaining - 1_000_000 - time.sleep(sleep_ns / 1e9) - else: - pass - - t_ns = time.perf_counter_ns() - start_ns - actual.append(t_ns) - print(i + 1, f"{t_ns/1e9:.6f}s", flush=True) - -elapsed_ns = time.perf_counter_ns() - start_ns -print(f"elapsed={elapsed_ns/1e9:.6f}s") -print("deltas_ms=" + ",".join(f"{(actual[i] - i*interval_ns)/1e6:+.3f}" for i in range(n))) -PY -ā± Timeout: 50s -1 0.000001s -2 5.837502s -3 11.666669s -4 17.504098s -5 23.337381s -6 29.170745s -7 35.000993s -elapsed=35.001056s -deltas_ms=+0.001,+4.169,+0.002,+4.098,+4.048,+4.079,+0.993 - - - SHELL COMMAND šŸš€ $ python3 - <<'PY' -import time - -N = 7 -TOTAL_NS = 35_000_000_000 -HEADROOM_NS = 15_000_000 # 15ms pre-spin - -start_ns = time.perf_counter_ns() -actual = [] -target_offsets = [(i * TOTAL_NS + (N - 2)//2) // (N - 1) for i in range(N)] -target_offsets[0] = 0 -target_offsets[-1] = TOTAL_NS - -for i, off in enumerate(target_offsets): - target_ns = start_ns + off - - while True: - now = time.perf_counter_ns() - rem = target_ns - now - if rem <= HEADROOM_NS: - break - time.sleep((rem - HEADROOM_NS) / 1e9) - - while time.perf_counter_ns() < target_ns: - pass - - t = time.perf_counter_ns() - start_ns - actual.append(t) - print(f"{i+1} {t/1e9:.9f}s") - -end_ns = time.perf_counter_ns() -elapsed_ns = end_ns - start_ns - -print(f"elapsed={elapsed_ns/1e9:.9f}s") -print("delta_ns=" + ",".join(str(actual[i]-target_offsets[i]) for i in range(N))) -print("max_abs_delta_ns=" + str(max(abs(actual[i]-target_offsets[i]) for i in range(N)))) -PY -ā± Timeout: 50s -1 0.000004667s -2 5.833334375s -3 11.666666833s -4 17.500000167s -5 23.333333583s -6 29.166668208s -7 35.000000208s -elapsed=35.000029125s -delta_ns=4667,1042,166,167,250,1541,208 -max_abs_delta_ns=4667 - - - AGENT RESPONSE - -RAHHHHHH we went feral on precision 😤🐶 - -New run (inline only, no script files) got: - - • max timing error: 4,667 ns (that’s ~0.0047 ms) - • final ā€œ7ā€ at: 35.000000208s (off by 208 ns from ideal target) - • elapsed line: 35.000029125s (printed after the final number, so expected tiny overhead) - -At this point we’re fighting OS scheduling physics, not code quality šŸ˜Ž - -───────────────────────────────────────────────────────────────────────────────────────────────────────────── -╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) -ā•°ā”€āÆ Nice gimme a reporto please!!! - - AGENT RESPONSE - -Yessss boss, here’s your Reportoā„¢ šŸ“‹šŸ¶ - -Objective - -Count from 1 to 7 over ~35 seconds total with equal spacing between numbers, and then push accuracy as far as -possible. - -Conclusion - -Mission absolutely smashed. -We’re in microsecond-to-nanosecond error territory, and now the limiting factor is mostly OS scheduler / -runtime overhead, not algorithm design. -āœ“ [QUEUE] finished: Nice gimme a reporto please!!! - -───────────────────────────────────────────────────────────────────────────────────────────────────────────── -╭─ 🐶 Adrius [Code-Puppy 🐶] [chatgpt-gpt-5.3-codex] (~/code-puppy-interject-queue-v2) -ā•°ā”€āÆ -``` From b2a61de2a358ef34915db713250cda5acc5194d4 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Tue, 10 Mar 2026 10:52:27 -0500 Subject: [PATCH 27/40] Polish transcript visibility --- IMPLEMENTATION_GUARDRAILS.md | 11 + code_puppy/cli_runner.py | 75 ++++- .../command_line/prompt_toolkit_completion.py | 39 ++- code_puppy/messaging/__init__.py | 2 + code_puppy/messaging/messages.py | 9 + code_puppy/messaging/rich_renderer.py | 17 ++ code_puppy/tools/agent_tools.py | 33 +-- tests/messaging/test_rich_renderer.py | 37 +++ tests/test_agent_tools_coverage.py | 85 +++--- tests/test_cli_runner_full_coverage.py | 265 +++++++++++++++++- tests/test_prompt_toolkit_completion.py | 64 ++++- 11 files changed, 535 insertions(+), 102 deletions(-) diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md index d84e691ed..6fac38ae8 100644 --- a/IMPLEMENTATION_GUARDRAILS.md +++ b/IMPLEMENTATION_GUARDRAILS.md @@ -15,6 +15,8 @@ Read this before making changes in this repo. - Interject must aggressively cancel current work and run before normal queued prompts. - Queued/interjected prompts must echo into the transcript before dispatch. - Interjected prompts must echo the user's exact text into the transcript before any apply/start lifecycle lines. +- Direct prompts submitted from erasable prompt sessions must echo once into the transcript before any response or tool output. +- Normal direct follow-up prompts after queue/interject cycles must remain visible in the transcript. - Interject injection text must tell the agent to continue the interrupted task after acknowledging the interjection. - Queue preview must stay above the composer, max 3 visible rows, scroll when over 3, and cap at 25 prompts. - Queued prompt preview rows must stay one line, truncated, and never wrap. @@ -28,6 +30,15 @@ Read this before making changes in this repo. - Shell output with the prompt mounted must stay plain text. Do not reintroduce Rich dim styling or raw ANSI fragments there. - Do not reintroduce the shell warning line in the prompt header if typing is still enabled. +## Queue Transcript Rules + +- Keep queued-save transcript copy compact: `[Queued][N] {text}`. +- Keep queued-launch transcript copy as `[QUEUE TRIGGERED] {text}` and print it before the echoed queued prompt. +- Keep `list_agents` output visible in the transcript before downstream sub-agent invocation output. +- Do not reintroduce visible interject `applying now` lines. +- Do not reintroduce late launched-item `finished`, `applied`, or `run_cancelled` transcript noise. +- Keep transcript polish presentation-only; do not change queue/interject runtime semantics when adjusting these lines. + ## Spinner And Context Rules - The spinner is a visual heartbeat only. It should not control real work or real redraw priority. diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index e3179b09e..1021022ec 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -152,30 +152,37 @@ def _format_queue_lifecycle_text( return f"[QUEUE] {action}" if item.kind == "interject": + if action in {"started", "completed"}: + return None + if action == "cancelled" and reason == "run_cancelled": + return None action_text = { "queued": "stopping current work", - "started": "applying now", - "completed": "applied", "cancelled": "cancelled", "failed": "failed", "rejected": "couldn't apply", }.get(action, action.replace("_", " ")) return f"[INTERJECT] {action_text}: {item.text}" + if action == "queued": + if position is not None: + return f"[Queued][{position}] {item.text}" + return f"[Queued] {item.text}" + if action == "started": return None + if action == "completed" and reason is None: + return None + if action == "cancelled" and reason == "run_cancelled": + return None action_text = { - "queued": "saved for after this task", "completed": "finished", "cancelled": "cancelled", "failed": "failed", "rejected": "couldn't save", }.get(action, action.replace("_", " ")) - text = f"[QUEUE] {action_text}: {item.text}" - if action == "queued" and position is not None: - text = f"{text} [position {position}]" - return text + return f"[QUEUE] {action_text}: {item.text}" def _build_interject_submission_text(text: str) -> str: @@ -893,12 +900,26 @@ def queue_level(item: QueuedPrompt) -> str: """Return the lifecycle level for a queued item.""" return "warning" if item.kind == "interject" else "success" - def emit_queue_dispatch(item: QueuedPrompt) -> None: + async def emit_queue_dispatch(item: QueuedPrompt) -> None: """Emit UI markers before a queued/interjected item is dispatched.""" if item.kind == "queued": - from code_puppy.messaging import emit_success + try: + from code_puppy.command_line.prompt_toolkit_completion import ( + render_transcript_notice, + ) + except Exception: + render_transcript_notice = None - emit_success(f"[QUEUE] running queued prompt: {item.text}") + notice_text = f"[QUEUE TRIGGERED] {item.text.strip()}" + if notice_text.strip() and render_transcript_notice is not None: + if runtime.has_prompt_surface(): + rendered = await runtime.run_above_prompt_async( + lambda: render_transcript_notice(notice_text) + ) + if not rendered: + render_transcript_notice(notice_text) + else: + render_transcript_notice(notice_text) emit_interject_queue_lifecycle( runtime, "dequeued", @@ -927,6 +948,31 @@ async def echo_dispatched_prompt(item: QueuedPrompt) -> None: render_submitted_prompt_echo(prompt_text) + async def echo_direct_prompt_if_needed( + prompt_text: str, *, echo_in_transcript: bool + ) -> None: + """Echo direct submissions only when the prompt line was erased.""" + if not echo_in_transcript: + return + try: + from code_puppy.command_line.prompt_toolkit_completion import ( + render_submitted_prompt_echo, + ) + except Exception: + return + + visible_text = prompt_text.strip() + if not visible_text: + return + + if runtime.has_prompt_surface(): + if await runtime.run_above_prompt_async( + lambda: render_submitted_prompt_echo(visible_text) + ): + return + + render_submitted_prompt_echo(visible_text) + def complete_queue_item(item: QueuedPrompt, reason: str) -> None: """Mark a queued item as handled without launching the agent.""" emit_interject_queue_lifecycle( @@ -1049,6 +1095,7 @@ async def dispatch_submission( *, requested_action: Literal["submit", "queue", "interject"] = "submit", source_item: QueuedPrompt | None = None, + echo_in_transcript: bool = False, save_history: bool = True, allow_command_dispatch: bool = True, ) -> str: @@ -1144,8 +1191,13 @@ async def dispatch_submission( return "consumed" if source_item: + await emit_queue_dispatch(source_item) await echo_dispatched_prompt(source_item) - emit_queue_dispatch(source_item) + else: + await echo_direct_prompt_if_needed( + raw_task, + echo_in_transcript=echo_in_transcript, + ) if allow_command_dispatch and stripped_task.lower() in {"clear", "/clear"}: await clear_conversation_history() @@ -1395,6 +1447,7 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: outcome = await dispatch_submission( submission.text, requested_action=submission.action, + echo_in_transcript=submission.echo_in_transcript, ) if outcome == "exit": break diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index ffcb8cf54..302efc3c2 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -57,6 +57,7 @@ class PromptSubmission: action: Literal["submit", "queue", "interject"] text: str + echo_in_transcript: bool = False def _get_runtime() -> PromptRuntimeState | None: @@ -779,6 +780,32 @@ def render_submitted_prompt_echo(text: str) -> None: print_formatted_text(formatted, style=style, output=out) +def render_transcript_notice(text: str) -> None: + """Print a plain transcript line above the composer without prompt chrome.""" + notice_text = text.rstrip("\n") + if not notice_text: + return + + from prompt_toolkit.output.defaults import create_output + + out = create_output(stdout=sys.__stdout__) + if hasattr(out, "enable_cpr"): + out.enable_cpr = False + + formatted = FormattedText([("", notice_text), ("", "\n")]) + + runtime = _get_runtime() + app = getattr(getattr(runtime, "prompt_session", None), "app", None) + if app is not None: + try: + app.print_text(formatted) + return + except Exception: + pass + + print_formatted_text(formatted, output=out) + + async def prompt_for_submission( prompt_str=">>> ", history_file: Optional[str] = None, erase_when_done: bool = False ) -> PromptSubmission: @@ -1127,12 +1154,20 @@ def _(event): finally: clear_active_prompt_surface(session) if isinstance(result, PromptSubmission): - return result + return PromptSubmission( + action=result.action, + text=result.text, + echo_in_transcript=erase_when_done, + ) # NOTE: We used to call update_model_in_input(text) here to handle /model and /m # commands at the prompt level, but that prevented the command handler from running # and emitting success messages. Now we let all /model commands fall through to # the command handler in main.py for consistent handling. - return PromptSubmission(action="submit", text=result) + return PromptSubmission( + action="submit", + text=result, + echo_in_transcript=erase_when_done, + ) async def get_input_with_combined_completion( diff --git a/code_puppy/messaging/__init__.py b/code_puppy/messaging/__init__.py index 7d968256c..cad1a6171 100644 --- a/code_puppy/messaging/__init__.py +++ b/code_puppy/messaging/__init__.py @@ -90,6 +90,7 @@ # Message types and enums from .messages import ( # Enums, Base, Text, File ops, Diff, Shell, Agent, etc. + AgentListMessage, AgentReasoningMessage, AgentResponseMessage, AnyMessage, @@ -215,6 +216,7 @@ "DividerMessage", "StatusPanelMessage", "VersionCheckMessage", + "AgentListMessage", "SkillEntry", "SkillListMessage", "SkillActivateMessage", diff --git a/code_puppy/messaging/messages.py b/code_puppy/messaging/messages.py index 9efa4f394..02d375c90 100644 --- a/code_puppy/messaging/messages.py +++ b/code_puppy/messaging/messages.py @@ -456,6 +456,13 @@ class SkillEntry(BaseModel): model_config = {"frozen": True, "extra": "forbid"} +class AgentListMessage(BaseMessage): + """Summary banner for list_agents tool output.""" + + category: MessageCategory = MessageCategory.TOOL_OUTPUT + agent_count: int = Field(ge=0, description="Total number of available agents") + + class SkillListMessage(BaseMessage): """Result of listing or searching skills.""" @@ -511,6 +518,7 @@ class SkillActivateMessage(BaseMessage): DividerMessage, StatusPanelMessage, VersionCheckMessage, + AgentListMessage, SkillListMessage, SkillActivateMessage, ] @@ -560,6 +568,7 @@ class SkillActivateMessage(BaseMessage): # Status "StatusPanelMessage", "VersionCheckMessage", + "AgentListMessage", # Skills "SkillEntry", "SkillListMessage", diff --git a/code_puppy/messaging/rich_renderer.py b/code_puppy/messaging/rich_renderer.py index 205393808..6f8f0d44f 100644 --- a/code_puppy/messaging/rich_renderer.py +++ b/code_puppy/messaging/rich_renderer.py @@ -31,6 +31,7 @@ UserInputResponse, ) from .messages import ( + AgentListMessage, AgentReasoningMessage, AgentResponseMessage, AnyMessage, @@ -387,6 +388,8 @@ def _do_render_direct(self, message: AnyMessage) -> None: self._render_status_panel(message) elif isinstance(message, VersionCheckMessage): self._render_version_check(message) + elif isinstance(message, AgentListMessage): + self._render_agent_list(message) elif isinstance(message, SkillListMessage): self._render_skill_list(message) elif isinstance(message, SkillActivateMessage): @@ -1152,6 +1155,20 @@ def _get_file_icon(self, file_path: str) -> str: } return icons.get(ext, "šŸ“„") + # ========================================================================= + # Agent Lists + # ========================================================================= + + def _render_agent_list(self, msg: AgentListMessage) -> None: + """Render the list_agents summary banner.""" + if self._should_suppress_subagent_output(): + return + + banner = self._format_banner("list_agents", "LIST AGENTS") + self._console.print( + f"\n{banner} [dim]Found {msg.agent_count} agent(s).[/dim]" + ) + # ========================================================================= # Skills # ========================================================================= diff --git a/code_puppy/tools/agent_tools.py b/code_puppy/tools/agent_tools.py index 8793da860..5d7716498 100644 --- a/code_puppy/tools/agent_tools.py +++ b/code_puppy/tools/agent_tools.py @@ -25,10 +25,12 @@ get_value, ) from code_puppy.messaging import ( + AgentListMessage, + MessageLevel, SubAgentInvocationMessage, SubAgentResponseMessage, + TextMessage, emit_error, - emit_info, emit_success, get_message_bus, get_session_context, @@ -240,21 +242,6 @@ def register_list_agents(agent): @agent.tool def list_agents(context: RunContext) -> ListAgentsOutput: """List all available sub-agents that can be invoked.""" - # Generate a group ID for this tool execution - group_id = generate_group_id("list_agents") - - from rich.text import Text - - from code_puppy.config import get_banner_color - - list_agents_color = get_banner_color("list_agents") - emit_info( - Text.from_markup( - f"\n[bold white on {list_agents_color}] LIST AGENTS [/bold white on {list_agents_color}]" - ), - message_group=group_id, - ) - try: from code_puppy.agents import get_agent_descriptions, get_available_agents @@ -272,21 +259,15 @@ def list_agents(context: RunContext) -> ListAgentsOutput: for name, display_name in agents_dict.items() ] - # Accumulate output into a single string and emit once - # Use Text.from_markup() to pass a Rich object that won't be escaped - lines = [] - for agent_item in agents: - lines.append( - f"- [bold]{agent_item.name}[/bold]: {agent_item.display_name}\n" - f" [dim]{agent_item.description}[/dim]" - ) - emit_info(Text.from_markup("\n".join(lines)), message_group=group_id) + get_message_bus().emit(AgentListMessage(agent_count=len(agents))) return ListAgentsOutput(agents=agents) except Exception as e: error_msg = f"Error listing agents: {str(e)}" - emit_error(error_msg, message_group=group_id) + get_message_bus().emit( + TextMessage(level=MessageLevel.ERROR, text=error_msg) + ) return ListAgentsOutput(agents=[], error=error_msg) return list_agents diff --git a/tests/messaging/test_rich_renderer.py b/tests/messaging/test_rich_renderer.py index 386913ce7..330b887a4 100644 --- a/tests/messaging/test_rich_renderer.py +++ b/tests/messaging/test_rich_renderer.py @@ -10,6 +10,7 @@ from code_puppy.messaging.bus import MessageBus from code_puppy.messaging.messages import ( + AgentListMessage, AgentReasoningMessage, AgentResponseMessage, ConfirmationRequest, @@ -741,6 +742,36 @@ def test_render_version_check_current(renderer, console): assert "latest" in out +# ========================================================================= +# Agent Lists +# ========================================================================= + + +@patch("code_puppy.messaging.rich_renderer.is_subagent", return_value=False) +def test_render_agent_list(mock_sub, renderer, console): + msg = AgentListMessage(agent_count=3) + renderer._render_agent_list(msg) + out = output(console) + assert "LIST AGENTS" in out + assert "Found 3 agent(s)." in out + + +@patch("code_puppy.messaging.rich_renderer.is_subagent", return_value=False) +def test_render_agent_list_before_subagent_invocation(mock_sub, renderer, console): + renderer._render_agent_list(AgentListMessage(agent_count=2)) + renderer._render_subagent_invocation( + SubAgentInvocationMessage( + agent_name="python-programmer", + session_id="python-programmer-session-1", + prompt="hello", + is_new_session=True, + message_count=0, + ) + ) + out = output(console) + assert out.index("LIST AGENTS") < out.index("INVOKE AGENT") + + # ========================================================================= # Skills # ========================================================================= @@ -923,6 +954,12 @@ def test_do_render_skill_list_dispatch(mock_sub, renderer, console): renderer._do_render(msg) +@patch("code_puppy.messaging.rich_renderer.is_subagent", return_value=False) +def test_do_render_agent_list_dispatch(mock_sub, renderer, console): + msg = AgentListMessage(agent_count=4) + renderer._do_render(msg) + + @patch("code_puppy.messaging.rich_renderer.is_subagent", return_value=False) def test_do_render_skill_activate_dispatch(mock_sub, renderer, console): msg = SkillActivateMessage( diff --git a/tests/test_agent_tools_coverage.py b/tests/test_agent_tools_coverage.py index 00615df6b..2c08b3690 100644 --- a/tests/test_agent_tools_coverage.py +++ b/tests/test_agent_tools_coverage.py @@ -14,6 +14,7 @@ import pytest +from code_puppy.messaging.messages import AgentListMessage, MessageLevel, TextMessage from code_puppy.tools.agent_tools import ( AgentInfo, AgentInvokeOutput, @@ -278,18 +279,9 @@ def capture_tool(func): register_list_agents(mock_agent) assert registered_func is not None - # Mock the agent manager functions and config - # Note: get_banner_color is imported from code_puppy.config inside the function + mock_bus = MagicMock() with ( - patch( - "code_puppy.config.get_banner_color", - return_value="blue", - ), - patch("code_puppy.tools.agent_tools.emit_info"), - patch( - "code_puppy.tools.agent_tools.generate_group_id", - return_value="test-group", - ), + patch("code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus), patch("code_puppy.agents.get_available_agents") as mock_available, patch("code_puppy.agents.get_agent_descriptions") as mock_descriptions, ): @@ -314,6 +306,10 @@ def capture_tool(func): agent_names = [a.name for a in result.agents] assert "code-reviewer" in agent_names assert "qa-expert" in agent_names + mock_bus.emit.assert_called_once() + emitted = mock_bus.emit.call_args.args[0] + assert isinstance(emitted, AgentListMessage) + assert emitted.agent_count == 2 def test_list_agents_handles_exception(self): """Test that list_agents handles exceptions gracefully.""" @@ -330,18 +326,9 @@ def capture_tool(func): mock_agent.tool = capture_tool register_list_agents(mock_agent) - # Mock to raise an exception + mock_bus = MagicMock() with ( - patch( - "code_puppy.config.get_banner_color", - return_value="blue", - ), - patch("code_puppy.tools.agent_tools.emit_info"), - patch("code_puppy.tools.agent_tools.emit_error") as mock_emit_error, - patch( - "code_puppy.tools.agent_tools.generate_group_id", - return_value="test-group", - ), + patch("code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus), patch( "code_puppy.agents.get_available_agents", side_effect=RuntimeError("Database connection failed"), @@ -353,7 +340,11 @@ def capture_tool(func): assert isinstance(result, ListAgentsOutput) assert len(result.agents) == 0 assert "Database connection failed" in result.error - assert mock_emit_error.called + mock_bus.emit.assert_called_once() + emitted = mock_bus.emit.call_args.args[0] + assert isinstance(emitted, TextMessage) + assert emitted.level == MessageLevel.ERROR + assert emitted.text == "Error listing agents: Database connection failed" def test_list_agents_with_missing_description(self): """Test that list_agents handles missing descriptions.""" @@ -370,16 +361,9 @@ def capture_tool(func): mock_agent.tool = capture_tool register_list_agents(mock_agent) + mock_bus = MagicMock() with ( - patch( - "code_puppy.config.get_banner_color", - return_value="blue", - ), - patch("code_puppy.tools.agent_tools.emit_info"), - patch( - "code_puppy.tools.agent_tools.generate_group_id", - return_value="test-group", - ), + patch("code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus), patch("code_puppy.agents.get_available_agents") as mock_available, patch("code_puppy.agents.get_agent_descriptions") as mock_descriptions, ): @@ -394,6 +378,10 @@ def capture_tool(func): # Should use default description assert len(result.agents) == 1 assert result.agents[0].description == "No description available" + mock_bus.emit.assert_called_once() + emitted = mock_bus.emit.call_args.args[0] + assert isinstance(emitted, AgentListMessage) + assert emitted.agent_count == 1 class TestRegisterInvokeAgentExecution: @@ -709,13 +697,14 @@ async def test_too_long_session_id_rejected(self): assert "128 characters or less" in result.error -class TestListAgentsEmitsBannerAndInfo: - """Test that list_agents properly emits banner and info messages.""" +class TestListAgentsStructuredOutput: + """Test that list_agents emits structured output for the bus renderer.""" - def test_emits_banner_message(self): - """Test that list_agents emits a banner message.""" + def test_emits_agent_list_message(self): + """Test that list_agents emits a structured agent list summary.""" mock_agent = MagicMock() mock_context = MagicMock() + mock_bus = MagicMock() registered_func = None @@ -728,28 +717,18 @@ def capture_tool(func): register_list_agents(mock_agent) with ( - patch( - "code_puppy.config.get_banner_color", - return_value="green", - ) as mock_banner_color, - patch("code_puppy.tools.agent_tools.emit_info") as mock_emit_info, - patch( - "code_puppy.tools.agent_tools.generate_group_id", - return_value="banner-group", - ), + patch("code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus), patch( "code_puppy.agents.get_available_agents", - return_value={}, + return_value={"python-programmer": "Python Programmer"}, ), patch( "code_puppy.agents.get_agent_descriptions", - return_value={}, + return_value={"python-programmer": "Writes Python"}, ), ): registered_func(mock_context) - - # Verify banner color was fetched - mock_banner_color.assert_called_once_with("list_agents") - - # Verify emit_info was called (at least for banner) - assert mock_emit_info.called + mock_bus.emit.assert_called_once() + emitted = mock_bus.emit.call_args.args[0] + assert isinstance(emitted, AgentListMessage) + assert emitted.agent_count == 1 diff --git a/tests/test_cli_runner_full_coverage.py b/tests/test_cli_runner_full_coverage.py index 507288d14..b7bc270a9 100644 --- a/tests/test_cli_runner_full_coverage.py +++ b/tests/test_cli_runner_full_coverage.py @@ -47,8 +47,14 @@ def _mock_clipboard(images=None): return mgr -def _submission(text: str, action: str = "submit") -> PromptSubmission: - return PromptSubmission(action=action, text=text) +def _submission( + text: str, action: str = "submit", echo_in_transcript: bool = False +) -> PromptSubmission: + return PromptSubmission( + action=action, + text=text, + echo_in_transcript=echo_in_transcript, + ) def _apply_patches(stack, patches_dict): @@ -200,7 +206,7 @@ def test_emit_interject_queue_lifecycle_uses_friendly_queue_copy(): ) emitted = message_bus.emit.call_args[0][0] - assert emitted.text == "[QUEUE] saved for after this task: follow up [position 2]" + assert emitted.text == "[Queued][2] follow up" def test_emit_interject_queue_lifecycle_skips_dequeued_user_message(): @@ -243,6 +249,89 @@ def test_emit_interject_queue_lifecycle_skips_started_queue_user_message(): message_bus.emit.assert_not_called() +def test_emit_interject_queue_lifecycle_skips_started_interject_user_message(): + from code_puppy.cli_runner import ( + PromptRuntimeState, + QueuedPrompt, + emit_interject_queue_lifecycle, + ) + + message_bus = MagicMock() + + with patch("code_puppy.messaging.get_message_bus", return_value=message_bus): + emit_interject_queue_lifecycle( + PromptRuntimeState(), + "started", + item=QueuedPrompt(kind="interject", text="steer now"), + level="warning", + ) + + message_bus.emit.assert_not_called() + + +def test_emit_interject_queue_lifecycle_skips_completed_launch_message(): + from code_puppy.cli_runner import ( + PromptRuntimeState, + QueuedPrompt, + emit_interject_queue_lifecycle, + ) + + message_bus = MagicMock() + + with patch("code_puppy.messaging.get_message_bus", return_value=message_bus): + emit_interject_queue_lifecycle( + PromptRuntimeState(), + "completed", + item=QueuedPrompt(kind="queued", text="follow up"), + level="success", + ) + + message_bus.emit.assert_not_called() + + +def test_emit_interject_queue_lifecycle_skips_run_cancelled_message(): + from code_puppy.cli_runner import ( + PromptRuntimeState, + QueuedPrompt, + emit_interject_queue_lifecycle, + ) + + message_bus = MagicMock() + + with patch("code_puppy.messaging.get_message_bus", return_value=message_bus): + emit_interject_queue_lifecycle( + PromptRuntimeState(), + "cancelled", + item=QueuedPrompt(kind="queued", text="follow up"), + reason="run_cancelled", + level="warning", + ) + + message_bus.emit.assert_not_called() + + +def test_emit_interject_queue_lifecycle_keeps_command_completion_message(): + from code_puppy.cli_runner import ( + PromptRuntimeState, + QueuedPrompt, + emit_interject_queue_lifecycle, + ) + + message_bus = MagicMock() + + with patch("code_puppy.messaging.get_message_bus", return_value=message_bus): + emit_interject_queue_lifecycle( + PromptRuntimeState(), + "completed", + item=QueuedPrompt(kind="queued", text="/help"), + reason="command_consumed", + level="success", + ) + + emitted = message_bus.emit.call_args[0][0] + assert emitted.text == "[QUEUE] finished: /help" + + # --------------------------------------------------------------------------- # main() tests # --------------------------------------------------------------------------- @@ -925,8 +1014,11 @@ async def test_queued_prompt_runs_after_current_task_finishes(self): second_prompt_seen = asyncio.Event() queued_prompt_started = asyncio.Event() started_prompts = [] - emit_success = MagicMock() - render_prompt_echo = MagicMock() + render_notice = MagicMock() + render_prompt_echo = MagicMock( + side_effect=lambda text: render_order.append(("echo", text)) + ) + render_order = [] async def fake_input(*a, **kw): nonlocal call_count @@ -942,6 +1034,7 @@ async def fake_input(*a, **kw): async def fake_run(*args, **kwargs): prompt = args[1] started_prompts.append(prompt) + render_order.append(("start", prompt)) if prompt == "first task": await second_prompt_seen.wait() await asyncio.sleep(0.05) @@ -963,14 +1056,152 @@ async def fake_run(*args, **kwargs): "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( return_value=False ), - "code_puppy.messaging.emit_success": emit_success, + "code_puppy.command_line.prompt_toolkit_completion.render_transcript_notice": MagicMock( + side_effect=lambda text: render_order.append(("notice", text)) + or render_notice(text) + ), "code_puppy.command_line.prompt_toolkit_completion.render_submitted_prompt_echo": render_prompt_echo, }, ) assert started_prompts[:2] == ["first task", "queued task"] - emit_success.assert_any_call("[QUEUE] running queued prompt: queued task") + render_notice.assert_any_call("[QUEUE TRIGGERED] queued task") render_prompt_echo.assert_any_call("queued task") + queued_notice_idx = render_order.index(("notice", "[QUEUE TRIGGERED] queued task")) + queued_echo_idx = render_order.index(("echo", "queued task")) + queued_start_idx = render_order.index(("start", "queued task")) + assert queued_notice_idx < queued_echo_idx < queued_start_idx + + @pytest.mark.anyio + async def test_hidden_direct_submission_echoes_before_agent_starts(self): + call_count = 0 + launched = asyncio.Event() + render_order = [] + render_prompt_echo = MagicMock( + side_effect=lambda text: render_order.append(("echo", text)) + ) + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _submission("nice work!", echo_in_transcript=True) + await launched.wait() + return _submission("/exit") + + async def fake_run(*args, **kwargs): + prompt = args[1] + render_order.append(("start", prompt)) + launched.set() + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.prompt_toolkit_completion.render_submitted_prompt_echo": render_prompt_echo, + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + }, + ) + + render_prompt_echo.assert_any_call("nice work!") + assert render_order.index(("echo", "nice work!")) < render_order.index( + ("start", "nice work!") + ) + + @pytest.mark.anyio + async def test_hidden_direct_submission_echoes_before_command_dispatch(self): + call_count = 0 + render_order = [] + render_prompt_echo = MagicMock( + side_effect=lambda text: render_order.append(("echo", text)) + ) + run_prompt = AsyncMock() + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _submission("/help", echo_in_transcript=True) + return _submission("/exit") + + def fake_handle_command(command): + render_order.append(("command", command)) + if command == "/help": + return True + return False + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": run_prompt, + "code_puppy.command_line.command_handler.handle_command": MagicMock( + side_effect=fake_handle_command + ), + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.prompt_toolkit_completion.render_submitted_prompt_echo": render_prompt_echo, + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + }, + ) + + render_prompt_echo.assert_any_call("/help") + assert render_order.index(("echo", "/help")) < render_order.index( + ("command", "/help") + ) + run_prompt.assert_not_called() + + @pytest.mark.anyio + async def test_visible_direct_submission_does_not_duplicate_echo(self): + call_count = 0 + launched = asyncio.Event() + render_prompt_echo = MagicMock() + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _submission("already visible") + await launched.wait() + return _submission("/exit") + + async def fake_run(*args, **kwargs): + launched.set() + result = MagicMock(output=f"done: {args[1]}") + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.prompt_toolkit_completion.render_submitted_prompt_echo": render_prompt_echo, + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + }, + ) + + render_prompt_echo.assert_not_called() @pytest.mark.anyio async def test_queued_prompt_starts_when_run_finishes_during_choice_menu(self): @@ -1135,7 +1366,11 @@ async def test_interject_runs_before_queued_prompts(self): first_task_started = asyncio.Event() queued_prompt_started = asyncio.Event() started_prompts = [] - render_prompt_echo = MagicMock() + render_notice = MagicMock() + render_order = [] + render_prompt_echo = MagicMock( + side_effect=lambda text: render_order.append(("echo", text)) + ) async def fake_input(*a, **kw): nonlocal call_count @@ -1153,6 +1388,7 @@ async def fake_input(*a, **kw): async def fake_run(*args, **kwargs): prompt = args[1] started_prompts.append(prompt) + render_order.append(("start", prompt)) if prompt == "first task": first_task_started.set() await asyncio.sleep(10) @@ -1171,6 +1407,10 @@ async def fake_run(*args, **kwargs): "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( side_effect=lambda text: _mock_parse_result(text) ), + "code_puppy.command_line.prompt_toolkit_completion.render_transcript_notice": MagicMock( + side_effect=lambda text: render_order.append(("notice", text)) + or render_notice(text) + ), "code_puppy.command_line.prompt_toolkit_completion.render_submitted_prompt_echo": render_prompt_echo, "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( return_value=False @@ -1187,6 +1427,15 @@ async def fake_run(*args, **kwargs): assert "continue the interrupted task" in started_prompts[1] assert started_prompts[2] == "second queued" render_prompt_echo.assert_any_call("steer now") + render_notice.assert_any_call("[QUEUE TRIGGERED] second queued") + queued_notice_idx = render_order.index(("notice", "[QUEUE TRIGGERED] second queued")) + queued_echo_idx = render_order.index(("echo", "second queued")) + queued_start_idx = render_order.index(("start", "second queued")) + assert queued_notice_idx < queued_echo_idx < queued_start_idx + assert not any( + call.args[0] == "[QUEUE TRIGGERED] steer now" + for call in render_notice.call_args_list + ) @pytest.mark.anyio async def test_exit_while_running_cancels_runtime_task(self): diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index 70387f61f..25ab01399 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -26,6 +26,7 @@ has_active_prompt_surface, is_shell_prompt_suspended, prompt_for_submission, + render_transcript_notice, render_submitted_prompt_echo, register_active_prompt_surface, set_shell_prompt_suspended, @@ -280,6 +281,36 @@ def test_render_submitted_prompt_echo_uses_prompt_app_when_available( mock_print_formatted_text.assert_not_called() +@patch("code_puppy.command_line.prompt_toolkit_completion.print_formatted_text") +@patch("prompt_toolkit.output.defaults.create_output") +def test_render_transcript_notice(mock_create_output, mock_print_formatted_text): + mock_output = MagicMock() + mock_create_output.return_value = mock_output + + render_transcript_notice("[QUEUE TRIGGERED] queued task") + + mock_create_output.assert_called_once() + mock_print_formatted_text.assert_called_once() + rendered = mock_print_formatted_text.call_args.args[0] + assert any("[QUEUE TRIGGERED] queued task" in text for _style, text in rendered) + + +@patch("code_puppy.command_line.prompt_toolkit_completion.print_formatted_text") +@patch("prompt_toolkit.output.defaults.create_output") +def test_render_transcript_notice_uses_prompt_app_when_available( + mock_create_output, mock_print_formatted_text, active_runtime +): + session = MagicMock() + session.app = MagicMock() + active_runtime.register_prompt_surface(session) + + render_transcript_notice("[QUEUE TRIGGERED] queued task") + + session.app.print_text.assert_called_once() + mock_create_output.assert_called_once() + mock_print_formatted_text.assert_not_called() + + def test_set_completer_excludes_puppy_token(monkeypatch): # Ensure 'puppy_token' is a config key but SetCompleter doesn't offer it monkeypatch.setattr( @@ -595,10 +626,35 @@ async def test_prompt_for_submission_uses_non_raw_patch_stdout( result = await prompt_for_submission() - assert result == PromptSubmission(action="submit", text="test input") + assert result == PromptSubmission( + action="submit", + text="test input", + echo_in_transcript=False, + ) mock_patch_stdout.assert_called_once_with() +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.patch_stdout") +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +async def test_prompt_for_submission_sets_echo_flag_when_erasing_prompt( + mock_prompt_session_cls, mock_patch_stdout +): + mock_session_instance = MagicMock() + mock_session_instance.prompt_async = AsyncMock(return_value="hidden input") + mock_prompt_session_cls.return_value = mock_session_instance + mock_patch_stdout.return_value.__enter__ = MagicMock() + mock_patch_stdout.return_value.__exit__ = MagicMock(return_value=False) + + result = await prompt_for_submission(erase_when_done=True) + + assert result == PromptSubmission( + action="submit", + text="hidden input", + echo_in_transcript=True, + ) + + # To test key bindings, we need to inspect the KeyBindings object passed to PromptSession # We can get it from the mock_prompt_session_cls.call_args @@ -891,7 +947,11 @@ async def test_prompt_for_submission_returns_inline_queue_action( result = await prompt_for_submission() - assert result == PromptSubmission(action="queue", text="queued task") + assert result == PromptSubmission( + action="queue", + text="queued task", + echo_in_transcript=False, + ) @pytest.mark.asyncio From 0eb01de8257227d4c341262140747851efe6b927 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Tue, 10 Mar 2026 17:43:45 -0500 Subject: [PATCH 28/40] Overhaul interactive command handling --- IMPLEMENTATION_GUARDRAILS.md | 14 + code_puppy/cli_runner.py | 195 ++++++++-- code_puppy/command_line/command_handler.py | 3 + code_puppy/command_line/core_commands.py | 16 +- .../command_line/interactive_command.py | 19 + .../command_line/interactive_runtime.py | 75 +++- .../command_line/prompt_toolkit_completion.py | 190 ++++++++-- code_puppy/messaging/__init__.py | 4 + code_puppy/messaging/legacy_bridge.py | 50 +++ code_puppy/messaging/messages.py | 26 +- code_puppy/messaging/renderers.py | 228 ++++-------- code_puppy/messaging/rich_renderer.py | 26 ++ .../antigravity_oauth/register_callbacks.py | 58 ++- .../plugins/chatgpt_oauth/oauth_flow.py | 36 +- .../chatgpt_oauth/register_callbacks.py | 15 +- .../claude_code_oauth/register_callbacks.py | 55 ++- code_puppy/plugins/oauth_control.py | 47 +++ docs/INTERACTIVE_REGRESSION_CHECKLIST.md | 45 +++ .../test_core_commands_full_coverage.py | 22 +- tests/command_line/test_tutorial.py | 30 +- tests/messaging/test_legacy_bridge.py | 72 ++++ .../test_antigravity_callbacks_coverage.py | 44 ++- .../test_antigravity_register_callbacks.py | 30 +- tests/plugins/test_chatgpt_oauth_coverage.py | 18 +- .../plugins/test_chatgpt_oauth_integration.py | 12 +- .../test_claude_code_oauth_callbacks.py | 12 +- .../test_claude_code_oauth_coverage.py | 34 +- tests/test_command_overhaul_targeted.py | 346 ++++++++++++++++++ 28 files changed, 1401 insertions(+), 321 deletions(-) create mode 100644 code_puppy/command_line/interactive_command.py create mode 100644 code_puppy/messaging/legacy_bridge.py create mode 100644 code_puppy/plugins/oauth_control.py create mode 100644 docs/INTERACTIVE_REGRESSION_CHECKLIST.md create mode 100644 tests/messaging/test_legacy_bridge.py create mode 100644 tests/test_command_overhaul_targeted.py diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md index 6fac38ae8..eb573c8f5 100644 --- a/IMPLEMENTATION_GUARDRAILS.md +++ b/IMPLEMENTATION_GUARDRAILS.md @@ -29,6 +29,8 @@ Read this before making changes in this repo. - Structured banners like `AGENT REASONING`, listings, and tool blocks must use the prompt-safe render path. - Shell output with the prompt mounted must stay plain text. Do not reintroduce Rich dim styling or raw ANSI fragments there. - Do not reintroduce the shell warning line in the prompt header if typing is still enabled. +- Legacy slash-command output emitted through `emit_info`, `emit_warning`, `emit_error`, `emit_success`, and divider output must remain visible in interactive mode. +- Rich slash-command renderables like `Text`, `Table`, and `Markdown` must still render above the mounted composer. ## Queue Transcript Rules @@ -47,6 +49,17 @@ Read this before making changes in this repo. - Seed the token/context line at run start so it does not show stale data from the previous run. - Keep the prompt-native spinner; do not bring back the old Rich live spinner for interactive runs. +## Command And OAuth Rules + +- OAuth setup flows (`/antigravity-auth`, `/antigravity-add`, `/claude-code-auth`, `/chatgpt-auth`, tutorial/onboarding auth handoff) are core functionality and must remain working. +- During OAuth callback waits, `/exit`, `/quit`, and the configured cancel key must still work. +- Cancelling auth must not half-apply model switches, reloads, or config changes. +- Queueing or interjecting during auth or other cooperative external waits must never crash; cancel/cleanup races must be harmless. +- While work is active, only `/exit` and `/quit` keep slash-command semantics. +- Busy slash-prefixed text other than `/exit` and `/quit` must remain literal user text if queued or interjected. +- The chooser state must not show slash-command menus or execute slash commands. +- `Ctrl+C` from the composer must remain the universal busy-state cancel path: shell interrupt, background command cancel, or agent cancel as appropriate. + ## Config And Runtime Notes - Use `./.cp-local/run-code-puppy-local.sh` when you need isolated local setup/auth for this repo. @@ -56,5 +69,6 @@ Read this before making changes in this repo. ## Pre-Implementation Check - Read this file. +- Read `docs/INTERACTIVE_REGRESSION_CHECKLIST.md`. - Check for prompt/render/runtime side effects before editing. - If a change touches interactive runtime, prompt rendering, shell integration, queue/interject flow, or spinner behavior, run focused tests and do a real terminal smoke pass afterward. diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 1021022ec..8b319e67d 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -26,6 +26,7 @@ from code_puppy.agents import get_current_agent from code_puppy.command_line.attachments import parse_prompt_attachments from code_puppy.command_line.clipboard import get_clipboard_manager +from code_puppy.command_line.interactive_command import BackgroundInteractiveCommand from code_puppy.command_line.interactive_runtime import ( PromptRuntimeState, QueuedPrompt, @@ -293,24 +294,11 @@ async def main(): args = parser.parse_args() from code_puppy.messaging import ( + LegacyQueueToBusBridge, RichConsoleRenderer, get_global_queue, get_message_bus, ) - try: - from code_puppy.messaging.legacy_bridge import LegacyQueueToBusBridge - except ImportError: - class LegacyQueueToBusBridge: # type: ignore[no-redef] - """No-op fallback when legacy bridge module is unavailable.""" - - def __init__(self, *_args, **_kwargs): - pass - - def start(self): - return None - - def stop(self): - return None capture_session = None if args.debug_capture: @@ -708,6 +696,8 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non # Autosave loading is now manual - use /autosave_load command + startup_oauth_command: str | None = None + # Auto-run tutorial on first startup try: from code_puppy.command_line.onboarding_wizard import should_show_onboarding @@ -718,7 +708,6 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non from code_puppy.command_line.onboarding_wizard import ( run_onboarding_wizard, ) - from code_puppy.config import set_model_name from code_puppy.messaging import emit_info with concurrent.futures.ThreadPoolExecutor() as executor: @@ -727,18 +716,10 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non if result == "chatgpt": emit_info("šŸ” Starting ChatGPT OAuth flow...") - from code_puppy.plugins.chatgpt_oauth.oauth_flow import run_oauth_flow - - run_oauth_flow() - set_model_name("chatgpt-gpt-5.3-codex") + startup_oauth_command = "/chatgpt-auth" elif result == "claude": emit_info("šŸ” Starting Claude Code OAuth flow...") - from code_puppy.plugins.claude_code_oauth.register_callbacks import ( - _perform_authentication, - ) - - _perform_authentication() - set_model_name("claude-code-claude-opus-4-6") + startup_oauth_command = "/claude-code-auth" elif result == "completed": emit_info("šŸŽ‰ Tutorial complete! Happy coding!") elif result == "skipped": @@ -762,13 +743,34 @@ async def cancel_active_run(reason: str) -> None: get_running_shell_process_count = lambda: 0 kill_all_running_shell_processes = lambda: None - if runtime.bg_task is None or runtime.bg_task.done(): - runtime.mark_idle() + active_task = runtime.bg_task + active_cancel_hook = runtime.active_cancel_hook + + if active_task is None or active_task.done(): + runtime.mark_idle_if_task(active_task) return runtime.cancelling = True log_event("cancel_start", reason=reason) + if active_cancel_hook is not None: + try: + active_cancel_hook() + except Exception: + pass + try: + await asyncio.wait_for(asyncio.shield(active_task), timeout=1.5) + except asyncio.TimeoutError: + pass + except asyncio.CancelledError: + pass + except Exception: + pass + if active_task.done(): + runtime.mark_idle_if_task(active_task) + log_event("cancel_done", reason=reason) + return + # First kill nested shell activity, repeating briefly if needed. for _ in range(3): kill_all_running_shell_processes() @@ -777,9 +779,14 @@ async def cancel_active_run(reason: str) -> None: await asyncio.sleep(0.15) # Then cancel the active background agent task and await completion. - runtime.bg_task.cancel() + if active_task.done(): + runtime.mark_idle_if_task(active_task) + log_event("cancel_done", reason=reason) + return + + active_task.cancel() try: - await asyncio.wait_for(runtime.bg_task, timeout=6.0) + await asyncio.wait_for(active_task, timeout=6.0) except asyncio.CancelledError: pass except TimeoutError: @@ -787,7 +794,7 @@ async def cancel_active_run(reason: str) -> None: except Exception: pass finally: - runtime.mark_idle() + runtime.mark_idle_if_task(active_task) log_event("cancel_done", reason=reason) except Exception: clear_active_interactive_runtime(runtime) @@ -801,9 +808,13 @@ async def shutdown_interactive_session(message: str, *, reason: str) -> None: shutdown_requested = True emit_success(message) if runtime.running and runtime.bg_task is not None and not runtime.bg_task.done(): - emit_info("Cancelling running agent task...") + emit_info("Cancelling running task...") await cancel_active_run(reason) + runtime.set_active_cancel_requester( + lambda reason: asyncio.create_task(cancel_active_run(reason)) + ) + async def restore_autosave_state() -> None: """Handle the /autosave_load command.""" try: @@ -1066,9 +1077,13 @@ async def run_agent_bg( level="error", ) finally: - was_cancelling = runtime.cancelling - runtime.mark_idle() + active_task = asyncio.current_task() + owns_runtime = runtime.is_active_task(active_task) + was_cancelling = runtime.cancelling if owns_runtime else False + runtime.mark_idle_if_task(active_task) log_event("agent_end", prompt=task_text) + if not owns_runtime: + return if was_cancelling: if shutdown_requested: log_event( @@ -1090,6 +1105,82 @@ async def run_agent_bg( return await drain_pending_work_if_idle(origin="run_complete") + async def run_interactive_command_bg( + command_result: BackgroundInteractiveCommand, + command_text: str, + *, + source_item: QueuedPrompt | None = None, + ) -> None: + """Run a long-lived interactive command without blocking the composer.""" + try: + log_event("interactive_command_start", command=command_text) + if source_item: + emit_interject_queue_lifecycle( + runtime, + "started", + item=source_item, + level="warning" if source_item.kind == "interject" else "success", + ) + + await asyncio.to_thread(command_result.run, command_result.cancel_event) + + if source_item and not command_result.cancel_event.is_set(): + emit_interject_queue_lifecycle( + runtime, + "completed", + item=source_item, + level="success", + ) + except asyncio.CancelledError: + if source_item: + emit_interject_queue_lifecycle( + runtime, + "cancelled", + item=source_item, + reason="run_cancelled", + level="warning", + ) + except Exception: + from code_puppy.messaging.queue_console import get_queue_console + + get_queue_console().print_exception() + if source_item: + emit_interject_queue_lifecycle( + runtime, + "failed", + item=source_item, + reason="exception", + level="error", + ) + finally: + active_task = asyncio.current_task() + owns_runtime = runtime.is_active_task(active_task) + was_cancelling = runtime.cancelling if owns_runtime else False + runtime.mark_idle_if_task(active_task) + log_event("interactive_command_end", command=command_text) + if not owns_runtime: + return + if was_cancelling: + if shutdown_requested: + log_event( + "queue_autodrain_skipped", + reason="shutdown_requested", + remaining=len(runtime.queue), + ) + return + log_event( + "queue_autodrain_skipped", + reason="cancelling", + remaining=len(runtime.queue), + ) + asyncio.create_task( + kick_drain_after_cancel_boundary( + origin="cancel_boundary_fallback", + ) + ) + return + await drain_pending_work_if_idle(origin="interactive_command_complete") + async def dispatch_submission( task_text: str, *, @@ -1120,7 +1211,10 @@ async def dispatch_submission( if requested_action == "interject": log_event("interject_banner", text=stripped_task) await cancel_active_run("interject") - ok, position, item = runtime.request_interject(stripped_task) + ok, position, item = runtime.request_interject( + stripped_task, + allow_command_dispatch=allow_command_dispatch, + ) if not ok: emit_warning("Queue full (25). Cannot interject right now.") emit_interject_queue_lifecycle( @@ -1158,7 +1252,10 @@ async def dispatch_submission( ) return "consumed" - ok, position, item = runtime.request_queue(stripped_task) + ok, position, item = runtime.request_queue( + stripped_task, + allow_command_dispatch=allow_command_dispatch, + ) if not ok: emit_warning("Queue full (25). Prompt was not queued.") emit_interject_queue_lifecycle( @@ -1240,6 +1337,21 @@ async def dispatch_submission( complete_queue_item(source_item, "autosave_load") return "consumed" candidate_task = command_result + elif isinstance(command_result, BackgroundInteractiveCommand): + if save_history: + save_command_to_history(raw_task) + runtime.mark_running( + asyncio.create_task( + run_interactive_command_bg( + command_result, + cleaned_for_commands, + source_item=source_item, + ) + ), + kind="interactive_command", + cancel_hook=command_result.request_cancel, + ) + return "launched" candidate_task = candidate_task.strip() if not candidate_task: @@ -1344,7 +1456,7 @@ async def drain_pending_work_if_idle(*, origin: str) -> bool: else _build_interject_submission_text(next_item.text), source_item=next_item, save_history=False, - allow_command_dispatch=next_item.kind == "queued", + allow_command_dispatch=next_item.allow_command_dispatch, ) handled_any = True if outcome == "launched": @@ -1374,6 +1486,16 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: await asyncio.sleep(0) return await drain_pending_work_if_idle(origin=origin) + if startup_oauth_command: + startup_outcome = await dispatch_submission( + startup_oauth_command, + save_history=False, + allow_command_dispatch=True, + ) + if startup_outcome == "exit": + clear_active_interactive_runtime(runtime) + return + while True: from code_puppy.agents.agent_manager import get_current_agent from code_puppy.messaging import emit_info @@ -1448,6 +1570,7 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: submission.text, requested_action=submission.action, echo_in_transcript=submission.echo_in_transcript, + allow_command_dispatch=submission.allow_command_dispatch, ) if outcome == "exit": break diff --git a/code_puppy/command_line/command_handler.py b/code_puppy/command_line/command_handler.py index f0d153dce..ab08c9aaf 100644 --- a/code_puppy/command_line/command_handler.py +++ b/code_puppy/command_line/command_handler.py @@ -171,6 +171,7 @@ def handle_command(command: str): """ from rich.text import Text + from code_puppy.command_line.interactive_command import BackgroundInteractiveCommand from code_puppy.command_line.command_registry import get_command from code_puppy.messaging import emit_info, emit_warning @@ -246,6 +247,8 @@ def handle_command(command: str): for res in results: if res is True: return True + if isinstance(res, BackgroundInteractiveCommand): + return res if MarkdownCommandResult and isinstance(res, MarkdownCommandResult): # Special case: markdown command that should be processed as input # Replace the command with the markdown content and let it be processed diff --git a/code_puppy/command_line/core_commands.py b/code_puppy/command_line/core_commands.py index 26360f6e1..85940040f 100644 --- a/code_puppy/command_line/core_commands.py +++ b/code_puppy/command_line/core_commands.py @@ -156,7 +156,7 @@ def handle_paste_command(command: str) -> bool: usage="/tutorial", category="core", ) -def handle_tutorial_command(command: str) -> bool: +def handle_tutorial_command(command: str): """Run the interactive tutorial wizard. Usage: @@ -165,11 +165,11 @@ def handle_tutorial_command(command: str) -> bool: import asyncio import concurrent.futures + from code_puppy.command_line.interactive_command import BackgroundInteractiveCommand from code_puppy.command_line.onboarding_wizard import ( reset_onboarding, run_onboarding_wizard, ) - from code_puppy.model_switching import set_model_and_reload_agent # Always reset so user can re-run the tutorial anytime reset_onboarding() @@ -181,18 +181,18 @@ def handle_tutorial_command(command: str) -> bool: if result == "chatgpt": emit_info("šŸ” Starting ChatGPT OAuth flow...") - from code_puppy.plugins.chatgpt_oauth.oauth_flow import run_oauth_flow + from code_puppy.plugins.chatgpt_oauth.register_callbacks import ( + start_chatgpt_oauth_setup, + ) - run_oauth_flow() - set_model_and_reload_agent("chatgpt-gpt-5.3-codex") + return BackgroundInteractiveCommand(run=start_chatgpt_oauth_setup) elif result == "claude": emit_info("šŸ” Starting Claude Code OAuth flow...") from code_puppy.plugins.claude_code_oauth.register_callbacks import ( - _perform_authentication, + start_claude_code_oauth_setup, ) - _perform_authentication() - set_model_and_reload_agent("claude-code-claude-opus-4-6") + return BackgroundInteractiveCommand(run=start_claude_code_oauth_setup) elif result == "completed": emit_info("šŸŽ‰ Tutorial complete! Happy coding!") elif result == "skipped": diff --git a/code_puppy/command_line/interactive_command.py b/code_puppy/command_line/interactive_command.py new file mode 100644 index 000000000..052995d9c --- /dev/null +++ b/code_puppy/command_line/interactive_command.py @@ -0,0 +1,19 @@ +"""Helpers for long-running interactive commands that need cooperative cancel.""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field +from typing import Callable + + +@dataclass +class BackgroundInteractiveCommand: + """Background command work that should keep the composer alive.""" + + run: Callable[[threading.Event], object | None] + cancel_event: threading.Event = field(default_factory=threading.Event) + + def request_cancel(self) -> None: + """Signal the background command to stop cooperatively.""" + self.cancel_event.set() diff --git a/code_puppy/command_line/interactive_runtime.py b/code_puppy/command_line/interactive_runtime.py index 40b72acd4..f8cd30543 100644 --- a/code_puppy/command_line/interactive_runtime.py +++ b/code_puppy/command_line/interactive_runtime.py @@ -19,6 +19,7 @@ class QueuedPrompt: kind: Literal["queued", "interject"] text: str + allow_command_dispatch: bool = True created_at: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() ) @@ -40,17 +41,29 @@ class PromptRuntimeState: shell_depth: int = 0 queue_view_offset: int = 0 pending_submission: str | None = None + pending_submission_allow_command_dispatch: bool = True prompt_surface_kind: Literal["main"] | None = None prompt_session: object | None = None prompt_status_started_at: float | None = None prompt_status_task: asyncio.Task | None = None last_prompt_invalidation_at: float = 0.0 last_spinner_invalidation_at: float = 0.0 - - def mark_running(self, task: asyncio.Task) -> None: + active_run_kind: Literal["agent", "interactive_command"] | None = None + active_cancel_hook: Callable[[], None] | None = None + active_cancel_requester: Callable[[str], None] | None = None + + def mark_running( + self, + task: asyncio.Task, + *, + kind: Literal["agent", "interactive_command"] = "agent", + cancel_hook: Callable[[], None] | None = None, + ) -> None: self.running = True self.cancelling = False self.bg_task = task + self.active_run_kind = kind + self.active_cancel_hook = cancel_hook self.prompt_status_started_at = time.monotonic() self._ensure_prompt_status_task() self.invalidate_prompt() @@ -59,10 +72,21 @@ def mark_idle(self) -> None: self.running = False self.cancelling = False self.bg_task = None + self.active_run_kind = None + self.active_cancel_hook = None self.prompt_status_started_at = None self._stop_prompt_status_task() self.invalidate_prompt() + def is_active_task(self, task: asyncio.Task | None) -> bool: + return task is not None and self.bg_task is task + + def mark_idle_if_task(self, task: asyncio.Task | None) -> bool: + if not self.is_active_task(task): + return False + self.mark_idle() + return True + def _can_enqueue(self) -> bool: return len(self.queue) < MAX_PROMPT_QUEUE @@ -70,19 +94,31 @@ def _clamp_queue_view_offset(self, *, max_visible: int = 3) -> None: max_start = max(0, len(self.queue) - max_visible) self.queue_view_offset = max(0, min(self.queue_view_offset, max_start)) - def request_queue(self, prompt: str) -> tuple[bool, int, QueuedPrompt | None]: + def request_queue( + self, prompt: str, *, allow_command_dispatch: bool = True + ) -> tuple[bool, int, QueuedPrompt | None]: if not self._can_enqueue(): return False, len(self.queue), None - item = QueuedPrompt(kind="queued", text=prompt) + item = QueuedPrompt( + kind="queued", + text=prompt, + allow_command_dispatch=allow_command_dispatch, + ) self.queue.append(item) self._clamp_queue_view_offset() self.invalidate_prompt() return True, len(self.queue), item - def request_interject(self, prompt: str) -> tuple[bool, int, QueuedPrompt | None]: + def request_interject( + self, prompt: str, *, allow_command_dispatch: bool = True + ) -> tuple[bool, int, QueuedPrompt | None]: if not self._can_enqueue(): return False, len(self.queue), None - item = QueuedPrompt(kind="interject", text=prompt) + item = QueuedPrompt( + kind="interject", + text=prompt, + allow_command_dispatch=allow_command_dispatch, + ) self.queue.insert(0, item) self._clamp_queue_view_offset() self.invalidate_prompt() @@ -102,15 +138,24 @@ def queue_preview_texts(self) -> list[str]: def has_pending_submission(self) -> bool: return bool(self.pending_submission) - def set_pending_submission(self, text: str | None) -> None: + def set_pending_submission( + self, text: str | None, *, allow_command_dispatch: bool = True + ) -> None: self.pending_submission = text + self.pending_submission_allow_command_dispatch = allow_command_dispatch self.invalidate_prompt() def take_pending_submission(self) -> str | None: + text, _ = self.take_pending_submission_with_policy() + return text + + def take_pending_submission_with_policy(self) -> tuple[str | None, bool]: text = self.pending_submission + allow_command_dispatch = self.pending_submission_allow_command_dispatch self.pending_submission = None + self.pending_submission_allow_command_dispatch = True self.invalidate_prompt() - return text + return text, allow_command_dispatch def has_active_shell(self) -> bool: return self.shell_depth > 0 @@ -124,6 +169,20 @@ def notify_shell_finished(self) -> None: self.shell_depth -= 1 self.invalidate_prompt() + def has_active_interactive_command(self) -> bool: + return self.active_run_kind == "interactive_command" and self.running + + def set_active_cancel_requester( + self, requester: Callable[[str], None] | None + ) -> None: + self.active_cancel_requester = requester + + def request_active_cancel(self, reason: str) -> bool: + if self.active_cancel_requester is None: + return False + self.active_cancel_requester(reason) + return True + def shift_queue_view_offset(self, delta: int, *, max_visible: int = 3) -> bool: old_offset = self.queue_view_offset self._clamp_queue_view_offset(max_visible=max_visible) diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 302efc3c2..abac9813b 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -14,7 +14,12 @@ from prompt_toolkit import PromptSession from prompt_toolkit.patch_stdout import patch_stdout -from prompt_toolkit.completion import Completer, Completion, merge_completers +from prompt_toolkit.completion import ( + Completer, + Completion, + ConditionalCompleter, + merge_completers, +) from prompt_toolkit.filters import Condition, is_searching from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.history import FileHistory @@ -58,6 +63,7 @@ class PromptSubmission: action: Literal["submit", "queue", "interject"] text: str echo_in_transcript: bool = False + allow_command_dispatch: bool = True def _get_runtime() -> PromptRuntimeState | None: @@ -156,6 +162,11 @@ def _is_exit_text(text: str) -> bool: return text.strip().lower() in {"exit", "quit", "/exit", "/quit"} +def _allows_busy_command_dispatch(text: str) -> bool: + stripped = text.strip() + return not stripped.startswith("/") or _is_exit_text(stripped) + + def _sanitize_for_encoding(text: str) -> str: """Remove or replace characters that can't be safely encoded. @@ -812,23 +823,6 @@ async def prompt_for_submission( # Use SafeFileHistory to handle encoding errors gracefully on Windows history = SafeFileHistory(history_file) if history_file else None runtime = _get_runtime() - completer = merge_completers( - [ - FilePathCompleter(symbol="@"), - ModelNameCompleter(trigger="/model"), - ModelNameCompleter(trigger="/m"), - CDCompleter(trigger="/cd"), - SetCompleter(trigger="/set"), - LoadContextCompleter(trigger="/load_context"), - PinCompleter(trigger="/pin_model"), - UnpinCompleter(trigger="/unpin"), - AgentCompleter(trigger="/agent"), - AgentCompleter(trigger="/a"), - MCPCompleter(trigger="/mcp"), - SkillsCompleter(trigger="/skills"), - SlashCompleter(), - ] - ) # Add custom key bindings and multiline toggle bindings = KeyBindings() pending_decision_filter = Condition( @@ -837,6 +831,65 @@ async def prompt_for_submission( shell_active_filter = Condition( lambda: runtime is not None and runtime.has_active_shell() ) + busy_run_filter = Condition(lambda: runtime is not None and runtime.running) + command_completion_filter = Condition( + lambda: runtime is None + or not (runtime.running or runtime.has_pending_submission()) + ) + + completer = merge_completers( + [ + FilePathCompleter(symbol="@"), + ConditionalCompleter( + ModelNameCompleter(trigger="/model"), + filter=command_completion_filter, + ), + ConditionalCompleter( + ModelNameCompleter(trigger="/m"), + filter=command_completion_filter, + ), + ConditionalCompleter( + CDCompleter(trigger="/cd"), + filter=command_completion_filter, + ), + ConditionalCompleter( + SetCompleter(trigger="/set"), + filter=command_completion_filter, + ), + ConditionalCompleter( + LoadContextCompleter(trigger="/load_context"), + filter=command_completion_filter, + ), + ConditionalCompleter( + PinCompleter(trigger="/pin_model"), + filter=command_completion_filter, + ), + ConditionalCompleter( + UnpinCompleter(trigger="/unpin"), + filter=command_completion_filter, + ), + ConditionalCompleter( + AgentCompleter(trigger="/agent"), + filter=command_completion_filter, + ), + ConditionalCompleter( + AgentCompleter(trigger="/a"), + filter=command_completion_filter, + ), + ConditionalCompleter( + MCPCompleter(trigger="/mcp"), + filter=command_completion_filter, + ), + ConditionalCompleter( + SkillsCompleter(trigger="/skills"), + filter=command_completion_filter, + ), + ConditionalCompleter( + SlashCompleter(), + filter=command_completion_filter, + ), + ] + ) # Multiline mode state multiline = {"enabled": False} @@ -844,6 +897,12 @@ async def prompt_for_submission( def awaiting_decision() -> bool: return runtime is not None and runtime.has_pending_submission() + def clear_chooser_input(event) -> None: + try: + event.app.current_buffer.reset() + except Exception: + pass + # Ctrl+X keybinding - exit with KeyboardInterrupt for input cancellation @bindings.add(Keys.ControlX) def _(event): @@ -857,9 +916,43 @@ def _(event): # This happens when user presses multiple exit keys in quick succession pass - @bindings.add("c-c", filter=shell_active_filter, eager=True) + @bindings.add("c-c", filter=busy_run_filter, eager=True) def _(event): - _interrupt_shell_from_prompt("Ctrl-C") + if runtime is not None and runtime.has_active_shell(): + _interrupt_shell_from_prompt("Ctrl-C") + runtime.set_pending_submission(None) + clear_chooser_input(event) + return + if runtime is not None: + runtime.set_pending_submission(None) + clear_chooser_input(event) + if runtime.request_active_cancel("ctrl_c"): + return + try: + event.app.exit(exception=KeyboardInterrupt) + except Exception: + pass + + configured_cancel_key = str(get_value("cancel_agent_key") or "ctrl+c").lower() + configured_binding = {"ctrl+k": "c-k", "ctrl+q": "c-q"}.get(configured_cancel_key) + if configured_binding is not None: + + @bindings.add(configured_binding, filter=busy_run_filter, eager=True) + def _(event): + if runtime is not None and runtime.has_active_shell(): + _interrupt_shell_from_prompt(configured_cancel_key.upper()) + runtime.set_pending_submission(None) + clear_chooser_input(event) + return + if runtime is not None: + runtime.set_pending_submission(None) + clear_chooser_input(event) + if runtime.request_active_cancel(configured_cancel_key): + return + try: + event.app.exit(exception=KeyboardInterrupt) + except Exception: + pass # Escape keybinding - exit with KeyboardInterrupt @bindings.add(Keys.Escape) @@ -875,22 +968,35 @@ def _(event): # Ignore "Return value already set" errors when exit was already called pass - # NOTE: We intentionally do NOT override Ctrl+C here. - # prompt_toolkit's default Ctrl+C handler properly resets the terminal state on Windows. - # Overriding it with event.app.exit(exception=KeyboardInterrupt) can leave the terminal - # in a bad state where characters cannot be typed. Let prompt_toolkit handle Ctrl+C natively. + # Idle Ctrl+C is still left to prompt_toolkit. + # We only intercept it while work is actively running so busy-state cancel stays local + # to the interactive runtime instead of tearing down the terminal session. @bindings.add("i", filter=pending_decision_filter, eager=True) @bindings.add("I", filter=pending_decision_filter, eager=True) def _(event): - text = runtime.take_pending_submission() or event.app.current_buffer.text - event.app.exit(result=PromptSubmission(action="interject", text=text)) + text, allow_command_dispatch = runtime.take_pending_submission_with_policy() + clear_chooser_input(event) + event.app.exit( + result=PromptSubmission( + action="interject", + text=text or "", + allow_command_dispatch=allow_command_dispatch, + ) + ) @bindings.add("q", filter=pending_decision_filter, eager=True) @bindings.add("Q", filter=pending_decision_filter, eager=True) def _(event): - text = runtime.take_pending_submission() or event.app.current_buffer.text - event.app.exit(result=PromptSubmission(action="queue", text=text)) + text, allow_command_dispatch = runtime.take_pending_submission_with_policy() + clear_chooser_input(event) + event.app.exit( + result=PromptSubmission( + action="queue", + text=text or "", + allow_command_dispatch=allow_command_dispatch, + ) + ) # Toggle multiline with Alt+M @bindings.add(Keys.Escape, "m") @@ -937,6 +1043,17 @@ def _(event): @bindings.add("enter", filter=~is_searching, eager=True) def _(event): if awaiting_decision(): + choice = event.app.current_buffer.text.strip() + if _is_exit_text(choice): + runtime.set_pending_submission(None) + clear_chooser_input(event) + event.app.exit( + result=PromptSubmission( + action="submit", + text=choice, + allow_command_dispatch=True, + ) + ) return text = event.app.current_buffer.text if ( @@ -945,7 +1062,11 @@ def _(event): and text.strip() and not _is_exit_text(text) ): - runtime.set_pending_submission(text) + runtime.set_pending_submission( + text, + allow_command_dispatch=_allows_busy_command_dispatch(text), + ) + clear_chooser_input(event) return if multiline["enabled"]: event.app.current_buffer.insert_text("\n") @@ -965,7 +1086,7 @@ def handle_backspace_with_completion(event): buffer.delete_before_cursor(count=1) # Then trigger completion if text starts with '/' text = buffer.text.lstrip() - if text.startswith("/"): + if text.startswith("/") and command_completion_filter(): buffer.start_completion(select_first=False) @bindings.add("delete", eager=True) @@ -977,7 +1098,7 @@ def handle_delete_with_completion(event): buffer.delete(count=1) # Then trigger completion if text starts with '/' text = buffer.text.lstrip() - if text.startswith("/"): + if text.startswith("/") and command_completion_filter(): buffer.start_completion(select_first=False) @bindings.add("c-up", eager=True) @@ -1118,11 +1239,6 @@ def handle_image_paste_f3(event): event.app.current_buffer.insert_text("[āŒ clipboard error] ") event.app.output.bell() - @bindings.add("", filter=pending_decision_filter, eager=True) - def _(event): - # Freeze the submitted text until the user chooses queue or interject. - return - from prompt_toolkit.output.defaults import create_output out = create_output(stdout=sys.stdout) @@ -1158,6 +1274,7 @@ def _(event): action=result.action, text=result.text, echo_in_transcript=erase_when_done, + allow_command_dispatch=result.allow_command_dispatch, ) # NOTE: We used to call update_model_in_input(text) here to handle /model and /m # commands at the prompt level, but that prevented the command handler from running @@ -1167,6 +1284,7 @@ def _(event): action="submit", text=result, echo_in_transcript=erase_when_done, + allow_command_dispatch=True, ) diff --git a/code_puppy/messaging/__init__.py b/code_puppy/messaging/__init__.py index cad1a6171..8f147b7ce 100644 --- a/code_puppy/messaging/__init__.py +++ b/code_puppy/messaging/__init__.py @@ -87,6 +87,7 @@ get_global_queue, provide_prompt_response, ) +from .legacy_bridge import LegacyQueueToBusBridge # Message types and enums from .messages import ( # Enums, Base, Text, File ops, Diff, Shell, Agent, etc. @@ -104,6 +105,7 @@ FileListingMessage, GrepMatch, GrepResultMessage, + LegacyQueueMessage, MessageCategory, MessageLevel, SelectionRequest, @@ -182,6 +184,7 @@ "SynchronousInteractiveRenderer", "QueueConsole", "get_queue_console", + "LegacyQueueToBusBridge", # ------------------------------------------------------------------------- # New Structured Messaging API # ------------------------------------------------------------------------- @@ -193,6 +196,7 @@ "BaseCommand", # Message types "TextMessage", + "LegacyQueueMessage", "FileEntry", "FileListingMessage", "FileContentMessage", diff --git a/code_puppy/messaging/legacy_bridge.py b/code_puppy/messaging/legacy_bridge.py new file mode 100644 index 000000000..4b4fdce0c --- /dev/null +++ b/code_puppy/messaging/legacy_bridge.py @@ -0,0 +1,50 @@ +"""Bridge legacy MessageQueue output into the structured MessageBus.""" + +from __future__ import annotations + +import logging + +from .bus import MessageBus +from .message_queue import MessageQueue, MessageType, UIMessage +from .messages import LegacyQueueMessage + +logger = logging.getLogger(__name__) + + +class LegacyQueueToBusBridge: + """Forward legacy queue messages into the structured bus.""" + + def __init__(self, queue: MessageQueue, bus: MessageBus) -> None: + self._queue = queue + self._bus = bus + self._started = False + + def start(self) -> None: + if self._started: + return + self._started = True + + for message in self._queue.get_buffered_messages(): + self._forward_message(message) + self._queue.clear_startup_buffer() + self._queue.add_listener(self._forward_message) + + def stop(self) -> None: + if not self._started: + return + self._started = False + self._queue.remove_listener(self._forward_message) + + def _forward_message(self, message: UIMessage) -> None: + if message.type == MessageType.HUMAN_INPUT_REQUEST: + logger.debug("Skipping legacy human-input queue message in bridge") + return + + self._bus.emit( + LegacyQueueMessage( + legacy_type=message.type.value, + content=message.content, + legacy_metadata=dict(message.metadata or {}), + legacy_timestamp=message.timestamp, + ) + ) diff --git a/code_puppy/messaging/messages.py b/code_puppy/messaging/messages.py index 02d375c90..e53c61a9e 100644 --- a/code_puppy/messaging/messages.py +++ b/code_puppy/messaging/messages.py @@ -7,7 +7,7 @@ from datetime import datetime, timezone from enum import Enum -from typing import Dict, List, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union from uuid import uuid4 from pydantic import BaseModel, Field @@ -77,6 +77,28 @@ class TextMessage(BaseMessage): text: str = Field(description="Plain text content - NO Rich markup allowed") +class LegacyQueueMessage(BaseMessage): + """Wrapped legacy MessageQueue output for prompt-safe bus rendering.""" + + category: MessageCategory = MessageCategory.SYSTEM + legacy_type: str = Field(description="Original legacy MessageType value") + content: Any = Field(description="Original legacy content object") + legacy_metadata: Dict[str, Any] = Field( + default_factory=dict, + description="Original legacy UIMessage metadata", + ) + legacy_timestamp: Optional[datetime] = Field( + default=None, + description="Original legacy UIMessage timestamp", + ) + + model_config = { + "frozen": False, + "extra": "forbid", + "arbitrary_types_allowed": True, + } + + # ============================================================================= # File Operation Messages # ============================================================================= @@ -498,6 +520,7 @@ class SkillActivateMessage(BaseMessage): # All concrete message types (excludes BaseMessage itself) AnyMessage = Union[ TextMessage, + LegacyQueueMessage, FileListingMessage, FileContentMessage, GrepResultMessage, @@ -537,6 +560,7 @@ class SkillActivateMessage(BaseMessage): "BaseMessage", # Text "TextMessage", + "LegacyQueueMessage", # File operations "FileEntry", "FileListingMessage", diff --git a/code_puppy/messaging/renderers.py b/code_puppy/messaging/renderers.py index b20570079..1675517b6 100644 --- a/code_puppy/messaging/renderers.py +++ b/code_puppy/messaging/renderers.py @@ -17,6 +17,81 @@ from .message_queue import MessageQueue, MessageType, UIMessage +def render_legacy_ui_message( + console: Console, message: UIMessage, *, allow_human_input: bool = True +) -> None: + """Render a legacy UIMessage using the old interactive semantics.""" + if message.type == MessageType.HUMAN_INPUT_REQUEST: + if not allow_human_input: + safe_content = escape_rich_markup(str(message.content)) + console.print(f"[dim]Input requested: {safe_content}[/dim]") + if hasattr(console.file, "flush"): + console.file.flush() + return + + prompt_id = message.metadata.get("prompt_id") if message.metadata else None + if not prompt_id: + console.print("[bold red]Error: Invalid human input request[/bold red]") + return + + safe_content = escape_rich_markup(str(message.content)) + console.print(f"[bold cyan]{safe_content}[/bold cyan]") + if hasattr(console.file, "flush"): + console.file.flush() + + try: + response = input(">>> ") + from .message_queue import provide_prompt_response + + provide_prompt_response(prompt_id, response) + except (EOFError, KeyboardInterrupt): + provide_prompt_response(prompt_id, "") + except Exception as e: + console.print(f"[bold red]Error getting input: {e}[/bold red]") + provide_prompt_response(prompt_id, "") + return + + if message.type == MessageType.ERROR: + style = "bold red" + elif message.type == MessageType.WARNING: + style = "yellow" + elif message.type == MessageType.SUCCESS: + style = "green" + elif message.type == MessageType.TOOL_OUTPUT: + style = "blue" + elif message.type == MessageType.AGENT_REASONING: + style = None + elif message.type == MessageType.PLANNED_NEXT_STEPS: + style = None + elif message.type == MessageType.AGENT_RESPONSE: + style = None + elif message.type == MessageType.SYSTEM: + style = "dim" + else: + style = None + + if isinstance(message.content, str) and ( + "Current version:" in message.content or "Latest version:" in message.content + ): + style = "dim" + + if isinstance(message.content, str): + if message.type == MessageType.AGENT_RESPONSE: + try: + console.print(Markdown(message.content)) + except Exception: + console.print(escape_rich_markup(message.content)) + elif style: + console.print(escape_rich_markup(message.content), style=style) + else: + console.print(escape_rich_markup(message.content)) + else: + console.print(message.content) + + if hasattr(console.file, "flush"): + console.file.flush() + + class MessageRenderer(ABC): """Base class for message renderers.""" @@ -84,66 +159,7 @@ def __init__(self, queue: MessageQueue, console: Optional[Console] = None): async def render_message(self, message: UIMessage): """Render a message using Rich console.""" - # Handle human input requests - if message.type == MessageType.HUMAN_INPUT_REQUEST: - await self._handle_human_input_request(message) - return - - # Convert message type to appropriate Rich styling - if message.type == MessageType.ERROR: - style = "bold red" - elif message.type == MessageType.WARNING: - style = "yellow" - elif message.type == MessageType.SUCCESS: - style = "green" - elif message.type == MessageType.TOOL_OUTPUT: - style = "blue" - elif message.type == MessageType.AGENT_REASONING: - style = None - elif message.type == MessageType.PLANNED_NEXT_STEPS: - style = None - elif message.type == MessageType.AGENT_RESPONSE: - # Special handling for agent responses - they'll be rendered as markdown - style = None - elif message.type == MessageType.SYSTEM: - style = "dim" - else: - style = None - - # Make version messages dim regardless of message type - if isinstance(message.content, str): - if ( - "Current version:" in message.content - or "Latest version:" in message.content - ): - style = "dim" - - # Render the content - if isinstance(message.content, str): - if message.type == MessageType.AGENT_RESPONSE: - # Render agent responses as markdown - try: - markdown = Markdown(message.content) - self.console.print(markdown) - except Exception: - # Fallback to plain text if markdown parsing fails - safe_content = escape_rich_markup(message.content) - self.console.print(safe_content) - elif style: - # Escape Rich markup to prevent crashes from malformed tags - safe_content = escape_rich_markup(message.content) - self.console.print(safe_content, style=style) - else: - safe_content = escape_rich_markup(message.content) - self.console.print(safe_content) - else: - # For complex Rich objects (Tables, Markdown, Text, etc.) - self.console.print(message.content) - - # Ensure output is immediately flushed to the terminal - # This fixes the issue where messages don't appear until user input - if hasattr(self.console.file, "flush"): - self.console.file.flush() + render_legacy_ui_message(self.console, message, allow_human_input=False) async def _handle_human_input_request(self, message: UIMessage): """Handle a human input request in async mode.""" @@ -218,94 +234,4 @@ def _consume_messages(self): def _render_message(self, message: UIMessage): """Render a message using Rich console.""" - # Handle human input requests - if message.type == MessageType.HUMAN_INPUT_REQUEST: - self._handle_human_input_request(message) - return - - # Convert message type to appropriate Rich styling - if message.type == MessageType.ERROR: - style = "bold red" - elif message.type == MessageType.WARNING: - style = "yellow" - elif message.type == MessageType.SUCCESS: - style = "green" - elif message.type == MessageType.TOOL_OUTPUT: - style = "blue" - elif message.type == MessageType.AGENT_REASONING: - style = None - elif message.type == MessageType.AGENT_RESPONSE: - # Special handling for agent responses - they'll be rendered as markdown - style = None - elif message.type == MessageType.SYSTEM: - style = "dim" - else: - style = None - - # Make version messages dim regardless of message type - if isinstance(message.content, str): - if ( - "Current version:" in message.content - or "Latest version:" in message.content - ): - style = "dim" - - # Render the content - if isinstance(message.content, str): - if message.type == MessageType.AGENT_RESPONSE: - # Render agent responses as markdown - try: - markdown = Markdown(message.content) - self.console.print(markdown) - except Exception: - # Fallback to plain text if markdown parsing fails - safe_content = escape_rich_markup(message.content) - self.console.print(safe_content) - elif style: - # Escape Rich markup to prevent crashes from malformed tags - # in shell output or other user-provided content - safe_content = escape_rich_markup(message.content) - self.console.print(safe_content, style=style) - else: - safe_content = escape_rich_markup(message.content) - self.console.print(safe_content) - else: - # For complex Rich objects (Tables, Markdown, Text, etc.) - self.console.print(message.content) - - # Ensure output is immediately flushed to the terminal - # This fixes the issue where messages don't appear until user input - if hasattr(self.console.file, "flush"): - self.console.file.flush() - - def _handle_human_input_request(self, message: UIMessage): - """Handle a human input request in interactive mode.""" - prompt_id = message.metadata.get("prompt_id") if message.metadata else None - if not prompt_id: - self.console.print( - "[bold red]Error: Invalid human input request[/bold red]" - ) - return - - # Display the prompt - escape to prevent markup injection - safe_content = escape_rich_markup(str(message.content)) - self.console.print(f"[bold cyan]{safe_content}[/bold cyan]") - if hasattr(self.console.file, "flush"): - self.console.file.flush() - - # Get user input - try: - # Use basic input for now - could be enhanced with prompt_toolkit later - response = input(">>> ") - - # Provide the response back to the queue - from .message_queue import provide_prompt_response - - provide_prompt_response(prompt_id, response) - - except (EOFError, KeyboardInterrupt): - # Handle Ctrl+C or Ctrl+D - provide_prompt_response(prompt_id, "") - except Exception as e: - self.console.print(f"[bold red]Error getting input: {e}[/bold red]") - provide_prompt_response(prompt_id, "") + render_legacy_ui_message(self.console, message, allow_human_input=True) diff --git a/code_puppy/messaging/rich_renderer.py b/code_puppy/messaging/rich_renderer.py index 6f8f0d44f..43a16e475 100644 --- a/code_puppy/messaging/rich_renderer.py +++ b/code_puppy/messaging/rich_renderer.py @@ -41,6 +41,7 @@ FileContentMessage, FileListingMessage, GrepResultMessage, + LegacyQueueMessage, MessageLevel, SelectionRequest, ShellLineMessage, @@ -57,6 +58,8 @@ UserInputRequest, VersionCheckMessage, ) +from .message_queue import MessageType, UIMessage +from .renderers import render_legacy_ui_message # Note: Text and Tree were removed - no longer used in this implementation @@ -390,6 +393,8 @@ def _do_render_direct(self, message: AnyMessage) -> None: self._render_version_check(message) elif isinstance(message, AgentListMessage): self._render_agent_list(message) + elif isinstance(message, LegacyQueueMessage): + self._render_legacy_queue_message(message) elif isinstance(message, SkillListMessage): self._render_skill_list(message) elif isinstance(message, SkillActivateMessage): @@ -444,6 +449,27 @@ def _render_text(self, msg: TextMessage) -> None: safe_text = escape_rich_markup(msg.text) self._console.print(f"{prefix}{safe_text}", style=style) + def _render_legacy_queue_message(self, msg: LegacyQueueMessage) -> None: + """Render wrapped legacy queue output with old semantics.""" + try: + legacy_type = MessageType(msg.legacy_type) + except ValueError: + legacy_type = MessageType.DEBUG + + legacy_message = UIMessage( + type=legacy_type, + content=msg.content, + metadata=dict(msg.legacy_metadata or {}), + ) + if msg.legacy_timestamp is not None: + legacy_message.timestamp = msg.legacy_timestamp + + render_legacy_ui_message( + self._console, + legacy_message, + allow_human_input=False, + ) + def _get_level_prefix(self, level: MessageLevel) -> str: """Get a prefix icon for the message level.""" prefixes = { diff --git a/code_puppy/plugins/antigravity_oauth/register_callbacks.py b/code_puppy/plugins/antigravity_oauth/register_callbacks.py index c170038f3..39adeff73 100644 --- a/code_puppy/plugins/antigravity_oauth/register_callbacks.py +++ b/code_puppy/plugins/antigravity_oauth/register_callbacks.py @@ -14,10 +14,12 @@ from urllib.parse import parse_qs, urlparse from code_puppy.callbacks import register_callback +from code_puppy.command_line.interactive_command import BackgroundInteractiveCommand from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning from code_puppy.model_switching import set_model_and_reload_agent from ..oauth_puppy_html import oauth_failure_html, oauth_success_html +from ..oauth_control import wait_for_event_or_cancel from .accounts import AccountManager from .config import ( ANTIGRAVITY_OAUTH_CONFIG, @@ -125,7 +127,9 @@ def run_server(server=server) -> None: return None -def _await_callback(context: Any) -> Optional[Tuple[str, str, str]]: +def _await_callback( + context: Any, cancel_event: threading.Event | None = None +) -> Optional[Tuple[str, str, str]]: """Wait for OAuth callback and return (code, state, redirect_uri).""" timeout = ANTIGRAVITY_OAUTH_CONFIG["callback_timeout"] @@ -157,7 +161,17 @@ def _await_callback(context: Any) -> Optional[Tuple[str, str, str]]: emit_info(f"ā³ Waiting for callback on {redirect_uri}") - if not event.wait(timeout=timeout): + wait_result = wait_for_event_or_cancel( + event, + timeout=timeout, + cancel_event=cancel_event, + ) + if wait_result == "cancelled": + emit_info("Antigravity OAuth authentication cancelled.") + server.shutdown() + return None + + if wait_result == "timeout": emit_error("OAuth callback timed out. Please try again.") server.shutdown() return None @@ -174,6 +188,7 @@ def _await_callback(context: Any) -> Optional[Tuple[str, str, str]]: def _perform_authentication( add_account: bool = False, reload_agent: bool = True, + cancel_event: threading.Event | None = None, ) -> bool: """Run the OAuth authentication flow. @@ -182,10 +197,13 @@ def _perform_authentication( reload_agent: Whether to reload the current agent after auth. """ context = prepare_oauth_context() - callback_result = _await_callback(context) + callback_result = _await_callback(context, cancel_event=cancel_event) if not callback_result: return False + if cancel_event is not None and cancel_event.is_set(): + emit_info("Antigravity OAuth authentication cancelled.") + return False code, state, redirect_uri = callback_result @@ -195,6 +213,9 @@ def _perform_authentication( if not isinstance(result, TokenExchangeSuccess): emit_error(f"Token exchange failed: {result.error}") return False + if cancel_event is not None and cancel_event.is_set(): + emit_info("Antigravity OAuth authentication cancelled.") + return False # Save tokens tokens = { @@ -231,6 +252,9 @@ def _perform_authentication( # Add models emit_info("šŸ“¦ Configuring available models…") + if cancel_event is not None and cancel_event.is_set(): + emit_info("Antigravity OAuth authentication cancelled.") + return False if add_models_to_config(result.access_token, result.project_id): model_count = len(ANTIGRAVITY_MODELS) emit_success(f"āœ… {model_count} Antigravity models configured!") @@ -245,6 +269,19 @@ def _perform_authentication( return True +def start_antigravity_oauth_setup( + cancel_event: threading.Event, *, add_account: bool = False +) -> bool: + success = _perform_authentication( + add_account=add_account, + reload_agent=False, + cancel_event=cancel_event, + ) + if success and not cancel_event.is_set() and not add_account: + set_model_and_reload_agent("antigravity-gemini-3-pro-high") + return success + + def _custom_help() -> List[Tuple[str, str]]: """Return help entries for Antigravity commands.""" return [ @@ -379,7 +416,7 @@ def _handle_logout() -> None: emit_success("šŸ‘‹ Antigravity logout complete") -def _handle_custom_command(command: str, name: str) -> Optional[bool]: +def _handle_custom_command(command: str, name: str) -> object | None: """Handle Antigravity custom commands.""" if not name: return None @@ -391,17 +428,18 @@ def _handle_custom_command(command: str, name: str) -> Optional[bool]: emit_warning( "Existing tokens found. This will refresh your authentication." ) - - if _perform_authentication(reload_agent=False): - set_model_and_reload_agent("antigravity-gemini-3-pro-high") - return True + return BackgroundInteractiveCommand(run=start_antigravity_oauth_setup) if name == "antigravity-add": emit_info("āž• Adding another Google account…") manager = AccountManager.load_from_disk() emit_info(f"Current accounts: {manager.account_count}") - _perform_authentication(add_account=True) - return True + return BackgroundInteractiveCommand( + run=lambda cancel_event: start_antigravity_oauth_setup( + cancel_event, + add_account=True, + ) + ) if name == "antigravity-status": _handle_status() diff --git a/code_puppy/plugins/chatgpt_oauth/oauth_flow.py b/code_puppy/plugins/chatgpt_oauth/oauth_flow.py index 0ae360e7e..a3ebba7cd 100644 --- a/code_puppy/plugins/chatgpt_oauth/oauth_flow.py +++ b/code_puppy/plugins/chatgpt_oauth/oauth_flow.py @@ -15,6 +15,7 @@ from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning from ..oauth_puppy_html import oauth_failure_html, oauth_success_html +from ..oauth_control import wait_for_predicate_or_cancel from .config import CHATGPT_OAUTH_CONFIG from .utils import ( add_models_to_extra_config, @@ -248,7 +249,7 @@ def _later() -> None: threading.Thread(target=_later, daemon=True).start() -def run_oauth_flow() -> None: +def run_oauth_flow(cancel_event: threading.Event | None = None) -> bool: existing_tokens = load_stored_tokens() if existing_tokens and existing_tokens.get("access_token"): emit_warning("Existing ChatGPT tokens will be overwritten.") @@ -258,7 +259,7 @@ def run_oauth_flow() -> None: except OSError as exc: emit_error(f"Could not start OAuth server on port {REQUIRED_PORT}: {exc}") emit_info(f"Use `lsof -ti:{REQUIRED_PORT} | xargs kill` to free the port.") - return + return False auth_url = server.auth_url() emit_info(f"Open this URL in your browser: {auth_url}") @@ -284,26 +285,31 @@ def run_oauth_flow() -> None: emit_info("Waiting for authentication callback…") - elapsed = 0.0 - timeout = CHATGPT_OAUTH_CONFIG["callback_timeout"] - interval = 0.25 - while elapsed < timeout: - time.sleep(interval) - elapsed += interval - if server.exit_code == 0: - break + wait_result = wait_for_predicate_or_cancel( + lambda: server.exit_code == 0, + timeout=CHATGPT_OAUTH_CONFIG["callback_timeout"], + cancel_event=cancel_event, + poll_interval=0.25, + ) server.shutdown() server_thread.join(timeout=5) - if server.exit_code != 0: + if wait_result == "cancelled": + emit_info("ChatGPT OAuth authentication cancelled.") + return False + + if wait_result == "timeout" or server.exit_code != 0: emit_error("Authentication failed or timed out.") - return + return False + if cancel_event is not None and cancel_event.is_set(): + emit_info("ChatGPT OAuth authentication cancelled.") + return False tokens = load_stored_tokens() if not tokens: emit_error("Tokens saved during OAuth flow could not be loaded.") - return + return False api_key = tokens.get("api_key") if api_key: @@ -322,7 +328,11 @@ def run_oauth_flow() -> None: models = DEFAULT_CODEX_MODELS if models: + if cancel_event is not None and cancel_event.is_set(): + emit_info("ChatGPT OAuth authentication cancelled.") + return False if add_models_to_extra_config(models): emit_success( "ChatGPT models registered. Use the `chatgpt-` prefix in /model." ) + return True diff --git a/code_puppy/plugins/chatgpt_oauth/register_callbacks.py b/code_puppy/plugins/chatgpt_oauth/register_callbacks.py index 956e520ba..5b062a49f 100644 --- a/code_puppy/plugins/chatgpt_oauth/register_callbacks.py +++ b/code_puppy/plugins/chatgpt_oauth/register_callbacks.py @@ -7,9 +7,11 @@ from __future__ import annotations import os +import threading from typing import Any, Dict, List, Optional, Tuple from code_puppy.callbacks import register_callback +from code_puppy.command_line.interactive_command import BackgroundInteractiveCommand from code_puppy.messaging import emit_info, emit_success, emit_warning from code_puppy.model_switching import set_model_and_reload_agent @@ -79,14 +81,19 @@ def _handle_chatgpt_logout() -> None: emit_success("ChatGPT logout complete") -def _handle_custom_command(command: str, name: str) -> Optional[bool]: +def start_chatgpt_oauth_setup(cancel_event: threading.Event) -> bool: + success = run_oauth_flow(cancel_event=cancel_event) + if success and not cancel_event.is_set(): + set_model_and_reload_agent("chatgpt-gpt-5.3-codex") + return success + + +def _handle_custom_command(command: str, name: str) -> object | None: if not name: return None if name == "chatgpt-auth": - run_oauth_flow() - set_model_and_reload_agent("chatgpt-gpt-5.3-codex") - return True + return BackgroundInteractiveCommand(run=start_chatgpt_oauth_setup) if name == "chatgpt-status": _handle_chatgpt_status() diff --git a/code_puppy/plugins/claude_code_oauth/register_callbacks.py b/code_puppy/plugins/claude_code_oauth/register_callbacks.py index 2a5936c26..ce0a9438e 100644 --- a/code_puppy/plugins/claude_code_oauth/register_callbacks.py +++ b/code_puppy/plugins/claude_code_oauth/register_callbacks.py @@ -15,10 +15,12 @@ from urllib.parse import parse_qs, urlparse from code_puppy.callbacks import register_callback +from code_puppy.command_line.interactive_command import BackgroundInteractiveCommand from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning from code_puppy.model_switching import set_model_and_reload_agent from ..oauth_puppy_html import oauth_failure_html, oauth_success_html +from ..oauth_control import wait_for_event_or_cancel from .config import CLAUDE_CODE_OAUTH_CONFIG, get_token_storage_path from .utils import ( OAuthContext, @@ -112,7 +114,9 @@ def run_server(server=server) -> None: return None -def _await_callback(context: OAuthContext) -> Optional[str]: +def _await_callback( + context: OAuthContext, cancel_event: threading.Event | None = None +) -> Optional[str]: timeout = CLAUDE_CODE_OAUTH_CONFIG["callback_timeout"] started = _start_callback_server(context) @@ -153,7 +157,17 @@ def _await_callback(context: OAuthContext) -> Optional[str]: "and paste it back into Code Puppy." ) - if not event.wait(timeout=timeout): + wait_result = wait_for_event_or_cancel( + event, + timeout=timeout, + cancel_event=cancel_event, + ) + if wait_result == "cancelled": + emit_info("Claude Code OAuth authentication cancelled.") + server.shutdown() + return None + + if wait_result == "timeout": emit_error("OAuth callback timed out. Please try again.") server.shutdown() return None @@ -185,30 +199,36 @@ def _custom_help() -> List[Tuple[str, str]]: ] -def _perform_authentication() -> None: +def _perform_authentication(cancel_event: threading.Event | None = None) -> bool: context = prepare_oauth_context() - code = _await_callback(context) + code = _await_callback(context, cancel_event=cancel_event) if not code: - return + return False + if cancel_event is not None and cancel_event.is_set(): + emit_info("Claude Code OAuth authentication cancelled.") + return False emit_info("Exchanging authorization code for tokens…") tokens = exchange_code_for_tokens(code, context) if not tokens: emit_error("Token exchange failed. Please retry the authentication flow.") - return + return False + if cancel_event is not None and cancel_event.is_set(): + emit_info("Claude Code OAuth authentication cancelled.") + return False if not save_tokens(tokens): emit_error( "Tokens retrieved but failed to save locally. Check file permissions." ) - return + return False emit_success("Claude Code OAuth authentication successful!") access_token = tokens.get("access_token") if not access_token: emit_warning("No access token returned; skipping model discovery.") - return + return True emit_info("Fetching available Claude Code models…") models = fetch_claude_code_models(access_token) @@ -216,16 +236,27 @@ def _perform_authentication() -> None: emit_warning( "Claude Code authentication succeeded but no models were returned." ) - return + return True + if cancel_event is not None and cancel_event.is_set(): + emit_info("Claude Code OAuth authentication cancelled.") + return False emit_info(f"Discovered {len(models)} models: {', '.join(models)}") if add_models_to_extra_config(models): emit_success( "Claude Code models added to your configuration. Use the `claude-code-` prefix!" ) + return True + + +def start_claude_code_oauth_setup(cancel_event: threading.Event) -> bool: + success = _perform_authentication(cancel_event=cancel_event) + if success and not cancel_event.is_set(): + set_model_and_reload_agent("claude-code-claude-opus-4-6") + return success -def _handle_custom_command(command: str, name: str) -> Optional[bool]: +def _handle_custom_command(command: str, name: str) -> object | None: if not name: return None @@ -236,9 +267,7 @@ def _handle_custom_command(command: str, name: str) -> Optional[bool]: emit_warning( "Existing Claude Code tokens found. Continuing will overwrite them." ) - _perform_authentication() - set_model_and_reload_agent("claude-code-claude-opus-4-6") - return True + return BackgroundInteractiveCommand(run=start_claude_code_oauth_setup) if name == "claude-code-status": tokens = load_stored_tokens() diff --git a/code_puppy/plugins/oauth_control.py b/code_puppy/plugins/oauth_control.py new file mode 100644 index 000000000..f6fa1d585 --- /dev/null +++ b/code_puppy/plugins/oauth_control.py @@ -0,0 +1,47 @@ +"""Shared cooperative-cancel helpers for OAuth callback waits.""" + +from __future__ import annotations + +import threading +import time +from typing import Callable, Literal + + +WaitResult = Literal["completed", "timeout", "cancelled"] + + +def wait_for_event_or_cancel( + event: threading.Event, + *, + timeout: float, + cancel_event: threading.Event | None, + poll_interval: float = 0.1, +) -> WaitResult: + """Wait for an event while also honoring cooperative cancellation.""" + deadline = time.monotonic() + timeout + while True: + if cancel_event is not None and cancel_event.is_set(): + return "cancelled" + if event.wait(timeout=min(poll_interval, max(0.0, deadline - time.monotonic()))): + return "completed" + if time.monotonic() >= deadline: + return "timeout" + + +def wait_for_predicate_or_cancel( + predicate: Callable[[], bool], + *, + timeout: float, + cancel_event: threading.Event | None, + poll_interval: float = 0.25, +) -> WaitResult: + """Poll a predicate while also honoring cooperative cancellation.""" + deadline = time.monotonic() + timeout + while True: + if cancel_event is not None and cancel_event.is_set(): + return "cancelled" + if predicate(): + return "completed" + if time.monotonic() >= deadline: + return "timeout" + time.sleep(min(poll_interval, max(0.0, deadline - time.monotonic()))) diff --git a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md new file mode 100644 index 000000000..232d236bc --- /dev/null +++ b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md @@ -0,0 +1,45 @@ +# Interactive Regression Checklist + +Run this before merging changes that touch the interactive runtime, prompt surface, queue/interject flow, slash commands, or OAuth setup. + +## Transcript And Rendering + +- Run `/help` and confirm normal slash-command output appears above the composer. +- Run `/show`, `/cd` with no args, `/tools`, and `/skills list` and confirm `Text`, `Table`, and `Markdown` output still render cleanly above the composer. +- Trigger one invalid command such as a bad `/set` value and confirm warning/error text is visible above the composer. +- Confirm no raw ANSI fragments or prompt corruption appear while command output is printed. + +## OAuth Setup And Escape Paths + +- Start `/antigravity-add` and confirm browser/callback status text appears above the composer. +- While waiting for callback, verify `/exit` exits immediately. +- Rerun the auth flow and verify `Ctrl+C` cancels the auth wait cleanly. +- Rerun the auth flow and verify interject cancels the active auth wait first, then proceeds. +- Rerun the auth flow and verify queue saves cleanly while waiting, then drains after the wait ends. +- After cancel, confirm there is no timeout/failure spam and no half-applied model/config change. +- After queueing or interjecting during the auth wait, confirm there is no traceback even if cancel completes immediately. +- Verify a successful auth path still completes normally when not cancelled. +- Verify `/tutorial` auth handoff still routes into the same cancellable auth path. + +## Busy Slash-Command Gating + +- While an agent run is active, type `/model` and confirm slash-command completion does not open. +- While active work is running, submit `/model` and queue it; confirm it is treated as literal text for the agent, not executed as a command. +- While active work is running, submit `/model` and interject it; confirm it is treated as literal text for the agent, not executed as a command. +- While active work is running, verify `/exit` and `/quit` still bypass queue/interject handling immediately. +- While the inline chooser is visible, confirm slash-command menus do not appear and stray typing does not replace the stored pending prompt. + +## Cancel Behavior + +- During a foreground shell command, press `Ctrl+C` and confirm the shell is interrupted without tearing down the outer session. +- During a background interactive command or sub-agent action, press `Ctrl+C` and confirm the inner work cancels and control returns cleanly. +- During a normal active agent run, press `Ctrl+C` and confirm the run cancels and the prompt becomes usable again. +- If the chooser is visible during active work, press `Ctrl+C` and confirm the active work cancels and the chooser/input state clears. + +## Queue And Prompt Stability + +- Queue a normal follow-up while shell output is still streaming and confirm the save line appears immediately. +- Verify queued and interjected prompts still echo into the transcript before dispatch. +- Verify `[QUEUE TRIGGERED]` still prints before the echoed queued prompt. +- Verify a normal direct prompt after a queue/interject cycle still appears in the transcript once. +- Confirm the composer stays fixed, the shell output remains plain text, and the queue/interject visuals do not regress. diff --git a/tests/command_line/test_core_commands_full_coverage.py b/tests/command_line/test_core_commands_full_coverage.py index 41e115a69..3decc4830 100644 --- a/tests/command_line/test_core_commands_full_coverage.py +++ b/tests/command_line/test_core_commands_full_coverage.py @@ -168,6 +168,12 @@ def test_failure(self): class TestHandleTutorialCommand: def test_chatgpt(self): from code_puppy.command_line.core_commands import handle_tutorial_command + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) + from code_puppy.plugins.chatgpt_oauth.register_callbacks import ( + start_chatgpt_oauth_setup, + ) with ( patch("code_puppy.command_line.onboarding_wizard.reset_onboarding"), @@ -181,10 +187,19 @@ def test_chatgpt(self): pool.return_value.__enter__ = MagicMock(return_value=pool.return_value) pool.return_value.__exit__ = MagicMock(return_value=False) pool.return_value.submit.return_value = mock_future - assert handle_tutorial_command("/tutorial") is True + result = handle_tutorial_command("/tutorial") + + assert isinstance(result, BackgroundInteractiveCommand) + assert result.run is start_chatgpt_oauth_setup def test_claude(self): from code_puppy.command_line.core_commands import handle_tutorial_command + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) + from code_puppy.plugins.claude_code_oauth.register_callbacks import ( + start_claude_code_oauth_setup, + ) with ( patch("code_puppy.command_line.onboarding_wizard.reset_onboarding"), @@ -200,7 +215,10 @@ def test_claude(self): pool.return_value.__enter__ = MagicMock(return_value=pool.return_value) pool.return_value.__exit__ = MagicMock(return_value=False) pool.return_value.submit.return_value = mock_future - assert handle_tutorial_command("/tutorial") is True + result = handle_tutorial_command("/tutorial") + + assert isinstance(result, BackgroundInteractiveCommand) + assert result.run is start_claude_code_oauth_setup def test_completed(self): from code_puppy.command_line.core_commands import handle_tutorial_command diff --git a/tests/command_line/test_tutorial.py b/tests/command_line/test_tutorial.py index 9c3679483..9171f01ae 100644 --- a/tests/command_line/test_tutorial.py +++ b/tests/command_line/test_tutorial.py @@ -7,8 +7,6 @@ import pytest -from code_puppy.command_line.core_commands import handle_tutorial_command - def _mock_tutorial_result(mock_executor_class: Any, result: str) -> None: mock_future = MagicMock() @@ -22,6 +20,12 @@ def _mock_tutorial_result(mock_executor_class: Any, result: str) -> None: def test_tutorial_chatgpt_flow() -> None: """Test tutorial triggers ChatGPT OAuth and model switch.""" + from code_puppy.command_line.interactive_command import BackgroundInteractiveCommand + from code_puppy.command_line.core_commands import handle_tutorial_command + from code_puppy.plugins.chatgpt_oauth.register_callbacks import ( + start_chatgpt_oauth_setup, + ) + with patch("concurrent.futures.ThreadPoolExecutor") as mock_executor_class: _mock_tutorial_result(mock_executor_class, "chatgpt") @@ -37,14 +41,21 @@ def test_tutorial_chatgpt_flow() -> None: with patch("code_puppy.command_line.core_commands.emit_info"): result = handle_tutorial_command("/tutorial") - assert result is True + assert isinstance(result, BackgroundInteractiveCommand) + assert result.run is start_chatgpt_oauth_setup mock_reset.assert_called_once() - mock_oauth.assert_called_once() - mock_set_model.assert_called_once_with("chatgpt-gpt-5.3-codex") + mock_oauth.assert_not_called() + mock_set_model.assert_not_called() def test_tutorial_claude_flow() -> None: """Test tutorial triggers Claude Code OAuth and model switch.""" + from code_puppy.command_line.interactive_command import BackgroundInteractiveCommand + from code_puppy.command_line.core_commands import handle_tutorial_command + from code_puppy.plugins.claude_code_oauth.register_callbacks import ( + start_claude_code_oauth_setup, + ) + with patch("concurrent.futures.ThreadPoolExecutor") as mock_executor_class: _mock_tutorial_result(mock_executor_class, "claude") @@ -60,10 +71,11 @@ def test_tutorial_claude_flow() -> None: with patch("code_puppy.command_line.core_commands.emit_info"): result = handle_tutorial_command("/tutorial") - assert result is True + assert isinstance(result, BackgroundInteractiveCommand) + assert result.run is start_claude_code_oauth_setup mock_reset.assert_called_once() - mock_auth.assert_called_once() - mock_set_model.assert_called_once_with("claude-code-claude-opus-4-6") + mock_auth.assert_not_called() + mock_set_model.assert_not_called() @pytest.mark.parametrize( @@ -75,6 +87,8 @@ def test_tutorial_claude_flow() -> None: ) def test_tutorial_terminal_paths(wizard_result: str, expected_message: str) -> None: """Test tutorial completion and skip paths.""" + from code_puppy.command_line.core_commands import handle_tutorial_command + with patch("concurrent.futures.ThreadPoolExecutor") as mock_executor_class: _mock_tutorial_result(mock_executor_class, wizard_result) diff --git a/tests/messaging/test_legacy_bridge.py b/tests/messaging/test_legacy_bridge.py new file mode 100644 index 000000000..967268eea --- /dev/null +++ b/tests/messaging/test_legacy_bridge.py @@ -0,0 +1,72 @@ +from io import StringIO +from unittest.mock import MagicMock + +from rich.console import Console +from rich.table import Table + +from code_puppy.messaging.bus import MessageBus +from code_puppy.messaging.legacy_bridge import LegacyQueueToBusBridge +from code_puppy.messaging.message_queue import MessageQueue, MessageType, UIMessage +from code_puppy.messaging.messages import LegacyQueueMessage +from code_puppy.messaging.rich_renderer import RichConsoleRenderer + + +def _console(): + return Console(file=StringIO(), force_terminal=False, width=100) + + +def test_legacy_bridge_replays_buffered_messages_into_bus(): + queue = MessageQueue() + bus = MagicMock(spec=MessageBus) + bridge = LegacyQueueToBusBridge(queue, bus) + + queue.emit_simple(MessageType.INFO, "hello") + queue.emit_simple(MessageType.DIVIDER, "---") + + bridge.start() + + emitted = [call.args[0] for call in bus.emit.call_args_list] + assert len(emitted) == 2 + assert all(isinstance(message, LegacyQueueMessage) for message in emitted) + assert emitted[0].legacy_type == "info" + assert emitted[0].content == "hello" + assert emitted[1].legacy_type == "divider" + + +def test_legacy_bridge_skips_human_input_messages(): + queue = MessageQueue() + bus = MagicMock(spec=MessageBus) + bridge = LegacyQueueToBusBridge(queue, bus) + + queue.emit( + UIMessage( + type=MessageType.HUMAN_INPUT_REQUEST, + content="Enter value", + metadata={"prompt_id": "p1"}, + ) + ) + + bridge.start() + + bus.emit.assert_not_called() + + +def test_rich_renderer_renders_wrapped_legacy_renderables(): + console = _console() + renderer = RichConsoleRenderer(MessageBus(), console=console) + + table = Table() + table.add_column("Col") + table.add_row("value") + + renderer._render_sync( + LegacyQueueMessage( + legacy_type="info", + content=table, + legacy_metadata={}, + ) + ) + + output = console.file.getvalue() + assert "Col" in output + assert "value" in output diff --git a/tests/plugins/test_antigravity_callbacks_coverage.py b/tests/plugins/test_antigravity_callbacks_coverage.py index e1fd80276..52caa9677 100644 --- a/tests/plugins/test_antigravity_callbacks_coverage.py +++ b/tests/plugins/test_antigravity_callbacks_coverage.py @@ -656,6 +656,9 @@ def test_unknown(self): assert _handle_custom_command("/x", "unknown") is None def test_auth_success(self): + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) from code_puppy.plugins.antigravity_oauth.register_callbacks import ( _handle_custom_command, ) @@ -675,13 +678,18 @@ def test_auth_success(self): ), patch( "code_puppy.plugins.antigravity_oauth.register_callbacks.set_model_and_reload_agent" - ), + ) as mock_set_model, ): - assert ( - _handle_custom_command("/antigravity-auth", "antigravity-auth") is True - ) + result = _handle_custom_command("/antigravity-auth", "antigravity-auth") + assert isinstance(result, BackgroundInteractiveCommand) + cancel_event = threading.Event() + assert result.run(cancel_event) is True + mock_set_model.assert_called_once_with("antigravity-gemini-3-pro-high") def test_auth_failure(self): + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) from code_puppy.plugins.antigravity_oauth.register_callbacks import ( _handle_custom_command, ) @@ -695,13 +703,22 @@ def test_auth_failure(self): patch( "code_puppy.plugins.antigravity_oauth.register_callbacks._perform_authentication", return_value=False, - ), + ) as mock_auth, ): - assert ( - _handle_custom_command("/antigravity-auth", "antigravity-auth") is True + result = _handle_custom_command("/antigravity-auth", "antigravity-auth") + assert isinstance(result, BackgroundInteractiveCommand) + cancel_event = threading.Event() + assert result.run(cancel_event) is False + mock_auth.assert_called_once_with( + add_account=False, + reload_agent=False, + cancel_event=cancel_event, ) def test_add(self): + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) from code_puppy.plugins.antigravity_oauth.register_callbacks import ( _handle_custom_command, ) @@ -716,9 +733,18 @@ def test_add(self): ), patch( "code_puppy.plugins.antigravity_oauth.register_callbacks._perform_authentication" - ), + ) as mock_auth, ): - assert _handle_custom_command("/antigravity-add", "antigravity-add") is True + mock_auth.return_value = True + result = _handle_custom_command("/antigravity-add", "antigravity-add") + assert isinstance(result, BackgroundInteractiveCommand) + cancel_event = threading.Event() + assert result.run(cancel_event) is True + mock_auth.assert_called_once_with( + add_account=True, + reload_agent=False, + cancel_event=cancel_event, + ) def test_status(self): from code_puppy.plugins.antigravity_oauth.register_callbacks import ( diff --git a/tests/plugins/test_antigravity_register_callbacks.py b/tests/plugins/test_antigravity_register_callbacks.py index 2630c4154..3a69a5351 100644 --- a/tests/plugins/test_antigravity_register_callbacks.py +++ b/tests/plugins/test_antigravity_register_callbacks.py @@ -752,13 +752,23 @@ def test_handle_custom_command_auth( mock_set_model, ): """Test antigravity-auth command.""" + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) + mock_load_tokens.return_value = {} mock_perform_auth.return_value = True result = _handle_custom_command("custom_command", "antigravity-auth") - assert result is True - mock_perform_auth.assert_called_once_with(reload_agent=False) + assert isinstance(result, BackgroundInteractiveCommand) + cancel_event = threading.Event() + assert result.run(cancel_event) is True + mock_perform_auth.assert_called_once_with( + add_account=False, + reload_agent=False, + cancel_event=cancel_event, + ) mock_set_model.assert_called_once_with("antigravity-gemini-3-pro-high") @patch( @@ -771,6 +781,10 @@ def test_handle_custom_command_add( mock_perform_auth, ): """Test antigravity-add command.""" + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) + mock_perform_auth.return_value = True with patch( @@ -782,10 +796,14 @@ def test_handle_custom_command_add( result = _handle_custom_command("custom_command", "antigravity-add") - assert result is True - # Verify add_account=True was passed - call_kwargs = mock_perform_auth.call_args - assert call_kwargs[1].get("add_account") is True + assert isinstance(result, BackgroundInteractiveCommand) + cancel_event = threading.Event() + assert result.run(cancel_event) is True + mock_perform_auth.assert_called_once_with( + add_account=True, + reload_agent=False, + cancel_event=cancel_event, + ) @patch("code_puppy.plugins.antigravity_oauth.register_callbacks._handle_status") def test_handle_custom_command_status(self, mock_handle_status): diff --git a/tests/plugins/test_chatgpt_oauth_coverage.py b/tests/plugins/test_chatgpt_oauth_coverage.py index 5fb95ef3a..3865fd217 100644 --- a/tests/plugins/test_chatgpt_oauth_coverage.py +++ b/tests/plugins/test_chatgpt_oauth_coverage.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import threading from unittest.mock import MagicMock, patch @@ -146,17 +147,28 @@ def test_unknown(self): assert _handle_custom_command("/x", "unknown") is None def test_auth(self): + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) from code_puppy.plugins.chatgpt_oauth.register_callbacks import ( _handle_custom_command, ) with ( - patch("code_puppy.plugins.chatgpt_oauth.register_callbacks.run_oauth_flow"), + patch( + "code_puppy.plugins.chatgpt_oauth.register_callbacks.run_oauth_flow", + return_value=True, + ) as mock_oauth, patch( "code_puppy.plugins.chatgpt_oauth.register_callbacks.set_model_and_reload_agent" - ), + ) as mock_set_model, ): - assert _handle_custom_command("/chatgpt-auth", "chatgpt-auth") is True + result = _handle_custom_command("/chatgpt-auth", "chatgpt-auth") + assert isinstance(result, BackgroundInteractiveCommand) + cancel_event = threading.Event() + assert result.run(cancel_event) is True + mock_oauth.assert_called_once_with(cancel_event=cancel_event) + mock_set_model.assert_called_once_with("chatgpt-gpt-5.3-codex") def test_status(self): from code_puppy.plugins.chatgpt_oauth.register_callbacks import ( diff --git a/tests/plugins/test_chatgpt_oauth_integration.py b/tests/plugins/test_chatgpt_oauth_integration.py index 2f4fe2f18..6132e4490 100644 --- a/tests/plugins/test_chatgpt_oauth_integration.py +++ b/tests/plugins/test_chatgpt_oauth_integration.py @@ -9,6 +9,7 @@ import json import os +import threading from unittest.mock import Mock, patch import requests @@ -282,10 +283,17 @@ def test_handle_chatgpt_logout( ) def test_handle_custom_command_auth(self, mock_set_model, mock_oauth): """Test chatgpt-auth command triggers OAuth flow.""" + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) + + mock_oauth.return_value = True result = _handle_custom_command("custom_command", "chatgpt-auth") - assert result is True - mock_oauth.assert_called_once() + assert isinstance(result, BackgroundInteractiveCommand) + cancel_event = threading.Event() + assert result.run(cancel_event) is True + mock_oauth.assert_called_once_with(cancel_event=cancel_event) mock_set_model.assert_called_once_with("chatgpt-gpt-5.3-codex") @patch("code_puppy.plugins.chatgpt_oauth.register_callbacks.load_stored_tokens") diff --git a/tests/plugins/test_claude_code_oauth_callbacks.py b/tests/plugins/test_claude_code_oauth_callbacks.py index 5e80cbf7a..73cc734ac 100644 --- a/tests/plugins/test_claude_code_oauth_callbacks.py +++ b/tests/plugins/test_claude_code_oauth_callbacks.py @@ -382,12 +382,22 @@ def test_empty_name(self): def test_auth_with_existing_tokens( self, mock_warn, mock_info, mock_set, mock_auth, mock_tokens ): + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) from code_puppy.plugins.claude_code_oauth.register_callbacks import ( _handle_custom_command, ) - assert _handle_custom_command("/claude-code-auth", "claude-code-auth") is True + mock_auth.return_value = True + result = _handle_custom_command("/claude-code-auth", "claude-code-auth") + + assert isinstance(result, BackgroundInteractiveCommand) + cancel_event = threading.Event() + assert result.run(cancel_event) is True mock_warn.assert_called() # warns about overwriting + mock_auth.assert_called_once_with(cancel_event=cancel_event) + mock_set.assert_called_once_with("claude-code-claude-opus-4-6") @patch( f"{MOD}.load_stored_tokens", diff --git a/tests/plugins/test_claude_code_oauth_coverage.py b/tests/plugins/test_claude_code_oauth_coverage.py index 74d5ecd13..4999f2862 100644 --- a/tests/plugins/test_claude_code_oauth_coverage.py +++ b/tests/plugins/test_claude_code_oauth_coverage.py @@ -539,6 +539,9 @@ def test_unknown(self): assert _handle_custom_command("/x", "unknown") is None def test_auth(self): + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) from code_puppy.plugins.claude_code_oauth.register_callbacks import ( _handle_custom_command, ) @@ -554,16 +557,23 @@ def test_auth(self): ), patch( "code_puppy.plugins.claude_code_oauth.register_callbacks._perform_authentication" - ), + ) as mock_auth, patch( "code_puppy.plugins.claude_code_oauth.register_callbacks.set_model_and_reload_agent" - ), + ) as mock_set_model, ): - assert ( - _handle_custom_command("/claude-code-auth", "claude-code-auth") is True - ) + mock_auth.return_value = True + result = _handle_custom_command("/claude-code-auth", "claude-code-auth") + assert isinstance(result, BackgroundInteractiveCommand) + cancel_event = threading.Event() + assert result.run(cancel_event) is True + mock_auth.assert_called_once_with(cancel_event=cancel_event) + mock_set_model.assert_called_once_with("claude-code-claude-opus-4-6") def test_auth_no_existing_tokens(self): + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) from code_puppy.plugins.claude_code_oauth.register_callbacks import ( _handle_custom_command, ) @@ -576,14 +586,18 @@ def test_auth_no_existing_tokens(self): ), patch( "code_puppy.plugins.claude_code_oauth.register_callbacks._perform_authentication" - ), + ) as mock_auth, patch( "code_puppy.plugins.claude_code_oauth.register_callbacks.set_model_and_reload_agent" - ), + ) as mock_set_model, ): - assert ( - _handle_custom_command("/claude-code-auth", "claude-code-auth") is True - ) + mock_auth.return_value = True + result = _handle_custom_command("/claude-code-auth", "claude-code-auth") + assert isinstance(result, BackgroundInteractiveCommand) + cancel_event = threading.Event() + assert result.run(cancel_event) is True + mock_auth.assert_called_once_with(cancel_event=cancel_event) + mock_set_model.assert_called_once_with("claude-code-claude-opus-4-6") def test_status_authenticated(self): from code_puppy.plugins.claude_code_oauth.register_callbacks import ( diff --git a/tests/test_command_overhaul_targeted.py b/tests/test_command_overhaul_targeted.py new file mode 100644 index 000000000..04a9c94c9 --- /dev/null +++ b/tests/test_command_overhaul_targeted.py @@ -0,0 +1,346 @@ +import asyncio +import importlib +import threading +from contextlib import ExitStack +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest + +from code_puppy.command_line.interactive_command import BackgroundInteractiveCommand +from code_puppy.command_line.prompt_toolkit_completion import PromptSubmission + + +def _renderer(): + renderer = MagicMock() + renderer.console = MagicMock() + renderer.console.file = MagicMock() + renderer.console.file.flush = MagicMock() + return renderer + + +def _submission(text: str, *, action: str = "submit", allow_command_dispatch: bool = True): + return PromptSubmission( + action=action, + text=text, + allow_command_dispatch=allow_command_dispatch, + ) + + +async def _run_interactive(prompt_side_effect, *, run_prompt_side_effect, handle_command): + agent = MagicMock() + agent.get_user_prompt.return_value = "task:" + fake_agents_pkg = ModuleType("code_puppy.agents") + fake_agent_manager = ModuleType("code_puppy.agents.agent_manager") + fake_agent_manager.get_current_agent = MagicMock(return_value=agent) + fake_agents_pkg.agent_manager = fake_agent_manager + fake_agents_pkg.get_current_agent = fake_agent_manager.get_current_agent + + with ExitStack() as stack: + stack.enter_context( + patch.dict( + "sys.modules", + { + "code_puppy.agents": fake_agents_pkg, + "code_puppy.agents.agent_manager": fake_agent_manager, + "code_puppy.command_line.command_handler": MagicMock( + handle_command=handle_command + ), + }, + ) + ) + cli_runner_module = importlib.import_module("code_puppy.cli_runner") + stack.enter_context( + patch( + "code_puppy.command_line.prompt_toolkit_completion.prompt_for_submission", + side_effect=prompt_side_effect, + ) + ) + stack.enter_context( + patch( + "code_puppy.command_line.prompt_toolkit_completion.get_prompt_with_active_model", + return_value="> ", + ) + ) + stack.enter_context(patch.object(cli_runner_module, "print_truecolor_warning")) + stack.enter_context( + patch.object( + cli_runner_module, + "get_cancel_agent_display_name", + return_value="Ctrl+C", + ) + ) + stack.enter_context( + patch.object(cli_runner_module, "reset_windows_terminal_ansi") + ) + stack.enter_context( + patch.object(cli_runner_module, "reset_windows_terminal_full") + ) + stack.enter_context(patch.object(cli_runner_module, "save_command_to_history")) + stack.enter_context( + patch("code_puppy.command_line.motd.print_motd") + ) + stack.enter_context( + patch( + "code_puppy.command_line.onboarding_wizard.should_show_onboarding", + return_value=False, + ) + ) + stack.enter_context( + patch.object( + cli_runner_module, + "run_prompt_with_attachments", + side_effect=run_prompt_side_effect, + ) + ) + await cli_runner_module.interactive_mode(_renderer()) + + +@pytest.mark.anyio +async def test_busy_slash_text_queues_as_literal_prompt(): + release_first = asyncio.Event() + queued_started = asyncio.Event() + started_prompts: list[str] = [] + handle_command = MagicMock(return_value=True) + + async def prompt_side_effect(*_args, **_kwargs): + prompt_side_effect.calls += 1 + if prompt_side_effect.calls == 1: + return _submission("first task") + if prompt_side_effect.calls == 2: + release_first.set() + return _submission( + "/model", + action="queue", + allow_command_dispatch=False, + ) + await queued_started.wait() + return _submission("/exit") + + prompt_side_effect.calls = 0 + + async def run_prompt_side_effect(_agent, prompt, **_kwargs): + started_prompts.append(prompt) + if prompt == "first task": + await release_first.wait() + if prompt == "/model": + queued_started.set() + result = MagicMock() + result.output = f"response for {prompt}" + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + prompt_side_effect, + run_prompt_side_effect=run_prompt_side_effect, + handle_command=handle_command, + ) + + assert started_prompts[:2] == ["first task", "/model"] + handle_command.assert_not_called() + + +def test_chatgpt_auth_returns_background_interactive_command(): + from code_puppy.plugins.chatgpt_oauth.register_callbacks import ( + _handle_custom_command, + start_chatgpt_oauth_setup, + ) + + result = _handle_custom_command("/chatgpt-auth", "chatgpt-auth") + + assert isinstance(result, BackgroundInteractiveCommand) + assert result.run is start_chatgpt_oauth_setup + + +def test_chatgpt_auth_switches_model_only_on_success(): + from code_puppy.plugins.chatgpt_oauth.register_callbacks import ( + start_chatgpt_oauth_setup, + ) + + cancel_event = threading.Event() + + with ( + patch( + "code_puppy.plugins.chatgpt_oauth.register_callbacks.run_oauth_flow", + return_value=True, + ) as mock_flow, + patch( + "code_puppy.plugins.chatgpt_oauth.register_callbacks.set_model_and_reload_agent" + ) as mock_set_model, + ): + assert start_chatgpt_oauth_setup(cancel_event) is True + + mock_flow.assert_called_once_with(cancel_event=cancel_event) + mock_set_model.assert_called_once_with("chatgpt-gpt-5.3-codex") + + +def test_chatgpt_auth_cancel_does_not_switch_model(): + from code_puppy.plugins.chatgpt_oauth.register_callbacks import ( + start_chatgpt_oauth_setup, + ) + + cancel_event = threading.Event() + cancel_event.set() + + with ( + patch( + "code_puppy.plugins.chatgpt_oauth.register_callbacks.run_oauth_flow", + return_value=False, + ), + patch( + "code_puppy.plugins.chatgpt_oauth.register_callbacks.set_model_and_reload_agent" + ) as mock_set_model, + ): + assert start_chatgpt_oauth_setup(cancel_event) is False + + mock_set_model.assert_not_called() + + +def test_claude_auth_returns_background_interactive_command(): + from code_puppy.plugins.claude_code_oauth.register_callbacks import ( + _handle_custom_command, + start_claude_code_oauth_setup, + ) + + result = _handle_custom_command("/claude-code-auth", "claude-code-auth") + + assert isinstance(result, BackgroundInteractiveCommand) + assert result.run is start_claude_code_oauth_setup + + +def test_antigravity_add_returns_background_interactive_command(): + from code_puppy.plugins.antigravity_oauth.register_callbacks import ( + _handle_custom_command, + ) + + with patch( + "code_puppy.plugins.antigravity_oauth.register_callbacks.AccountManager.load_from_disk", + return_value=MagicMock(account_count=1), + ): + result = _handle_custom_command("/antigravity-add", "antigravity-add") + + assert isinstance(result, BackgroundInteractiveCommand) + + +@pytest.mark.anyio +async def test_interject_during_background_command_cancels_cleanly(): + wait_started = asyncio.Event() + interject_started = asyncio.Event() + loop = asyncio.get_running_loop() + started_prompts: list[str] = [] + + def auth_wait(cancel_event: threading.Event) -> None: + loop.call_soon_threadsafe(wait_started.set) + cancel_event.wait(timeout=5) + + handle_command = MagicMock( + side_effect=lambda command: ( + BackgroundInteractiveCommand(run=auth_wait) + if command == "/claude-code-auth" + else True + ) + ) + + async def prompt_side_effect(*_args, **_kwargs): + prompt_side_effect.calls += 1 + if prompt_side_effect.calls == 1: + return _submission("/claude-code-auth") + if prompt_side_effect.calls == 2: + await wait_started.wait() + return _submission("please continue", action="interject") + await interject_started.wait() + return _submission("/exit") + + prompt_side_effect.calls = 0 + + async def run_prompt_side_effect(_agent, prompt, **_kwargs): + started_prompts.append(prompt) + if prompt.startswith("user interjects - please continue"): + interject_started.set() + result = MagicMock() + result.output = f"response for {prompt}" + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + prompt_side_effect, + run_prompt_side_effect=run_prompt_side_effect, + handle_command=handle_command, + ) + + assert len(started_prompts) == 1 + assert started_prompts[0].startswith("user interjects - please continue - ") + assert "continue the interrupted task" in started_prompts[0] + handle_command.assert_called_once_with("/claude-code-auth") + + +@pytest.mark.anyio +async def test_queue_during_background_command_drains_after_wait_completes(): + wait_started = asyncio.Event() + queued_started = asyncio.Event() + release_wait = threading.Event() + loop = asyncio.get_running_loop() + started_prompts: list[str] = [] + + def auth_wait(cancel_event: threading.Event) -> None: + loop.call_soon_threadsafe(wait_started.set) + while not cancel_event.is_set(): + if release_wait.wait(timeout=0.05): + return + + handle_command = MagicMock( + side_effect=lambda command: ( + BackgroundInteractiveCommand(run=auth_wait) + if command == "/claude-code-auth" + else True + ) + ) + + async def prompt_side_effect(*_args, **_kwargs): + prompt_side_effect.calls += 1 + if prompt_side_effect.calls == 1: + return _submission("/claude-code-auth") + if prompt_side_effect.calls == 2: + await wait_started.wait() + release_wait.set() + return _submission("report later", action="queue") + await queued_started.wait() + return _submission("/exit") + + prompt_side_effect.calls = 0 + + async def run_prompt_side_effect(_agent, prompt, **_kwargs): + started_prompts.append(prompt) + if prompt == "report later": + queued_started.set() + result = MagicMock() + result.output = f"response for {prompt}" + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + prompt_side_effect, + run_prompt_side_effect=run_prompt_side_effect, + handle_command=handle_command, + ) + + assert started_prompts == ["report later"] + handle_command.assert_called_once_with("/claude-code-auth") + + +@pytest.mark.anyio +async def test_mark_idle_if_task_is_idempotent_for_finished_background_work(): + from code_puppy.command_line.interactive_runtime import PromptRuntimeState + + runtime = PromptRuntimeState() + + async def noop() -> None: + return + + task = asyncio.create_task(noop()) + runtime.mark_running(task, kind="interactive_command") + await task + + assert runtime.mark_idle_if_task(task) is True + assert runtime.mark_idle_if_task(task) is False + assert runtime.bg_task is None From 671238a8bb89e04db675133836ca3184a316ccec Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Tue, 10 Mar 2026 18:39:32 -0500 Subject: [PATCH 29/40] Restore interactive autosave triggers --- IMPLEMENTATION_GUARDRAILS.md | 2 + code_puppy/cli_runner.py | 3 + docs/INTERACTIVE_REGRESSION_CHECKLIST.md | 8 ++ tests/test_cli_runner_full_coverage.py | 95 +++++++++++++++++++++++- tests/test_command_overhaul_targeted.py | 43 +++++++++++ 5 files changed, 147 insertions(+), 4 deletions(-) diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md index eb573c8f5..c3576ef38 100644 --- a/IMPLEMENTATION_GUARDRAILS.md +++ b/IMPLEMENTATION_GUARDRAILS.md @@ -18,6 +18,8 @@ Read this before making changes in this repo. - Direct prompts submitted from erasable prompt sessions must echo once into the transcript before any response or tool output. - Normal direct follow-up prompts after queue/interject cycles must remain visible in the transcript. - Interject injection text must tell the agent to continue the interrupted task after acknowledging the interjection. +- Auto-save must fire after each successfully completed interactive agent response, including queued and interjected turns, before the next queued turn launches. +- Cancelled turns, slash commands, and OAuth/background interactive commands must not trigger auto-save. - Queue preview must stay above the composer, max 3 visible rows, scroll when over 3, and cap at 25 prompts. - Queued prompt preview rows must stay one line, truncated, and never wrap. - Keep the composer at a fixed two-line viewport with internal scrolling for long prompts. diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 8b319e67d..af770381d 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -1056,6 +1056,9 @@ async def run_agent_bg( display_console.file.flush() await asyncio.sleep(0.1) + from code_puppy.config import auto_save_session_if_enabled + + auto_save_session_if_enabled() if source_item: emit_interject_queue_lifecycle( runtime, diff --git a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md index 232d236bc..044e0740b 100644 --- a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md +++ b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md @@ -43,3 +43,11 @@ Run this before merging changes that touch the interactive runtime, prompt surfa - Verify `[QUEUE TRIGGERED]` still prints before the echoed queued prompt. - Verify a normal direct prompt after a queue/interject cycle still appears in the transcript once. - Confirm the composer stays fixed, the shell output remains plain text, and the queue/interject visuals do not regress. + +## Autosave + +- Submit a normal prompt and confirm `Auto-saved session` appears after the completed response. +- Queue multiple prompts and confirm each completed response auto-saves before the next queued turn starts. +- Interject during a run and confirm the completed interjected response auto-saves. +- Cancel a run and confirm no autosave fires for that cancelled turn. +- Run an OAuth/background interactive command and confirm it does not auto-save on its own. diff --git a/tests/test_cli_runner_full_coverage.py b/tests/test_cli_runner_full_coverage.py index b7bc270a9..27e34dde4 100644 --- a/tests/test_cli_runner_full_coverage.py +++ b/tests/test_cli_runner_full_coverage.py @@ -1004,6 +1004,77 @@ async def fake_input(*a, **kw): }, ) + @pytest.mark.anyio + async def test_successful_interactive_response_autosaves(self): + call_count = 0 + patches = _interactive_patches() + autosave_mock = patches["code_puppy.config.auto_save_session_if_enabled"] + autosave_done = asyncio.Event() + autosave_mock.side_effect = lambda: autosave_done.set() + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _submission("write hello") + await autosave_done.wait() + return _submission("/exit") + + async def fake_run(*args, **kwargs): + result = MagicMock(output="done: write hello") + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + patches, + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + return_value=_mock_parse_result("write hello") + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + }, + ) + + autosave_mock.assert_called_once_with() + + @pytest.mark.anyio + async def test_cancelled_interactive_response_does_not_autosave(self): + call_count = 0 + patches = _interactive_patches() + autosave_mock = patches["code_puppy.config.auto_save_session_if_enabled"] + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _submission("write hello") + return _submission("/exit") + + async def fake_run(*args, **kwargs): + return None, MagicMock() + + await _run_interactive( + _mock_renderer(), + patches, + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + return_value=_mock_parse_result("write hello") + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + }, + ) + + autosave_mock.assert_not_called() + class TestInteractiveQueueHandoff: """Test unified queue, interject, and idle-drain behavior.""" @@ -1014,6 +1085,12 @@ async def test_queued_prompt_runs_after_current_task_finishes(self): second_prompt_seen = asyncio.Event() queued_prompt_started = asyncio.Event() started_prompts = [] + patches = _interactive_patches() + autosave_mock = patches["code_puppy.config.auto_save_session_if_enabled"] + all_autosaves_done = asyncio.Event() + autosave_mock.side_effect = lambda: ( + all_autosaves_done.set() if autosave_mock.call_count >= 2 else None + ) render_notice = MagicMock() render_prompt_echo = MagicMock( side_effect=lambda text: render_order.append(("echo", text)) @@ -1028,7 +1105,7 @@ async def fake_input(*a, **kw): if call_count == 2: second_prompt_seen.set() return _submission("queued task", action="queue") - await queued_prompt_started.wait() + await all_autosaves_done.wait() return _submission("/exit") async def fake_run(*args, **kwargs): @@ -1039,6 +1116,7 @@ async def fake_run(*args, **kwargs): await second_prompt_seen.wait() await asyncio.sleep(0.05) if prompt == "queued task": + assert autosave_mock.call_count == 1 queued_prompt_started.set() result = MagicMock(output=f"done: {prompt}") result.all_messages.return_value = [] @@ -1046,7 +1124,7 @@ async def fake_run(*args, **kwargs): await _run_interactive( _mock_renderer(), - _interactive_patches(), + patches, fake_input, extra_patches={ "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, @@ -1071,6 +1149,7 @@ async def fake_run(*args, **kwargs): queued_echo_idx = render_order.index(("echo", "queued task")) queued_start_idx = render_order.index(("start", "queued task")) assert queued_notice_idx < queued_echo_idx < queued_start_idx + assert autosave_mock.call_count == 2 @pytest.mark.anyio async def test_hidden_direct_submission_echoes_before_agent_starts(self): @@ -1366,6 +1445,12 @@ async def test_interject_runs_before_queued_prompts(self): first_task_started = asyncio.Event() queued_prompt_started = asyncio.Event() started_prompts = [] + patches = _interactive_patches() + autosave_mock = patches["code_puppy.config.auto_save_session_if_enabled"] + all_autosaves_done = asyncio.Event() + autosave_mock.side_effect = lambda: ( + all_autosaves_done.set() if autosave_mock.call_count >= 2 else None + ) render_notice = MagicMock() render_order = [] render_prompt_echo = MagicMock( @@ -1382,7 +1467,7 @@ async def fake_input(*a, **kw): return _submission("second queued", action="queue") if call_count == 3: return _submission("steer now", action="interject") - await queued_prompt_started.wait() + await all_autosaves_done.wait() return _submission("/exit") async def fake_run(*args, **kwargs): @@ -1393,6 +1478,7 @@ async def fake_run(*args, **kwargs): first_task_started.set() await asyncio.sleep(10) if prompt == "second queued": + assert autosave_mock.call_count == 1 queued_prompt_started.set() result = MagicMock(output=f"done: {prompt}") result.all_messages.return_value = [] @@ -1400,7 +1486,7 @@ async def fake_run(*args, **kwargs): await _run_interactive( _mock_renderer(), - _interactive_patches(), + patches, fake_input, extra_patches={ "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, @@ -1436,6 +1522,7 @@ async def fake_run(*args, **kwargs): call.args[0] == "[QUEUE TRIGGERED] steer now" for call in render_notice.call_args_list ) + assert autosave_mock.call_count == 2 @pytest.mark.anyio async def test_exit_while_running_cancels_runtime_task(self): diff --git a/tests/test_command_overhaul_targeted.py b/tests/test_command_overhaul_targeted.py index 04a9c94c9..9b0af68af 100644 --- a/tests/test_command_overhaul_targeted.py +++ b/tests/test_command_overhaul_targeted.py @@ -328,6 +328,49 @@ async def run_prompt_side_effect(_agent, prompt, **_kwargs): handle_command.assert_called_once_with("/claude-code-auth") +@pytest.mark.anyio +async def test_background_command_wait_does_not_autosave(): + wait_started = asyncio.Event() + cancel_seen = threading.Event() + loop = asyncio.get_running_loop() + handle_command = MagicMock() + + def auth_wait(cancel_event: threading.Event) -> None: + loop.call_soon_threadsafe(wait_started.set) + cancel_event.wait(timeout=5) + if cancel_event.is_set(): + cancel_seen.set() + + handle_command.side_effect = lambda command: ( + BackgroundInteractiveCommand(run=auth_wait) + if command == "/claude-code-auth" + else True + ) + + async def prompt_side_effect(*_args, **_kwargs): + prompt_side_effect.calls += 1 + if prompt_side_effect.calls == 1: + return _submission("/claude-code-auth") + await wait_started.wait() + return _submission("/exit") + + prompt_side_effect.calls = 0 + + async def run_prompt_side_effect(_agent, prompt, **_kwargs): + raise AssertionError(f"unexpected agent run for {prompt}") + + with patch("code_puppy.config.auto_save_session_if_enabled") as mock_autosave: + await _run_interactive( + prompt_side_effect, + run_prompt_side_effect=run_prompt_side_effect, + handle_command=handle_command, + ) + + assert cancel_seen.is_set() + mock_autosave.assert_not_called() + handle_command.assert_called_once_with("/claude-code-auth") + + @pytest.mark.anyio async def test_mark_idle_if_task_is_idempotent_for_finished_background_work(): from code_puppy.command_line.interactive_runtime import PromptRuntimeState From 9c2c92f4a6354aefa8055e8a41886099037b1c4b Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Wed, 11 Mar 2026 02:43:24 -0500 Subject: [PATCH 30/40] Serialize above-prompt rendering --- IMPLEMENTATION_GUARDRAILS.md | 7 + .../command_line/interactive_runtime.py | 42 +++++- .../command_line/prompt_toolkit_completion.py | 56 +++++--- docs/INTERACTIVE_REGRESSION_CHECKLIST.md | 8 ++ tests/test_cli_runner_full_coverage.py | 131 +++++++++++++++++- tests/test_prompt_toolkit_completion.py | 106 +++++++++++++- 6 files changed, 316 insertions(+), 34 deletions(-) diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md index c3576ef38..1976c5885 100644 --- a/IMPLEMENTATION_GUARDRAILS.md +++ b/IMPLEMENTATION_GUARDRAILS.md @@ -30,6 +30,7 @@ Read this before making changes in this repo. - Final `AGENT RESPONSE` output must render as a proper banner above the prompt. - Structured banners like `AGENT REASONING`, listings, and tool blocks must use the prompt-safe render path. - Shell output with the prompt mounted must stay plain text. Do not reintroduce Rich dim styling or raw ANSI fragments there. +- Above-prompt Rich blocks and transcript notices must share one serialized render path and must never interleave at the byte level. - Do not reintroduce the shell warning line in the prompt header if typing is still enabled. - Legacy slash-command output emitted through `emit_info`, `emit_warning`, `emit_error`, `emit_success`, and divider output must remain visible in interactive mode. - Rich slash-command renderables like `Text`, `Table`, and `Markdown` must still render above the mounted composer. @@ -62,6 +63,12 @@ Read this before making changes in this repo. - The chooser state must not show slash-command menus or execute slash commands. - `Ctrl+C` from the composer must remain the universal busy-state cancel path: shell interrupt, background command cancel, or agent cancel as appropriate. +## Wiggum Rules + +- When Wiggum mode is active, queued work must drain before the next Wiggum rerun starts. +- After queued work completes, Wiggum should resume its stored loop prompt if Wiggum mode is still active. +- Busy slash-prefixed text queued during Wiggum must remain literal agent text, not execute as a slash command. + ## Config And Runtime Notes - Use `./.cp-local/run-code-puppy-local.sh` when you need isolated local setup/auth for this repo. diff --git a/code_puppy/command_line/interactive_runtime.py b/code_puppy/command_line/interactive_runtime.py index f8cd30543..544d38c09 100644 --- a/code_puppy/command_line/interactive_runtime.py +++ b/code_puppy/command_line/interactive_runtime.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextvars import time from dataclasses import dataclass, field from datetime import datetime, timezone @@ -11,6 +12,10 @@ MAX_PROMPT_QUEUE = 25 PROMPT_STATUS_FRAME_INTERVAL = 0.09 PROMPT_STATUS_BACKOFF_WINDOW = 0.045 +_ABOVE_PROMPT_RENDER_ACTIVE: contextvars.ContextVar[bool] = contextvars.ContextVar( + "above_prompt_render_active", + default=False, +) @dataclass @@ -46,6 +51,12 @@ class PromptRuntimeState: prompt_session: object | None = None prompt_status_started_at: float | None = None prompt_status_task: asyncio.Task | None = None + above_prompt_lock: asyncio.Lock | None = field(default=None, init=False, repr=False) + above_prompt_lock_loop: asyncio.AbstractEventLoop | None = field( + default=None, + init=False, + repr=False, + ) last_prompt_invalidation_at: float = 0.0 last_spinner_invalidation_at: float = 0.0 active_run_kind: Literal["agent", "interactive_command"] | None = None @@ -211,6 +222,9 @@ def clear_prompt_surface(self, session: object | None = None) -> None: def has_prompt_surface(self) -> bool: return self.prompt_session is not None + def is_rendering_above_prompt(self) -> bool: + return _ABOVE_PROMPT_RENDER_ACTIVE.get() + def get_prompt_status_frame(self) -> str: from code_puppy.messaging.spinner.spinner_base import SpinnerBase @@ -256,6 +270,26 @@ def _invalidate_prompt(self, *, low_priority: bool) -> None: def _should_refresh_prompt_status(self) -> bool: return self.running and self.has_prompt_surface() + def _get_above_prompt_lock( + self, loop: asyncio.AbstractEventLoop + ) -> asyncio.Lock: + if self.above_prompt_lock is None or self.above_prompt_lock_loop is not loop: + self.above_prompt_lock = asyncio.Lock() + self.above_prompt_lock_loop = loop + return self.above_prompt_lock + + async def _run_above_prompt_serialized(self, func: Callable[[], None]) -> None: + from prompt_toolkit.application import run_in_terminal + + loop = asyncio.get_running_loop() + lock = self._get_above_prompt_lock(loop) + async with lock: + token = _ABOVE_PROMPT_RENDER_ACTIVE.set(True) + try: + await run_in_terminal(func) + finally: + _ABOVE_PROMPT_RENDER_ACTIVE.reset(token) + def _ensure_prompt_status_task(self) -> None: if not self._should_refresh_prompt_status(): return @@ -302,10 +336,8 @@ def run_above_prompt(self, func: Callable[[], None], *, timeout: float = 5.0) -> if current_loop is loop: return False - from prompt_toolkit.application import run_in_terminal - async def _runner() -> None: - await run_in_terminal(func) + await self._run_above_prompt_serialized(func) future = asyncio.run_coroutine_threadsafe(_runner(), loop) try: @@ -327,10 +359,8 @@ async def run_above_prompt_async(self, func: Callable[[], None]) -> bool: except RuntimeError: return False - from prompt_toolkit.application import run_in_terminal - async def _runner() -> None: - await run_in_terminal(func) + await self._run_above_prompt_serialized(func) try: if current_loop is loop: diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index abac9813b..11d9400ca 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -763,12 +763,6 @@ def render_submitted_prompt_echo(text: str) -> None: if not echo_text: return - from prompt_toolkit.output.defaults import create_output - - out = create_output(stdout=sys.__stdout__) - if hasattr(out, "enable_cpr"): - out.enable_cpr = False - parts = _build_prompt_parts( is_interject=False, include_queue_preview=False, @@ -778,17 +772,28 @@ def render_submitted_prompt_echo(text: str) -> None: parts.append(("", "\n")) formatted = FormattedText(parts) style = _build_prompt_style() - runtime = _get_runtime() - app = getattr(getattr(runtime, "prompt_session", None), "app", None) - if app is not None: + + def _print_echo() -> None: + from prompt_toolkit.output.defaults import create_output + + out = create_output(stdout=sys.__stdout__) + if hasattr(out, "enable_cpr"): + out.enable_cpr = False + print_formatted_text(formatted, style=style, output=out) + + if ( + runtime is not None + and runtime.has_prompt_surface() + and not runtime.is_rendering_above_prompt() + ): try: - app.print_text(formatted, style=style) - return + if runtime.run_above_prompt(_print_echo): + return except Exception: pass - print_formatted_text(formatted, style=style, output=out) + _print_echo() def render_transcript_notice(text: str) -> None: @@ -797,24 +802,29 @@ def render_transcript_notice(text: str) -> None: if not notice_text: return - from prompt_toolkit.output.defaults import create_output + formatted = FormattedText([("", notice_text), ("", "\n")]) + runtime = _get_runtime() - out = create_output(stdout=sys.__stdout__) - if hasattr(out, "enable_cpr"): - out.enable_cpr = False + def _print_notice() -> None: + from prompt_toolkit.output.defaults import create_output - formatted = FormattedText([("", notice_text), ("", "\n")]) + out = create_output(stdout=sys.__stdout__) + if hasattr(out, "enable_cpr"): + out.enable_cpr = False + print_formatted_text(formatted, output=out) - runtime = _get_runtime() - app = getattr(getattr(runtime, "prompt_session", None), "app", None) - if app is not None: + if ( + runtime is not None + and runtime.has_prompt_surface() + and not runtime.is_rendering_above_prompt() + ): try: - app.print_text(formatted) - return + if runtime.run_above_prompt(_print_notice): + return except Exception: pass - print_formatted_text(formatted, output=out) + _print_notice() async def prompt_for_submission( diff --git a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md index 044e0740b..f8e56de97 100644 --- a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md +++ b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md @@ -8,6 +8,8 @@ Run this before merging changes that touch the interactive runtime, prompt surfa - Run `/show`, `/cd` with no args, `/tools`, and `/skills list` and confirm `Text`, `Table`, and `Markdown` output still render cleanly above the composer. - Trigger one invalid command such as a bad `/set` value and confirm warning/error text is visible above the composer. - Confirm no raw ANSI fragments or prompt corruption appear while command output is printed. +- Interject during repeated directory listings or other multi-line tool output and confirm listings/notice text do not interleave or leak raw `?[32m`-style fragments. +- Repeat the directory-listing/interject check on a second machine or terminal when possible, since the bug is timing-sensitive. ## OAuth Setup And Escape Paths @@ -44,6 +46,12 @@ Run this before merging changes that touch the interactive runtime, prompt surfa - Verify a normal direct prompt after a queue/interject cycle still appears in the transcript once. - Confirm the composer stays fixed, the shell output remains plain text, and the queue/interject visuals do not regress. +## Wiggum + +- Start `/wiggum hello`, queue a normal follow-up, and confirm the queued turn runs before the next Wiggum rerun. +- After that queued turn completes, confirm Wiggum resumes its stored loop prompt. +- While Wiggum is active, queue a slash-prefixed prompt such as `/agent` and confirm it is treated as literal agent text, not executed as a slash command. + ## Autosave - Submit a normal prompt and confirm `Auto-saved session` appears after the completed response. diff --git a/tests/test_cli_runner_full_coverage.py b/tests/test_cli_runner_full_coverage.py index 27e34dde4..fee4a1c3f 100644 --- a/tests/test_cli_runner_full_coverage.py +++ b/tests/test_cli_runner_full_coverage.py @@ -48,12 +48,16 @@ def _mock_clipboard(images=None): def _submission( - text: str, action: str = "submit", echo_in_transcript: bool = False + text: str, + action: str = "submit", + echo_in_transcript: bool = False, + allow_command_dispatch: bool = True, ) -> PromptSubmission: return PromptSubmission( action=action, text=text, echo_in_transcript=echo_in_transcript, + allow_command_dispatch=allow_command_dispatch, ) @@ -1851,6 +1855,131 @@ def fake_wiggum(): }, ) + @pytest.mark.anyio + async def test_wiggum_queued_prompt_runs_before_reloop_and_resumes(self): + call_count = 0 + queued_submitted = asyncio.Event() + started_prompts = [] + patches = _interactive_patches() + autosave_mock = patches["code_puppy.config.auto_save_session_if_enabled"] + all_autosaves_done = asyncio.Event() + autosave_mock.side_effect = lambda: ( + all_autosaves_done.set() if autosave_mock.call_count >= 3 else None + ) + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _submission("write hello") + if call_count == 2: + queued_submitted.set() + return _submission("queued task", action="queue") + await all_autosaves_done.wait() + return _submission("/exit") + + def fake_wiggum_active(): + return len(started_prompts) >= 1 and len(started_prompts) < 3 + + async def fake_run(*a, **kw): + prompt = a[1] + started_prompts.append(prompt) + if prompt == "write hello": + await queued_submitted.wait() + await asyncio.sleep(0.05) + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + patches, + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + side_effect=fake_wiggum_active + ), + "code_puppy.command_line.wiggum_state.get_wiggum_prompt": MagicMock( + return_value="repeat" + ), + "code_puppy.command_line.wiggum_state.increment_wiggum_count": MagicMock( + return_value=1 + ), + "code_puppy.command_line.wiggum_state.stop_wiggum": MagicMock(), + }, + ) + + assert started_prompts[:3] == ["write hello", "queued task", "repeat"] + assert autosave_mock.call_count == 3 + + @pytest.mark.anyio + async def test_wiggum_queued_busy_slash_text_stays_literal_before_reloop(self): + call_count = 0 + queued_submitted = asyncio.Event() + repeat_finished = asyncio.Event() + started_prompts = [] + handle_command = MagicMock(return_value=True) + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _submission("write hello") + if call_count == 2: + queued_submitted.set() + return _submission( + "/agent", + action="queue", + allow_command_dispatch=False, + ) + await repeat_finished.wait() + return _submission("/exit") + + def fake_wiggum_active(): + return len(started_prompts) >= 1 and len(started_prompts) < 3 + + async def fake_run(*a, **kw): + prompt = a[1] + started_prompts.append(prompt) + if prompt == "write hello": + await queued_submitted.wait() + await asyncio.sleep(0.05) + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + if prompt == "repeat": + repeat_finished.set() + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.command_line.command_handler.handle_command": handle_command, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + side_effect=fake_wiggum_active + ), + "code_puppy.command_line.wiggum_state.get_wiggum_prompt": MagicMock( + return_value="repeat" + ), + "code_puppy.command_line.wiggum_state.increment_wiggum_count": MagicMock( + return_value=1 + ), + "code_puppy.command_line.wiggum_state.stop_wiggum": MagicMock(), + }, + ) + + assert started_prompts[:3] == ["write hello", "/agent", "repeat"] + handle_command.assert_not_called() + @pytest.mark.anyio async def test_wiggum_loop_cancelled(self): call_count = 0 diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index 25ab01399..6324a5066 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -2,6 +2,7 @@ import contextlib import os import sys +import threading from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -273,11 +274,13 @@ def test_render_submitted_prompt_echo_uses_prompt_app_when_available( session = MagicMock() session.app = MagicMock() active_runtime.register_prompt_surface(session) + active_runtime.run_above_prompt = MagicMock(return_value=True) render_submitted_prompt_echo("queued task") - session.app.print_text.assert_called_once() - mock_create_output.assert_called_once() + active_runtime.run_above_prompt.assert_called_once() + session.app.print_text.assert_not_called() + mock_create_output.assert_not_called() mock_print_formatted_text.assert_not_called() @@ -303,11 +306,13 @@ def test_render_transcript_notice_uses_prompt_app_when_available( session = MagicMock() session.app = MagicMock() active_runtime.register_prompt_surface(session) + active_runtime.run_above_prompt = MagicMock(return_value=True) render_transcript_notice("[QUEUE TRIGGERED] queued task") - session.app.print_text.assert_called_once() - mock_create_output.assert_called_once() + active_runtime.run_above_prompt.assert_called_once() + session.app.print_text.assert_not_called() + mock_create_output.assert_not_called() mock_print_formatted_text.assert_not_called() @@ -759,6 +764,99 @@ def test_spinner_invalidation_yields_to_recent_prompt_redraw(monkeypatch, active session.app.invalidate.assert_called_once() +@pytest.mark.asyncio +async def test_run_above_prompt_async_serializes_callbacks(active_runtime, monkeypatch): + session = MagicMock() + session.app = MagicMock() + session.app.loop = asyncio.get_running_loop() + active_runtime.register_prompt_surface(session) + + active_count = 0 + max_active = 0 + seen: list[str] = [] + + async def fake_run_in_terminal(func): + nonlocal active_count, max_active + active_count += 1 + max_active = max(max_active, active_count) + await asyncio.sleep(0.01) + func() + await asyncio.sleep(0.01) + active_count -= 1 + + monkeypatch.setattr( + "prompt_toolkit.application.run_in_terminal", + fake_run_in_terminal, + ) + + first = asyncio.create_task( + active_runtime.run_above_prompt_async(lambda: seen.append("first")) + ) + await asyncio.sleep(0) + second = asyncio.create_task( + active_runtime.run_above_prompt_async(lambda: seen.append("second")) + ) + + assert await first is True + assert await second is True + assert seen == ["first", "second"] + assert max_active == 1 + + +@pytest.mark.asyncio +async def test_run_above_prompt_sync_and_async_share_serialization( + active_runtime, monkeypatch +): + session = MagicMock() + session.app = MagicMock() + session.app.loop = asyncio.get_running_loop() + active_runtime.register_prompt_surface(session) + + active_count = 0 + max_active = 0 + seen: list[str] = [] + sync_result: dict[str, bool] = {} + + async def fake_run_in_terminal(func): + nonlocal active_count, max_active + active_count += 1 + max_active = max(max_active, active_count) + await asyncio.sleep(0.01) + func() + await asyncio.sleep(0.01) + active_count -= 1 + + monkeypatch.setattr( + "prompt_toolkit.application.run_in_terminal", + fake_run_in_terminal, + ) + + async_task = asyncio.create_task( + active_runtime.run_above_prompt_async(lambda: seen.append("async")) + ) + await asyncio.sleep(0.005) + + def call_sync() -> None: + sync_result["ok"] = active_runtime.run_above_prompt( + lambda: seen.append("sync"), + timeout=1.0, + ) + + thread = threading.Thread(target=call_sync) + thread.start() + + assert await async_task is True + for _ in range(50): + if "ok" in sync_result: + break + await asyncio.sleep(0.01) + thread.join() + + assert sync_result == {"ok": True} + assert seen == ["async", "sync"] + assert max_active == 1 + + def test_get_prompt_with_active_model_omits_shell_status(monkeypatch, active_runtime): clear_active_prompt_surface() session = MagicMock() From 6c1b17950f7edd2b6c74dd7f1b303c46d9b78220 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Wed, 11 Mar 2026 12:20:10 -0500 Subject: [PATCH 31/40] Harden parity for busy attachments --- IMPLEMENTATION_GUARDRAILS.md | 9 +++ .../command_line/prompt_toolkit_completion.py | 12 ++- docs/INTERACTIVE_REGRESSION_CHECKLIST.md | 13 ++++ tests/test_command_overhaul_targeted.py | 24 ++++++ tests/test_prompt_toolkit_completion.py | 75 +++++++++++++++++++ 5 files changed, 132 insertions(+), 1 deletion(-) diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md index 1976c5885..c5ec0a368 100644 --- a/IMPLEMENTATION_GUARDRAILS.md +++ b/IMPLEMENTATION_GUARDRAILS.md @@ -50,6 +50,8 @@ Read this before making changes in this repo. - Spinner redraws must stay low-priority and yield behind real prompt/state redraws. - Token/context updates should invalidate promptly on their own and should not wait on spinner timing. - Seed the token/context line at run start so it does not show stale data from the previous run. +- Keep seeded token/context estimation parity-safe: active-agent token estimates, context length, and attachment/link overhead must still feed the top-level prompt status line. +- Keep sub-agent token accumulation in the sub-agent console path; do not collapse it into the top-level prompt status line. - Keep the prompt-native spinner; do not bring back the old Rich live spinner for interactive runs. ## Command And OAuth Rules @@ -61,7 +63,14 @@ Read this before making changes in this repo. - While work is active, only `/exit` and `/quit` keep slash-command semantics. - Busy slash-prefixed text other than `/exit` and `/quit` must remain literal user text if queued or interjected. - The chooser state must not show slash-command menus or execute slash commands. +- Keep `@` as attachment/path completion, not as a picker-style command menu. +- Bare `@` should continue to offer current-directory completion candidates. +- `@` completion must keep prompt_toolkit-style semantics: `Tab` only cycles/advances completions, prompt_toolkit accept-completion keys remain available, and `Enter` keeps submit semantics. +- Typing a space after an `@` path is normal text continuation, not a special acceptance action. +- Busy `@` attachment completion is allowed while the always-on composer is open, but the chooser state must stay modal and must not show `@` completions or attachment placeholder transforms. +- Chooser typing must not mutate the stored pending submission. - `Ctrl+C` from the composer must remain the universal busy-state cancel path: shell interrupt, background command cancel, or agent cancel as appropriate. +- Hook commands and hook-engine behavior must remain functional in the mounted-composer fork; preserve their legacy command output path rather than rewriting them. ## Wiggum Rules diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 11d9400ca..eea225a1b 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -292,6 +292,10 @@ class AttachmentPlaceholderProcessor(Processor): _MAX_TEXT_LENGTH_FOR_REALTIME = 500 def apply_transformation(self, transformation_input): + runtime = _get_runtime() + if runtime is not None and runtime.has_pending_submission(): + return Transformation(list(transformation_input.fragments)) + document = transformation_input.document text = document.text if not text: @@ -846,10 +850,16 @@ async def prompt_for_submission( lambda: runtime is None or not (runtime.running or runtime.has_pending_submission()) ) + attachment_completion_filter = Condition( + lambda: runtime is None or not runtime.has_pending_submission() + ) completer = merge_completers( [ - FilePathCompleter(symbol="@"), + ConditionalCompleter( + FilePathCompleter(symbol="@"), + filter=attachment_completion_filter, + ), ConditionalCompleter( ModelNameCompleter(trigger="/model"), filter=command_completion_filter, diff --git a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md index f8e56de97..e62c7f12d 100644 --- a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md +++ b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md @@ -30,6 +30,12 @@ Run this before merging changes that touch the interactive runtime, prompt surfa - While active work is running, submit `/model` and interject it; confirm it is treated as literal text for the agent, not executed as a command. - While active work is running, verify `/exit` and `/quit` still bypass queue/interject handling immediately. - While the inline chooser is visible, confirm slash-command menus do not appear and stray typing does not replace the stored pending prompt. +- In the idle composer, type bare `@` and confirm current-directory file/path completions appear. +- Confirm `@` completion behaves like prompt_toolkit completion, not a picker: `Tab` should only cycle/advance candidates, the prompt_toolkit accept-completion keys should still work, and `Enter` should still submit the buffer. +- After an `@` path is in the buffer, confirm typing space just continues the prompt normally; it is not a special acceptance key. +- While active work is running and the chooser is not visible yet, type `@` and confirm file/document completion still works for composing a future prompt. +- Once the chooser is visible, confirm `@` completion does not appear and attachment placeholder rendering does not activate for chooser typing. +- Queue or interject a prompt that already includes `@...` before the chooser opens, then confirm attachments still resolve when that stored prompt later runs. ## Cancel Behavior @@ -46,6 +52,13 @@ Run this before merging changes that touch the interactive runtime, prompt surfa - Verify a normal direct prompt after a queue/interject cycle still appears in the transcript once. - Confirm the composer stays fixed, the shell output remains plain text, and the queue/interject visuals do not regress. +## Tokens And Hooks + +- Run a normal prompt with a link or document attachment and confirm the top-level token/context line still seeds sensibly before the response starts. +- Invoke a sub-agent and confirm token counting still updates in the sub-agent console path rather than corrupting the top-level prompt line. +- Run `/hooks`, `/hook list`, and `/hooks status` and confirm they still execute and render above the composer in the mounted-composer fork. +- Re-run the hook-engine regression slice if hook-related code or command output wiring changes. + ## Wiggum - Start `/wiggum hello`, queue a normal follow-up, and confirm the queued turn runs before the next Wiggum rerun. diff --git a/tests/test_command_overhaul_targeted.py b/tests/test_command_overhaul_targeted.py index 9b0af68af..27e60359a 100644 --- a/tests/test_command_overhaul_targeted.py +++ b/tests/test_command_overhaul_targeted.py @@ -140,6 +140,30 @@ async def run_prompt_side_effect(_agent, prompt, **_kwargs): handle_command.assert_not_called() +@pytest.mark.anyio +async def test_hooks_list_dispatches_as_idle_command(): + handle_command = MagicMock(return_value=True) + + async def prompt_side_effect(*_args, **_kwargs): + prompt_side_effect.calls += 1 + if prompt_side_effect.calls == 1: + return _submission("/hooks list") + return _submission("/exit") + + prompt_side_effect.calls = 0 + + async def run_prompt_side_effect(_agent, prompt, **_kwargs): + raise AssertionError(f"unexpected agent run for {prompt}") + + await _run_interactive( + prompt_side_effect, + run_prompt_side_effect=run_prompt_side_effect, + handle_command=handle_command, + ) + + handle_command.assert_called_once_with("/hooks list") + + def test_chatgpt_auth_returns_background_interactive_command(): from code_puppy.plugins.chatgpt_oauth.register_callbacks import ( _handle_custom_command, diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index 6324a5066..5a13dcea2 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -8,6 +8,7 @@ import pytest from prompt_toolkit.buffer import Buffer +from prompt_toolkit.completion import ConditionalCompleter from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.keys import Keys @@ -131,6 +132,21 @@ def test_completion_with_hidden_file(tmp_path): os.chdir(cwd) +def test_completion_bare_at_lists_current_directory(tmp_path): + setup_files(tmp_path) + completer = FilePathCompleter(symbol="@") + cwd = os.getcwd() + os.chdir(tmp_path) + try: + doc = Document(text="@", cursor_position=1) + completions = list(completer.get_completions(doc, None)) + values = {c.text for c in completions} + assert any("file3.txt" in value for value in values) + assert any("dir" in value for value in values) + finally: + os.chdir(cwd) + + def test_completion_handles_permissionerror(monkeypatch): # Patch os.listdir to explode! completer = FilePathCompleter(symbol="@") @@ -660,6 +676,34 @@ async def test_prompt_for_submission_sets_echo_flag_when_erasing_prompt( ) +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.patch_stdout") +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +@patch("code_puppy.command_line.prompt_toolkit_completion.merge_completers") +async def test_prompt_for_submission_allows_at_completion_while_busy_but_blocks_it_in_chooser( + mock_merge_completers, + mock_prompt_session_cls, + mock_patch_stdout, + active_runtime, +): + active_runtime.running = True + mock_session_instance = MagicMock() + mock_session_instance.prompt_async = AsyncMock(return_value="test input") + mock_prompt_session_cls.return_value = mock_session_instance + mock_merge_completers.return_value = MagicMock() + mock_patch_stdout.return_value.__enter__ = MagicMock() + mock_patch_stdout.return_value.__exit__ = MagicMock(return_value=False) + + await prompt_for_submission() + + attachment_completer = mock_merge_completers.call_args.args[0][0] + assert isinstance(attachment_completer, ConditionalCompleter) + assert attachment_completer.filter() is True + + active_runtime.set_pending_submission("queued task") + assert attachment_completer.filter() is False + + # To test key bindings, we need to inspect the KeyBindings object passed to PromptSession # We can get it from the mock_prompt_session_cls.call_args @@ -1079,3 +1123,34 @@ async def test_attachment_placeholder_processor_renders_images(tmp_path: Path) - assert "[png image]" in rendered_text assert "fluffy pupper" not in rendered_text + + +def test_attachment_placeholder_processor_skips_replacement_while_chooser_visible( + tmp_path: Path, active_runtime +) -> None: + image_path = tmp_path / "chooser.png" + image_path.write_bytes(b"png") + active_runtime.set_pending_submission("queued task") + + processor = AttachmentPlaceholderProcessor() + document_text = f"describe {image_path} now" + document = Document(text=document_text, cursor_position=len(document_text)) + + fragments = [("", document_text)] + buffer = Buffer(document=document) + control = BufferControl(buffer=buffer) + transformation_input = TransformationInput( + buffer_control=control, + document=document, + lineno=0, + source_to_display=lambda i: i, + fragments=fragments, + width=len(document_text), + height=1, + ) + + transformed = processor.apply_transformation(transformation_input) + rendered_text = "".join(text for _style, text in transformed.fragments) + + assert str(image_path) in rendered_text + assert "[png image]" not in rendered_text From 2bd1c82a069391d55d9e40d04df983add6c7ece5 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Wed, 11 Mar 2026 18:30:01 -0500 Subject: [PATCH 32/40] Add chooser edit and escape paths --- IMPLEMENTATION_GUARDRAILS.md | 4 + .../command_line/prompt_toolkit_completion.py | 32 +++- docs/INTERACTIVE_REGRESSION_CHECKLIST.md | 4 + tests/test_prompt_toolkit_completion.py | 137 ++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md index c5ec0a368..b2cd04702 100644 --- a/IMPLEMENTATION_GUARDRAILS.md +++ b/IMPLEMENTATION_GUARDRAILS.md @@ -63,6 +63,10 @@ Read this before making changes in this repo. - While work is active, only `/exit` and `/quit` keep slash-command semantics. - Busy slash-prefixed text other than `/exit` and `/quit` must remain literal user text if queued or interjected. - The chooser state must not show slash-command menus or execute slash commands. +- While the chooser is visible, `e` must restore the saved drafted prompt back into the composer. +- `Up Arrow` may remain as a compatibility alias for restore, but chooser copy should advertise `e`. +- While the chooser is visible, `Esc` must drop the saved drafted prompt and leave the composer empty. +- Keep chooser hint copy concise; do not turn the inline chooser row into a sentence-length help block. - Keep `@` as attachment/path completion, not as a picker-style command menu. - Bare `@` should continue to offer current-directory completion candidates. - `@` completion must keep prompt_toolkit-style semantics: `Tab` only cycles/advances completions, prompt_toolkit accept-completion keys remain available, and `Enter` keeps submit semantics. diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index eea225a1b..a706602b8 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -20,6 +20,7 @@ ConditionalCompleter, merge_completers, ) +from prompt_toolkit.document import Document from prompt_toolkit.filters import Condition, is_searching from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.history import FileHistory @@ -751,7 +752,12 @@ def _build_prompt_parts( is_interject or (runtime is not None and runtime.has_pending_submission()) ): # Add hint above the prompt line to keep the cursor position consistent - parts.append(("class:queue-item", " [i]nterject or [q]ueue\n")) + parts.append( + ( + "class:queue-item", + " [i]nterject [q]ueue [e]dit [esc]ape\n", + ) + ) parts.extend([ ("class:separator", "╰─"), @@ -923,6 +929,22 @@ def clear_chooser_input(event) -> None: except Exception: pass + def restore_pending_submission_to_buffer(event) -> None: + if runtime is None: + return + + text = runtime.take_pending_submission() or "" + try: + event.app.current_buffer.document = Document( + text=text, + cursor_position=len(text), + ) + except Exception: + try: + event.app.current_buffer.text = text + except Exception: + pass + # Ctrl+X keybinding - exit with KeyboardInterrupt for input cancellation @bindings.add(Keys.ControlX) def _(event): @@ -979,6 +1001,7 @@ def _(event): def _(event): if awaiting_decision(): runtime.set_pending_submission(None) + clear_chooser_input(event) return if runtime is not None and runtime.has_active_shell(): return @@ -1018,6 +1041,13 @@ def _(event): ) ) + @bindings.add("e", filter=pending_decision_filter, eager=True) + @bindings.add("E", filter=pending_decision_filter, eager=True) + @bindings.add("up", filter=pending_decision_filter, eager=True) + def _(event): + clear_chooser_input(event) + restore_pending_submission_to_buffer(event) + # Toggle multiline with Alt+M @bindings.add(Keys.Escape, "m") def _(event): diff --git a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md index e62c7f12d..c8c1e025e 100644 --- a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md +++ b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md @@ -30,6 +30,10 @@ Run this before merging changes that touch the interactive runtime, prompt surfa - While active work is running, submit `/model` and interject it; confirm it is treated as literal text for the agent, not executed as a command. - While active work is running, verify `/exit` and `/quit` still bypass queue/interject handling immediately. - While the inline chooser is visible, confirm slash-command menus do not appear and stray typing does not replace the stored pending prompt. +- While the inline chooser is visible, press `e` and confirm the saved draft returns to the composer exactly as written. +- If `Up Arrow` is still supported as an alias, confirm it also restores the saved draft. +- While the inline chooser is visible, press `Esc` and confirm the saved draft is dropped and the composer is empty. +- Confirm the chooser hint stays compact and readable while still exposing `i`, `q`, `e`, and `Esc`. - In the idle composer, type bare `@` and confirm current-directory file/path completions appear. - Confirm `@` completion behaves like prompt_toolkit completion, not a picker: `Tab` should only cycle/advance candidates, the prompt_toolkit accept-completion keys should still work, and `Enter` should still submit the buffer. - After an `@` path is in the buffer, confirm typing space just continues the prompt normally; it is not a special acceptance key. diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index 5a13dcea2..70e9ad9e2 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -762,6 +762,106 @@ async def test_get_input_key_binding_escape(mock_prompt_session_cls): mock_event.app.exit.assert_called_once_with(exception=KeyboardInterrupt) +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +async def test_get_input_key_binding_escape_drops_pending_submission( + mock_prompt_session_cls, active_runtime +): + mock_session_instance = MagicMock() + mock_session_instance.prompt_async = AsyncMock(return_value="test") + mock_prompt_session_cls.return_value = mock_session_instance + + await get_input_with_combined_completion() + + bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] + escape_binding = next( + binding_obj + for binding_obj in bindings.bindings + if binding_obj.keys == (Keys.Escape,) + ) + + active_runtime.set_pending_submission("queued task") + + buffer = Buffer(document=Document(text="stray chooser text", cursor_position=18)) + mock_event = MagicMock() + mock_event.app = MagicMock() + mock_event.app.current_buffer = buffer + + escape_binding.handler(mock_event) + + assert active_runtime.has_pending_submission() is False + assert buffer.text == "" + mock_event.app.exit.assert_not_called() + + +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +async def test_get_input_key_binding_up_restores_pending_submission( + mock_prompt_session_cls, active_runtime +): + mock_session_instance = MagicMock() + mock_session_instance.prompt_async = AsyncMock(return_value="test") + mock_prompt_session_cls.return_value = mock_session_instance + + await get_input_with_combined_completion() + + bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] + up_binding = next( + binding_obj for binding_obj in bindings.bindings if binding_obj.keys == (Keys.Up,) + ) + + assert up_binding.filter() is False + + active_runtime.set_pending_submission("queued task") + assert up_binding.filter() is True + + buffer = Buffer(document=Document(text="stray chooser text", cursor_position=18)) + mock_event = MagicMock() + mock_event.app = MagicMock() + mock_event.app.current_buffer = buffer + + up_binding.handler(mock_event) + + assert active_runtime.has_pending_submission() is False + assert buffer.text == "queued task" + assert buffer.cursor_position == len("queued task") + mock_event.app.exit.assert_not_called() + + +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +async def test_get_input_key_binding_edit_restores_pending_submission( + mock_prompt_session_cls, active_runtime +): + mock_session_instance = MagicMock() + mock_session_instance.prompt_async = AsyncMock(return_value="test") + mock_prompt_session_cls.return_value = mock_session_instance + + await get_input_with_combined_completion() + + bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] + edit_binding = next( + binding_obj for binding_obj in bindings.bindings if binding_obj.keys == ("e",) + ) + + assert edit_binding.filter() is False + + active_runtime.set_pending_submission("queued task") + assert edit_binding.filter() is True + + buffer = Buffer(document=Document(text="stray chooser text", cursor_position=18)) + mock_event = MagicMock() + mock_event.app = MagicMock() + mock_event.app.current_buffer = buffer + + edit_binding.handler(mock_event) + + assert active_runtime.has_pending_submission() is False + assert buffer.text == "queued task" + assert buffer.cursor_position == len("queued task") + mock_event.app.exit.assert_not_called() + + def test_prompt_runtime_registry_round_trip(active_runtime): session = MagicMock() session.app = MagicMock() @@ -985,6 +1085,43 @@ def test_get_prompt_with_active_model_shows_thinking_status(monkeypatch, active_ assert "Tokens: 1,650/272,000 (0.6% used)" in rendered assert rendered.index("Buddy is thinking...") < rendered.index("─" * 80) clear_active_prompt_surface() + + +def test_get_prompt_with_active_model_shows_pending_hint_copy(monkeypatch, active_runtime): + clear_active_prompt_surface() + session = MagicMock() + session.app = MagicMock() + register_active_prompt_surface("main", session) + active_runtime.set_pending_submission("queued task") + + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.get_puppy_name", + lambda: "Buddy", + ) + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.get_active_model", + lambda: "gpt-test", + ) + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.os.getcwd", + lambda: "/tmp/demo", + ) + + agent = MagicMock() + agent.display_name = "code-puppy" + agent.get_model_name.return_value = "gpt-test" + + with ( + patch( + "code_puppy.agents.agent_manager.get_current_agent", + return_value=agent, + ), + patch("shutil.get_terminal_size", return_value=os.terminal_size((80, 24))), + ): + rendered = "".join(text for _style, text in get_prompt_with_active_model()) + + assert "[i]nterject [q]ueue [e]dit [esc]ape" in rendered + clear_active_prompt_surface() @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") async def test_get_input_registers_active_prompt_surface( From 9827fc3ea52eaa76f23572c18a57ce4cbda62198 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Fri, 13 Mar 2026 16:57:17 -0500 Subject: [PATCH 33/40] Make chooser input modal --- IMPLEMENTATION_GUARDRAILS.md | 1 + .../command_line/prompt_toolkit_completion.py | 3 +++ docs/INTERACTIVE_REGRESSION_CHECKLIST.md | 1 + tests/test_prompt_toolkit_completion.py | 22 +++++++++++++++++++ 4 files changed, 27 insertions(+) diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md index b2cd04702..fda560c5d 100644 --- a/IMPLEMENTATION_GUARDRAILS.md +++ b/IMPLEMENTATION_GUARDRAILS.md @@ -66,6 +66,7 @@ Read this before making changes in this repo. - While the chooser is visible, `e` must restore the saved drafted prompt back into the composer. - `Up Arrow` may remain as a compatibility alias for restore, but chooser copy should advertise `e`. - While the chooser is visible, `Esc` must drop the saved drafted prompt and leave the composer empty. +- While the chooser is visible, non-chooser typing must be inert; the chooser buffer should be effectively read-only except for the explicit chooser keys and immediate cancel/exit paths. - Keep chooser hint copy concise; do not turn the inline chooser row into a sentence-length help block. - Keep `@` as attachment/path completion, not as a picker-style command menu. - Bare `@` should continue to offer current-directory completion candidates. diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index a706602b8..f25764922 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -1303,6 +1303,9 @@ def handle_image_paste_f3(event): output=out, erase_when_done=erase_when_done, ) + # Keep the chooser truly modal: while a pending submission exists, only the + # explicit chooser bindings should work and the buffer should reject edits. + session.default_buffer.read_only = pending_decision_filter # If they pass a string, backward-compat: convert it to formatted_text if isinstance(prompt_str, str): from prompt_toolkit.formatted_text import FormattedText diff --git a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md index c8c1e025e..ea237cab9 100644 --- a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md +++ b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md @@ -30,6 +30,7 @@ Run this before merging changes that touch the interactive runtime, prompt surfa - While active work is running, submit `/model` and interject it; confirm it is treated as literal text for the agent, not executed as a command. - While active work is running, verify `/exit` and `/quit` still bypass queue/interject handling immediately. - While the inline chooser is visible, confirm slash-command menus do not appear and stray typing does not replace the stored pending prompt. +- While the inline chooser is visible, mash a few normal typing keys and confirm the buffer stays inert instead of accepting text. - While the inline chooser is visible, press `e` and confirm the saved draft returns to the composer exactly as written. - If `Up Arrow` is still supported as an alias, confirm it also restores the saved draft. - While the inline chooser is visible, press `Esc` and confirm the saved draft is dropped and the composer is empty. diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index 70e9ad9e2..4a1c492e0 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -862,6 +862,28 @@ async def test_get_input_key_binding_edit_restores_pending_submission( mock_event.app.exit.assert_not_called() +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +async def test_get_input_chooser_makes_buffer_read_only( + mock_prompt_session_cls, active_runtime +): + mock_session_instance = MagicMock() + mock_session_instance.prompt_async = AsyncMock(return_value="test") + mock_session_instance.default_buffer = MagicMock() + mock_prompt_session_cls.return_value = mock_session_instance + + await get_input_with_combined_completion() + + read_only_filter = mock_session_instance.default_buffer.read_only + assert read_only_filter() is False + + active_runtime.set_pending_submission("queued task") + assert read_only_filter() is True + + active_runtime.set_pending_submission(None) + assert read_only_filter() is False + + def test_prompt_runtime_registry_round_trip(active_runtime): session = MagicMock() session.app = MagicMock() From be6ab3e07b0aa4c494724040333adf8416ae4d37 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Mon, 16 Mar 2026 13:23:10 -0500 Subject: [PATCH 34/40] Refine shell, wiggum, and paused queue behavior --- IMPLEMENTATION_GUARDRAILS.md | 12 +- code_puppy/cli_runner.py | 123 +++++++-- .../command_line/interactive_runtime.py | 20 ++ .../command_line/prompt_toolkit_completion.py | 44 +++- docs/INTERACTIVE_REGRESSION_CHECKLIST.md | 12 +- tests/test_cli_runner_full_coverage.py | 113 ++++++-- tests/test_command_overhaul_targeted.py | 242 ++++++++++++++++++ tests/test_prompt_toolkit_completion.py | 153 +++++++++++ 8 files changed, 680 insertions(+), 39 deletions(-) diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md index fda560c5d..be63d0460 100644 --- a/IMPLEMENTATION_GUARDRAILS.md +++ b/IMPLEMENTATION_GUARDRAILS.md @@ -75,13 +75,19 @@ Read this before making changes in this repo. - Busy `@` attachment completion is allowed while the always-on composer is open, but the chooser state must stay modal and must not show `@` completions or attachment placeholder transforms. - Chooser typing must not mutate the stored pending submission. - `Ctrl+C` from the composer must remain the universal busy-state cancel path: shell interrupt, background command cancel, or agent cancel as appropriate. +- Manual cancel from the composer (`Ctrl+C` or configured cancel key) must stop current work without auto-launching queued prompts; queued items should remain queued until the user explicitly submits something new. +- Manual shell interrupt from the composer must follow the same queue-pause rule: interrupt the shell, keep queued items intact, and stop there until explicit user input resumes flow. +- When queue autodrain is paused and the runtime is idle, pressing `Enter` on an empty composer should recall the next queued prompt into the composer for editing; it must not auto-run the queued item. - Hook commands and hook-engine behavior must remain functional in the mounted-composer fork; preserve their legacy command output path rather than rewriting them. ## Wiggum Rules -- When Wiggum mode is active, queued work must drain before the next Wiggum rerun starts. -- After queued work completes, Wiggum should resume its stored loop prompt if Wiggum mode is still active. -- Busy slash-prefixed text queued during Wiggum must remain literal agent text, not execute as a slash command. +- When Wiggum mode is active, ordinary queued work must not drain before the next Wiggum rerun starts. +- Only interject items may bypass an active Wiggum loop; ordinary queued prompts must wait until Wiggum is no longer active. +- Interjecting during Wiggum must affect only the current iteration; it must not stop the stored Wiggum loop prompt from continuing afterward. +- Busy slash-prefixed text queued during Wiggum must remain literal agent text, not execute as a slash command when it later drains. +- `Ctrl+C` during Wiggum must stop future reruns cleanly, without emitting duplicate stop/cancel lines or a stray `Input cancelled` afterward. +- Manually stopping Wiggum must not auto-trigger ordinary queued prompts; they should remain queued and paused until explicit user input resumes flow. ## Config And Runtime Notes diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index f485bab3a..e7690e40c 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -745,6 +745,23 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non queue_start_lock = asyncio.Lock() shutdown_requested = False + suppress_next_input_cancel_message = False + active_cancel_state = {"reason": None} + + def stop_wiggum_with_notice(message: str) -> bool: + nonlocal suppress_next_input_cancel_message + from code_puppy.command_line.wiggum_state import ( + is_wiggum_active, + stop_wiggum, + ) + from code_puppy.messaging import emit_warning + + if not is_wiggum_active(): + return False + stop_wiggum() + suppress_next_input_cancel_message = True + emit_warning(message) + return True async def cancel_active_run(reason: str) -> None: """Aggressively stop shell + agent execution and wait for cancellation.""" @@ -765,6 +782,9 @@ async def cancel_active_run(reason: str) -> None: return runtime.cancelling = True + active_cancel_state["reason"] = reason + if is_manual_cancel_reason(reason): + runtime.suppress_queue_autodrain() log_event("cancel_start", reason=reason) if active_cancel_hook is not None: @@ -921,6 +941,10 @@ def is_exit_text(text: str) -> bool: """Check if text should terminate interactive mode.""" return text.strip().lower() in {"exit", "quit", "/exit", "/quit"} + def is_manual_cancel_reason(reason: str) -> bool: + """Return whether a cancel reason should pause queue autodrain.""" + return reason in {"ctrl_c", "ctrl+k", "ctrl+q"} + def queue_level(item: QueuedPrompt) -> str: """Return the lifecycle level for a queued item.""" return "warning" if item.kind == "interject" else "success" @@ -1026,6 +1050,7 @@ async def run_agent_bg( spinner_console=message_renderer.console, ) if result is None: + cancel_reason = active_cancel_state["reason"] reset_windows_terminal_ansi() try: from code_puppy.terminal_utils import ensure_ctrl_c_disabled @@ -1033,16 +1058,10 @@ async def run_agent_bg( ensure_ctrl_c_disabled() except ImportError: pass - from code_puppy.command_line.wiggum_state import ( - is_wiggum_active, - stop_wiggum, - ) - - if is_wiggum_active(): - stop_wiggum() - from code_puppy.messaging import emit_warning - - emit_warning("šŸ© Wiggum loop stopped due to cancellation") + if cancel_reason != "interject" and stop_wiggum_with_notice( + "šŸ© Wiggum loop stopped due to cancellation" + ): + log_event("wiggum_stopped", reason="cancelled_active_run") if source_item: emit_interject_queue_lifecycle( runtime, @@ -1098,6 +1117,8 @@ async def run_agent_bg( owns_runtime = runtime.is_active_task(active_task) was_cancelling = runtime.cancelling if owns_runtime else False runtime.mark_idle_if_task(active_task) + if owns_runtime: + active_cancel_state["reason"] = None log_event("agent_end", prompt=task_text) if not owns_runtime: return @@ -1109,6 +1130,13 @@ async def run_agent_bg( remaining=len(runtime.queue), ) return + if runtime.is_queue_autodrain_suppressed(): + log_event( + "queue_autodrain_skipped", + reason="manual_cancel_pause", + remaining=len(runtime.queue), + ) + return log_event( "queue_autodrain_skipped", reason="cancelling", @@ -1174,6 +1202,8 @@ async def run_interactive_command_bg( owns_runtime = runtime.is_active_task(active_task) was_cancelling = runtime.cancelling if owns_runtime else False runtime.mark_idle_if_task(active_task) + if owns_runtime: + active_cancel_state["reason"] = None log_event("interactive_command_end", command=command_text) if not owns_runtime: return @@ -1185,6 +1215,13 @@ async def run_interactive_command_bg( remaining=len(runtime.queue), ) return + if runtime.is_queue_autodrain_suppressed(): + log_event( + "queue_autodrain_skipped", + reason="manual_cancel_pause", + remaining=len(runtime.queue), + ) + return log_event( "queue_autodrain_skipped", reason="cancelling", @@ -1427,8 +1464,8 @@ async def dispatch_wiggum_if_idle() -> str: allow_command_dispatch=False, ) except KeyboardInterrupt: - emit_warning("\nšŸ© Wiggum loop interrupted by Ctrl+C") - stop_wiggum() + runtime.suppress_queue_autodrain() + stop_wiggum_with_notice("\nšŸ© Wiggum loop stopped!") return "consumed" except Exception as e: from code_puppy.messaging import emit_error @@ -1451,6 +1488,14 @@ async def drain_pending_work_if_idle(*, origin: str) -> bool: ) return handled_any + if runtime.is_queue_autodrain_suppressed(): + log_event( + "queue_autodrain_noop", + origin=origin, + reason="manual_cancel_pause", + ) + return handled_any + if runtime.running: active_task = runtime.bg_task if active_task is None or active_task.done(): @@ -1465,6 +1510,40 @@ async def drain_pending_work_if_idle(*, origin: str) -> bool: log_event("queue_autodrain_noop", origin=origin, reason="running") return handled_any + from code_puppy.command_line.wiggum_state import is_wiggum_active + + if is_wiggum_active(): + next_item = runtime.dequeue_next_interject() + if next_item is not None: + outcome = await dispatch_submission( + _build_interject_submission_text(next_item.text), + source_item=next_item, + save_history=False, + allow_command_dispatch=next_item.allow_command_dispatch, + ) + handled_any = True + if outcome == "launched": + log_event( + "queue_autodrain_triggered", + origin=origin, + remaining=len(runtime.queue), + kind=next_item.kind, + text=next_item.text, + ) + return True + continue + + outcome = await dispatch_wiggum_if_idle() + if outcome == "launched": + log_event("queue_autodrain_triggered", origin=origin, kind="wiggum") + return True + if outcome == "consumed": + log_event("queue_autodrain_consumed", origin=origin, kind="wiggum") + return True + + log_event("queue_autodrain_noop", origin=origin, reason="wiggum_idle") + return handled_any + next_item = runtime.dequeue() if next_item is not None: outcome = await dispatch_submission( @@ -1544,6 +1623,8 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: action=submission.action, text=submission.text, ) + if submission.text.strip(): + suppress_next_input_cancel_message = False # Windows+uvx: Re-disable Ctrl+C after prompt_toolkit # (prompt_toolkit restores console mode which re-enables Ctrl+C) @@ -1573,9 +1654,11 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: ) from code_puppy.messaging import emit_warning - if is_wiggum_active(): - stop_wiggum() - emit_warning("\nšŸ© Wiggum loop stopped!") + if stop_wiggum_with_notice("\nšŸ© Wiggum loop stopped!"): + runtime.suppress_queue_autodrain() + continue + if suppress_next_input_cancel_message: + suppress_next_input_cancel_message = False else: emit_warning("\nInput cancelled") continue @@ -1591,9 +1674,19 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: ) if is_shell_passthrough(submission.text): + if submission.text.strip(): + runtime.clear_queue_autodrain_suppression() execute_shell_passthrough(submission.text) continue + if submission.text.strip() and runtime.is_queue_autodrain_suppressed(): + runtime.clear_queue_autodrain_suppression() + log_event( + "queue_autodrain_resumed", + reason="explicit_submission", + text=submission.text.strip(), + ) + outcome = await dispatch_submission( submission.text, requested_action=submission.action, diff --git a/code_puppy/command_line/interactive_runtime.py b/code_puppy/command_line/interactive_runtime.py index 544d38c09..838c93584 100644 --- a/code_puppy/command_line/interactive_runtime.py +++ b/code_puppy/command_line/interactive_runtime.py @@ -62,6 +62,7 @@ class PromptRuntimeState: active_run_kind: Literal["agent", "interactive_command"] | None = None active_cancel_hook: Callable[[], None] | None = None active_cancel_requester: Callable[[str], None] | None = None + queue_autodrain_suppressed: bool = False def mark_running( self, @@ -143,6 +144,16 @@ def dequeue(self) -> QueuedPrompt | None: self.invalidate_prompt() return value + def dequeue_next_interject(self) -> QueuedPrompt | None: + for index, item in enumerate(self.queue): + if item.kind != "interject": + continue + value = self.queue.pop(index) + self._clamp_queue_view_offset() + self.invalidate_prompt() + return value + return None + def queue_preview_texts(self) -> list[str]: return [item.preview_text() for item in self.queue] @@ -194,6 +205,15 @@ def request_active_cancel(self, reason: str) -> bool: self.active_cancel_requester(reason) return True + def suppress_queue_autodrain(self) -> None: + self.queue_autodrain_suppressed = True + + def clear_queue_autodrain_suppression(self) -> None: + self.queue_autodrain_suppressed = False + + def is_queue_autodrain_suppressed(self) -> bool: + return self.queue_autodrain_suppressed + def shift_queue_view_offset(self, delta: int, *, max_visible: int = 3) -> bool: old_offset = self.queue_view_offset self._clamp_queue_view_offset(max_visible=max_visible) diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index f25764922..1c80cb1bd 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -845,6 +845,8 @@ async def prompt_for_submission( runtime = _get_runtime() # Add custom key bindings and multiline toggle bindings = KeyBindings() + recalled_queue_item = {"item": None} + recalled_queue_allow_command_dispatch = {"value": True} pending_decision_filter = Condition( lambda: runtime is not None and runtime.has_pending_submission() ) @@ -945,6 +947,31 @@ def restore_pending_submission_to_buffer(event) -> None: except Exception: pass + def recall_next_paused_queue_to_buffer(event) -> bool: + if runtime is None: + return False + if runtime.running or runtime.has_pending_submission(): + return False + if not runtime.is_queue_autodrain_suppressed(): + return False + if not runtime.queue: + return False + + item = runtime.queue[0] + recalled_queue_item["item"] = item + recalled_queue_allow_command_dispatch["value"] = item.allow_command_dispatch + try: + event.app.current_buffer.document = Document( + text=item.text, + cursor_position=len(item.text), + ) + except Exception: + try: + event.app.current_buffer.text = item.text + except Exception: + return False + return True + # Ctrl+X keybinding - exit with KeyboardInterrupt for input cancellation @bindings.add(Keys.ControlX) def _(event): @@ -962,6 +989,7 @@ def _(event): def _(event): if runtime is not None and runtime.has_active_shell(): _interrupt_shell_from_prompt("Ctrl-C") + runtime.suppress_queue_autodrain() runtime.set_pending_submission(None) clear_chooser_input(event) return @@ -983,6 +1011,7 @@ def _(event): def _(event): if runtime is not None and runtime.has_active_shell(): _interrupt_shell_from_prompt(configured_cancel_key.upper()) + runtime.suppress_queue_autodrain() runtime.set_pending_submission(None) clear_chooser_input(event) return @@ -1106,6 +1135,8 @@ def _(event): ) return text = event.app.current_buffer.text + if not text.strip() and recall_next_paused_queue_to_buffer(event): + return if ( runtime is not None and runtime.running @@ -1329,6 +1360,17 @@ def handle_image_paste_f3(event): echo_in_transcript=erase_when_done, allow_command_dispatch=result.allow_command_dispatch, ) + allow_command_dispatch = True + recalled_item = recalled_queue_item["item"] + if recalled_item is not None: + allow_command_dispatch = recalled_queue_allow_command_dispatch["value"] + if ( + runtime is not None + and result.strip() + and runtime.queue + and runtime.queue[0] is recalled_item + ): + runtime.dequeue() # NOTE: We used to call update_model_in_input(text) here to handle /model and /m # commands at the prompt level, but that prevented the command handler from running # and emitting success messages. Now we let all /model commands fall through to @@ -1337,7 +1379,7 @@ def handle_image_paste_f3(event): action="submit", text=result, echo_in_transcript=erase_when_done, - allow_command_dispatch=True, + allow_command_dispatch=allow_command_dispatch, ) diff --git a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md index ea237cab9..644414bd9 100644 --- a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md +++ b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md @@ -45,8 +45,12 @@ Run this before merging changes that touch the interactive runtime, prompt surfa ## Cancel Behavior - During a foreground shell command, press `Ctrl+C` and confirm the shell is interrupted without tearing down the outer session. +- During a foreground shell command with queued prompts waiting, press `Ctrl+C` and confirm the shell stops and queued prompts remain queued instead of auto-starting. - During a background interactive command or sub-agent action, press `Ctrl+C` and confirm the inner work cancels and control returns cleanly. - During a normal active agent run, press `Ctrl+C` and confirm the run cancels and the prompt becomes usable again. +- During a normal active agent run with queued prompts waiting, press `Ctrl+C` and confirm the current run stops but queued prompts do not auto-start. +- After that manual cancel, submit one fresh prompt and confirm normal queue draining resumes only after that explicit submission path runs. +- While idle with queue autodrain paused and queued prompts waiting, press `Enter` on an empty composer and confirm the next queued prompt is recalled into the composer for editing instead of auto-starting. - If the chooser is visible during active work, press `Ctrl+C` and confirm the active work cancels and the chooser/input state clears. ## Queue And Prompt Stability @@ -66,9 +70,11 @@ Run this before merging changes that touch the interactive runtime, prompt surfa ## Wiggum -- Start `/wiggum hello`, queue a normal follow-up, and confirm the queued turn runs before the next Wiggum rerun. -- After that queued turn completes, confirm Wiggum resumes its stored loop prompt. -- While Wiggum is active, queue a slash-prefixed prompt such as `/agent` and confirm it is treated as literal agent text, not executed as a slash command. +- Start `/wiggum hello`, queue a normal follow-up, and confirm Wiggum keeps rerunning its stored prompt while the queued turn waits. +- Stop Wiggum, then confirm the queued turn still has not auto-started if Wiggum was stopped manually with `Ctrl+C`. +- While Wiggum is active, interject a follow-up and confirm it cuts in immediately, affects only the current iteration, and then Wiggum resumes its stored loop prompt. +- While Wiggum is active, queue a slash-prefixed prompt such as `/agent` and confirm it is treated as literal agent text, not executed as a slash command when it later drains. +- Press `Ctrl+C` during Wiggum and confirm it stops once cleanly, does not relaunch future loops, and does not print a stray `Input cancelled` after the stop. ## Autosave diff --git a/tests/test_cli_runner_full_coverage.py b/tests/test_cli_runner_full_coverage.py index fee4a1c3f..a5c0b982d 100644 --- a/tests/test_cli_runner_full_coverage.py +++ b/tests/test_cli_runner_full_coverage.py @@ -716,6 +716,7 @@ async def fake_input(*a, **kw): async def test_keyboard_interrupt_stops_wiggum(self): call_count = 0 wiggum_active = {"value": False} + mock_warning = MagicMock() async def fake_input(*a, **kw): nonlocal call_count @@ -745,9 +746,13 @@ def fake_stop_wiggum(): "code_puppy.command_line.wiggum_state.stop_wiggum": MagicMock( side_effect=fake_stop_wiggum ), + "code_puppy.messaging.emit_warning": mock_warning, }, ) mock_stop.assert_called() + warning_messages = [call.args[0] for call in mock_warning.call_args_list] + assert "\nšŸ© Wiggum loop stopped!" in warning_messages + assert "\nInput cancelled" not in warning_messages @pytest.mark.anyio async def test_clear_command(self): @@ -1856,7 +1861,7 @@ def fake_wiggum(): ) @pytest.mark.anyio - async def test_wiggum_queued_prompt_runs_before_reloop_and_resumes(self): + async def test_wiggum_queued_prompt_waits_until_loop_stops(self): call_count = 0 queued_submitted = asyncio.Event() started_prompts = [] @@ -1864,7 +1869,7 @@ async def test_wiggum_queued_prompt_runs_before_reloop_and_resumes(self): autosave_mock = patches["code_puppy.config.auto_save_session_if_enabled"] all_autosaves_done = asyncio.Event() autosave_mock.side_effect = lambda: ( - all_autosaves_done.set() if autosave_mock.call_count >= 3 else None + all_autosaves_done.set() if autosave_mock.call_count >= 4 else None ) async def fake_input(*a, **kw): @@ -1913,14 +1918,14 @@ async def fake_run(*a, **kw): }, ) - assert started_prompts[:3] == ["write hello", "queued task", "repeat"] - assert autosave_mock.call_count == 3 + assert started_prompts[:4] == ["write hello", "repeat", "repeat", "queued task"] + assert autosave_mock.call_count == 4 @pytest.mark.anyio - async def test_wiggum_queued_busy_slash_text_stays_literal_before_reloop(self): + async def test_wiggum_queued_busy_slash_text_stays_literal_after_loop_ends(self): call_count = 0 queued_submitted = asyncio.Event() - repeat_finished = asyncio.Event() + queued_finished = asyncio.Event() started_prompts = [] handle_command = MagicMock(return_value=True) @@ -1936,7 +1941,7 @@ async def fake_input(*a, **kw): action="queue", allow_command_dispatch=False, ) - await repeat_finished.wait() + await queued_finished.wait() return _submission("/exit") def fake_wiggum_active(): @@ -1950,8 +1955,8 @@ async def fake_run(*a, **kw): await asyncio.sleep(0.05) result = MagicMock(output=f"done: {prompt}") result.all_messages.return_value = [] - if prompt == "repeat": - repeat_finished.set() + if prompt == "/agent": + queued_finished.set() return result, MagicMock() await _run_interactive( @@ -1977,9 +1982,81 @@ async def fake_run(*a, **kw): }, ) - assert started_prompts[:3] == ["write hello", "/agent", "repeat"] + assert started_prompts[:4] == ["write hello", "repeat", "repeat", "/agent"] handle_command.assert_not_called() + @pytest.mark.anyio + async def test_wiggum_interject_runs_immediately_and_reloop_resumes(self): + call_count = 0 + repeat_started = asyncio.Event() + rerun_finished = asyncio.Event() + started_prompts = [] + repeat_runs = 0 + stop_wiggum = MagicMock() + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _submission("write hello") + if call_count == 2: + await repeat_started.wait() + return _submission("steer now", action="interject") + await rerun_finished.wait() + return _submission("/exit") + + def fake_wiggum_active(): + return len(started_prompts) >= 1 and len(started_prompts) < 4 + + async def fake_run(*a, **kw): + nonlocal repeat_runs + prompt = a[1] + started_prompts.append(prompt) + if prompt == "repeat": + repeat_runs += 1 + if repeat_runs == 1: + repeat_started.set() + try: + await asyncio.Future() + except asyncio.CancelledError: + return None, MagicMock() + else: + rerun_finished.set() + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + side_effect=fake_wiggum_active + ), + "code_puppy.command_line.wiggum_state.get_wiggum_prompt": MagicMock( + return_value="repeat" + ), + "code_puppy.command_line.wiggum_state.increment_wiggum_count": MagicMock( + return_value=1 + ), + "code_puppy.command_line.wiggum_state.stop_wiggum": stop_wiggum, + "code_puppy.tools.command_runner.get_running_shell_process_count": MagicMock( + return_value=0 + ), + }, + ) + + assert started_prompts[0] == "write hello" + assert started_prompts[1] == "repeat" + assert started_prompts[2].startswith("user interjects - steer now") + assert started_prompts[3] == "repeat" + stop_wiggum.assert_not_called() + @pytest.mark.anyio async def test_wiggum_loop_cancelled(self): call_count = 0 @@ -2044,7 +2121,7 @@ async def fake_input(*a, **kw): def fake_wiggum(): nonlocal wiggum_calls wiggum_calls += 1 - return wiggum_calls <= 1 + return wiggum_calls <= 2 mock_stop = MagicMock() await _run_interactive( @@ -2521,6 +2598,7 @@ async def fake_input(*a, **kw): async def test_wiggum_keyboard_interrupt(self): """Lines 874-876: KeyboardInterrupt in wiggum loop.""" call_count = 0 + mock_warning = MagicMock() async def fake_input(*a, **kw): nonlocal call_count @@ -2562,8 +2640,11 @@ def fake_wiggum(): return_value=1 ), "code_puppy.command_line.wiggum_state.stop_wiggum": MagicMock(), + "code_puppy.messaging.emit_warning": mock_warning, }, ) + warning_messages = [call.args[0] for call in mock_warning.call_args_list] + assert "\nInput cancelled" not in warning_messages # --------------------------------------------------------------------------- @@ -2702,17 +2783,15 @@ async def fake_input(*a, **kw): agent = MagicMock() agent.get_user_prompt.return_value = "task:" - # First call to is_wiggum_active: False (in the result==None block) - # But we need the result to be None AND wiggum to be active - # The code path: result is None -> reset terminal -> check wiggum -> stop + emit + # drain_pending_work_if_idle now checks Wiggum state before dispatching, so + # allow one extra truthy call before the cancelled-result stop path runs. wiggum_calls = 0 def fake_wiggum(): nonlocal wiggum_calls wiggum_calls += 1 - # Called from the result==None block - if wiggum_calls == 1: - return True # in the cancelled block + if wiggum_calls <= 2: + return True return False # after the while loop mock_stop = MagicMock() diff --git a/tests/test_command_overhaul_targeted.py b/tests/test_command_overhaul_targeted.py index 27e60359a..ce8f9bb50 100644 --- a/tests/test_command_overhaul_targeted.py +++ b/tests/test_command_overhaul_targeted.py @@ -8,6 +8,7 @@ import pytest from code_puppy.command_line.interactive_command import BackgroundInteractiveCommand +from code_puppy.command_line.interactive_runtime import get_active_interactive_runtime from code_puppy.command_line.prompt_toolkit_completion import PromptSubmission @@ -352,6 +353,247 @@ async def run_prompt_side_effect(_agent, prompt, **_kwargs): handle_command.assert_called_once_with("/claude-code-auth") +@pytest.mark.anyio +@pytest.mark.parametrize("cancel_reason", ["ctrl_c", "ctrl+k"]) +async def test_manual_cancel_pauses_queued_prompts_until_user_acts(cancel_reason: str): + first_cancelled = asyncio.Event() + queued_started = asyncio.Event() + started_prompts: list[str] = [] + handle_command = MagicMock(return_value=True) + + async def prompt_side_effect(*_args, **_kwargs): + prompt_side_effect.calls += 1 + if prompt_side_effect.calls == 1: + return _submission("first task") + if prompt_side_effect.calls == 2: + return _submission("queued task", action="queue") + if prompt_side_effect.calls == 3: + runtime = get_active_interactive_runtime() + assert runtime is not None + assert runtime.request_active_cancel(cancel_reason) is True + return _submission("") + + await first_cancelled.wait() + await asyncio.sleep(0.05) + runtime = get_active_interactive_runtime() + assert runtime is not None + assert runtime.is_queue_autodrain_suppressed() is True + assert [item.text for item in runtime.queue] == ["queued task"] + assert queued_started.is_set() is False + return _submission("/exit") + + prompt_side_effect.calls = 0 + + async def run_prompt_side_effect(_agent, prompt, **_kwargs): + started_prompts.append(prompt) + if prompt == "first task": + try: + await asyncio.Future() + except asyncio.CancelledError: + first_cancelled.set() + raise + if prompt == "queued task": + queued_started.set() + result = MagicMock() + result.output = f"response for {prompt}" + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + prompt_side_effect, + run_prompt_side_effect=run_prompt_side_effect, + handle_command=handle_command, + ) + + assert started_prompts == ["first task"] + + +@pytest.mark.anyio +async def test_manual_cancel_queue_pause_clears_after_new_submission(): + first_cancelled = asyncio.Event() + queued_started = asyncio.Event() + started_prompts: list[str] = [] + handle_command = MagicMock(return_value=True) + + async def prompt_side_effect(*_args, **_kwargs): + prompt_side_effect.calls += 1 + if prompt_side_effect.calls == 1: + return _submission("first task") + if prompt_side_effect.calls == 2: + return _submission("queued task", action="queue") + if prompt_side_effect.calls == 3: + runtime = get_active_interactive_runtime() + assert runtime is not None + assert runtime.request_active_cancel("ctrl_c") is True + return _submission("") + if prompt_side_effect.calls == 4: + await first_cancelled.wait() + await asyncio.sleep(0.05) + runtime = get_active_interactive_runtime() + assert runtime is not None + assert runtime.is_queue_autodrain_suppressed() is True + assert [item.text for item in runtime.queue] == ["queued task"] + return _submission("resume task") + + await queued_started.wait() + return _submission("/exit") + + prompt_side_effect.calls = 0 + + async def run_prompt_side_effect(_agent, prompt, **_kwargs): + started_prompts.append(prompt) + if prompt == "first task": + try: + await asyncio.Future() + except asyncio.CancelledError: + first_cancelled.set() + raise + if prompt == "queued task": + queued_started.set() + result = MagicMock() + result.output = f"response for {prompt}" + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + prompt_side_effect, + run_prompt_side_effect=run_prompt_side_effect, + handle_command=handle_command, + ) + + assert started_prompts[:3] == ["first task", "resume task", "queued task"] + + +@pytest.mark.anyio +async def test_wiggum_manual_cancel_does_not_emit_followup_input_cancelled(): + run_started = asyncio.Event() + run_cancelled = asyncio.Event() + handle_command = MagicMock(return_value=True) + warning_messages: list[str] = [] + wiggum_active = {"value": False} + + async def prompt_side_effect(*_args, **_kwargs): + prompt_side_effect.calls += 1 + if prompt_side_effect.calls == 1: + return _submission("first task") + if prompt_side_effect.calls == 2: + await run_started.wait() + runtime = get_active_interactive_runtime() + assert runtime is not None + assert runtime.request_active_cancel("ctrl_c") is True + return _submission("") + if prompt_side_effect.calls == 3: + await run_cancelled.wait() + raise KeyboardInterrupt + return _submission("/exit") + + prompt_side_effect.calls = 0 + + async def run_prompt_side_effect(_agent, prompt, **_kwargs): + wiggum_active["value"] = True + run_started.set() + try: + await asyncio.Future() + except asyncio.CancelledError: + run_cancelled.set() + raise + + def fake_is_wiggum_active(): + return wiggum_active["value"] + + def fake_stop_wiggum(): + wiggum_active["value"] = False + + with patch( + "code_puppy.command_line.wiggum_state.is_wiggum_active", + side_effect=fake_is_wiggum_active, + ), patch( + "code_puppy.command_line.wiggum_state.stop_wiggum", + side_effect=fake_stop_wiggum, + ), patch( + "code_puppy.messaging.emit_warning", + side_effect=warning_messages.append, + ): + await _run_interactive( + prompt_side_effect, + run_prompt_side_effect=run_prompt_side_effect, + handle_command=handle_command, + ) + + assert any("šŸ© Wiggum loop stopped" in message for message in warning_messages) + assert "\nInput cancelled" not in warning_messages + + +@pytest.mark.anyio +async def test_wiggum_manual_cancel_keeps_queued_prompts_paused(): + run_started = asyncio.Event() + run_cancelled = asyncio.Event() + queued_started = asyncio.Event() + handle_command = MagicMock(return_value=True) + wiggum_active = {"value": False} + + async def prompt_side_effect(*_args, **_kwargs): + prompt_side_effect.calls += 1 + if prompt_side_effect.calls == 1: + return _submission("first task") + if prompt_side_effect.calls == 2: + return _submission("queued task", action="queue") + if prompt_side_effect.calls == 3: + await run_started.wait() + runtime = get_active_interactive_runtime() + assert runtime is not None + assert runtime.request_active_cancel("ctrl_c") is True + return _submission("") + if prompt_side_effect.calls == 4: + await run_cancelled.wait() + raise KeyboardInterrupt + + await asyncio.sleep(0.05) + runtime = get_active_interactive_runtime() + assert runtime is not None + assert runtime.is_queue_autodrain_suppressed() is True + assert [item.text for item in runtime.queue] == ["queued task"] + assert queued_started.is_set() is False + return _submission("/exit") + + prompt_side_effect.calls = 0 + + async def run_prompt_side_effect(_agent, prompt, **_kwargs): + if prompt == "first task": + wiggum_active["value"] = True + run_started.set() + try: + await asyncio.Future() + except asyncio.CancelledError: + run_cancelled.set() + raise + if prompt == "queued task": + queued_started.set() + result = MagicMock() + result.output = f"response for {prompt}" + result.all_messages.return_value = [] + return result, MagicMock() + + def fake_is_wiggum_active(): + return wiggum_active["value"] + + def fake_stop_wiggum(): + wiggum_active["value"] = False + + with patch( + "code_puppy.command_line.wiggum_state.is_wiggum_active", + side_effect=fake_is_wiggum_active, + ), patch( + "code_puppy.command_line.wiggum_state.stop_wiggum", + side_effect=fake_stop_wiggum, + ): + await _run_interactive( + prompt_side_effect, + run_prompt_side_effect=run_prompt_side_effect, + handle_command=handle_command, + ) + + @pytest.mark.anyio async def test_background_command_wait_does_not_autosave(): wait_started = asyncio.Event() diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index 4a1c492e0..5e5875dc6 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -736,6 +736,82 @@ async def test_get_input_key_binding_alt_m(mock_prompt_session_cls): assert alt_m_handler is not None, "Alt+M keybinding not found" +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +@patch("code_puppy.command_line.prompt_toolkit_completion._interrupt_shell_from_prompt") +async def test_get_input_key_binding_ctrl_c_shell_interrupt_suppresses_queue_autodrain( + mock_interrupt_shell, mock_prompt_session_cls, active_runtime +): + mock_session_instance = MagicMock() + mock_session_instance.prompt_async = AsyncMock(return_value="test") + mock_prompt_session_cls.return_value = mock_session_instance + + await get_input_with_combined_completion() + + bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] + ctrl_c_binding = next( + binding_obj for binding_obj in bindings.bindings if binding_obj.keys == ("c-c",) + ) + + active_runtime.notify_shell_started() + active_runtime.request_queue("queued task") + active_runtime.set_pending_submission("draft") + + buffer = Buffer(document=Document(text="chooser text", cursor_position=11)) + mock_event = MagicMock() + mock_event.app = MagicMock() + mock_event.app.current_buffer = buffer + + ctrl_c_binding.handler(mock_event) + + mock_interrupt_shell.assert_called_once_with("Ctrl-C") + assert active_runtime.is_queue_autodrain_suppressed() is True + assert active_runtime.has_pending_submission() is False + assert buffer.text == "" + mock_event.app.exit.assert_not_called() + + +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +@patch("code_puppy.command_line.prompt_toolkit_completion._interrupt_shell_from_prompt") +async def test_get_input_key_binding_configured_cancel_shell_interrupt_suppresses_queue_autodrain( + mock_interrupt_shell, mock_prompt_session_cls, active_runtime +): + mock_session_instance = MagicMock() + mock_session_instance.prompt_async = AsyncMock(return_value="test") + mock_prompt_session_cls.return_value = mock_session_instance + + with patch( + "code_puppy.command_line.prompt_toolkit_completion.get_value", + side_effect=lambda key, default=None: "ctrl+k" + if key == "cancel_agent_key" + else default, + ): + await get_input_with_combined_completion() + + bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] + ctrl_k_binding = next( + binding_obj for binding_obj in bindings.bindings if binding_obj.keys == ("c-k",) + ) + + active_runtime.notify_shell_started() + active_runtime.request_queue("queued task") + active_runtime.set_pending_submission("draft") + + buffer = Buffer(document=Document(text="chooser text", cursor_position=11)) + mock_event = MagicMock() + mock_event.app = MagicMock() + mock_event.app.current_buffer = buffer + + ctrl_k_binding.handler(mock_event) + + mock_interrupt_shell.assert_called_once_with("CTRL+K") + assert active_runtime.is_queue_autodrain_suppressed() is True + assert active_runtime.has_pending_submission() is False + assert buffer.text == "" + mock_event.app.exit.assert_not_called() + + @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") async def test_get_input_key_binding_escape(mock_prompt_session_cls): @@ -862,6 +938,83 @@ async def test_get_input_key_binding_edit_restores_pending_submission( mock_event.app.exit.assert_not_called() +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +async def test_empty_enter_recalls_next_paused_queue_prompt( + mock_prompt_session_cls, active_runtime +): + mock_session_instance = MagicMock() + mock_session_instance.prompt_async = AsyncMock(return_value="test") + mock_prompt_session_cls.return_value = mock_session_instance + + await get_input_with_combined_completion() + + bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] + enter_binding = next( + binding_obj + for binding_obj in bindings.bindings + if binding_obj.keys == (Keys.ControlM,) + ) + + active_runtime.request_queue("queued task", allow_command_dispatch=False) + active_runtime.suppress_queue_autodrain() + + buffer = Buffer(document=Document(text="", cursor_position=0)) + mock_event = MagicMock() + mock_event.app = MagicMock() + mock_event.app.current_buffer = buffer + + enter_binding.handler(mock_event) + + assert buffer.text == "queued task" + assert buffer.cursor_position == len("queued task") + assert len(active_runtime.queue) == 1 + assert active_runtime.queue[0].text == "queued task" + assert active_runtime.queue[0].allow_command_dispatch is False + mock_event.app.exit.assert_not_called() + + +@pytest.mark.asyncio +@patch("code_puppy.command_line.prompt_toolkit_completion.patch_stdout") +@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") +async def test_prompt_for_submission_recalled_queue_preserves_policy_and_dequeues_on_submit( + mock_prompt_session_cls, mock_patch_stdout, active_runtime +): + mock_session_instance = MagicMock() + mock_session_instance.default_buffer = Buffer(document=Document(text="", cursor_position=0)) + mock_prompt_session_cls.return_value = mock_session_instance + mock_patch_stdout.return_value.__enter__ = MagicMock() + mock_patch_stdout.return_value.__exit__ = MagicMock(return_value=False) + + active_runtime.request_queue("/agent", allow_command_dispatch=False) + active_runtime.suppress_queue_autodrain() + + async def fake_prompt_async(*args, **kwargs): + bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] + enter_binding = next( + binding_obj + for binding_obj in bindings.bindings + if binding_obj.keys == (Keys.ControlM,) + ) + mock_event = MagicMock() + mock_event.app = MagicMock() + mock_event.app.current_buffer = mock_session_instance.default_buffer + enter_binding.handler(mock_event) + return mock_session_instance.default_buffer.text + + mock_session_instance.prompt_async = AsyncMock(side_effect=fake_prompt_async) + + result = await prompt_for_submission() + + assert result == PromptSubmission( + action="submit", + text="/agent", + echo_in_transcript=False, + allow_command_dispatch=False, + ) + assert active_runtime.queue == [] + + @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") async def test_get_input_chooser_makes_buffer_read_only( From 5246f8945c785db7e0e55c949ef9c2de3cb10f91 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Mon, 16 Mar 2026 14:47:29 -0500 Subject: [PATCH 35/40] Prep upstream PR branch --- AGENTS.md | 2 - IMPLEMENTATION_GUARDRAILS.md | 103 --- code_puppy/cli_runner.py | 78 +- .../command_line/interactive_runtime.py | 11 +- .../command_line/prompt_toolkit_completion.py | 63 +- code_puppy/messaging/__init__.py | 6 - code_puppy/messaging/renderers.py | 2 +- code_puppy/messaging/rich_renderer.py | 4 +- .../messaging/spinner/console_spinner.py | 6 + .../chatgpt_oauth/register_callbacks.py | 2 +- code_puppy/plugins/oauth_control.py | 4 +- code_puppy/tools/agent_tools.py | 6 +- code_puppy/tools/command_runner.py | 4 +- docs/INTERACTIVE_REGRESSION_CHECKLIST.md | 85 --- tests/agents/test_event_stream_handler.py | 8 +- .../test_add_model_menu_coverage.py | 1 + tests/plugins/conftest.py | 61 +- tests/test_agent_tools_coverage.py | 16 +- tests/test_cli_runner_full_coverage.py | 51 +- tests/test_command_overhaul_targeted.py | 49 +- tests/test_prompt_toolkit_completion.py | 675 +----------------- .../tools/browser/test_remaining_coverage.py | 9 +- 22 files changed, 265 insertions(+), 981 deletions(-) delete mode 100644 IMPLEMENTATION_GUARDRAILS.md delete mode 100644 docs/INTERACTIVE_REGRESSION_CHECKLIST.md diff --git a/AGENTS.md b/AGENTS.md index f690d89e0..1f27e5e7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,5 @@ # Contributing to Code Puppy -> Before implementing changes in this repo, read `IMPLEMENTATION_GUARDRAILS.md`. - > **Golden rule:** nearly all new functionality should be a **plugin** under `code_puppy/plugins/` > that hooks into core via `code_puppy/callbacks.py`. Don't edit `code_puppy/command_line/`. diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md deleted file mode 100644 index be63d0460..000000000 --- a/IMPLEMENTATION_GUARDRAILS.md +++ /dev/null @@ -1,103 +0,0 @@ -# Implementation Guardrails - -Read this before making changes in this repo. - -## Protected Behavior - -- Keep the always-on composer fixed at the bottom. -- Keep the composer usable while the agent is running. -- Keep the composer usable while a foreground shell command is running. -- Do not reintroduce a read-only or suspended prompt during shell execution. -- `/exit` and `/quit` must bypass queue/interject handling and win immediately. -- `Ctrl+C` from the composer must interrupt foreground shell work immediately. -- Busy submit must stay inline: `Enter` stores the prompt and shows `[i]nterject or [q]ueue`. -- Do not bring back a second prompt surface for interject decisions. -- Interject must aggressively cancel current work and run before normal queued prompts. -- Queued/interjected prompts must echo into the transcript before dispatch. -- Interjected prompts must echo the user's exact text into the transcript before any apply/start lifecycle lines. -- Direct prompts submitted from erasable prompt sessions must echo once into the transcript before any response or tool output. -- Normal direct follow-up prompts after queue/interject cycles must remain visible in the transcript. -- Interject injection text must tell the agent to continue the interrupted task after acknowledging the interjection. -- Auto-save must fire after each successfully completed interactive agent response, including queued and interjected turns, before the next queued turn launches. -- Cancelled turns, slash commands, and OAuth/background interactive commands must not trigger auto-save. -- Queue preview must stay above the composer, max 3 visible rows, scroll when over 3, and cap at 25 prompts. -- Queued prompt preview rows must stay one line, truncated, and never wrap. -- Keep the composer at a fixed two-line viewport with internal scrolling for long prompts. - -## Rendering Guardrails - -- Do not stream Rich/ANSI/termflow output directly into the mounted prompt surface. -- Final `AGENT RESPONSE` output must render as a proper banner above the prompt. -- Structured banners like `AGENT REASONING`, listings, and tool blocks must use the prompt-safe render path. -- Shell output with the prompt mounted must stay plain text. Do not reintroduce Rich dim styling or raw ANSI fragments there. -- Above-prompt Rich blocks and transcript notices must share one serialized render path and must never interleave at the byte level. -- Do not reintroduce the shell warning line in the prompt header if typing is still enabled. -- Legacy slash-command output emitted through `emit_info`, `emit_warning`, `emit_error`, `emit_success`, and divider output must remain visible in interactive mode. -- Rich slash-command renderables like `Text`, `Table`, and `Markdown` must still render above the mounted composer. - -## Queue Transcript Rules - -- Keep queued-save transcript copy compact: `[Queued][N] {text}`. -- Keep queued-launch transcript copy as `[QUEUE TRIGGERED] {text}` and print it before the echoed queued prompt. -- Keep `list_agents` output visible in the transcript before downstream sub-agent invocation output. -- Do not reintroduce visible interject `applying now` lines. -- Do not reintroduce late launched-item `finished`, `applied`, or `run_cancelled` transcript noise. -- Keep transcript polish presentation-only; do not change queue/interject runtime semantics when adjusting these lines. - -## Spinner And Context Rules - -- The spinner is a visual heartbeat only. It should not control real work or real redraw priority. -- Spinner redraws must stay low-priority and yield behind real prompt/state redraws. -- Token/context updates should invalidate promptly on their own and should not wait on spinner timing. -- Seed the token/context line at run start so it does not show stale data from the previous run. -- Keep seeded token/context estimation parity-safe: active-agent token estimates, context length, and attachment/link overhead must still feed the top-level prompt status line. -- Keep sub-agent token accumulation in the sub-agent console path; do not collapse it into the top-level prompt status line. -- Keep the prompt-native spinner; do not bring back the old Rich live spinner for interactive runs. - -## Command And OAuth Rules - -- OAuth setup flows (`/antigravity-auth`, `/antigravity-add`, `/claude-code-auth`, `/chatgpt-auth`, tutorial/onboarding auth handoff) are core functionality and must remain working. -- During OAuth callback waits, `/exit`, `/quit`, and the configured cancel key must still work. -- Cancelling auth must not half-apply model switches, reloads, or config changes. -- Queueing or interjecting during auth or other cooperative external waits must never crash; cancel/cleanup races must be harmless. -- While work is active, only `/exit` and `/quit` keep slash-command semantics. -- Busy slash-prefixed text other than `/exit` and `/quit` must remain literal user text if queued or interjected. -- The chooser state must not show slash-command menus or execute slash commands. -- While the chooser is visible, `e` must restore the saved drafted prompt back into the composer. -- `Up Arrow` may remain as a compatibility alias for restore, but chooser copy should advertise `e`. -- While the chooser is visible, `Esc` must drop the saved drafted prompt and leave the composer empty. -- While the chooser is visible, non-chooser typing must be inert; the chooser buffer should be effectively read-only except for the explicit chooser keys and immediate cancel/exit paths. -- Keep chooser hint copy concise; do not turn the inline chooser row into a sentence-length help block. -- Keep `@` as attachment/path completion, not as a picker-style command menu. -- Bare `@` should continue to offer current-directory completion candidates. -- `@` completion must keep prompt_toolkit-style semantics: `Tab` only cycles/advances completions, prompt_toolkit accept-completion keys remain available, and `Enter` keeps submit semantics. -- Typing a space after an `@` path is normal text continuation, not a special acceptance action. -- Busy `@` attachment completion is allowed while the always-on composer is open, but the chooser state must stay modal and must not show `@` completions or attachment placeholder transforms. -- Chooser typing must not mutate the stored pending submission. -- `Ctrl+C` from the composer must remain the universal busy-state cancel path: shell interrupt, background command cancel, or agent cancel as appropriate. -- Manual cancel from the composer (`Ctrl+C` or configured cancel key) must stop current work without auto-launching queued prompts; queued items should remain queued until the user explicitly submits something new. -- Manual shell interrupt from the composer must follow the same queue-pause rule: interrupt the shell, keep queued items intact, and stop there until explicit user input resumes flow. -- When queue autodrain is paused and the runtime is idle, pressing `Enter` on an empty composer should recall the next queued prompt into the composer for editing; it must not auto-run the queued item. -- Hook commands and hook-engine behavior must remain functional in the mounted-composer fork; preserve their legacy command output path rather than rewriting them. - -## Wiggum Rules - -- When Wiggum mode is active, ordinary queued work must not drain before the next Wiggum rerun starts. -- Only interject items may bypass an active Wiggum loop; ordinary queued prompts must wait until Wiggum is no longer active. -- Interjecting during Wiggum must affect only the current iteration; it must not stop the stored Wiggum loop prompt from continuing afterward. -- Busy slash-prefixed text queued during Wiggum must remain literal agent text, not execute as a slash command when it later drains. -- `Ctrl+C` during Wiggum must stop future reruns cleanly, without emitting duplicate stop/cancel lines or a stray `Input cancelled` afterward. -- Manually stopping Wiggum must not auto-trigger ordinary queued prompts; they should remain queued and paused until explicit user input resumes flow. - -## Config And Runtime Notes - -- Use `./.cp-local/run-code-puppy-local.sh` when you need isolated local setup/auth for this repo. -- Treat `/Users/nateoswalt/code-puppy-interject-queue-v2` as the rewrite repo. -- Treat `/Users/nateoswalt/code-puppy` as the older baseline repo. - -## Pre-Implementation Check - -- Read this file. -- Read `docs/INTERACTIVE_REGRESSION_CHECKLIST.md`. -- Check for prompt/render/runtime side effects before editing. -- If a change touches interactive runtime, prompt rendering, shell integration, queue/interject flow, or spinner behavior, run focused tests and do a real terminal smoke pass afterward. diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index e7690e40c..8c5c3bdf1 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -58,6 +58,7 @@ ) from code_puppy.tools.common import console from code_puppy.version_checker import default_version_mismatch_behavior + try: from code_puppy.debug_capture import ( get_active_capture, @@ -79,6 +80,7 @@ def set_active_capture(*args, **kwargs): def start_capture_session(): return None + plugins.load_plugin_callbacks() @@ -208,11 +210,11 @@ def _seed_spinner_context(agent, prompt: str, *, link_attachments: list[str]) -> try: history = ( - agent.get_message_history() - if hasattr(agent, "get_message_history") - else [] + agent.get_message_history() if hasattr(agent, "get_message_history") else [] ) or [] - estimate_tokens_for_message = getattr(agent, "estimate_tokens_for_message", None) + estimate_tokens_for_message = getattr( + agent, "estimate_tokens_for_message", None + ) estimate_context_overhead_tokens = getattr( agent, "estimate_context_overhead_tokens", None ) @@ -235,7 +237,9 @@ def _seed_spinner_context(agent, prompt: str, *, link_attachments: list[str]) -> ) prompt_tokens = estimate_token_count(prompt) if prompt else 0 link_tokens = sum(estimate_token_count(url) for url in link_attachments) - total_tokens = max(0, history_tokens + overhead_tokens + prompt_tokens + link_tokens) + total_tokens = max( + 0, history_tokens + overhead_tokens + prompt_tokens + link_tokens + ) capacity = max(1, int(get_model_context_length())) update_spinner_context( @@ -294,11 +298,11 @@ async def main(): args = parser.parse_args() from code_puppy.messaging import ( - LegacyQueueToBusBridge, RichConsoleRenderer, get_global_queue, get_message_bus, ) + from code_puppy.messaging.legacy_bridge import LegacyQueueToBusBridge capture_session = None if args.debug_capture: @@ -630,7 +634,11 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non # Initialize the runtime agent manager if initial_command: from code_puppy.agents import get_current_agent - from code_puppy.messaging import emit_info, emit_success, emit_system_message + from code_puppy.messaging import ( + emit_info, + emit_success, + emit_system_message, + ) agent = get_current_agent() emit_info(f"Processing initial command: {initial_command}") @@ -693,7 +701,14 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non import subprocess subprocess.check_call( - [sys.executable, "-m", "pip", "install", "--quiet", "prompt_toolkit"] + [ + sys.executable, + "-m", + "pip", + "install", + "--quiet", + "prompt_toolkit", + ] ) from code_puppy.messaging import emit_success @@ -725,7 +740,9 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non from code_puppy.messaging import emit_info with concurrent.futures.ThreadPoolExecutor() as executor: - future = executor.submit(lambda: asyncio.run(run_onboarding_wizard())) + future = executor.submit( + lambda: asyncio.run(run_onboarding_wizard()) + ) result = future.result(timeout=300) if result == "chatgpt": @@ -771,8 +788,12 @@ async def cancel_active_run(reason: str) -> None: kill_all_running_shell_processes, ) except ImportError: - get_running_shell_process_count = lambda: 0 - kill_all_running_shell_processes = lambda: None + + def get_running_shell_process_count() -> int: + return 0 + + def kill_all_running_shell_processes() -> None: + return None active_task = runtime.bg_task active_cancel_hook = runtime.active_cancel_hook @@ -841,7 +862,11 @@ async def shutdown_interactive_session(message: str, *, reason: str) -> None: shutdown_requested = True emit_success(message) - if runtime.running and runtime.bg_task is not None and not runtime.bg_task.done(): + if ( + runtime.running + and runtime.bg_task is not None + and not runtime.bg_task.done() + ): emit_info("Cancelling running task...") await cancel_active_run(reason) @@ -1032,9 +1057,7 @@ def complete_queue_item(item: QueuedPrompt, reason: str) -> None: level=queue_level(item), ) - async def run_agent_bg( - task_text, agent, source_item: QueuedPrompt | None = None - ): + async def run_agent_bg(task_text, agent, source_item: QueuedPrompt | None = None): try: log_event("agent_start", prompt=task_text) if source_item: @@ -1504,10 +1527,14 @@ async def drain_pending_work_if_idle(*, origin: str) -> bool: "queue_autodrain_reconciled", origin=origin, had_task=active_task is not None, - task_done=active_task.done() if active_task is not None else None, + task_done=active_task.done() + if active_task is not None + else None, ) else: - log_event("queue_autodrain_noop", origin=origin, reason="running") + log_event( + "queue_autodrain_noop", origin=origin, reason="running" + ) return handled_any from code_puppy.command_line.wiggum_state import is_wiggum_active @@ -1535,13 +1562,19 @@ async def drain_pending_work_if_idle(*, origin: str) -> bool: outcome = await dispatch_wiggum_if_idle() if outcome == "launched": - log_event("queue_autodrain_triggered", origin=origin, kind="wiggum") + log_event( + "queue_autodrain_triggered", origin=origin, kind="wiggum" + ) return True if outcome == "consumed": - log_event("queue_autodrain_consumed", origin=origin, kind="wiggum") + log_event( + "queue_autodrain_consumed", origin=origin, kind="wiggum" + ) return True - log_event("queue_autodrain_noop", origin=origin, reason="wiggum_idle") + log_event( + "queue_autodrain_noop", origin=origin, reason="wiggum_idle" + ) return handled_any next_item = runtime.dequeue() @@ -1647,11 +1680,6 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: # Windows-specific: Reset terminal state after interrupt to prevent # the terminal from becoming unresponsive (can't type characters) reset_windows_terminal_full() - # Stop wiggum mode on Ctrl+C - from code_puppy.command_line.wiggum_state import ( - is_wiggum_active, - stop_wiggum, - ) from code_puppy.messaging import emit_warning if stop_wiggum_with_notice("\nšŸ© Wiggum loop stopped!"): diff --git a/code_puppy/command_line/interactive_runtime.py b/code_puppy/command_line/interactive_runtime.py index 838c93584..6aa36a16b 100644 --- a/code_puppy/command_line/interactive_runtime.py +++ b/code_puppy/command_line/interactive_runtime.py @@ -274,7 +274,8 @@ def _invalidate_prompt(self, *, low_priority: bool) -> None: return if ( self.last_spinner_invalidation_at > 0 - and now - self.last_spinner_invalidation_at < PROMPT_STATUS_FRAME_INTERVAL + and now - self.last_spinner_invalidation_at + < PROMPT_STATUS_FRAME_INTERVAL ): return @@ -290,9 +291,7 @@ def _invalidate_prompt(self, *, low_priority: bool) -> None: def _should_refresh_prompt_status(self) -> bool: return self.running and self.has_prompt_surface() - def _get_above_prompt_lock( - self, loop: asyncio.AbstractEventLoop - ) -> asyncio.Lock: + def _get_above_prompt_lock(self, loop: asyncio.AbstractEventLoop) -> asyncio.Lock: if self.above_prompt_lock is None or self.above_prompt_lock_loop is not loop: self.above_prompt_lock = asyncio.Lock() self.above_prompt_lock_loop = loop @@ -342,7 +341,9 @@ async def _prompt_status_loop(self) -> None: self.prompt_status_task = None self.invalidate_prompt() - def run_above_prompt(self, func: Callable[[], None], *, timeout: float = 5.0) -> bool: + def run_above_prompt( + self, func: Callable[[], None], *, timeout: float = 5.0 + ) -> bool: """Run a synchronous callback above the mounted prompt surface.""" app = getattr(self.prompt_session, "app", None) loop = getattr(app, "loop", None) diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 1c80cb1bd..35544daec 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -55,7 +55,12 @@ from code_puppy.command_line.pin_command_completion import PinCompleter, UnpinCompleter from code_puppy.command_line.skills_completion import SkillsCompleter from code_puppy.command_line.utils import list_directory -from code_puppy.config import COMMAND_HISTORY_FILE, get_config_keys, get_puppy_name, get_value +from code_puppy.config import ( + COMMAND_HISTORY_FILE, + get_config_keys, + get_puppy_name, + get_value, +) from code_puppy.messaging.spinner.spinner_base import SpinnerBase @@ -71,6 +76,23 @@ def _get_runtime() -> PromptRuntimeState | None: return get_active_interactive_runtime() +def _get_current_agent_for_prompt(): + """Best-effort current-agent lookup for prompt rendering. + + Prompt painting should not fail just because optional agent dependencies + are unavailable in the current environment. + """ + try: + from code_puppy.agents.agent_manager import get_current_agent + except Exception: + return None + + try: + return get_current_agent() + except Exception: + return None + + def register_active_prompt_surface( kind: Literal["main", "interject"], session: PromptSession ) -> None: @@ -126,6 +148,7 @@ def _interrupt_shell_from_prompt(label: str) -> None: emit_warning(f"\nšŸ›‘ {label} detected! Interrupting shell command...") kill_all_running_shell_processes() + def _truncate_queue_line(text: str, max_len: int) -> str: if max_len <= 2: return ".." @@ -684,14 +707,12 @@ def _build_prompt_parts( include_queue_preview: bool, include_pending_hint: bool, ) -> list[tuple[str, str]]: - from code_puppy.agents.agent_manager import get_current_agent - puppy = get_puppy_name() global_model = get_active_model() or "(default)" runtime = _get_runtime() # Get current agent information - current_agent = get_current_agent() + current_agent = _get_current_agent_for_prompt() agent_display = current_agent.display_name if current_agent else "code-puppy" # Check if current agent has a pinned model @@ -738,15 +759,17 @@ def _build_prompt_parts( if hidden: parts.append(("class:queue-item", f" ... and {hidden} more\n")) - parts.extend([ - ("class:separator", "╭─ "), - ("bold", "🐶 "), - ("class:puppy", f"{puppy}"), - ("", " "), - ("class:agent", f"[{agent_display}] "), - ("class:model", model_display + " "), - ("class:cwd", "(" + str(cwd_display) + ") \n"), - ]) + parts.extend( + [ + ("class:separator", "╭─ "), + ("bold", "🐶 "), + ("class:puppy", f"{puppy}"), + ("", " "), + ("class:agent", f"[{agent_display}] "), + ("class:model", model_display + " "), + ("class:cwd", "(" + str(cwd_display) + ") \n"), + ] + ) if include_pending_hint and ( is_interject or (runtime is not None and runtime.has_pending_submission()) @@ -759,10 +782,12 @@ def _build_prompt_parts( ) ) - parts.extend([ - ("class:separator", "╰─"), - ("class:arrow", "āÆ "), - ]) + parts.extend( + [ + ("class:separator", "╰─"), + ("class:arrow", "āÆ "), + ] + ) return parts @@ -850,9 +875,6 @@ async def prompt_for_submission( pending_decision_filter = Condition( lambda: runtime is not None and runtime.has_pending_submission() ) - shell_active_filter = Condition( - lambda: runtime is not None and runtime.has_active_shell() - ) busy_run_filter = Condition(lambda: runtime is not None and runtime.running) command_completion_filter = Condition( lambda: runtime is None @@ -1407,7 +1429,6 @@ async def get_interject_action() -> str: return "" - if __name__ == "__main__": print("Type '@' for path-completion or '/model' to pick a model. Ctrl+D to exit.") diff --git a/code_puppy/messaging/__init__.py b/code_puppy/messaging/__init__.py index 8f147b7ce..7d968256c 100644 --- a/code_puppy/messaging/__init__.py +++ b/code_puppy/messaging/__init__.py @@ -87,11 +87,9 @@ get_global_queue, provide_prompt_response, ) -from .legacy_bridge import LegacyQueueToBusBridge # Message types and enums from .messages import ( # Enums, Base, Text, File ops, Diff, Shell, Agent, etc. - AgentListMessage, AgentReasoningMessage, AgentResponseMessage, AnyMessage, @@ -105,7 +103,6 @@ FileListingMessage, GrepMatch, GrepResultMessage, - LegacyQueueMessage, MessageCategory, MessageLevel, SelectionRequest, @@ -184,7 +181,6 @@ "SynchronousInteractiveRenderer", "QueueConsole", "get_queue_console", - "LegacyQueueToBusBridge", # ------------------------------------------------------------------------- # New Structured Messaging API # ------------------------------------------------------------------------- @@ -196,7 +192,6 @@ "BaseCommand", # Message types "TextMessage", - "LegacyQueueMessage", "FileEntry", "FileListingMessage", "FileContentMessage", @@ -220,7 +215,6 @@ "DividerMessage", "StatusPanelMessage", "VersionCheckMessage", - "AgentListMessage", "SkillEntry", "SkillListMessage", "SkillActivateMessage", diff --git a/code_puppy/messaging/renderers.py b/code_puppy/messaging/renderers.py index 1675517b6..2caed282d 100644 --- a/code_puppy/messaging/renderers.py +++ b/code_puppy/messaging/renderers.py @@ -24,7 +24,7 @@ def render_legacy_ui_message( if message.type == MessageType.HUMAN_INPUT_REQUEST: if not allow_human_input: safe_content = escape_rich_markup(str(message.content)) - console.print(f"[dim]Input requested: {safe_content}[/dim]") + console.print(f"[bold cyan]INPUT REQUESTED:[/bold cyan] {safe_content}") if hasattr(console.file, "flush"): console.file.flush() return diff --git a/code_puppy/messaging/rich_renderer.py b/code_puppy/messaging/rich_renderer.py index 62df7cf99..3473aa1d5 100644 --- a/code_puppy/messaging/rich_renderer.py +++ b/code_puppy/messaging/rich_renderer.py @@ -1196,9 +1196,7 @@ def _render_agent_list(self, msg: AgentListMessage) -> None: return banner = self._format_banner("list_agents", "LIST AGENTS") - self._console.print( - f"\n{banner} [dim]Found {msg.agent_count} agent(s).[/dim]" - ) + self._console.print(f"\n{banner} [dim]Found {msg.agent_count} agent(s).[/dim]") # ========================================================================= # Skills diff --git a/code_puppy/messaging/spinner/console_spinner.py b/code_puppy/messaging/spinner/console_spinner.py index c3335e44c..017615e26 100644 --- a/code_puppy/messaging/spinner/console_spinner.py +++ b/code_puppy/messaging/spinner/console_spinner.py @@ -45,6 +45,9 @@ def start(self): if self._thread and self._thread.is_alive(): return + # Print blank line before spinner for visual separation. + self.console.print() + # Create a Live display for the spinner self._live = Live( self._generate_spinner_panel(), @@ -204,6 +207,9 @@ def resume(self): sys.stdout.write("\x1b[K") # Clear to end of line sys.stdout.flush() + # Print blank line before spinner for visual separation. + self.console.print() + self._live = Live( self._generate_spinner_panel(), console=self.console, diff --git a/code_puppy/plugins/chatgpt_oauth/register_callbacks.py b/code_puppy/plugins/chatgpt_oauth/register_callbacks.py index 5b062a49f..d90bed3ad 100644 --- a/code_puppy/plugins/chatgpt_oauth/register_callbacks.py +++ b/code_puppy/plugins/chatgpt_oauth/register_callbacks.py @@ -8,7 +8,7 @@ import os import threading -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Tuple from code_puppy.callbacks import register_callback from code_puppy.command_line.interactive_command import BackgroundInteractiveCommand diff --git a/code_puppy/plugins/oauth_control.py b/code_puppy/plugins/oauth_control.py index f6fa1d585..9f52a07ea 100644 --- a/code_puppy/plugins/oauth_control.py +++ b/code_puppy/plugins/oauth_control.py @@ -22,7 +22,9 @@ def wait_for_event_or_cancel( while True: if cancel_event is not None and cancel_event.is_set(): return "cancelled" - if event.wait(timeout=min(poll_interval, max(0.0, deadline - time.monotonic()))): + if event.wait( + timeout=min(poll_interval, max(0.0, deadline - time.monotonic())) + ): return "completed" if time.monotonic() >= deadline: return "timeout" diff --git a/code_puppy/tools/agent_tools.py b/code_puppy/tools/agent_tools.py index 5d7716498..305147cc5 100644 --- a/code_puppy/tools/agent_tools.py +++ b/code_puppy/tools/agent_tools.py @@ -25,23 +25,27 @@ get_value, ) from code_puppy.messaging import ( - AgentListMessage, MessageLevel, SubAgentInvocationMessage, SubAgentResponseMessage, TextMessage, emit_error, + emit_info as _emit_info, emit_success, get_message_bus, get_session_context, set_session_context, ) +from code_puppy.messaging.messages import AgentListMessage from code_puppy.tools.common import generate_group_id from code_puppy.tools.subagent_context import subagent_context # Set to track active subagent invocation tasks _active_subagent_tasks: Set[asyncio.Task] = set() +# Preserve the historical module-level symbol for tests and call sites that patch it. +emit_info = _emit_info + # Atomic counter for DBOS workflow IDs - ensures uniqueness even in rapid back-to-back calls # itertools.count() is thread-safe for next() calls _dbos_workflow_counter = itertools.count() diff --git a/code_puppy/tools/command_runner.py b/code_puppy/tools/command_runner.py index d72ce4f10..725d29143 100644 --- a/code_puppy/tools/command_runner.py +++ b/code_puppy/tools/command_runner.py @@ -1211,7 +1211,9 @@ async def _execute_shell_command( get_active_interactive_runtime, ) except ImportError: - get_active_interactive_runtime = lambda: None # type: ignore[assignment] + + def get_active_interactive_runtime(): # type: ignore[no-redef] + return None interactive_runtime = get_active_interactive_runtime() if interactive_runtime is not None: diff --git a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md deleted file mode 100644 index 644414bd9..000000000 --- a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md +++ /dev/null @@ -1,85 +0,0 @@ -# Interactive Regression Checklist - -Run this before merging changes that touch the interactive runtime, prompt surface, queue/interject flow, slash commands, or OAuth setup. - -## Transcript And Rendering - -- Run `/help` and confirm normal slash-command output appears above the composer. -- Run `/show`, `/cd` with no args, `/tools`, and `/skills list` and confirm `Text`, `Table`, and `Markdown` output still render cleanly above the composer. -- Trigger one invalid command such as a bad `/set` value and confirm warning/error text is visible above the composer. -- Confirm no raw ANSI fragments or prompt corruption appear while command output is printed. -- Interject during repeated directory listings or other multi-line tool output and confirm listings/notice text do not interleave or leak raw `?[32m`-style fragments. -- Repeat the directory-listing/interject check on a second machine or terminal when possible, since the bug is timing-sensitive. - -## OAuth Setup And Escape Paths - -- Start `/antigravity-add` and confirm browser/callback status text appears above the composer. -- While waiting for callback, verify `/exit` exits immediately. -- Rerun the auth flow and verify `Ctrl+C` cancels the auth wait cleanly. -- Rerun the auth flow and verify interject cancels the active auth wait first, then proceeds. -- Rerun the auth flow and verify queue saves cleanly while waiting, then drains after the wait ends. -- After cancel, confirm there is no timeout/failure spam and no half-applied model/config change. -- After queueing or interjecting during the auth wait, confirm there is no traceback even if cancel completes immediately. -- Verify a successful auth path still completes normally when not cancelled. -- Verify `/tutorial` auth handoff still routes into the same cancellable auth path. - -## Busy Slash-Command Gating - -- While an agent run is active, type `/model` and confirm slash-command completion does not open. -- While active work is running, submit `/model` and queue it; confirm it is treated as literal text for the agent, not executed as a command. -- While active work is running, submit `/model` and interject it; confirm it is treated as literal text for the agent, not executed as a command. -- While active work is running, verify `/exit` and `/quit` still bypass queue/interject handling immediately. -- While the inline chooser is visible, confirm slash-command menus do not appear and stray typing does not replace the stored pending prompt. -- While the inline chooser is visible, mash a few normal typing keys and confirm the buffer stays inert instead of accepting text. -- While the inline chooser is visible, press `e` and confirm the saved draft returns to the composer exactly as written. -- If `Up Arrow` is still supported as an alias, confirm it also restores the saved draft. -- While the inline chooser is visible, press `Esc` and confirm the saved draft is dropped and the composer is empty. -- Confirm the chooser hint stays compact and readable while still exposing `i`, `q`, `e`, and `Esc`. -- In the idle composer, type bare `@` and confirm current-directory file/path completions appear. -- Confirm `@` completion behaves like prompt_toolkit completion, not a picker: `Tab` should only cycle/advance candidates, the prompt_toolkit accept-completion keys should still work, and `Enter` should still submit the buffer. -- After an `@` path is in the buffer, confirm typing space just continues the prompt normally; it is not a special acceptance key. -- While active work is running and the chooser is not visible yet, type `@` and confirm file/document completion still works for composing a future prompt. -- Once the chooser is visible, confirm `@` completion does not appear and attachment placeholder rendering does not activate for chooser typing. -- Queue or interject a prompt that already includes `@...` before the chooser opens, then confirm attachments still resolve when that stored prompt later runs. - -## Cancel Behavior - -- During a foreground shell command, press `Ctrl+C` and confirm the shell is interrupted without tearing down the outer session. -- During a foreground shell command with queued prompts waiting, press `Ctrl+C` and confirm the shell stops and queued prompts remain queued instead of auto-starting. -- During a background interactive command or sub-agent action, press `Ctrl+C` and confirm the inner work cancels and control returns cleanly. -- During a normal active agent run, press `Ctrl+C` and confirm the run cancels and the prompt becomes usable again. -- During a normal active agent run with queued prompts waiting, press `Ctrl+C` and confirm the current run stops but queued prompts do not auto-start. -- After that manual cancel, submit one fresh prompt and confirm normal queue draining resumes only after that explicit submission path runs. -- While idle with queue autodrain paused and queued prompts waiting, press `Enter` on an empty composer and confirm the next queued prompt is recalled into the composer for editing instead of auto-starting. -- If the chooser is visible during active work, press `Ctrl+C` and confirm the active work cancels and the chooser/input state clears. - -## Queue And Prompt Stability - -- Queue a normal follow-up while shell output is still streaming and confirm the save line appears immediately. -- Verify queued and interjected prompts still echo into the transcript before dispatch. -- Verify `[QUEUE TRIGGERED]` still prints before the echoed queued prompt. -- Verify a normal direct prompt after a queue/interject cycle still appears in the transcript once. -- Confirm the composer stays fixed, the shell output remains plain text, and the queue/interject visuals do not regress. - -## Tokens And Hooks - -- Run a normal prompt with a link or document attachment and confirm the top-level token/context line still seeds sensibly before the response starts. -- Invoke a sub-agent and confirm token counting still updates in the sub-agent console path rather than corrupting the top-level prompt line. -- Run `/hooks`, `/hook list`, and `/hooks status` and confirm they still execute and render above the composer in the mounted-composer fork. -- Re-run the hook-engine regression slice if hook-related code or command output wiring changes. - -## Wiggum - -- Start `/wiggum hello`, queue a normal follow-up, and confirm Wiggum keeps rerunning its stored prompt while the queued turn waits. -- Stop Wiggum, then confirm the queued turn still has not auto-started if Wiggum was stopped manually with `Ctrl+C`. -- While Wiggum is active, interject a follow-up and confirm it cuts in immediately, affects only the current iteration, and then Wiggum resumes its stored loop prompt. -- While Wiggum is active, queue a slash-prefixed prompt such as `/agent` and confirm it is treated as literal agent text, not executed as a slash command when it later drains. -- Press `Ctrl+C` during Wiggum and confirm it stops once cleanly, does not relaunch future loops, and does not print a stray `Input cancelled` after the stop. - -## Autosave - -- Submit a normal prompt and confirm `Auto-saved session` appears after the completed response. -- Queue multiple prompts and confirm each completed response auto-saves before the next queued turn starts. -- Interject during a run and confirm the completed interjected response auto-saves. -- Cancel a run and confirm no autosave fires for that cancelled turn. -- Run an OAuth/background interactive command and confirm it does not auto-save on its own. diff --git a/tests/agents/test_event_stream_handler.py b/tests/agents/test_event_stream_handler.py index 64462758e..f5f1b0778 100644 --- a/tests/agents/test_event_stream_handler.py +++ b/tests/agents/test_event_stream_handler.py @@ -523,7 +523,9 @@ async def event_stream(): assert any("token(s)" in str(call) for call in call_args_list) @pytest.mark.asyncio - async def test_tool_call_prompt_surface_mode_avoids_carriage_returns(self, mock_ctx): + async def test_tool_call_prompt_surface_mode_avoids_carriage_returns( + self, mock_ctx + ): """Prompt-surface mode should suppress terminal tool streaming entirely.""" tool_part = ToolCallPart(tool_call_id="tool_1", tool_name="test_tool", args={}) start_event = PartStartEvent(index=0, part=tool_part) @@ -596,7 +598,9 @@ async def test_prompt_surface_mode_streams_plain_text_response(self, mock_ctx): """Prompt-surface mode should suppress text streaming and skip termflow.""" text_part = TextPart(content="") start_event = PartStartEvent(index=0, part=text_part) - delta_event = PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello")) + delta_event = PartDeltaEvent( + index=0, delta=TextPartDelta(content_delta="hello") + ) end_event = PartEndEvent(index=0, part=text_part, next_part_kind=None) async def event_stream(): diff --git a/tests/command_line/test_add_model_menu_coverage.py b/tests/command_line/test_add_model_menu_coverage.py index 9ef8c56af..5bbd12efc 100644 --- a/tests/command_line/test_add_model_menu_coverage.py +++ b/tests/command_line/test_add_model_menu_coverage.py @@ -824,6 +824,7 @@ def test_run_pending_credentials_success( menu = _make_menu_with_providers([p]) mock_app = MagicMock() mock_app_cls.return_value = mock_app + mock_input.return_value = "test-credential" def run_side_effect(**kwargs): menu.result = "pending_credentials" diff --git a/tests/plugins/conftest.py b/tests/plugins/conftest.py index 25dfb7109..a16acabe4 100644 --- a/tests/plugins/conftest.py +++ b/tests/plugins/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +from types import ModuleType from unittest.mock import AsyncMock, MagicMock import pytest @@ -13,15 +14,59 @@ # Skip antigravity tests if pydantic/MCP conflict is detected def pytest_configure(config): """Configure pytest with compatibility workarounds.""" - # Pre-patch sys.modules to provide a mock mcp.types during collection - # This prevents the ValueError in pydantic's RootModel metaclass + # Pre-patch sys.modules to provide a lightweight MCP package during + # collection. Some plugin tests only need the names to import cleanly, but + # later suites may import pydantic_ai.mcp, which expects package-shaped + # modules such as mcp.client.sse to exist. if "mcp" not in sys.modules: - mcp_mock = MagicMock() - mcp_mock.types = MagicMock() - sys.modules["mcp"] = mcp_mock - sys.modules["mcp.types"] = mcp_mock.types - sys.modules["mcp.client"] = MagicMock() - sys.modules["mcp.client.session"] = MagicMock() + mcp_pkg = ModuleType("mcp") + types_mod = ModuleType("mcp.types") + client_pkg = ModuleType("mcp.client") + session_mod = ModuleType("mcp.client.session") + sse_mod = ModuleType("mcp.client.sse") + stdio_mod = ModuleType("mcp.client.stdio") + streamable_http_mod = ModuleType("mcp.client.streamable_http") + shared_pkg = ModuleType("mcp.shared") + exceptions_mod = ModuleType("mcp.shared.exceptions") + context_mod = ModuleType("mcp.shared.context") + message_mod = ModuleType("mcp.shared.message") + session_shared_mod = ModuleType("mcp.shared.session") + + session_mod.ClientSession = MagicMock() + session_mod.ElicitationFnT = MagicMock() + session_mod.LoggingFnT = MagicMock() + sse_mod.sse_client = MagicMock() + stdio_mod.StdioServerParameters = MagicMock() + stdio_mod.stdio_client = MagicMock() + streamable_http_mod.streamable_http_client = MagicMock() + context_mod.RequestContext = MagicMock() + message_mod.SessionMessage = MagicMock() + session_shared_mod.RequestResponder = MagicMock() + + mcp_pkg.types = types_mod + mcp_pkg.client = client_pkg + mcp_pkg.shared = shared_pkg + client_pkg.session = session_mod + client_pkg.sse = sse_mod + client_pkg.stdio = stdio_mod + client_pkg.streamable_http = streamable_http_mod + shared_pkg.exceptions = exceptions_mod + shared_pkg.context = context_mod + shared_pkg.message = message_mod + shared_pkg.session = session_shared_mod + + sys.modules["mcp"] = mcp_pkg + sys.modules["mcp.types"] = types_mod + sys.modules["mcp.client"] = client_pkg + sys.modules["mcp.client.session"] = session_mod + sys.modules["mcp.client.sse"] = sse_mod + sys.modules["mcp.client.stdio"] = stdio_mod + sys.modules["mcp.client.streamable_http"] = streamable_http_mod + sys.modules["mcp.shared"] = shared_pkg + sys.modules["mcp.shared.exceptions"] = exceptions_mod + sys.modules["mcp.shared.context"] = context_mod + sys.modules["mcp.shared.message"] = message_mod + sys.modules["mcp.shared.session"] = session_shared_mod class ClientShim: diff --git a/tests/test_agent_tools_coverage.py b/tests/test_agent_tools_coverage.py index 2c08b3690..dff481cbf 100644 --- a/tests/test_agent_tools_coverage.py +++ b/tests/test_agent_tools_coverage.py @@ -281,7 +281,9 @@ def capture_tool(func): mock_bus = MagicMock() with ( - patch("code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus), + patch( + "code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus + ), patch("code_puppy.agents.get_available_agents") as mock_available, patch("code_puppy.agents.get_agent_descriptions") as mock_descriptions, ): @@ -328,7 +330,9 @@ def capture_tool(func): mock_bus = MagicMock() with ( - patch("code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus), + patch( + "code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus + ), patch( "code_puppy.agents.get_available_agents", side_effect=RuntimeError("Database connection failed"), @@ -363,7 +367,9 @@ def capture_tool(func): mock_bus = MagicMock() with ( - patch("code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus), + patch( + "code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus + ), patch("code_puppy.agents.get_available_agents") as mock_available, patch("code_puppy.agents.get_agent_descriptions") as mock_descriptions, ): @@ -717,7 +723,9 @@ def capture_tool(func): register_list_agents(mock_agent) with ( - patch("code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus), + patch( + "code_puppy.tools.agent_tools.get_message_bus", return_value=mock_bus + ), patch( "code_puppy.agents.get_available_agents", return_value={"python-programmer": "Python Programmer"}, diff --git a/tests/test_cli_runner_full_coverage.py b/tests/test_cli_runner_full_coverage.py index a5c0b982d..d5c9b3b0c 100644 --- a/tests/test_cli_runner_full_coverage.py +++ b/tests/test_cli_runner_full_coverage.py @@ -1154,7 +1154,9 @@ async def fake_run(*args, **kwargs): assert started_prompts[:2] == ["first task", "queued task"] render_notice.assert_any_call("[QUEUE TRIGGERED] queued task") render_prompt_echo.assert_any_call("queued task") - queued_notice_idx = render_order.index(("notice", "[QUEUE TRIGGERED] queued task")) + queued_notice_idx = render_order.index( + ("notice", "[QUEUE TRIGGERED] queued task") + ) queued_echo_idx = render_order.index(("echo", "queued task")) queued_start_idx = render_order.index(("start", "queued task")) assert queued_notice_idx < queued_echo_idx < queued_start_idx @@ -1523,7 +1525,9 @@ async def fake_run(*args, **kwargs): assert started_prompts[2] == "second queued" render_prompt_echo.assert_any_call("steer now") render_notice.assert_any_call("[QUEUE TRIGGERED] second queued") - queued_notice_idx = render_order.index(("notice", "[QUEUE TRIGGERED] second queued")) + queued_notice_idx = render_order.index( + ("notice", "[QUEUE TRIGGERED] second queued") + ) queued_echo_idx = render_order.index(("echo", "second queued")) queued_start_idx = render_order.index(("start", "second queued")) assert queued_notice_idx < queued_echo_idx < queued_start_idx @@ -2770,49 +2774,6 @@ def fake_import(name, *args, **kwargs): class TestRemainingEdgeCases: """Cover the hardest-to-reach lines.""" - @pytest.mark.anyio - async def test_cancelled_result_wiggum_stop_message(self): - """Lines 750-751: cancelled result emits wiggum stop warning.""" - call_count = 0 - - async def fake_input(*a, **kw): - nonlocal call_count - call_count += 1 - return "write hello" if call_count == 1 else "/exit" - - agent = MagicMock() - agent.get_user_prompt.return_value = "task:" - - # drain_pending_work_if_idle now checks Wiggum state before dispatching, so - # allow one extra truthy call before the cancelled-result stop path runs. - wiggum_calls = 0 - - def fake_wiggum(): - nonlocal wiggum_calls - wiggum_calls += 1 - if wiggum_calls <= 2: - return True - return False # after the while loop - - mock_stop = MagicMock() - await _run_interactive( - _mock_renderer(), - _interactive_patches(), - fake_input, - agent=agent, - extra_patches={ - "code_puppy.cli_runner.run_prompt_with_attachments": AsyncMock( - return_value=(None, MagicMock()) - ), - "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( - return_value=_mock_parse_result("write hello") - ), - "code_puppy.command_line.wiggum_state.is_wiggum_active": fake_wiggum, - "code_puppy.command_line.wiggum_state.stop_wiggum": mock_stop, - }, - ) - mock_stop.assert_called() - @pytest.mark.anyio async def test_execute_single_prompt_success_path(self): """Lines 1005-1015: execute_single_prompt success with .output access.""" diff --git a/tests/test_command_overhaul_targeted.py b/tests/test_command_overhaul_targeted.py index ce8f9bb50..eefc146ee 100644 --- a/tests/test_command_overhaul_targeted.py +++ b/tests/test_command_overhaul_targeted.py @@ -20,7 +20,9 @@ def _renderer(): return renderer -def _submission(text: str, *, action: str = "submit", allow_command_dispatch: bool = True): +def _submission( + text: str, *, action: str = "submit", allow_command_dispatch: bool = True +): return PromptSubmission( action=action, text=text, @@ -28,7 +30,9 @@ def _submission(text: str, *, action: str = "submit", allow_command_dispatch: bo ) -async def _run_interactive(prompt_side_effect, *, run_prompt_side_effect, handle_command): +async def _run_interactive( + prompt_side_effect, *, run_prompt_side_effect, handle_command +): agent = MagicMock() agent.get_user_prompt.return_value = "task:" fake_agents_pkg = ModuleType("code_puppy.agents") @@ -78,9 +82,7 @@ async def _run_interactive(prompt_side_effect, *, run_prompt_side_effect, handle patch.object(cli_runner_module, "reset_windows_terminal_full") ) stack.enter_context(patch.object(cli_runner_module, "save_command_to_history")) - stack.enter_context( - patch("code_puppy.command_line.motd.print_motd") - ) + stack.enter_context(patch("code_puppy.command_line.motd.print_motd")) stack.enter_context( patch( "code_puppy.command_line.onboarding_wizard.should_show_onboarding", @@ -504,15 +506,19 @@ def fake_is_wiggum_active(): def fake_stop_wiggum(): wiggum_active["value"] = False - with patch( - "code_puppy.command_line.wiggum_state.is_wiggum_active", - side_effect=fake_is_wiggum_active, - ), patch( - "code_puppy.command_line.wiggum_state.stop_wiggum", - side_effect=fake_stop_wiggum, - ), patch( - "code_puppy.messaging.emit_warning", - side_effect=warning_messages.append, + with ( + patch( + "code_puppy.command_line.wiggum_state.is_wiggum_active", + side_effect=fake_is_wiggum_active, + ), + patch( + "code_puppy.command_line.wiggum_state.stop_wiggum", + side_effect=fake_stop_wiggum, + ), + patch( + "code_puppy.messaging.emit_warning", + side_effect=warning_messages.append, + ), ): await _run_interactive( prompt_side_effect, @@ -580,12 +586,15 @@ def fake_is_wiggum_active(): def fake_stop_wiggum(): wiggum_active["value"] = False - with patch( - "code_puppy.command_line.wiggum_state.is_wiggum_active", - side_effect=fake_is_wiggum_active, - ), patch( - "code_puppy.command_line.wiggum_state.stop_wiggum", - side_effect=fake_stop_wiggum, + with ( + patch( + "code_puppy.command_line.wiggum_state.is_wiggum_active", + side_effect=fake_is_wiggum_active, + ), + patch( + "code_puppy.command_line.wiggum_state.stop_wiggum", + side_effect=fake_stop_wiggum, + ), ): await _run_interactive( prompt_side_effect, diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index 5e5875dc6..c61c2e17a 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -1,7 +1,6 @@ import asyncio import contextlib import os -import sys import threading from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -10,37 +9,30 @@ from prompt_toolkit.buffer import Buffer from prompt_toolkit.completion import ConditionalCompleter from prompt_toolkit.document import Document -from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.keys import Keys from prompt_toolkit.layout.controls import BufferControl from prompt_toolkit.layout.processors import TransformationInput +from code_puppy.command_line.interactive_runtime import ( + PromptRuntimeState, + clear_active_interactive_runtime, + register_active_interactive_runtime, +) from code_puppy.command_line.prompt_toolkit_completion import ( AttachmentPlaceholderProcessor, - CDCompleter, - FilePathCompleter, PromptSubmission, - SetCompleter, clear_active_prompt_surface, get_active_prompt_surface_kind, - get_prompt_with_active_model, get_input_with_combined_completion, + get_prompt_with_active_model, has_active_prompt_surface, is_shell_prompt_suspended, prompt_for_submission, - render_transcript_notice, - render_submitted_prompt_echo, register_active_prompt_surface, + render_submitted_prompt_echo, + render_transcript_notice, set_shell_prompt_suspended, ) -from code_puppy.command_line.interactive_runtime import ( - PromptRuntimeState, - clear_active_interactive_runtime, - register_active_interactive_runtime, -) - -# Skip some path-format sensitive tests on Windows where backslashes are expected -IS_WINDOWS = os.name == "nt" or sys.platform.startswith("win") @pytest.fixture @@ -51,223 +43,6 @@ def active_runtime(): clear_active_interactive_runtime(runtime) -def setup_files(tmp_path): - d = tmp_path / "dir" - d.mkdir() - (d / "file1.txt").write_text("content1") - (d / "file2.py").write_text("content2") - (tmp_path / "file3.txt").write_text("hi") - (tmp_path / ".hiddenfile").write_text("sneaky") - return d - - -def test_no_symbol(tmp_path): - completer = FilePathCompleter(symbol="@") - doc = Document(text="no_completion_here", cursor_position=7) - completions = list(completer.get_completions(doc, None)) - assert completions == [] - - -def test_completion_basic(tmp_path, monkeypatch): - setup_files(tmp_path) - cwd = os.getcwd() - os.chdir(tmp_path) - try: - completer = FilePathCompleter(symbol="@") - doc = Document(text="run @fi", cursor_position=7) - completions = list(completer.get_completions(doc, None)) - # Should see file3.txt from the base dir, but NOT .hiddenfile - values = {c.text for c in completions} - assert any("file3.txt" in v for v in values) - assert not any(".hiddenfile" in v for v in values) - finally: - os.chdir(cwd) - - -def test_completion_directory_listing(tmp_path): - d = setup_files(tmp_path) - completer = FilePathCompleter(symbol="@") - # Set cwd so dir lookup matches. Fix cursor position off by one. - cwd = os.getcwd() - os.chdir(tmp_path) - try: - text = f"test @{d.name}/" - doc = Document(text=text, cursor_position=len(text)) - completions = list(completer.get_completions(doc, None)) - # In modern prompt_toolkit, display is a FormattedText: a list of (style, text) tuples - filenames = { - c.display[0][1] if hasattr(c.display, "__getitem__") else str(c.display) - for c in completions - } - assert "file1.txt" in filenames - assert "file2.py" in filenames - finally: - os.chdir(cwd) - - -def test_completion_symbol_in_middle(tmp_path): - setup_files(tmp_path) - completer = FilePathCompleter(symbol="@") - cwd = os.getcwd() - os.chdir(tmp_path) - try: - doc = Document(text="echo @fi then something", cursor_position=7) - completions = list(completer.get_completions(doc, None)) - assert any("file3.txt" in c.text for c in completions) - finally: - os.chdir(cwd) - - -def test_completion_with_hidden_file(tmp_path): - # Should show hidden files if user types starting with . - setup_files(tmp_path) - completer = FilePathCompleter(symbol="@") - cwd = os.getcwd() - os.chdir(tmp_path) - try: - doc = Document(text="@.", cursor_position=2) - completions = list(completer.get_completions(doc, None)) - assert any(".hiddenfile" in c.text for c in completions) - finally: - os.chdir(cwd) - - -def test_completion_bare_at_lists_current_directory(tmp_path): - setup_files(tmp_path) - completer = FilePathCompleter(symbol="@") - cwd = os.getcwd() - os.chdir(tmp_path) - try: - doc = Document(text="@", cursor_position=1) - completions = list(completer.get_completions(doc, None)) - values = {c.text for c in completions} - assert any("file3.txt" in value for value in values) - assert any("dir" in value for value in values) - finally: - os.chdir(cwd) - - -def test_completion_handles_permissionerror(monkeypatch): - # Patch os.listdir to explode! - completer = FilePathCompleter(symbol="@") - - def explode(path): - raise PermissionError - - monkeypatch.setattr(os, "listdir", explode) - doc = Document(text="@", cursor_position=1) - # Should not raise: - list(completer.get_completions(doc, None)) - - -def test_set_completer_on_non_trigger(): - completer = SetCompleter() - doc = Document(text="not_a_set_command") - assert list(completer.get_completions(doc, None)) == [] - - -def test_set_completer_exact_trigger(monkeypatch): - completer = SetCompleter() - doc = Document(text="/set", cursor_position=len("/set")) - completions = list(completer.get_completions(doc, None)) - assert len(completions) == 1 - assert completions[0].text == "/set " # Check the actual text to be inserted - # display_meta can be FormattedText, so access its content - assert completions[0].display_meta[0][1] == "set config key" - - -def test_set_completer_on_set_trigger(monkeypatch): - # Simulate config keys - monkeypatch.setattr( - "code_puppy.command_line.prompt_toolkit_completion.get_config_keys", - lambda: ["foo", "bar"], - ) - monkeypatch.setattr( - "code_puppy.command_line.prompt_toolkit_completion.get_value", - lambda key: "woo" if key == "foo" else None, - ) - completer = SetCompleter() - doc = Document(text="/set ", cursor_position=len("/set ")) - completions = list(completer.get_completions(doc, None)) - completion_texts = sorted([c.text for c in completions]) - completion_metas = sorted( - [c.display_meta for c in completions] - ) # Corrected display_meta access - - # The completer now provides 'key = value' as text, not '/set key = value' - assert completion_texts == sorted(["bar = ", "foo = woo"]) - # Display meta should be empty now - assert len(completion_metas) == 2 - for meta in completion_metas: - assert isinstance(meta, FormattedText) - assert len(meta) == 1 - assert meta[0][1] == "" - - -def test_set_completer_partial_key(monkeypatch): - monkeypatch.setattr( - "code_puppy.command_line.prompt_toolkit_completion.get_config_keys", - lambda: ["long_key_name", "other_key", "model"], - ) - monkeypatch.setattr( - "code_puppy.command_line.prompt_toolkit_completion.get_value", - lambda key: "value_for_" + key if key == "long_key_name" else None, - ) - completer = SetCompleter() - - doc = Document(text="/set long_k", cursor_position=len("/set long_k")) - completions = list(completer.get_completions(doc, None)) - assert len(completions) == 1 - # `text` for partial key completion should be the key itself and its value part - assert completions[0].text == "long_key_name = value_for_long_key_name" - # Display meta should be empty now - assert isinstance(completions[0].display_meta, FormattedText) - assert len(completions[0].display_meta) == 1 - assert completions[0].display_meta[0][1] == "" - - doc = Document(text="/set oth", cursor_position=len("/set oth")) - completions = list(completer.get_completions(doc, None)) - assert len(completions) == 1 - assert completions[0].text == "other_key = " - # Display meta should be empty now - assert isinstance(completions[0].display_meta, FormattedText) - assert len(completions[0].display_meta) == 1 - assert completions[0].display_meta[0][1] == "" - - -def test_set_completer_excludes_model_key(monkeypatch): - # Ensure 'model' is a config key but SetCompleter doesn't offer it - monkeypatch.setattr( - "code_puppy.command_line.prompt_toolkit_completion.get_config_keys", - lambda: ["api_key", "model", "temperature"], - ) - monkeypatch.setattr( - "code_puppy.command_line.prompt_toolkit_completion.get_value", - lambda key: "test_value", - ) - completer = SetCompleter() - - # Test with full "model" typed - doc = Document(text="/set model", cursor_position=len("/set model")) - completions = list(completer.get_completions(doc, None)) - assert completions == [], ( - "SetCompleter should not complete for 'model' key directly" - ) - - # Test with partial "mo" that would match "model" - doc = Document(text="/set mo", cursor_position=len("/set mo")) - completions = list(completer.get_completions(doc, None)) - assert completions == [], ( - "SetCompleter should not complete for 'model' key even partially" - ) - - # Ensure other keys are still completed - doc = Document(text="/set api", cursor_position=len("/set api")) - completions = list(completer.get_completions(doc, None)) - assert len(completions) == 1 - assert completions[0].text == "api_key = test_value" - - @patch("code_puppy.command_line.prompt_toolkit_completion.print_formatted_text") @patch("prompt_toolkit.output.defaults.create_output") def test_render_submitted_prompt_echo(mock_create_output, mock_print_formatted_text): @@ -332,350 +107,6 @@ def test_render_transcript_notice_uses_prompt_app_when_available( mock_print_formatted_text.assert_not_called() -def test_set_completer_excludes_puppy_token(monkeypatch): - # Ensure 'puppy_token' is a config key but SetCompleter doesn't offer it - monkeypatch.setattr( - "code_puppy.command_line.prompt_toolkit_completion.get_config_keys", - lambda: ["puppy_token", "user_name", "temp_dir"], - ) - monkeypatch.setattr( - "code_puppy.command_line.prompt_toolkit_completion.get_value", - lambda key: "sensitive_token_value" if key == "puppy_token" else "normal_value", - ) - completer = SetCompleter() - - # Test with full "puppy_token" typed - doc = Document(text="/set puppy_token", cursor_position=len("/set puppy_token")) - completions = list(completer.get_completions(doc, None)) - assert completions == [], ( - "SetCompleter should not complete for 'puppy_token' key directly" - ) - - # Test with partial "puppy" that would match "puppy_token" - doc = Document(text="/set puppy", cursor_position=len("/set puppy")) - completions = list(completer.get_completions(doc, None)) - assert completions == [], ( - "SetCompleter should not complete for 'puppy_token' key even partially" - ) - - # Ensure other keys are still completed - doc = Document(text="/set user", cursor_position=len("/set user")) - completions = list(completer.get_completions(doc, None)) - assert len(completions) == 1 - assert completions[0].text == "user_name = normal_value" - - -def test_set_completer_no_match(monkeypatch): - monkeypatch.setattr("code_puppy.config.get_config_keys", lambda: ["actual_key"]) - completer = SetCompleter() - doc = Document(text="/set non_existent", cursor_position=len("/set non_existent")) - completions = list(completer.get_completions(doc, None)) - assert completions == [] - - -def test_cd_completer_on_non_trigger(): - completer = CDCompleter() - doc = Document(text="something_else") - assert list(completer.get_completions(doc, None)) == [] - - -@pytest.fixture -def setup_cd_test_dirs(tmp_path): - # Current working directory structure - (tmp_path / "dir1").mkdir() - (tmp_path / "dir2_long_name").mkdir() - (tmp_path / "another_dir").mkdir() - (tmp_path / "file_not_dir.txt").write_text("hello") - - # Home directory structure for testing '~' expansion - mock_home_path = tmp_path / "mock_home" / "user" - mock_home_path.mkdir(parents=True, exist_ok=True) - (mock_home_path / "Documents").mkdir() - (mock_home_path / "Downloads").mkdir() - (mock_home_path / "Desktop").mkdir() - return tmp_path, mock_home_path - - -@pytest.mark.skipif(IS_WINDOWS, reason="Path separator expectations differ on Windows") -def test_cd_completer_initial_trigger(setup_cd_test_dirs, monkeypatch): - tmp_path, _ = setup_cd_test_dirs - monkeypatch.chdir(tmp_path) - completer = CDCompleter() - doc = Document(text="/cd ", cursor_position=len("/cd ")) - completions = list(completer.get_completions(doc, None)) - texts = sorted([c.text for c in completions]) - displays = sorted( - [ - "".join(item[1] for item in c.display) - if isinstance(c.display, list) - else str(c.display) - for c in completions - ] - ) - - # mock_home is also created at the root of tmp_path by the fixture - assert texts == sorted(["another_dir/", "dir1/", "dir2_long_name/", "mock_home/"]) - assert displays == sorted( - ["another_dir/", "dir1/", "dir2_long_name/", "mock_home/"] - ) - assert not any("file_not_dir.txt" in t for t in texts) - - -@pytest.mark.skipif(IS_WINDOWS, reason="Path separator expectations differ on Windows") -def test_cd_completer_partial_name(setup_cd_test_dirs, monkeypatch): - tmp_path, _ = setup_cd_test_dirs - monkeypatch.chdir(tmp_path) - completer = CDCompleter() - doc = Document(text="/cd di", cursor_position=len("/cd di")) - completions = list(completer.get_completions(doc, None)) - texts = sorted([c.text for c in completions]) - assert texts == sorted(["dir1/", "dir2_long_name/"]) - assert "another_dir/" not in texts - - -@pytest.mark.skipif(IS_WINDOWS, reason="Path separator expectations differ on Windows") -def test_cd_completer_sub_directory(setup_cd_test_dirs, monkeypatch): - tmp_path, _ = setup_cd_test_dirs - # Create a subdirectory with content - sub_dir = tmp_path / "dir1" / "sub1" - sub_dir.mkdir(parents=True) - (tmp_path / "dir1" / "sub2_another").mkdir() - - monkeypatch.chdir(tmp_path) - completer = CDCompleter() - doc = Document(text="/cd dir1/", cursor_position=len("/cd dir1/")) - completions = list(completer.get_completions(doc, None)) - texts = sorted([c.text for c in completions]) - # Completions should be relative to the 'base' typed in the command, which is 'dir1/' - # So, the 'text' part of completion should be 'dir1/sub1/' and 'dir1/sub2_another/' - assert texts == sorted(["dir1/sub1/", "dir1/sub2_another/"]) - displays = sorted(["".join(item[1] for item in c.display) for c in completions]) - assert displays == sorted(["sub1/", "sub2_another/"]) - - -@pytest.mark.skipif(IS_WINDOWS, reason="Path separator expectations differ on Windows") -def test_cd_completer_partial_sub_directory(setup_cd_test_dirs, monkeypatch): - tmp_path, _ = setup_cd_test_dirs - sub_dir = tmp_path / "dir1" / "sub_alpha" - sub_dir.mkdir(parents=True) - (tmp_path / "dir1" / "sub_beta").mkdir() - - monkeypatch.chdir(tmp_path) - completer = CDCompleter() - doc = Document(text="/cd dir1/sub_a", cursor_position=len("/cd dir1/sub_a")) - completions = list(completer.get_completions(doc, None)) - texts = sorted([c.text for c in completions]) - assert texts == ["dir1/sub_alpha/"] - displays = sorted(["".join(item[1] for item in c.display) for c in completions]) - assert displays == ["sub_alpha/"] - - -@pytest.mark.skipif(IS_WINDOWS, reason="Path separator expectations differ on Windows") -def test_cd_completer_home_directory_expansion(setup_cd_test_dirs, monkeypatch): - _, mock_home_path = setup_cd_test_dirs - monkeypatch.setattr( - os.path, "expanduser", lambda p: p.replace("~", str(mock_home_path)) - ) - # We don't chdir here, as ~ expansion should work irrespective of cwd - - completer = CDCompleter() - doc = Document(text="/cd ~/", cursor_position=len("/cd ~/")) - completions = list(completer.get_completions(doc, None)) - texts = sorted([c.text for c in completions]) - displays = sorted(["".join(item[1] for item in c.display) for c in completions]) - - # The 'text' should include the '~/' prefix as that's what the user typed as base - assert texts == sorted(["~/Desktop/", "~/Documents/", "~/Downloads/"]) - assert displays == sorted(["Desktop/", "Documents/", "Downloads/"]) - - -@pytest.mark.skipif(IS_WINDOWS, reason="Path separator expectations differ on Windows") -def test_cd_completer_home_directory_expansion_partial(setup_cd_test_dirs, monkeypatch): - _, mock_home_path = setup_cd_test_dirs - monkeypatch.setattr( - os.path, "expanduser", lambda p: p.replace("~", str(mock_home_path)) - ) - - completer = CDCompleter() - doc = Document(text="/cd ~/Do", cursor_position=len("/cd ~/Do")) - completions = list(completer.get_completions(doc, None)) - texts = sorted([c.text for c in completions]) - displays = sorted(["".join(item[1] for item in c.display) for c in completions]) - - assert texts == sorted(["~/Documents/", "~/Downloads/"]) - assert displays == sorted(["Documents/", "Downloads/"]) - assert "~/Desktop/" not in texts - - -def test_cd_completer_non_existent_base(setup_cd_test_dirs, monkeypatch): - tmp_path, _ = setup_cd_test_dirs - monkeypatch.chdir(tmp_path) - completer = CDCompleter() - doc = Document( - text="/cd non_existent_dir/", cursor_position=len("/cd non_existent_dir/") - ) - completions = list(completer.get_completions(doc, None)) - assert completions == [] - - -def test_cd_completer_permission_error_silently_handled(monkeypatch): - completer = CDCompleter() - # Patch the utility function used by CDCompleter - with patch( - "code_puppy.command_line.prompt_toolkit_completion.list_directory", - side_effect=PermissionError, - ) as mock_list_dir: - doc = Document(text="/cd somedir/", cursor_position=len("/cd somedir/")) - completions = list(completer.get_completions(doc, None)) - assert completions == [] - mock_list_dir.assert_called_once() - - -@pytest.mark.asyncio -@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") -@patch("code_puppy.command_line.prompt_toolkit_completion.FileHistory") -@patch("code_puppy.command_line.prompt_toolkit_completion.merge_completers") -async def test_get_input_with_combined_completion_defaults( - mock_merge_completers, mock_file_history, mock_prompt_session_cls -): - mock_session_instance = MagicMock() - mock_session_instance.prompt_async = AsyncMock(return_value="test input") - mock_prompt_session_cls.return_value = mock_session_instance - mock_merge_completers.return_value = MagicMock() # Mocked merged completer - - result = await get_input_with_combined_completion() - - mock_prompt_session_cls.assert_called_once() - assert ( - mock_prompt_session_cls.call_args[1]["completer"] - == mock_merge_completers.return_value - ) - assert mock_prompt_session_cls.call_args[1]["history"] is None - assert mock_prompt_session_cls.call_args[1]["complete_while_typing"] is True - assert "key_bindings" in mock_prompt_session_cls.call_args[1] - assert "input_processors" in mock_prompt_session_cls.call_args[1] - assert isinstance( - mock_prompt_session_cls.call_args[1]["input_processors"][0], - AttachmentPlaceholderProcessor, - ) - - mock_session_instance.prompt_async.assert_called_once() - # Check default prompt string was converted to FormattedText - assert isinstance(mock_session_instance.prompt_async.call_args[0][0], FormattedText) - assert mock_session_instance.prompt_async.call_args[0][0] == FormattedText( - [(None, ">>> ")] - ) - assert "style" in mock_session_instance.prompt_async.call_args[1] - - # NOTE: update_model_in_input is no longer called from the prompt layer. - # Instead, /model commands are handled by the command handler. - # The prompt layer now just returns the input as-is. - assert result == "test input" - mock_file_history.assert_not_called() - - -@pytest.mark.asyncio -@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") -@patch("code_puppy.command_line.prompt_toolkit_completion.SafeFileHistory") -async def test_get_input_with_combined_completion_with_history( - mock_safe_file_history, mock_prompt_session_cls -): - mock_session_instance = MagicMock() - mock_session_instance.prompt_async = AsyncMock(return_value="input with history") - mock_prompt_session_cls.return_value = mock_session_instance - mock_history_instance = MagicMock() - mock_safe_file_history.return_value = mock_history_instance - - history_path = "~/.my_test_history" - result = await get_input_with_combined_completion(history_file=history_path) - - mock_safe_file_history.assert_called_once_with(history_path) - assert mock_prompt_session_cls.call_args[1]["history"] == mock_history_instance - # NOTE: update_model_in_input is no longer called from the prompt layer. - assert result == "input with history" - - -@pytest.mark.asyncio -@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") -async def test_get_input_with_combined_completion_custom_prompt( - mock_prompt_session_cls, -): - mock_session_instance = MagicMock() - mock_session_instance.prompt_async = AsyncMock(return_value="custom prompt input") - mock_prompt_session_cls.return_value = mock_session_instance - - # Test with string prompt - custom_prompt_str = "Custom> " - await get_input_with_combined_completion(prompt_str=custom_prompt_str) - assert mock_session_instance.prompt_async.call_args[0][0] == FormattedText( - [(None, custom_prompt_str)] - ) - - # Test with FormattedText prompt - custom_prompt_ft = FormattedText([("class:test", "Formatted>")]) - await get_input_with_combined_completion(prompt_str=custom_prompt_ft) - assert mock_session_instance.prompt_async.call_args[0][0] == custom_prompt_ft - - -@pytest.mark.asyncio -@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") -async def test_get_input_with_combined_completion_no_model_update( - mock_prompt_session_cls, -): - raw_input = "raw user input" - mock_session_instance = MagicMock() - mock_session_instance.prompt_async = AsyncMock(return_value=raw_input) - mock_prompt_session_cls.return_value = mock_session_instance - - result = await get_input_with_combined_completion() - # NOTE: update_model_in_input is no longer called from the prompt layer. - # The prompt layer now just returns the input as-is. - assert result == raw_input - - -@pytest.mark.asyncio -@patch("code_puppy.command_line.prompt_toolkit_completion.patch_stdout") -@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") -async def test_prompt_for_submission_uses_non_raw_patch_stdout( - mock_prompt_session_cls, mock_patch_stdout -): - mock_session_instance = MagicMock() - mock_session_instance.prompt_async = AsyncMock(return_value="test input") - mock_prompt_session_cls.return_value = mock_session_instance - mock_patch_stdout.return_value.__enter__ = MagicMock() - mock_patch_stdout.return_value.__exit__ = MagicMock(return_value=False) - - result = await prompt_for_submission() - - assert result == PromptSubmission( - action="submit", - text="test input", - echo_in_transcript=False, - ) - mock_patch_stdout.assert_called_once_with() - - -@pytest.mark.asyncio -@patch("code_puppy.command_line.prompt_toolkit_completion.patch_stdout") -@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") -async def test_prompt_for_submission_sets_echo_flag_when_erasing_prompt( - mock_prompt_session_cls, mock_patch_stdout -): - mock_session_instance = MagicMock() - mock_session_instance.prompt_async = AsyncMock(return_value="hidden input") - mock_prompt_session_cls.return_value = mock_session_instance - mock_patch_stdout.return_value.__enter__ = MagicMock() - mock_patch_stdout.return_value.__exit__ = MagicMock(return_value=False) - - result = await prompt_for_submission(erase_when_done=True) - - assert result == PromptSubmission( - action="submit", - text="hidden input", - echo_in_transcript=True, - ) - - @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.patch_stdout") @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") @@ -704,38 +135,6 @@ async def test_prompt_for_submission_allows_at_completion_while_busy_but_blocks_ assert attachment_completer.filter() is False -# To test key bindings, we need to inspect the KeyBindings object passed to PromptSession -# We can get it from the mock_prompt_session_cls.call_args - - -@pytest.mark.xfail( - reason="Alt+M binding representation varies across prompt_toolkit versions; current implementation may not expose Keys.Escape + 'm' tuple.", - strict=False, -) -@pytest.mark.asyncio -@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") -async def test_get_input_key_binding_alt_m(mock_prompt_session_cls): - # We don't need the function to run fully, just to set up PromptSession - mock_session_instance = MagicMock() - mock_session_instance.prompt_async = AsyncMock(return_value="test") - mock_prompt_session_cls.return_value = mock_session_instance - - await get_input_with_combined_completion() - - bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] - # Find the Alt+M binding (Escape, 'm') - alt_m_handler = None - for binding in bindings.bindings: - if ( - len(binding.keys) == 2 - and binding.keys[0] == Keys.Escape - and binding.keys[1] == "m" - ): - alt_m_handler = binding.handler - break - assert alt_m_handler is not None, "Alt+M keybinding not found" - - @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") @patch("code_puppy.command_line.prompt_toolkit_completion._interrupt_shell_from_prompt") @@ -812,32 +211,6 @@ async def test_get_input_key_binding_configured_cancel_shell_interrupt_suppresse mock_event.app.exit.assert_not_called() -@pytest.mark.asyncio -@patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") -async def test_get_input_key_binding_escape(mock_prompt_session_cls): - mock_session_instance = MagicMock() - mock_session_instance.prompt_async = AsyncMock(return_value="test") - mock_prompt_session_cls.return_value = mock_session_instance - - await get_input_with_combined_completion() - - bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] - found_escape_handler = None - for binding_obj in bindings.bindings: - if binding_obj.keys == (Keys.Escape,): - found_escape_handler = binding_obj.handler - break - - assert found_escape_handler is not None, "Standalone Escape keybinding not found" - - mock_event = MagicMock() - mock_event.app = MagicMock() - mock_event.app.exit.side_effect = KeyboardInterrupt - with pytest.raises(KeyboardInterrupt): - found_escape_handler(mock_event) - mock_event.app.exit.assert_called_once_with(exception=KeyboardInterrupt) - - @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") async def test_get_input_key_binding_escape_drops_pending_submission( @@ -883,7 +256,9 @@ async def test_get_input_key_binding_up_restores_pending_submission( bindings = mock_prompt_session_cls.call_args[1]["key_bindings"] up_binding = next( - binding_obj for binding_obj in bindings.bindings if binding_obj.keys == (Keys.Up,) + binding_obj + for binding_obj in bindings.bindings + if binding_obj.keys == (Keys.Up,) ) assert up_binding.filter() is False @@ -981,7 +356,9 @@ async def test_prompt_for_submission_recalled_queue_preserves_policy_and_dequeue mock_prompt_session_cls, mock_patch_stdout, active_runtime ): mock_session_instance = MagicMock() - mock_session_instance.default_buffer = Buffer(document=Document(text="", cursor_position=0)) + mock_session_instance.default_buffer = Buffer( + document=Document(text="", cursor_position=0) + ) mock_prompt_session_cls.return_value = mock_session_instance mock_patch_stdout.return_value.__enter__ = MagicMock() mock_patch_stdout.return_value.__exit__ = MagicMock(return_value=False) @@ -1060,7 +437,9 @@ def test_prompt_runtime_registry_round_trip(active_runtime): assert is_shell_prompt_suspended() is False -def test_spinner_invalidation_yields_to_recent_prompt_redraw(monkeypatch, active_runtime): +def test_spinner_invalidation_yields_to_recent_prompt_redraw( + monkeypatch, active_runtime +): session = MagicMock() session.app = MagicMock() active_runtime.register_prompt_surface(session) @@ -1201,19 +580,19 @@ def test_get_prompt_with_active_model_omits_shell_status(monkeypatch, active_run agent.get_model_name.return_value = "gpt-test" with patch( - "code_puppy.agents.agent_manager.get_current_agent", + "code_puppy.command_line.prompt_toolkit_completion._get_current_agent_for_prompt", return_value=agent, ): - with patch( - "shutil.get_terminal_size", return_value=os.terminal_size((80, 24)) - ): + with patch("shutil.get_terminal_size", return_value=os.terminal_size((80, 24))): rendered = "".join(text for _style, text in get_prompt_with_active_model()) assert "shell running" not in rendered clear_active_prompt_surface() -def test_get_prompt_with_active_model_shows_thinking_status(monkeypatch, active_runtime): +def test_get_prompt_with_active_model_shows_thinking_status( + monkeypatch, active_runtime +): clear_active_prompt_surface() session = MagicMock() session.app = MagicMock() @@ -1244,7 +623,7 @@ def test_get_prompt_with_active_model_shows_thinking_status(monkeypatch, active_ with ( patch( - "code_puppy.agents.agent_manager.get_current_agent", + "code_puppy.command_line.prompt_toolkit_completion._get_current_agent_for_prompt", return_value=agent, ), patch( @@ -1262,7 +641,9 @@ def test_get_prompt_with_active_model_shows_thinking_status(monkeypatch, active_ clear_active_prompt_surface() -def test_get_prompt_with_active_model_shows_pending_hint_copy(monkeypatch, active_runtime): +def test_get_prompt_with_active_model_shows_pending_hint_copy( + monkeypatch, active_runtime +): clear_active_prompt_surface() session = MagicMock() session.app = MagicMock() @@ -1288,7 +669,7 @@ def test_get_prompt_with_active_model_shows_pending_hint_copy(monkeypatch, activ with ( patch( - "code_puppy.agents.agent_manager.get_current_agent", + "code_puppy.command_line.prompt_toolkit_completion._get_current_agent_for_prompt", return_value=agent, ), patch("shutil.get_terminal_size", return_value=os.terminal_size((80, 24))), @@ -1297,6 +678,8 @@ def test_get_prompt_with_active_model_shows_pending_hint_copy(monkeypatch, activ assert "[i]nterject [q]ueue [e]dit [esc]ape" in rendered clear_active_prompt_surface() + + @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") async def test_get_input_registers_active_prompt_surface( diff --git a/tests/tools/browser/test_remaining_coverage.py b/tests/tools/browser/test_remaining_coverage.py index 911e00bdd..60575ed19 100644 --- a/tests/tools/browser/test_remaining_coverage.py +++ b/tests/tools/browser/test_remaining_coverage.py @@ -150,13 +150,20 @@ async def test_list_workflows_file_error(self, tmp_path): wf_dir.mkdir() bad_file = wf_dir / "bad.md" bad_file.write_text("test") + original_stat = type(bad_file).stat + + def fake_stat(path_obj): + if path_obj == bad_file: + raise OSError("fail") + return original_stat(path_obj) with ( patch(f"{MOD_WF}.get_workflows_directory", return_value=wf_dir), + patch.object(type(wf_dir), "glob", return_value=[bad_file]), patch(f"{MOD_WF}.emit_info"), patch(f"{MOD_WF}.emit_warning") as mock_warn, patch(f"{MOD_WF}.emit_success"), - patch.object(type(bad_file), "stat", side_effect=OSError("fail")), + patch.object(type(bad_file), "stat", side_effect=fake_stat), ): r = await list_workflows() assert r["success"] is True From 55ff7638e31449d2e5bfbf88670dca850643c5da Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Mon, 16 Mar 2026 17:48:04 -0500 Subject: [PATCH 36/40] Add configurable interactive queue limit --- code_puppy/cli_runner.py | 9 +++-- code_puppy/command_line/config_commands.py | 13 +++++++ .../command_line/interactive_runtime.py | 6 ++-- code_puppy/config.py | 15 ++++++++ .../test_config_commands_full_coverage.py | 23 ++++++++++++ tests/test_config.py | 2 ++ tests/test_config_and_storage_edge_cases.py | 1 + tests/test_config_full_coverage.py | 17 +++++++++ tests/test_prompt_toolkit_completion.py | 35 +++++++++++++++++++ 9 files changed, 117 insertions(+), 4 deletions(-) diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 8c5c3bdf1..26c853200 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -39,6 +39,7 @@ DBOS_DATABASE_URL, ensure_config_exists, finalize_autosave_session, + get_queue_limit, get_use_dbos, initialize_command_history_file, save_command_to_history, @@ -1293,7 +1294,9 @@ async def dispatch_submission( allow_command_dispatch=allow_command_dispatch, ) if not ok: - emit_warning("Queue full (25). Cannot interject right now.") + emit_warning( + f"Queue full ({get_queue_limit()}). Cannot interject right now." + ) emit_interject_queue_lifecycle( runtime, "rejected", @@ -1334,7 +1337,9 @@ async def dispatch_submission( allow_command_dispatch=allow_command_dispatch, ) if not ok: - emit_warning("Queue full (25). Prompt was not queued.") + emit_warning( + f"Queue full ({get_queue_limit()}). Prompt was not queued." + ) emit_interject_queue_lifecycle( runtime, "rejected", diff --git a/code_puppy/command_line/config_commands.py b/code_puppy/command_line/config_commands.py index 8724ef24e..d013b7560 100644 --- a/code_puppy/command_line/config_commands.py +++ b/code_puppy/command_line/config_commands.py @@ -42,6 +42,7 @@ def handle_show_command(command: str) -> bool: get_owner_name, get_protected_token_count, get_puppy_name, + get_queue_limit, get_resume_message_count, get_temperature, get_use_dbos, @@ -77,6 +78,7 @@ def handle_show_command(command: str) -> bool: [bold]YOLO_MODE:[/bold] {"[red]ON[/red]" if yolo_mode else "[yellow]off[/yellow]"} [bold]DBOS:[/bold] {"[green]enabled[/green]" if get_use_dbos() else "[yellow]disabled[/yellow]"} (toggle: /set enable_dbos true|false) [bold]auto_save_session:[/bold] {"[green]enabled[/green]" if auto_save else "[yellow]disabled[/yellow]"} +[bold]queue_limit:[/bold] [cyan]{get_queue_limit()}[/cyan] queued prompts/interjects max [bold]protected_tokens:[/bold] [cyan]{protected_tokens:,}[/cyan] recent tokens preserved [bold]compaction_threshold:[/bold] [cyan]{compaction_threshold:.1%}[/cyan] context usage triggers compaction [bold]compaction_strategy:[/bold] [cyan]{compaction_strategy}[/cyan] (summarization or truncation) @@ -242,6 +244,17 @@ def handle_set_command(command: str) -> bool: ) ) + if key == "queue_limit": + try: + normalized_limit = int(value.strip()) + except ValueError: + emit_error("Invalid queue_limit. Enter a whole number >= 1.") + return True + if normalized_limit < 1: + emit_error("Invalid queue_limit. Enter a whole number >= 1.") + return True + value = str(normalized_limit) + set_config_value(key, value) emit_success(f'Set {key} = "{value}" in puppy.cfg!') diff --git a/code_puppy/command_line/interactive_runtime.py b/code_puppy/command_line/interactive_runtime.py index 6aa36a16b..d20352d16 100644 --- a/code_puppy/command_line/interactive_runtime.py +++ b/code_puppy/command_line/interactive_runtime.py @@ -9,7 +9,9 @@ from datetime import datetime, timezone from typing import Callable, Literal -MAX_PROMPT_QUEUE = 25 +from code_puppy.config import get_queue_limit + +DEFAULT_PROMPT_QUEUE_LIMIT = 25 PROMPT_STATUS_FRAME_INTERVAL = 0.09 PROMPT_STATUS_BACKOFF_WINDOW = 0.045 _ABOVE_PROMPT_RENDER_ACTIVE: contextvars.ContextVar[bool] = contextvars.ContextVar( @@ -100,7 +102,7 @@ def mark_idle_if_task(self, task: asyncio.Task | None) -> bool: return True def _can_enqueue(self) -> bool: - return len(self.queue) < MAX_PROMPT_QUEUE + return len(self.queue) < get_queue_limit(default=DEFAULT_PROMPT_QUEUE_LIMIT) def _clamp_queue_view_offset(self, *, max_visible: int = 3) -> None: max_start = max(0, len(self.queue) - max_visible) diff --git a/code_puppy/config.py b/code_puppy/config.py index 79b5c2088..8dd3e01d5 100644 --- a/code_puppy/config.py +++ b/code_puppy/config.py @@ -284,6 +284,7 @@ def get_config_keys(): "protected_token_count", "compaction_threshold", "message_limit", + "queue_limit", "allow_recursion", "openai_reasoning_effort", "openai_verbosity", @@ -1209,6 +1210,20 @@ def get_message_limit(default: int = 1000) -> int: return default +def get_queue_limit(default: int = 25) -> int: + """ + Returns the user-configured interactive prompt queue limit. + Defaults to 25 if unset or misconfigured. + Configurable by 'queue_limit' key. + """ + val = get_value("queue_limit") + try: + parsed = int(val) if val else default + except (ValueError, TypeError): + return default + return max(1, parsed) + + def save_command_to_history(command: str): """Save a command to the history file with an ISO format timestamp. diff --git a/tests/command_line/test_config_commands_full_coverage.py b/tests/command_line/test_config_commands_full_coverage.py index 9e0b15603..912f70ad7 100644 --- a/tests/command_line/test_config_commands_full_coverage.py +++ b/tests/command_line/test_config_commands_full_coverage.py @@ -42,6 +42,7 @@ def _show_patches(self, effective_temp=0.7, global_temp=0.7, yolo=True, dbos=Fal ), patch("code_puppy.config.get_default_agent", return_value="code-puppy"), patch("code_puppy.config.get_use_dbos", return_value=dbos), + patch("code_puppy.config.get_queue_limit", return_value=25), patch("code_puppy.config.get_resume_message_count", return_value=50), patch( "code_puppy.config.get_openai_reasoning_effort", return_value="medium" @@ -76,6 +77,7 @@ def test_show_command(self): patches[15], patches[16], patches[17], + patches[18], ): assert handle_show_command("/show") is True @@ -102,6 +104,7 @@ def test_show_effective_temp_none(self): patches[15], patches[16], patches[17], + patches[18], ): assert handle_show_command("/show") is True @@ -259,6 +262,26 @@ def test_cancel_agent_key_invalid(self): assert handle_set_command("/set cancel_agent_key bad_key") is True err.assert_called_once() + def test_queue_limit_valid(self): + from code_puppy.command_line.config_commands import handle_set_command + + mock_agent = MagicMock() + with ( + patch("code_puppy.config.set_config_value") as set_value, + patch("code_puppy.messaging.emit_success"), + patch("code_puppy.messaging.emit_info"), + patch("code_puppy.agents.get_current_agent", return_value=mock_agent), + ): + assert handle_set_command("/set queue_limit 7") is True + set_value.assert_called_once_with("queue_limit", "7") + + def test_queue_limit_invalid(self): + from code_puppy.command_line.config_commands import handle_set_command + + with patch("code_puppy.messaging.emit_error") as err: + assert handle_set_command("/set queue_limit 0") is True + err.assert_called_once() + def test_agent_reload_failure(self): from code_puppy.command_line.config_commands import handle_set_command diff --git a/tests/test_config.py b/tests/test_config.py index dc9cdba96..2584a5155 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -326,6 +326,7 @@ def test_get_config_keys_with_existing_keys( "openai_reasoning_effort", "openai_verbosity", "protected_token_count", + "queue_limit", "resume_message_count", "temperature", "yolo_mode", @@ -383,6 +384,7 @@ def test_get_config_keys_empty_config( "openai_reasoning_effort", "openai_verbosity", "protected_token_count", + "queue_limit", "resume_message_count", "temperature", "yolo_mode", diff --git a/tests/test_config_and_storage_edge_cases.py b/tests/test_config_and_storage_edge_cases.py index 674da22e4..e276c6aad 100644 --- a/tests/test_config_and_storage_edge_cases.py +++ b/tests/test_config_and_storage_edge_cases.py @@ -270,6 +270,7 @@ def test_get_config_keys_includes_defaults(self, mock_config_paths): "auto_save_session", "enable_dbos", "cancel_agent_key", + "queue_limit", ] for key in expected_keys: assert key in result, f"Expected key '{key}' not in config keys" diff --git a/tests/test_config_full_coverage.py b/tests/test_config_full_coverage.py index 1da133abe..d652e9eec 100644 --- a/tests/test_config_full_coverage.py +++ b/tests/test_config_full_coverage.py @@ -230,6 +230,22 @@ def test_get_message_limit_custom_default(self): cp_config.reset_value("message_limit") assert cp_config.get_message_limit(default=50) == 50 + def test_get_queue_limit_default(self): + cp_config.reset_value("queue_limit") + assert cp_config.get_queue_limit() == 25 + + def test_get_queue_limit_custom(self): + cp_config.set_config_value("queue_limit", "7") + assert cp_config.get_queue_limit() == 7 + + def test_get_queue_limit_invalid(self): + cp_config.set_config_value("queue_limit", "bad") + assert cp_config.get_queue_limit() == 25 + + def test_get_queue_limit_clamped_low(self): + cp_config.set_config_value("queue_limit", "0") + assert cp_config.get_queue_limit() == 1 + def test_get_diff_context_lines_default(self): cp_config.reset_value("diff_context_lines") assert cp_config.get_diff_context_lines() == 6 @@ -730,6 +746,7 @@ def test_get_config_keys_returns_sorted_list(self): assert "enable_dbos" in keys assert "enable_streaming" in keys assert "cancel_agent_key" in keys + assert "queue_limit" in keys assert "resume_message_count" in keys diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index c61c2e17a..b84cb3863 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -75,6 +75,41 @@ def test_render_submitted_prompt_echo_uses_prompt_app_when_available( mock_print_formatted_text.assert_not_called() +def test_runtime_request_queue_respects_configured_queue_limit(active_runtime): + with patch( + "code_puppy.command_line.interactive_runtime.get_queue_limit", return_value=2 + ): + ok, position, item = active_runtime.request_queue("first") + assert ok is True + assert position == 1 + assert item is not None + + ok, position, item = active_runtime.request_queue("second") + assert ok is True + assert position == 2 + assert item is not None + + ok, position, item = active_runtime.request_queue("third") + assert ok is False + assert position == 2 + assert item is None + + +def test_runtime_request_interject_respects_configured_queue_limit(active_runtime): + with patch( + "code_puppy.command_line.interactive_runtime.get_queue_limit", return_value=1 + ): + ok, position, item = active_runtime.request_interject("now") + assert ok is True + assert position == 1 + assert item is not None + + ok, position, item = active_runtime.request_interject("later") + assert ok is False + assert position == 1 + assert item is None + + @patch("code_puppy.command_line.prompt_toolkit_completion.print_formatted_text") @patch("prompt_toolkit.output.defaults.create_output") def test_render_transcript_notice(mock_create_output, mock_print_formatted_text): From bffca5f758fab9f7996b407ec1477967dc11fcbe Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Mon, 23 Mar 2026 15:11:31 -0500 Subject: [PATCH 37/40] Improve prompt-surface streaming previews --- IMPLEMENTATION_GUARDRAILS.md | 8 + code_puppy/agents/event_stream_handler.py | 243 +++++++++++------- .../command_line/interactive_runtime.py | 24 ++ .../command_line/prompt_toolkit_completion.py | 36 +++ code_puppy/messaging/rich_renderer.py | 36 ++- docs/INTERACTIVE_REGRESSION_CHECKLIST.md | 8 + tests/agents/test_event_stream_handler.py | 167 ++++++++++-- tests/messaging/test_rich_renderer.py | 48 ++++ tests/test_prompt_toolkit_completion.py | 85 ++++++ 9 files changed, 550 insertions(+), 105 deletions(-) create mode 100644 IMPLEMENTATION_GUARDRAILS.md create mode 100644 docs/INTERACTIVE_REGRESSION_CHECKLIST.md diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md new file mode 100644 index 000000000..09bb44927 --- /dev/null +++ b/IMPLEMENTATION_GUARDRAILS.md @@ -0,0 +1,8 @@ +# Implementation Guardrails + +- When the live prompt surface is active, `agent_share_your_reasoning` must render through the structured `AGENT REASONING` path, not as low-level `Calling ... token(s)` tool progress. +- When the live prompt surface is active, mutable tool progress that upstream prints and clears must render in the prompt-local ephemeral status strip, not as transcript output and not via above-prompt prints. +- When the live prompt surface is active, streamed `TextPart` content may appear only in the prompt-local ephemeral preview; the permanent transcript must still come only from the final `AGENT RESPONSE`. +- When the live prompt surface is active, shell output with carriage-return progress must use the prompt-local ephemeral status strip; ordinary shell lines remain on the durable shell output path. +- Durable structured outputs like `AGENT REASONING` and `DIRECTORY LISTING` should still render above the prompt. +- Prompt-surface stream fixes must not duplicate the final `AGENT RESPONSE`. diff --git a/code_puppy/agents/event_stream_handler.py b/code_puppy/agents/event_stream_handler.py index b882a0f1a..40db138a5 100644 --- a/code_puppy/agents/event_stream_handler.py +++ b/code_puppy/agents/event_stream_handler.py @@ -2,6 +2,7 @@ import asyncio import logging +import sys from collections.abc import AsyncIterable from typing import Any, Optional @@ -85,48 +86,95 @@ def _should_suppress_output() -> bool: return is_subagent() and not get_subagent_verbose() -def _has_active_prompt_surface() -> bool: - """Return True when the always-on prompt surface is mounted.""" +def _get_active_prompt_runtime() -> Any | None: + """Return the active interactive runtime, if available.""" try: from code_puppy.command_line.interactive_runtime import ( get_active_interactive_runtime, ) - runtime = get_active_interactive_runtime() - return runtime.has_prompt_surface() if runtime is not None else False + return get_active_interactive_runtime() except Exception: - return False + return None -async def _consume_events_without_console(events: AsyncIterable[Any]) -> None: - """Consume stream events without terminal rendering, but keep callbacks alive.""" - async for event in events: - if isinstance(event, PartStartEvent): - _fire_stream_event( - "part_start", - { - "index": event.index, - "part_type": type(event.part).__name__, - "part": event.part, - }, - ) - elif isinstance(event, PartDeltaEvent): - _fire_stream_event( - "part_delta", - { - "index": event.index, - "delta_type": type(event.delta).__name__, - "delta": event.delta, - }, - ) - elif isinstance(event, PartEndEvent): - _fire_stream_event( - "part_end", - { - "index": event.index, - "next_part_kind": getattr(event, "next_part_kind", None), - }, - ) +def _has_active_prompt_surface() -> bool: + """Return True when the always-on prompt surface is mounted.""" + runtime = _get_active_prompt_runtime() + return runtime.has_prompt_surface() if runtime is not None else False + + +def _set_prompt_ephemeral_status(text: str | None) -> None: + """Update transient prompt-local status for mutable stream output.""" + runtime = _get_active_prompt_runtime() + if runtime is None: + return + try: + runtime.set_prompt_ephemeral_status(text) + except Exception: + pass + + +def _clear_prompt_ephemeral_status() -> None: + """Clear transient prompt-local status.""" + runtime = _get_active_prompt_runtime() + if runtime is None: + return + try: + runtime.clear_prompt_ephemeral_status() + except Exception: + pass + + +def _set_prompt_ephemeral_preview(text: str | None) -> None: + """Update transient prompt-local preview for live response text.""" + runtime = _get_active_prompt_runtime() + if runtime is None: + return + try: + runtime.set_prompt_ephemeral_preview(text) + except Exception: + pass + + +def _merge_tool_name(current_name: str, tool_name_delta: str) -> str: + """Merge a streamed tool name delta without duplicating already-known names.""" + if not tool_name_delta: + return current_name + if not current_name: + return tool_name_delta + if tool_name_delta.startswith(current_name): + return tool_name_delta + if current_name.endswith(tool_name_delta): + return current_name + return current_name + tool_name_delta + + +def _build_prompt_safe_console(source_console: Console) -> Console: + """Create a console that writes to the real terminal above the prompt.""" + return Console( + file=sys.__stdout__, + force_terminal=source_console.is_terminal, + width=source_console.width, + color_system=source_console.color_system, + soft_wrap=source_console.soft_wrap, + legacy_windows=source_console.legacy_windows, + ) + + +async def _print_stream_output( + console: Console, *args: Any, **kwargs: Any +) -> None: + """Render stream output above the prompt when the prompt surface is mounted.""" + runtime = _get_active_prompt_runtime() + if runtime is not None and runtime.has_prompt_surface(): + prompt_safe_console = _build_prompt_safe_console(console) + rendered = await runtime.run_above_prompt_async( + lambda: prompt_safe_console.print(*args, **kwargs) + ) + if rendered: + return + console.print(*args, **kwargs) async def event_stream_handler( @@ -148,13 +196,6 @@ async def event_stream_handler( pass # Just consume events without rendering return - # The always-on prompt surface cannot safely coexist with live terminal streaming. - # In that mode, we keep callbacks/events flowing and render the final response - # through the normal AgentResponseMessage banner path instead. - if _has_active_prompt_surface(): - await _consume_events_without_console(events) - return - from termflow import Parser as TermflowParser from termflow import Renderer as TermflowRenderer @@ -169,9 +210,9 @@ async def event_stream_handler( banner_printed: set[int] = set() # Track if banner was already printed token_count: dict[int, int] = {} # Track token count per text/tool part tool_names: dict[int, str] = {} # Track tool name per tool part index - tool_progress_announced: set[int] = set() did_stream_anything = False # Track if we streamed any content spinner_paused = False + prompt_surface_response_preview = "" # Termflow streaming state for text parts termflow_parsers: dict[int, TermflowParser] = {} @@ -182,15 +223,20 @@ async def _print_thinking_banner() -> None: """Print the THINKING banner with spinner pause and line clear.""" nonlocal did_stream_anything, spinner_paused + prompt_surface_active = _has_active_prompt_surface() if not spinner_paused: pause_all_spinners() spinner_paused = True await asyncio.sleep(0.02) - console.print(" " * 50, end="\r") - console.print() # Newline before banner + if prompt_surface_active: + await _print_stream_output(console) + else: + await _print_stream_output(console, " " * 50, end="\r") + await _print_stream_output(console) # Newline before banner # Bold banner with configurable color and lightning bolt thinking_color = get_banner_color("thinking") - console.print( + await _print_stream_output( + console, Text.from_markup( f"[bold white on {thinking_color}] THINKING [/bold white on {thinking_color}] [dim]\u26a1 " ), @@ -202,14 +248,19 @@ async def _print_response_banner() -> None: """Print the AGENT RESPONSE banner with spinner pause and line clear.""" nonlocal did_stream_anything, spinner_paused + prompt_surface_active = _has_active_prompt_surface() if not spinner_paused: pause_all_spinners() spinner_paused = True await asyncio.sleep(0.02) - console.print(" " * 50, end="\r") - console.print() # Newline before banner + if prompt_surface_active: + await _print_stream_output(console) + else: + await _print_stream_output(console, " " * 50, end="\r") + await _print_stream_output(console) # Newline before banner response_color = get_banner_color("agent_response") - console.print( + await _print_stream_output( + console, Text.from_markup( f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]" ) @@ -237,32 +288,33 @@ async def _print_response_banner() -> None: if part.content and part.content.strip(): await _print_thinking_banner() escaped = escape(part.content) - console.print(f"[dim]{escaped}[/dim]", end="") + await _print_stream_output(console, f"[dim]{escaped}[/dim]", end="") banner_printed.add(event.index) elif isinstance(part, TextPart): streaming_parts.add(event.index) text_parts.add(event.index) - # Initialize termflow streaming for this text part - termflow_parsers[event.index] = TermflowParser() - termflow_renderers[event.index] = TermflowRenderer( - output=console.file, width=console.width - ) - termflow_line_buffers[event.index] = "" - # Handle initial content if present - if part.content and part.content.strip(): - await _print_response_banner() - banner_printed.add(event.index) - termflow_line_buffers[event.index] = part.content + if _has_active_prompt_surface(): + if part.content: + prompt_surface_response_preview += part.content + _set_prompt_ephemeral_preview(prompt_surface_response_preview) + else: + # Initialize termflow streaming for this text part + termflow_parsers[event.index] = TermflowParser() + termflow_renderers[event.index] = TermflowRenderer( + output=console.file, width=console.width + ) + termflow_line_buffers[event.index] = "" + # Handle initial content if present + if part.content and part.content.strip(): + await _print_response_banner() + banner_printed.add(event.index) + termflow_line_buffers[event.index] = part.content elif isinstance(part, ToolCallPart): streaming_parts.add(event.index) tool_parts.add(event.index) token_count[event.index] = 0 # Initialize token counter # Capture tool name from the start event tool_names[event.index] = part.tool_name or "" - # Track tool name for display - banner_printed.add( - event.index - ) # Use banner_printed to track if we've shown tool info # PartDeltaEvent - stream the content as it arrives elif isinstance(event, PartDeltaEvent): @@ -282,6 +334,12 @@ async def _print_response_banner() -> None: if delta.content_delta: # For text parts, stream markdown with termflow if event.index in text_parts: + if _has_active_prompt_surface(): + prompt_surface_response_preview += delta.content_delta + _set_prompt_ephemeral_preview( + prompt_surface_response_preview + ) + continue # Print banner on first content if event.index not in banner_printed: await _print_response_banner() @@ -307,7 +365,9 @@ async def _print_response_banner() -> None: await _print_thinking_banner() banner_printed.add(event.index) escaped = escape(delta.content_delta) - console.print(f"[dim]{escaped}[/dim]", end="") + await _print_stream_output( + console, f"[dim]{escaped}[/dim]", end="" + ) elif isinstance(delta, ToolCallPartDelta): prompt_surface_active = _has_active_prompt_surface() # For tool calls, estimate tokens from args_delta content @@ -324,30 +384,38 @@ async def _print_response_banner() -> None: # Update tool name if delta provides more of it tool_name_delta = getattr(delta, "tool_name_delta", "") or "" if tool_name_delta: - tool_names[event.index] = ( - tool_names.get(event.index, "") + tool_name_delta + tool_names[event.index] = _merge_tool_name( + tool_names.get(event.index, ""), tool_name_delta ) # Use stored tool name for display tool_name = tool_names.get(event.index, "") - count = token_count[event.index] if prompt_surface_active: - if event.index not in tool_progress_announced: - label = tool_name or "tool" - console.print(f" \U0001f527 Calling {label}...") - tool_progress_announced.add(event.index) + if tool_name != "agent_share_your_reasoning": + count = token_count[event.index] + if tool_name: + _set_prompt_ephemeral_status( + f"\U0001f527 Calling {tool_name}... {count} token(s)" + ) + else: + _set_prompt_ephemeral_status( + f"\U0001f527 Calling tool... {count} token(s)" + ) + continue + count = token_count[event.index] + # Display with tool wrench icon and tool name + if tool_name: + await _print_stream_output( + console, + f" \U0001f527 Calling {tool_name}... {count} token(s) ", + end="\r", + ) else: - # Display with tool wrench icon and tool name - if tool_name: - console.print( - f" \U0001f527 Calling {tool_name}... {count} token(s) ", - end="\r", - ) - else: - console.print( - f" \U0001f527 Calling tool... {count} token(s) ", - end="\r", - ) + await _print_stream_output( + console, + f" \U0001f527 Calling tool... {count} token(s) ", + end="\r", + ) # PartEndEvent - finish the streaming with a newline elif isinstance(event, PartEndEvent): @@ -384,17 +452,18 @@ async def _print_response_banner() -> None: del termflow_line_buffers[event.index] # For tool parts, clear the chunk counter line elif event.index in tool_parts: - if not _has_active_prompt_surface(): + if _has_active_prompt_surface(): + _clear_prompt_ephemeral_status() + else: # Clear the chunk counter line by printing spaces and returning - console.print(" " * 50, end="\r") + await _print_stream_output(console, " " * 50, end="\r") # For thinking parts, just print newline elif event.index in banner_printed: - console.print() # Final newline after streaming + await _print_stream_output(console) # Final newline after streaming # Clean up token count and tool names token_count.pop(event.index, None) tool_names.pop(event.index, None) - tool_progress_announced.discard(event.index) # Clean up all tracking sets streaming_parts.discard(event.index) thinking_parts.discard(event.index) diff --git a/code_puppy/command_line/interactive_runtime.py b/code_puppy/command_line/interactive_runtime.py index d20352d16..8d032c433 100644 --- a/code_puppy/command_line/interactive_runtime.py +++ b/code_puppy/command_line/interactive_runtime.py @@ -53,6 +53,8 @@ class PromptRuntimeState: prompt_session: object | None = None prompt_status_started_at: float | None = None prompt_status_task: asyncio.Task | None = None + prompt_ephemeral_status: str | None = None + prompt_ephemeral_preview: str | None = None above_prompt_lock: asyncio.Lock | None = field(default=None, init=False, repr=False) above_prompt_lock_loop: asyncio.AbstractEventLoop | None = field( default=None, @@ -89,6 +91,8 @@ def mark_idle(self) -> None: self.active_run_kind = None self.active_cancel_hook = None self.prompt_status_started_at = None + self.prompt_ephemeral_status = None + self.prompt_ephemeral_preview = None self._stop_prompt_status_task() self.invalidate_prompt() @@ -196,6 +200,26 @@ def notify_shell_finished(self) -> None: def has_active_interactive_command(self) -> bool: return self.active_run_kind == "interactive_command" and self.running + def set_prompt_ephemeral_status(self, text: str | None) -> None: + normalized = text.strip() if text and text.strip() else None + if normalized == self.prompt_ephemeral_status: + return + self.prompt_ephemeral_status = normalized + self.invalidate_prompt_for_spinner() + + def clear_prompt_ephemeral_status(self) -> None: + self.set_prompt_ephemeral_status(None) + + def set_prompt_ephemeral_preview(self, text: str | None) -> None: + normalized = text if text and text.strip() else None + if normalized == self.prompt_ephemeral_preview: + return + self.prompt_ephemeral_preview = normalized + self.invalidate_prompt_for_spinner() + + def clear_prompt_ephemeral_preview(self) -> None: + self.set_prompt_ephemeral_preview(None) + def set_active_cancel_requester( self, requester: Callable[[str], None] | None ) -> None: diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index 35544daec..b363fc0e5 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -687,6 +687,31 @@ def _build_prompt_style() -> Style: ) +def _truncate_prompt_preview( + text: str, *, max_chars: int = 1000, max_lines: int = 6 +) -> str: + normalized = text.rstrip("\n") + if not normalized.strip(): + return "" + + char_trimmed = len(normalized) > max_chars + if char_trimmed: + normalized = normalized[-max_chars:] + + lines = normalized.splitlines() + if not lines: + lines = [normalized] + + line_trimmed = len(lines) > max_lines + if line_trimmed: + lines = lines[-max_lines:] + + preview = "\n".join(lines) + if char_trimmed or line_trimmed: + preview = f"...\n{preview}" + return preview + + def _build_prompt_status_parts(runtime: PromptRuntimeState) -> list[tuple[str, str]]: """Build the lightweight thinking line shown above the prompt separator.""" parts: list[tuple[str, str]] = [ @@ -698,6 +723,17 @@ def _build_prompt_status_parts(runtime: PromptRuntimeState) -> list[tuple[str, s parts.append(("", " ")) parts.append(("class:thinking-context", context_info)) parts.append(("", "\n")) + if runtime.prompt_ephemeral_status: + parts.append(("class:thinking-context", runtime.prompt_ephemeral_status)) + parts.append(("", "\n")) + if runtime.prompt_ephemeral_preview: + parts.append( + ( + "class:thinking-context", + _truncate_prompt_preview(runtime.prompt_ephemeral_preview), + ) + ) + parts.append(("", "\n")) return parts diff --git a/code_puppy/messaging/rich_renderer.py b/code_puppy/messaging/rich_renderer.py index 3473aa1d5..5721a3eb1 100644 --- a/code_puppy/messaging/rich_renderer.py +++ b/code_puppy/messaging/rich_renderer.py @@ -188,6 +188,27 @@ def _get_prompt_runtime(self): except Exception: return None + def _set_prompt_ephemeral_status(self, text: str | None) -> None: + runtime = self._get_prompt_runtime() + if runtime is None: + return + try: + runtime.set_prompt_ephemeral_status(text) + except Exception: + pass + + def _clear_prompt_ephemeral_status(self) -> None: + self._set_prompt_ephemeral_status(None) + + def _clear_prompt_ephemeral_preview(self) -> None: + runtime = self._get_prompt_runtime() + if runtime is None: + return + try: + runtime.clear_prompt_ephemeral_preview() + except Exception: + pass + def _should_render_agent_response(self) -> bool: """Render final agent responses when the interactive prompt is mounted.""" runtime = self._get_prompt_runtime() @@ -238,6 +259,9 @@ def _render_message_above_prompt(self, message: AnyMessage) -> bool: if runtime is None or not runtime.has_prompt_surface(): return False + if isinstance(message, AgentResponseMessage): + self._clear_prompt_ephemeral_preview() + console = self._build_prompt_safe_console() return runtime.run_above_prompt( lambda: self._render_message_with_console(message, console) @@ -814,9 +838,14 @@ def _render_shell_line(self, msg: ShellLineMessage) -> None: runtime = self._get_prompt_runtime() if runtime is not None and runtime.has_prompt_surface(): if "\r" in msg.line: - sys.stdout.write(msg.line) - else: - sys.stdout.write(msg.line + "\n") + normalized = Text.from_ansi(msg.line.split("\r")[-1]).plain + normalized = normalized.replace("\n", " ") + normalized = "".join( + char for char in normalized if char == "\t" or char.isprintable() + ).strip() + self._set_prompt_ephemeral_status(normalized or None) + return + sys.stdout.write(msg.line + "\n") sys.stdout.flush() return @@ -837,6 +866,7 @@ def _render_shell_output(self, msg: ShellOutputMessage) -> None: Shell command results are already returned to the LLM via tool responses, so we don't need to clutter the UI with redundant output. """ + self._clear_prompt_ephemeral_status() # Just print trailing newline for spinner separation self._console.print() diff --git a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md new file mode 100644 index 000000000..d1b8b15c7 --- /dev/null +++ b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md @@ -0,0 +1,8 @@ +# Interactive Regression Checklist + +- Prompt-surface runs show `AGENT REASONING` above the textbox when `agent_share_your_reasoning` is used. +- Prompt-surface runs do not show `Calling agent_share_your_reasoning... N token(s)` above the textbox or in the prompt-local ephemeral status strip. +- Prompt-surface runs show ordinary mutable tool progress in the prompt-local ephemeral status strip without moving the prompt box or adding transcript spam; structured tool outputs like `DIRECTORY LISTING` still render normally. +- Prompt-surface tool progress must not leak raw ANSI or flash the prompt surface on each delta. +- Prompt-surface runs may show live response text only in the prompt-local ephemeral preview, and the final `AGENT RESPONSE` still renders once. +- Prompt-surface shell carriage-return progress updates in place in the prompt-local ephemeral status strip and clears on completion without transcript spam. diff --git a/tests/agents/test_event_stream_handler.py b/tests/agents/test_event_stream_handler.py index f5f1b0778..39296c8b7 100644 --- a/tests/agents/test_event_stream_handler.py +++ b/tests/agents/test_event_stream_handler.py @@ -10,7 +10,7 @@ """ from io import StringIO -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from pydantic_ai import PartDeltaEvent, PartEndEvent, PartStartEvent, RunContext @@ -523,10 +523,10 @@ async def event_stream(): assert any("token(s)" in str(call) for call in call_args_list) @pytest.mark.asyncio - async def test_tool_call_prompt_surface_mode_avoids_carriage_returns( + async def test_tool_call_prompt_surface_mode_hides_tool_progress( self, mock_ctx ): - """Prompt-surface mode should suppress terminal tool streaming entirely.""" + """Prompt-surface mode should route tool progress into transient prompt state.""" tool_part = ToolCallPart(tool_call_id="tool_1", tool_name="test_tool", args={}) start_event = PartStartEvent(index=0, part=tool_part) delta_event = PartDeltaEvent( @@ -542,18 +542,143 @@ async def event_stream(): console = MagicMock(spec=Console) set_streaming_console(console) + safe_console = MagicMock(spec=Console) + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + runtime.set_prompt_ephemeral_status = MagicMock() + runtime.clear_prompt_ephemeral_status = MagicMock() + + async def _run_above_prompt(func): + func() + return True + + runtime.run_above_prompt_async = AsyncMock(side_effect=_run_above_prompt) with patch( - "code_puppy.agents.event_stream_handler._has_active_prompt_surface", - return_value=True, + "code_puppy.agents.event_stream_handler._get_active_prompt_runtime", + return_value=runtime, ): - with patch("code_puppy.agents.event_stream_handler.pause_all_spinners"): - with patch( - "code_puppy.agents.event_stream_handler.resume_all_spinners" - ): - await event_stream_handler(mock_ctx, event_stream()) + with patch( + "code_puppy.agents.event_stream_handler._build_prompt_safe_console", + return_value=safe_console, + ): + with patch("code_puppy.agents.event_stream_handler.pause_all_spinners"): + with patch( + "code_puppy.agents.event_stream_handler.resume_all_spinners" + ): + await event_stream_handler(mock_ctx, event_stream()) console.print.assert_not_called() + safe_console.print.assert_not_called() + runtime.run_above_prompt_async.assert_not_awaited() + runtime.set_prompt_ephemeral_status.assert_called_once_with( + "šŸ”§ Calling test_tool... 1 token(s)" + ) + runtime.clear_prompt_ephemeral_status.assert_called_once() + + @pytest.mark.asyncio + async def test_reasoning_tool_prompt_surface_mode_hides_tool_progress( + self, mock_ctx + ): + """Prompt-surface mode should suppress reasoning-tool status spam.""" + tool_part = ToolCallPart( + tool_call_id="tool_1", tool_name="agent_share_your_reasoning", args={} + ) + start_event = PartStartEvent(index=0, part=tool_part) + delta_event = PartDeltaEvent( + index=0, + delta=ToolCallPartDelta( + tool_name_delta="agent_share_your_reasoning", + args_delta='{"reasoning":"thinking"}', + ), + ) + end_event = PartEndEvent(index=0, part=tool_part, next_part_kind=None) + + async def event_stream(): + yield start_event + yield delta_event + yield end_event + + console = MagicMock(spec=Console) + set_streaming_console(console) + safe_console = MagicMock(spec=Console) + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + runtime.set_prompt_ephemeral_status = MagicMock() + runtime.clear_prompt_ephemeral_status = MagicMock() + runtime.run_above_prompt_async = AsyncMock(return_value=True) + + with patch( + "code_puppy.agents.event_stream_handler._get_active_prompt_runtime", + return_value=runtime, + ): + with patch( + "code_puppy.agents.event_stream_handler._build_prompt_safe_console", + return_value=safe_console, + ): + with patch("code_puppy.agents.event_stream_handler.pause_all_spinners"): + with patch( + "code_puppy.agents.event_stream_handler.resume_all_spinners" + ): + await event_stream_handler(mock_ctx, event_stream()) + + console.print.assert_not_called() + safe_console.print.assert_not_called() + runtime.run_above_prompt_async.assert_not_awaited() + runtime.set_prompt_ephemeral_status.assert_not_called() + runtime.clear_prompt_ephemeral_status.assert_called_once() + + @pytest.mark.asyncio + async def test_thinking_prompt_surface_mode_renders_above_prompt(self, mock_ctx): + """Prompt-surface mode should show thinking output above the prompt.""" + thinking_part = ThinkingPart(content="") + start_event = PartStartEvent(index=0, part=thinking_part) + delta_event = PartDeltaEvent( + index=0, delta=ThinkingPartDelta(content_delta="Think...") + ) + end_event = PartEndEvent(index=0, part=thinking_part, next_part_kind=None) + + async def event_stream(): + yield start_event + yield delta_event + yield end_event + + console = MagicMock(spec=Console) + set_streaming_console(console) + safe_console = MagicMock(spec=Console) + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + + async def _run_above_prompt(func): + func() + return True + + runtime.run_above_prompt_async = AsyncMock(side_effect=_run_above_prompt) + + with patch( + "code_puppy.agents.event_stream_handler._get_active_prompt_runtime", + return_value=runtime, + ): + with patch( + "code_puppy.agents.event_stream_handler._build_prompt_safe_console", + return_value=safe_console, + ): + with patch("code_puppy.agents.event_stream_handler.pause_all_spinners"): + with patch( + "code_puppy.agents.event_stream_handler.resume_all_spinners" + ): + with patch( + "code_puppy.agents.event_stream_handler.get_banner_color", + return_value="blue", + ): + await event_stream_handler(mock_ctx, event_stream()) + + call_args_list = [str(call) for call in safe_console.print.call_args_list] + assert any("THINKING" in call for call in call_args_list) + assert any("Think..." in call for call in call_args_list) + console.print.assert_not_called() + assert safe_console.print.called + assert runtime.run_above_prompt_async.await_count >= 2 @pytest.mark.asyncio async def test_text_banner_prompt_surface_mode_skips_clear_line(self, mock_ctx): @@ -570,9 +695,13 @@ async def event_stream(): console.file = StringIO() set_streaming_console(console) + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + runtime.run_above_prompt_async = AsyncMock(return_value=True) + with patch( - "code_puppy.agents.event_stream_handler._has_active_prompt_surface", - return_value=True, + "code_puppy.agents.event_stream_handler._get_active_prompt_runtime", + return_value=runtime, ): with patch("code_puppy.agents.event_stream_handler.pause_all_spinners"): with patch( @@ -592,10 +721,11 @@ async def event_stream(): await event_stream_handler(mock_ctx, event_stream()) console.print.assert_not_called() + runtime.run_above_prompt_async.assert_not_awaited() @pytest.mark.asyncio async def test_prompt_surface_mode_streams_plain_text_response(self, mock_ctx): - """Prompt-surface mode should suppress text streaming and skip termflow.""" + """Prompt-surface mode should route live text into transient preview state.""" text_part = TextPart(content="") start_event = PartStartEvent(index=0, part=text_part) delta_event = PartDeltaEvent( @@ -612,9 +742,14 @@ async def event_stream(): console.file = StringIO() set_streaming_console(console) + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + runtime.run_above_prompt_async = AsyncMock(return_value=True) + runtime.set_prompt_ephemeral_preview = MagicMock() + with patch( - "code_puppy.agents.event_stream_handler._has_active_prompt_surface", - return_value=True, + "code_puppy.agents.event_stream_handler._get_active_prompt_runtime", + return_value=runtime, ): with patch("code_puppy.agents.event_stream_handler.pause_all_spinners"): with patch( @@ -626,6 +761,8 @@ async def event_stream(): console.print.assert_not_called() mock_parser_cls.assert_not_called() + runtime.run_above_prompt_async.assert_not_awaited() + runtime.set_prompt_ephemeral_preview.assert_called_once_with("hello") @pytest.mark.asyncio async def test_thinking_part_without_initial_content_defers_banner(self, mock_ctx): diff --git a/tests/messaging/test_rich_renderer.py b/tests/messaging/test_rich_renderer.py index 330b887a4..9a63acf47 100644 --- a/tests/messaging/test_rich_renderer.py +++ b/tests/messaging/test_rich_renderer.py @@ -396,6 +396,23 @@ def test_render_shell_line_with_cr(renderer, console): renderer._render_shell_line(msg) +def test_render_shell_line_with_cr_and_prompt_surface_uses_ephemeral_status(renderer): + msg = ShellLineMessage(line="\x1b[2Kprogress\r50%", stream="stdout") + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + mock_stdout = MagicMock() + + with ( + patch.object(renderer, "_get_prompt_runtime", return_value=runtime), + patch("sys.stdout", mock_stdout), + ): + renderer._render_shell_line(msg) + + runtime.set_prompt_ephemeral_status.assert_called_once_with("50%") + mock_stdout.write.assert_not_called() + mock_stdout.flush.assert_not_called() + + def test_render_shell_output(renderer, console): msg = ShellOutputMessage( command="ls", exit_code=0, stdout="", stderr="", duration_seconds=0.5 @@ -403,6 +420,20 @@ def test_render_shell_output(renderer, console): renderer._render_shell_output(msg) +def test_render_shell_output_clears_ephemeral_status_when_prompt_surface_active( + renderer, +): + msg = ShellOutputMessage( + command="ls", exit_code=0, stdout="", stderr="", duration_seconds=0.5 + ) + runtime = MagicMock() + + with patch.object(renderer, "_get_prompt_runtime", return_value=runtime): + renderer._render_shell_output(msg) + + runtime.set_prompt_ephemeral_status.assert_called_once_with(None) + + # ========================================================================= # Agent Messages # ========================================================================= @@ -454,6 +485,23 @@ def test_do_render_agent_response_uses_prompt_runtime(renderer): ): renderer._do_render(msg) + runtime.run_above_prompt.assert_called_once() + runtime.clear_prompt_ephemeral_preview.assert_called_once() + mock_render.assert_not_called() + + +def test_do_render_agent_reasoning_uses_prompt_runtime(renderer): + msg = AgentReasoningMessage(reasoning="Because", next_steps="Do X") + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + runtime.run_above_prompt.return_value = True + + with ( + patch.object(renderer, "_get_prompt_runtime", return_value=runtime), + patch.object(renderer, "_render_agent_reasoning") as mock_render, + ): + renderer._do_render(msg) + runtime.run_above_prompt.assert_called_once() mock_render.assert_not_called() diff --git a/tests/test_prompt_toolkit_completion.py b/tests/test_prompt_toolkit_completion.py index b84cb3863..ef7eb194d 100644 --- a/tests/test_prompt_toolkit_completion.py +++ b/tests/test_prompt_toolkit_completion.py @@ -715,6 +715,91 @@ def test_get_prompt_with_active_model_shows_pending_hint_copy( clear_active_prompt_surface() +def test_get_prompt_with_active_model_shows_ephemeral_status(monkeypatch, active_runtime): + clear_active_prompt_surface() + session = MagicMock() + session.app = MagicMock() + register_active_prompt_surface("main", session) + active_runtime.running = True + active_runtime.prompt_status_started_at = 0.0 + active_runtime.set_prompt_ephemeral_status("šŸ”§ Calling list_files... 11 token(s)") + active_runtime.set_prompt_ephemeral_preview( + "\n".join( + [ + "line 1", + "line 2", + "line 3", + "line 4", + "line 5", + "line 6", + "line 7", + "line 8", + ] + ) + ) + + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.get_puppy_name", + lambda: "Buddy", + ) + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.get_active_model", + lambda: "gpt-test", + ) + monkeypatch.setattr( + "code_puppy.command_line.prompt_toolkit_completion.os.getcwd", + lambda: "/tmp/demo", + ) + monkeypatch.setattr( + "code_puppy.command_line.interactive_runtime.time.monotonic", + lambda: 0.18, + ) + + agent = MagicMock() + agent.display_name = "code-puppy" + agent.get_model_name.return_value = "gpt-test" + + with ( + patch( + "code_puppy.command_line.prompt_toolkit_completion._get_current_agent_for_prompt", + return_value=agent, + ), + patch( + "code_puppy.command_line.prompt_toolkit_completion.SpinnerBase.get_context_info", + return_value="", + ), + patch("shutil.get_terminal_size", return_value=os.terminal_size((80, 24))), + ): + rendered = "".join(text for _style, text in get_prompt_with_active_model()) + + assert "šŸ”§ Calling list_files... 11 token(s)" in rendered + assert "line 1" not in rendered + assert "line 2" not in rendered + assert "line 3" in rendered + assert "line 8" in rendered + assert rendered.index("šŸ”§ Calling list_files... 11 token(s)") < rendered.index( + "line 3" + ) + active_runtime.clear_prompt_ephemeral_status() + active_runtime.clear_prompt_ephemeral_preview() + with ( + patch( + "code_puppy.command_line.prompt_toolkit_completion._get_current_agent_for_prompt", + return_value=agent, + ), + patch( + "code_puppy.command_line.prompt_toolkit_completion.SpinnerBase.get_context_info", + return_value="", + ), + patch("shutil.get_terminal_size", return_value=os.terminal_size((80, 24))), + ): + cleared = "".join(text for _style, text in get_prompt_with_active_model()) + + assert "šŸ”§ Calling list_files... 11 token(s)" not in cleared + assert "line 8" not in cleared + clear_active_prompt_surface() + + @pytest.mark.asyncio @patch("code_puppy.command_line.prompt_toolkit_completion.PromptSession") async def test_get_input_registers_active_prompt_surface( From f86d1cb3ea04d7489d7c1b6207ff5e3d51e59e0a Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Tue, 7 Apr 2026 01:20:51 -0500 Subject: [PATCH 38/40] Lean terminal compat and protect foreground ephemeral UI --- IMPLEMENTATION_GUARDRAILS.md | 2 + .../command_line/prompt_toolkit_completion.py | 77 ++--- code_puppy/messaging/rich_renderer.py | 40 ++- .../messaging/spinner/console_spinner.py | 59 ++-- code_puppy/terminal_utils.py | 298 +++++++++++++----- docs/INTERACTIVE_REGRESSION_CHECKLIST.md | 3 + .../test_prompt_toolkit_coverage.py | 39 ++- tests/messaging/test_rich_renderer.py | 72 +++++ tests/test_console_spinner_coverage.py | 27 +- tests/test_terminal_utils.py | 33 +- tests/test_terminal_utils_comprehensive.py | 5 +- 11 files changed, 472 insertions(+), 183 deletions(-) diff --git a/IMPLEMENTATION_GUARDRAILS.md b/IMPLEMENTATION_GUARDRAILS.md index 09bb44927..10edba15d 100644 --- a/IMPLEMENTATION_GUARDRAILS.md +++ b/IMPLEMENTATION_GUARDRAILS.md @@ -6,3 +6,5 @@ - When the live prompt surface is active, shell output with carriage-return progress must use the prompt-local ephemeral status strip; ordinary shell lines remain on the durable shell output path. - Durable structured outputs like `AGENT REASONING` and `DIRECTORY LISTING` should still render above the prompt. - Prompt-surface stream fixes must not duplicate the final `AGENT RESPONSE`. +- The prompt-local ephemeral status/preview is foreground-only; session-tagged sub-agent messages must never write to it or clear it. +- Terminal/emulator-specific behavior must flow through the shared terminal-capability helper in `terminal_utils` rather than adding new scattered env checks. diff --git a/code_puppy/command_line/prompt_toolkit_completion.py b/code_puppy/command_line/prompt_toolkit_completion.py index b363fc0e5..abbe7a3e9 100644 --- a/code_puppy/command_line/prompt_toolkit_completion.py +++ b/code_puppy/command_line/prompt_toolkit_completion.py @@ -191,6 +191,45 @@ def _allows_busy_command_dispatch(text: str) -> bool: return not stripped.startswith("/") or _is_exit_text(stripped) +def _run_text_clipboard_command(command: list[str]) -> str | None: + import subprocess + + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=2) + except FileNotFoundError: + return None + + if result.returncode == 0: + return result.stdout + return None + + +def _read_text_clipboard_fallback() -> str | None: + import platform + + system = platform.system() + if system == "Darwin": + return _run_text_clipboard_command(["pbpaste"]) + if system == "Windows": + for command in ( + ["pwsh", "-NoProfile", "-Command", "Get-Clipboard -Raw"], + ["powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"], + ): + text = _run_text_clipboard_command(command) + if text is not None: + return text + return None + + for command in ( + ["xclip", "-selection", "clipboard", "-o"], + ["xsel", "--clipboard", "--output"], + ): + text = _run_text_clipboard_command(command) + if text is not None: + return text + return None + + def _sanitize_for_encoding(text: str) -> str: """Remove or replace characters that can't be safely encoded. @@ -1309,43 +1348,7 @@ def handle_smart_paste(event): # No image (or error) - do normal text paste # prompt_toolkit doesn't have built-in paste, so we handle it manually try: - import platform - import subprocess - - text = None - system = platform.system() - - if system == "Darwin": # macOS - result = subprocess.run( - ["pbpaste"], capture_output=True, text=True, timeout=2 - ) - if result.returncode == 0: - text = result.stdout - elif system == "Windows": - # Windows - use powershell - result = subprocess.run( - ["powershell", "-command", "Get-Clipboard"], - capture_output=True, - text=True, - timeout=2, - ) - if result.returncode == 0: - text = result.stdout - else: # Linux - # Try xclip first, then xsel - for cmd in [ - ["xclip", "-selection", "clipboard", "-o"], - ["xsel", "--clipboard", "--output"], - ]: - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=2 - ) - if result.returncode == 0: - text = result.stdout - break - except FileNotFoundError: - continue + text = _read_text_clipboard_fallback() if text: # Normalize Windows line endings to Unix style diff --git a/code_puppy/messaging/rich_renderer.py b/code_puppy/messaging/rich_renderer.py index 5721a3eb1..474e0065e 100644 --- a/code_puppy/messaging/rich_renderer.py +++ b/code_puppy/messaging/rich_renderer.py @@ -21,6 +21,7 @@ from rich.table import Table from code_puppy.config import get_subagent_verbose +from code_puppy.terminal_utils import supports_live_terminal_updates from code_puppy.tools.common import format_diff_with_colors from code_puppy.tools.subagent_context import is_subagent @@ -188,7 +189,14 @@ def _get_prompt_runtime(self): except Exception: return None - def _set_prompt_ephemeral_status(self, text: str | None) -> None: + def _is_background_session_message(self, message: AnyMessage | None) -> bool: + return bool(getattr(message, "session_id", None)) + + def _set_prompt_ephemeral_status( + self, text: str | None, message: AnyMessage | None = None + ) -> None: + if self._is_background_session_message(message): + return runtime = self._get_prompt_runtime() if runtime is None: return @@ -197,10 +205,16 @@ def _set_prompt_ephemeral_status(self, text: str | None) -> None: except Exception: pass - def _clear_prompt_ephemeral_status(self) -> None: - self._set_prompt_ephemeral_status(None) + def _clear_prompt_ephemeral_status( + self, message: AnyMessage | None = None + ) -> None: + self._set_prompt_ephemeral_status(None, message=message) - def _clear_prompt_ephemeral_preview(self) -> None: + def _clear_prompt_ephemeral_preview( + self, message: AnyMessage | None = None + ) -> None: + if self._is_background_session_message(message): + return runtime = self._get_prompt_runtime() if runtime is None: return @@ -260,7 +274,7 @@ def _render_message_above_prompt(self, message: AnyMessage) -> bool: return False if isinstance(message, AgentResponseMessage): - self._clear_prompt_ephemeral_preview() + self._clear_prompt_ephemeral_preview(message=message) console = self._build_prompt_safe_console() return runtime.run_above_prompt( @@ -843,7 +857,7 @@ def _render_shell_line(self, msg: ShellLineMessage) -> None: normalized = "".join( char for char in normalized if char == "\t" or char.isprintable() ).strip() - self._set_prompt_ephemeral_status(normalized or None) + self._set_prompt_ephemeral_status(normalized or None, message=msg) return sys.stdout.write(msg.line + "\n") sys.stdout.flush() @@ -851,10 +865,14 @@ def _render_shell_line(self, msg: ShellLineMessage) -> None: # Check if line contains carriage return (progress bar style output) if "\r" in msg.line: - # Bypass Rich entirely - write directly to stdout so terminal interprets \r - # Apply dim styling manually via ANSI codes - sys.stdout.write(f"\033[2m{msg.line}\033[0m") - sys.stdout.flush() + if supports_live_terminal_updates(self._console): + # Bypass Rich entirely - write directly to stdout so terminal interprets \r + # Apply dim styling manually via ANSI codes + sys.stdout.write(f"\033[2m{msg.line}\033[0m") + sys.stdout.flush() + else: + normalized = Text.from_ansi(msg.line.split("\r")[-1]) + self._console.print(normalized, style="dim") else: # Normal line: use Rich for nice formatting text = Text.from_ansi(msg.line) @@ -866,7 +884,7 @@ def _render_shell_output(self, msg: ShellOutputMessage) -> None: Shell command results are already returned to the LLM via tool responses, so we don't need to clutter the UI with redundant output. """ - self._clear_prompt_ephemeral_status() + self._clear_prompt_ephemeral_status(message=msg) # Just print trailing newline for spinner separation self._console.print() diff --git a/code_puppy/messaging/spinner/console_spinner.py b/code_puppy/messaging/spinner/console_spinner.py index 017615e26..a5930d46a 100644 --- a/code_puppy/messaging/spinner/console_spinner.py +++ b/code_puppy/messaging/spinner/console_spinner.py @@ -10,6 +10,13 @@ from rich.live import Live from rich.text import Text +from code_puppy.terminal_utils import ( + clear_live_terminal_line, + flush_windows_keyboard_buffer, + reset_windows_terminal_ansi, + supports_live_terminal_updates, +) + from .spinner_base import SpinnerBase @@ -41,6 +48,9 @@ def start(self): super().start() self._stop_event.clear() + if not supports_live_terminal_updates(self.console): + return + # Don't start a new thread if one is already running if self._thread and self._thread.is_alive(): return @@ -80,32 +90,10 @@ def stop(self): self._thread = None - # Windows-specific cleanup: Rich's Live display can leave terminal in corrupted state if platform.system() == "Windows": - import sys - - try: - # Reset ANSI formatting for both stdout and stderr - sys.stdout.write("\x1b[0m") # Reset all attributes - sys.stdout.flush() - sys.stderr.write("\x1b[0m") - sys.stderr.flush() - - # Clear the line and reposition cursor - sys.stdout.write("\r") # Return to start of line - sys.stdout.write("\x1b[K") # Clear to end of line - sys.stdout.flush() - - # Flush keyboard input buffer to clear any stuck keys - try: - import msvcrt - - while msvcrt.kbhit(): - msvcrt.getch() - except ImportError: - pass # msvcrt not available (not Windows or different Python impl) - except Exception: - pass # Fail silently if cleanup doesn't work + reset_windows_terminal_ansi() + clear_live_terminal_line() + flush_windows_keyboard_buffer() # Unregister this spinner from global management from . import unregister_spinner @@ -178,12 +166,7 @@ def pause(self): try: self._live.stop() self._live = None - # Clear the line to remove any artifacts - import sys - - sys.stdout.write("\r") # Return to start of line - sys.stdout.write("\x1b[K") # Clear to end of line - sys.stdout.flush() + clear_live_terminal_line(console=self.console) except Exception: pass @@ -197,15 +180,12 @@ def resume(self): if self._is_spinning and self._paused: self._paused = False + if not supports_live_terminal_updates(self.console): + return # Restart the live display if it was stopped during pause if not self._live: try: - # Clear any leftover artifacts before starting - import sys - - sys.stdout.write("\r") # Return to start of line - sys.stdout.write("\x1b[K") # Clear to end of line - sys.stdout.flush() + clear_live_terminal_line(console=self.console) # Print blank line before spinner for visual separation. self.console.print() @@ -225,10 +205,7 @@ def resume(self): try: # Force Rich to reset any cached console state if hasattr(self.console, "_buffer"): - # Clear Rich's internal buffer to prevent artifacts - self.console.file.write("\r") # Return to start - self.console.file.write("\x1b[K") # Clear line - self.console.file.flush() + clear_live_terminal_line(stream=self.console.file, console=self.console) self._live.update(self._generate_spinner_panel()) self._live.refresh() diff --git a/code_puppy/terminal_utils.py b/code_puppy/terminal_utils.py index 6efb37e02..14d250347 100644 --- a/code_puppy/terminal_utils.py +++ b/code_puppy/terminal_utils.py @@ -7,6 +7,7 @@ import platform import subprocess import sys +from dataclasses import dataclass from typing import TYPE_CHECKING, Callable, Optional if TYPE_CHECKING: @@ -15,6 +16,144 @@ # Store the original console ctrl handler so we can restore it if needed _original_ctrl_handler: Optional[Callable] = None +_TRUECOLOR_TERM_MARKERS = ( + "xterm-direct", + "xterm-ghostty", + "xterm-truecolor", + "iterm2", + "vte-256color", +) + + +@dataclass(frozen=True) +class TerminalProfile: + terminal_family: str + supports_truecolor: bool + live_updates_safe: bool + + +def _get_terminal_stream(console: Optional["Console"] = None): + if console is not None and getattr(console, "file", None) is not None: + return console.file + return getattr(sys, "__stdout__", None) or sys.stdout + + +def _stream_is_tty(stream) -> bool: + try: + isatty = getattr(stream, "isatty", None) + return bool(isatty and isatty()) + except Exception: + return False + + +def _detect_terminal_family() -> str: + term = os.environ.get("TERM", "").lower() + term_program = os.environ.get("TERM_PROGRAM", "").lower() + + if os.environ.get("WT_SESSION"): + return "windows_terminal" + if term_program == "apple_terminal": + return "terminal_app" + if ( + term_program == "ghostty" + or "xterm-ghostty" in term + or os.environ.get("GHOSTTY_RESOURCES_DIR") + ): + return "ghostty" + if ( + os.environ.get("ITERM_SESSION_ID") + or term_program == "iterm.app" + or "iterm2" in term + ): + return "iterm2" + if os.environ.get("KITTY_WINDOW_ID"): + return "kitty" + if os.environ.get("ALACRITTY_SOCKET"): + return "alacritty" + return "unknown" + + +def _detect_rich_truecolor(console: Optional["Console"] = None) -> bool: + stream = _get_terminal_stream(console) + if not _stream_is_tty(stream): + return False + + try: + if console is None: + from rich.console import Console + + console = Console(file=stream) + except Exception: + return False + + return (console.color_system or "").lower() == "truecolor" + + +def get_terminal_profile(console: Optional["Console"] = None) -> TerminalProfile: + """Return the best-effort terminal profile for the active session.""" + terminal_family = _detect_terminal_family() + stream = _get_terminal_stream(console) + is_interactive = _stream_is_tty(stream) + + colorterm = os.environ.get("COLORTERM", "").lower() + term = os.environ.get("TERM", "").lower() + + supports_truecolor = False + if colorterm in ("truecolor", "24bit"): + supports_truecolor = True + elif any(marker in term for marker in _TRUECOLOR_TERM_MARKERS): + supports_truecolor = True + elif terminal_family in {"ghostty", "iterm2"}: + supports_truecolor = True + elif any( + os.environ.get(var) + for var in ( + "ITERM_SESSION_ID", + "KITTY_WINDOW_ID", + "ALACRITTY_SOCKET", + "WT_SESSION", + ) + ): + supports_truecolor = True + else: + supports_truecolor = _detect_rich_truecolor(console) + + live_updates_safe = is_interactive and os.environ.get("CI", "").lower() not in { + "1", + "true", + "yes", + } + if platform.system() == "Windows": + live_updates_safe = live_updates_safe and terminal_family == "windows_terminal" + + return TerminalProfile( + terminal_family=terminal_family, + supports_truecolor=supports_truecolor, + live_updates_safe=live_updates_safe, + ) + + +def supports_live_terminal_updates(console: Optional["Console"] = None) -> bool: + """Return whether live CR/ANSI redraws are safe for the active terminal.""" + return get_terminal_profile(console).live_updates_safe + + +def clear_live_terminal_line( + stream=None, console: Optional["Console"] = None +) -> bool: + """Clear the current live terminal line when CR-based redraw is supported.""" + target = stream or _get_terminal_stream(console) + if not supports_live_terminal_updates(console) or not _stream_is_tty(target): + return False + + try: + target.write("\r") + target.write("\x1b[K") + target.flush() + return True + except Exception: + return False + def reset_windows_terminal_ansi() -> None: """Reset ANSI formatting on Windows stdout/stderr. @@ -298,51 +437,19 @@ def ensure_ctrl_c_disabled() -> bool: def detect_truecolor_support() -> bool: """Detect if the terminal supports truecolor (24-bit color). - Checks multiple indicators: - 1. COLORTERM environment variable (most reliable) - 2. TERM environment variable patterns - 3. Rich's Console color_system detection as fallback - Returns: True if truecolor is supported, False otherwise. """ - # Check COLORTERM - this is the most reliable indicator - colorterm = os.environ.get("COLORTERM", "").lower() - if colorterm in ("truecolor", "24bit"): - return True - - # Check TERM for known truecolor-capable terminals - term = os.environ.get("TERM", "").lower() - truecolor_terms = ( - "xterm-direct", - "xterm-truecolor", - "iterm2", - "vte-256color", # Many modern terminals set this - ) - if any(t in term for t in truecolor_terms): - return True - - # Some terminals like iTerm2, Kitty, Alacritty set specific env vars - if os.environ.get("ITERM_SESSION_ID"): - return True - if os.environ.get("KITTY_WINDOW_ID"): - return True - if os.environ.get("ALACRITTY_SOCKET"): - return True - if os.environ.get("WT_SESSION"): # Windows Terminal - return True + return get_terminal_profile().supports_truecolor - # Use Rich's detection as a fallback - try: - from rich.console import Console - - console = Console(force_terminal=True) - color_system = console.color_system - return color_system == "truecolor" - except Exception: - pass - return False +def _terminal_display_name(terminal_family: str) -> str: + return { + "ghostty": "Ghostty", + "iterm2": "iTerm2", + "terminal_app": "Terminal.app", + "windows_terminal": "Windows Terminal", + }.get(terminal_family, terminal_family) def print_truecolor_warning(console: Optional["Console"] = None) -> None: @@ -353,66 +460,85 @@ def print_truecolor_warning(console: Optional["Console"] = None) -> None: """ if detect_truecolor_support(): return # All good, no warning needed + profile = get_terminal_profile(console) if console is None: try: from rich.console import Console - console = Console() + console = Console(file=_get_terminal_stream()) except ImportError: # Rich not available, fall back to plain print print("\n" + "=" * 70) - print("āš ļø WARNING: TERMINAL DOES NOT SUPPORT TRUECOLOR (24-BIT COLOR)") - print("=" * 70) - print("Code Puppy looks best with truecolor support.") - print("Consider using a modern terminal like:") - print(" • iTerm2 (macOS)") - print(" • Windows Terminal (Windows)") - print(" • Kitty, Alacritty, or any modern terminal emulator") - print("") - print("You can also try setting: export COLORTERM=truecolor") - print("") - print("Note: The built-in macOS Terminal.app does not support truecolor") - print("(Sequoia and earlier). You'll need a different terminal app.") + if profile.terminal_family == "terminal_app": + print("NOTICE: Terminal.app works, but colors will be reduced.") + print("=" * 70) + print("Consider iTerm2 or Ghostty for full color fidelity on macOS.") + else: + print("āš ļø WARNING: TERMINAL DOES NOT SUPPORT TRUECOLOR (24-BIT COLOR)") + print("=" * 70) + print("Code Puppy looks best with truecolor support.") + print("Consider using a modern terminal like:") + print(" • iTerm2 (macOS)") + print(" • Ghostty (macOS)") + print(" • Windows Terminal (Windows)") + print(" • Kitty, Alacritty, or Warp") + print("") + print("You can also try setting: export COLORTERM=truecolor") print("=" * 70 + "\n") return # Get detected color system for diagnostic info color_system = console.color_system or "unknown" - # Build the warning box - warning_lines = [ - "", - "[bold bright_red on red]" + "━" * 72 + "[/]", - "[bold bright_red on red]ā”ƒ[/][bold bright_white on red]" - + " " * 70 - + "[/][bold bright_red on red]ā”ƒ[/]", - "[bold bright_red on red]ā”ƒ[/][bold bright_white on red] āš ļø WARNING: TERMINAL DOES NOT SUPPORT TRUECOLOR (24-BIT COLOR) āš ļø [/][bold bright_red on red]ā”ƒ[/]", - "[bold bright_red on red]ā”ƒ[/][bold bright_white on red]" - + " " * 70 - + "[/][bold bright_red on red]ā”ƒ[/]", - "[bold bright_red on red]" + "━" * 72 + "[/]", - "", - f"[yellow]Detected color system:[/] [bold]{color_system}[/]", - "", - "[bold white]Code Puppy uses rich colors and will look degraded without truecolor.[/]", - "", - "[cyan]Consider using a modern terminal emulator:[/]", - " [green]•[/] [bold]iTerm2[/] (macOS) - https://iterm2.com", - " [green]•[/] [bold]Windows Terminal[/] (Windows) - Built into Windows 11", - " [green]•[/] [bold]Kitty[/] - https://sw.kovidgoyal.net/kitty", - " [green]•[/] [bold]Alacritty[/] - https://alacritty.org", - " [green]•[/] [bold]Warp[/] (macOS) - https://warp.dev", - "", - "[cyan]Or try setting the COLORTERM environment variable:[/]", - " [dim]export COLORTERM=truecolor[/]", - "", - "[dim italic]Note: The built-in macOS Terminal.app does not support truecolor (Sequoia and earlier).[/]", - "[dim italic]Setting COLORTERM=truecolor won't help - you'll need a different terminal app.[/]", - "", - "[bold bright_red]" + "─" * 72 + "[/]", - "", - ] + if profile.terminal_family == "terminal_app": + warning_lines = [ + "", + "[bold yellow]" + "━" * 72 + "[/]", + "[bold yellow]NOTICE: TERMINAL.APP COLORS WILL BE REDUCED[/]", + "", + f"[yellow]Detected terminal:[/] [bold]{_terminal_display_name(profile.terminal_family)}[/]", + f"[yellow]Detected color system:[/] [bold]{color_system}[/]", + "", + "[bold white]Code Puppy should work normally here, but Terminal.app does not advertise truecolor.[/]", + "", + "[cyan]For full color fidelity on macOS, consider iTerm2 or Ghostty.[/]", + "", + "[bold yellow]" + "─" * 72 + "[/]", + "", + ] + else: + warning_lines = [ + "", + "[bold bright_red on red]" + "━" * 72 + "[/]", + "[bold bright_red on red]ā”ƒ[/][bold bright_white on red]" + + " " * 70 + + "[/][bold bright_red on red]ā”ƒ[/]", + "[bold bright_red on red]ā”ƒ[/][bold bright_white on red] āš ļø WARNING: TERMINAL DOES NOT SUPPORT TRUECOLOR (24-BIT COLOR) āš ļø [/][bold bright_red on red]ā”ƒ[/]", + "[bold bright_red on red]ā”ƒ[/][bold bright_white on red]" + + " " * 70 + + "[/][bold bright_red on red]ā”ƒ[/]", + "[bold bright_red on red]" + "━" * 72 + "[/]", + "", + f"[yellow]Detected terminal:[/] [bold]{_terminal_display_name(profile.terminal_family)}[/]", + f"[yellow]Detected color system:[/] [bold]{color_system}[/]", + "", + "[bold white]Code Puppy uses rich colors and will look degraded without truecolor.[/]", + "", + "[cyan]Consider using a modern terminal emulator:[/]", + " [green]•[/] [bold]iTerm2[/] (macOS) - https://iterm2.com", + " [green]•[/] [bold]Ghostty[/] (macOS) - https://ghostty.org", + " [green]•[/] [bold]Windows Terminal[/] (Windows) - Built into Windows 11", + " [green]•[/] [bold]Kitty[/] - https://sw.kovidgoyal.net/kitty", + " [green]•[/] [bold]Alacritty[/] - https://alacritty.org", + " [green]•[/] [bold]Warp[/] (macOS) - https://warp.dev", + "", + "[cyan]Or try setting the COLORTERM environment variable:[/]", + " [dim]export COLORTERM=truecolor[/]", + "", + "[bold bright_red]" + "─" * 72 + "[/]", + "", + ] for line in warning_lines: console.print(line) diff --git a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md index d1b8b15c7..3c26fcfae 100644 --- a/docs/INTERACTIVE_REGRESSION_CHECKLIST.md +++ b/docs/INTERACTIVE_REGRESSION_CHECKLIST.md @@ -6,3 +6,6 @@ - Prompt-surface tool progress must not leak raw ANSI or flash the prompt surface on each delta. - Prompt-surface runs may show live response text only in the prompt-local ephemeral preview, and the final `AGENT RESPONSE` still renders once. - Prompt-surface shell carriage-return progress updates in place in the prompt-local ephemeral status strip and clears on completion without transcript spam. +- Parallel sub-agents must not overwrite or clear the foreground prompt-local ephemeral status/preview. +- Windows text clipboard fallback must work through `pwsh` first and then Windows PowerShell without changing paste semantics. +- Unknown, CI, or non-interactive terminals must degrade away from risky live CR/ANSI redraw behavior instead of leaking raw control sequences. diff --git a/tests/command_line/test_prompt_toolkit_coverage.py b/tests/command_line/test_prompt_toolkit_coverage.py index 3b87313a8..72cfbb739 100644 --- a/tests/command_line/test_prompt_toolkit_coverage.py +++ b/tests/command_line/test_prompt_toolkit_coverage.py @@ -865,10 +865,47 @@ def test_ctrl_v_no_image_windows(self, captured_bindings): return_value=False, ), patch("platform.system", return_value="Windows"), - patch("subprocess.run", return_value=mock_result), + patch("subprocess.run", return_value=mock_result) as mock_run, ): handler(event) event.app.current_buffer.insert_text.assert_called() + assert mock_run.call_args.args[0] == [ + "pwsh", + "-NoProfile", + "-Command", + "Get-Clipboard -Raw", + ] + + def test_ctrl_v_no_image_windows_falls_back_to_powershell( + self, captured_bindings + ): + handler = self._find_handler(captured_bindings, "c-v") + event = MagicMock() + + def run_side_effect(cmd, **kwargs): + if cmd[0] == "pwsh": + raise FileNotFoundError() + result = MagicMock() + result.returncode = 0 + result.stdout = "windows text\r\n" + return result + + with ( + patch( + "code_puppy.command_line.prompt_toolkit_completion.has_image_in_clipboard", + return_value=False, + ), + patch("platform.system", return_value="Windows"), + patch("subprocess.run", side_effect=run_side_effect) as mock_run, + ): + handler(event) + event.app.current_buffer.insert_text.assert_called_with("windows text") + assert mock_run.call_args.args[0] == [ + "powershell", + "-NoProfile", + "-Command", + "Get-Clipboard -Raw", + ] def test_ctrl_v_no_image_linux(self, captured_bindings): handler = self._find_handler(captured_bindings, "c-v") diff --git a/tests/messaging/test_rich_renderer.py b/tests/messaging/test_rich_renderer.py index 9a63acf47..917a59628 100644 --- a/tests/messaging/test_rich_renderer.py +++ b/tests/messaging/test_rich_renderer.py @@ -396,6 +396,17 @@ def test_render_shell_line_with_cr(renderer, console): renderer._render_shell_line(msg) +def test_render_shell_line_with_cr_without_live_updates_uses_console(renderer, console): + msg = ShellLineMessage(line="progress\r50%", stream="stdout") + with patch( + "code_puppy.messaging.rich_renderer.supports_live_terminal_updates", + return_value=False, + ): + renderer._render_shell_line(msg) + out = output(console) + assert "50%" in out + + def test_render_shell_line_with_cr_and_prompt_surface_uses_ephemeral_status(renderer): msg = ShellLineMessage(line="\x1b[2Kprogress\r50%", stream="stdout") runtime = MagicMock() @@ -413,6 +424,27 @@ def test_render_shell_line_with_cr_and_prompt_surface_uses_ephemeral_status(rend mock_stdout.flush.assert_not_called() +def test_render_shell_line_with_cr_and_prompt_surface_skips_subagent_ephemeral_status( + renderer, +): + msg = ShellLineMessage( + line="\x1b[2Kprogress\r50%", stream="stdout", session_id="subagent-1" + ) + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + mock_stdout = MagicMock() + + with ( + patch.object(renderer, "_get_prompt_runtime", return_value=runtime), + patch("sys.stdout", mock_stdout), + ): + renderer._render_shell_line(msg) + + runtime.set_prompt_ephemeral_status.assert_not_called() + mock_stdout.write.assert_not_called() + mock_stdout.flush.assert_not_called() + + def test_render_shell_output(renderer, console): msg = ShellOutputMessage( command="ls", exit_code=0, stdout="", stderr="", duration_seconds=0.5 @@ -434,6 +466,25 @@ def test_render_shell_output_clears_ephemeral_status_when_prompt_surface_active( runtime.set_prompt_ephemeral_status.assert_called_once_with(None) +def test_render_shell_output_with_session_id_does_not_clear_foreground_ephemeral_status( + renderer, +): + msg = ShellOutputMessage( + command="ls", + exit_code=0, + stdout="", + stderr="", + duration_seconds=0.5, + session_id="subagent-1", + ) + runtime = MagicMock() + + with patch.object(renderer, "_get_prompt_runtime", return_value=runtime): + renderer._render_shell_output(msg) + + runtime.set_prompt_ephemeral_status.assert_not_called() + + # ========================================================================= # Agent Messages # ========================================================================= @@ -490,6 +541,27 @@ def test_do_render_agent_response_uses_prompt_runtime(renderer): mock_render.assert_not_called() +def test_do_render_session_tagged_agent_response_does_not_clear_foreground_preview( + renderer, +): + msg = AgentResponseMessage( + content="plain text", is_markdown=False, session_id="subagent-1" + ) + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + runtime.run_above_prompt.return_value = True + + with ( + patch.object(renderer, "_get_prompt_runtime", return_value=runtime), + patch.object(renderer, "_render_agent_response") as mock_render, + ): + renderer._do_render(msg) + + runtime.run_above_prompt.assert_called_once() + runtime.clear_prompt_ephemeral_preview.assert_not_called() + mock_render.assert_not_called() + + def test_do_render_agent_reasoning_uses_prompt_runtime(renderer): msg = AgentReasoningMessage(reasoning="Because", next_steps="Do X") runtime = MagicMock() diff --git a/tests/test_console_spinner_coverage.py b/tests/test_console_spinner_coverage.py index 5c470675e..e8533734f 100644 --- a/tests/test_console_spinner_coverage.py +++ b/tests/test_console_spinner_coverage.py @@ -186,6 +186,24 @@ def test_start_does_not_create_thread_if_already_running(self): # Should not create a new thread mock_thread_class.assert_not_called() + def test_start_skips_live_display_when_terminal_updates_are_unsafe(self): + from code_puppy.messaging.spinner.console_spinner import ConsoleSpinner + + mock_console = MagicMock(spec=Console) + spinner = ConsoleSpinner(console=mock_console) + + with ( + patch( + "code_puppy.messaging.spinner.console_spinner.supports_live_terminal_updates", + return_value=False, + ), + patch("code_puppy.messaging.spinner.console_spinner.Live") as mock_live_cls, + ): + spinner.start() + + mock_live_cls.assert_not_called() + mock_console.print.assert_not_called() + class TestConsoleSpinnerStop: """Tests for ConsoleSpinner.stop() method.""" @@ -612,13 +630,12 @@ def test_pause_clears_line(self): mock_live = MagicMock() spinner._live = mock_live - mock_stdout = MagicMock() - - with patch.object(sys, "stdout", mock_stdout): + with patch( + "code_puppy.messaging.spinner.console_spinner.clear_live_terminal_line" + ) as mock_clear_line: spinner.pause() - # Should write cursor/line clear codes - mock_stdout.write.assert_called() + mock_clear_line.assert_called_once() def test_pause_does_nothing_when_not_spinning(self): """Test that pause does nothing when not spinning.""" diff --git a/tests/test_terminal_utils.py b/tests/test_terminal_utils.py index e8612459f..82c6955a7 100644 --- a/tests/test_terminal_utils.py +++ b/tests/test_terminal_utils.py @@ -360,6 +360,24 @@ def test_windows_terminal(self, monkeypatch): monkeypatch.setenv("WT_SESSION", "abc") assert terminal_utils.detect_truecolor_support() is True + def test_ghostty_term_is_detected(self, monkeypatch): + monkeypatch.delenv("COLORTERM", raising=False) + monkeypatch.setenv("TERM", "xterm-ghostty") + assert terminal_utils.detect_truecolor_support() is True + + def test_terminal_app_profile_is_degraded_not_unknown(self, monkeypatch): + monkeypatch.delenv("COLORTERM", raising=False) + monkeypatch.delenv("TERM", raising=False) + monkeypatch.setenv("TERM_PROGRAM", "Apple_Terminal") + profile = terminal_utils.get_terminal_profile() + assert profile.terminal_family == "terminal_app" + assert profile.supports_truecolor is False + + def test_windows_terminal_profile_is_detected(self, monkeypatch): + monkeypatch.setenv("WT_SESSION", "abc") + profile = terminal_utils.get_terminal_profile() + assert profile.terminal_family == "windows_terminal" + def test_rich_fallback_truecolor(self, monkeypatch): monkeypatch.delenv("COLORTERM", raising=False) monkeypatch.setenv("TERM", "dumb") @@ -367,6 +385,7 @@ def test_rich_fallback_truecolor(self, monkeypatch): monkeypatch.delenv("KITTY_WINDOW_ID", raising=False) monkeypatch.delenv("ALACRITTY_SOCKET", raising=False) monkeypatch.delenv("WT_SESSION", raising=False) + monkeypatch.setattr(terminal_utils, "_stream_is_tty", lambda _stream: True) mock_console_cls = MagicMock() mock_console_cls.return_value.color_system = "truecolor" monkeypatch.setattr( @@ -430,7 +449,7 @@ def test_creates_console_when_none(self, monkeypatch): mock_console.color_system = "standard" import rich.console - monkeypatch.setattr(rich.console, "Console", lambda: mock_console) + monkeypatch.setattr(rich.console, "Console", lambda *args, **kwargs: mock_console) terminal_utils.print_truecolor_warning(console=None) assert mock_console.print.call_count > 10 @@ -460,3 +479,15 @@ def test_console_color_system_none(self, monkeypatch): # Should use "unknown" for color_system calls = [str(c) for c in mock_console.print.call_args_list] assert any("unknown" in c for c in calls) + +class TestLiveTerminalUpdates: + def test_windows_terminal_allows_live_updates(self, monkeypatch): + monkeypatch.setattr(terminal_utils.platform, "system", lambda: "Windows") + monkeypatch.setenv("WT_SESSION", "abc") + monkeypatch.setattr(terminal_utils, "_stream_is_tty", lambda _stream: True) + assert terminal_utils.supports_live_terminal_updates() is True + + def test_ci_disables_live_updates(self, monkeypatch): + monkeypatch.setattr(terminal_utils, "_stream_is_tty", lambda _stream: True) + monkeypatch.setenv("CI", "1") + assert terminal_utils.supports_live_terminal_updates() is False diff --git a/tests/test_terminal_utils_comprehensive.py b/tests/test_terminal_utils_comprehensive.py index fde6db23d..4027ef71b 100644 --- a/tests/test_terminal_utils_comprehensive.py +++ b/tests/test_terminal_utils_comprehensive.py @@ -730,7 +730,10 @@ def test_no_truecolor_support(self): def test_rich_fallback_truecolor(self): """Test Rich fallback detects truecolor.""" with patch.dict(os.environ, {}, clear=True): - with patch("rich.console.Console") as mock_console_class: + with ( + patch("code_puppy.terminal_utils._stream_is_tty", return_value=True), + patch("rich.console.Console") as mock_console_class, + ): mock_console = MagicMock() mock_console.color_system = "truecolor" mock_console_class.return_value = mock_console From 77d4f8be5d234d3b11b75f5b2a2bb2395ee1d329 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Tue, 7 Apr 2026 02:19:41 -0500 Subject: [PATCH 39/40] Gracefully reject malformed replace_in_file payloads --- code_puppy/tools/file_modifications.py | 28 +++++++++++++++---- .../tools/test_file_modifications_extended.py | 22 +++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/code_puppy/tools/file_modifications.py b/code_puppy/tools/file_modifications.py index f3fa8643d..e0ca06eb5 100644 --- a/code_puppy/tools/file_modifications.py +++ b/code_puppy/tools/file_modifications.py @@ -829,10 +829,28 @@ def replace_in_file( Replacements are applied sequentially. Prefer this over full file rewrites. """ group_id = generate_group_id("replace_in_file", file_path) - # replacements arrive as plain dicts — pass them straight through - replacements_dict = [ - {"old_str": r["old_str"], "new_str": r["new_str"]} for r in replacements - ] + invalid_payload_result = { + "success": False, + "path": os.path.abspath(file_path) if file_path else file_path, + "message": ( + "Invalid replacements payload: each replacement must include " + "string 'old_str' and 'new_str' fields." + ), + "changed": False, + } + try: + replacements_dict = [ + {"old_str": r["old_str"], "new_str": r["new_str"]} + for r in replacements + ] + except (KeyError, TypeError): + return invalid_payload_result + if any( + not isinstance(r["old_str"], str) or not isinstance(r["new_str"], str) + for r in replacements_dict + ): + return invalid_payload_result + result = _replace_in_file_helper( context, file_path, replacements_dict, message_group=group_id ) @@ -844,7 +862,7 @@ def replace_in_file( file_path=file_path, replacements=[ Replacement(old_str=r["old_str"], new_str=r["new_str"]) - for r in replacements + for r in replacements_dict ], ) enhanced_results = on_edit_file(context, result, payload) diff --git a/tests/tools/test_file_modifications_extended.py b/tests/tools/test_file_modifications_extended.py index 52da75c3f..4e064aabd 100644 --- a/tests/tools/test_file_modifications_extended.py +++ b/tests/tools/test_file_modifications_extended.py @@ -11,6 +11,7 @@ ReplacementsPayload, _delete_file, _edit_file, + register_replace_in_file, ) @@ -221,6 +222,27 @@ def func3(): assert "def func1():" in content # Should remain assert "def func3():" in content # Should remain + def test_register_replace_in_file_rejects_missing_old_str(self, tmp_path): + registered = {} + + class Agent: + def tool(self, fn): + registered[fn.__name__] = fn + return fn + + register_replace_in_file(Agent()) + fn = registered["replace_in_file"] + + result = fn( + Mock(), + file_path=str(tmp_path / "test.py"), + replacements=[{"new_str": "updated"}], + ) + + assert result["success"] is False + assert result["changed"] is False + assert "old_str" in result["message"] + def test_error_recovery_file_permissions(self, tmp_path): """Test error recovery when file permissions prevent modification.""" test_file = tmp_path / "readonly.py" From 87d4af4b4826abedd32b1e2ddd57fca2f78c9ed0 Mon Sep 17 00:00:00 2001 From: Nate Oswalt Date: Tue, 7 Apr 2026 10:47:24 -0500 Subject: [PATCH 40/40] fix(runtime): address review correctness issues --- code_puppy/agents/event_stream_handler.py | 15 +- code_puppy/cli_runner.py | 201 +++++++++--------- .../antigravity_oauth/register_callbacks.py | 1 + code_puppy/tools/command_runner.py | 27 ++- tests/agents/test_event_stream_handler.py | 51 +++++ .../test_antigravity_callbacks_coverage.py | 6 +- .../test_antigravity_register_callbacks.py | 53 +++++ tests/test_cli_runner_coverage.py | 2 +- tests/test_cli_runner_full_coverage.py | 179 +++++++++++++++- .../test_command_runner_full_coverage.py | 93 +++++++- 10 files changed, 512 insertions(+), 116 deletions(-) diff --git a/code_puppy/agents/event_stream_handler.py b/code_puppy/agents/event_stream_handler.py index 40db138a5..2052619ce 100644 --- a/code_puppy/agents/event_stream_handler.py +++ b/code_puppy/agents/event_stream_handler.py @@ -145,11 +145,22 @@ def _merge_tool_name(current_name: str, tool_name_delta: str) -> str: return tool_name_delta if tool_name_delta.startswith(current_name): return tool_name_delta - if current_name.endswith(tool_name_delta): + if tool_name_delta in current_name: return current_name + for overlap in range(min(len(current_name), len(tool_name_delta)), 0, -1): + if current_name.endswith(tool_name_delta[:overlap]): + return current_name + tool_name_delta[overlap:] return current_name + tool_name_delta +def _is_reasoning_tool_name(tool_name: str) -> bool: + """Return True for the reasoning tool, including streamed prefixes.""" + reasoning_tool = "agent_share_your_reasoning" + return bool(tool_name) and ( + reasoning_tool.startswith(tool_name) or tool_name.startswith(reasoning_tool) + ) + + def _build_prompt_safe_console(source_console: Console) -> Console: """Create a console that writes to the real terminal above the prompt.""" return Console( @@ -391,7 +402,7 @@ async def _print_response_banner() -> None: # Use stored tool name for display tool_name = tool_names.get(event.index, "") if prompt_surface_active: - if tool_name != "agent_share_your_reasoning": + if not _is_reasoning_tool_name(tool_name): count = token_count[event.index] if tool_name: _set_prompt_ephemeral_status( diff --git a/code_puppy/cli_runner.py b/code_puppy/cli_runner.py index 26c853200..b50d88390 100644 --- a/code_puppy/cli_runner.py +++ b/code_puppy/cli_runner.py @@ -1191,9 +1191,11 @@ async def run_interactive_command_bg( level="warning" if source_item.kind == "interject" else "success", ) - await asyncio.to_thread(command_result.run, command_result.cancel_event) + completed = await asyncio.to_thread( + command_result.run, command_result.cancel_event + ) - if source_item and not command_result.cancel_event.is_set(): + if source_item and completed and not command_result.cancel_event.is_set(): emit_interject_queue_lifecycle( runtime, "completed", @@ -1288,7 +1290,6 @@ async def dispatch_submission( if requested_action == "interject": log_event("interject_banner", text=stripped_task) - await cancel_active_run("interject") ok, position, item = runtime.request_interject( stripped_task, allow_command_dispatch=allow_command_dispatch, @@ -1310,6 +1311,7 @@ async def dispatch_submission( ) return "consumed" + await cancel_active_run("interject") emit_interject_queue_lifecycle( runtime, "queued", @@ -1620,117 +1622,120 @@ async def kick_drain_after_cancel_boundary(*, origin: str) -> bool: await asyncio.sleep(0) return await drain_pending_work_if_idle(origin=origin) - if startup_oauth_command: - startup_outcome = await dispatch_submission( - startup_oauth_command, - save_history=False, - allow_command_dispatch=True, - ) - if startup_outcome == "exit": - clear_active_interactive_runtime(runtime) - return + try: + if startup_oauth_command: + startup_outcome = await dispatch_submission( + startup_oauth_command, + save_history=False, + allow_command_dispatch=True, + ) + if startup_outcome == "exit": + return - while True: - from code_puppy.agents.agent_manager import get_current_agent - from code_puppy.messaging import emit_info + while True: + from code_puppy.agents.agent_manager import get_current_agent + from code_puppy.messaging import emit_info - # Get the custom prompt from the current agent, or use default - current_agent = get_current_agent() - user_prompt = current_agent.get_user_prompt() or "Enter your coding task:" - if not runtime.running: - handled = await drain_pending_work_if_idle(origin="loop_idle_check") - if handled: - continue + # Get the custom prompt from the current agent, or use default + current_agent = get_current_agent() + user_prompt = current_agent.get_user_prompt() or "Enter your coding task:" + if not runtime.running: + handled = await drain_pending_work_if_idle(origin="loop_idle_check") + if handled: + continue - if not runtime.running and not runtime.has_pending_submission(): - emit_info(f"{user_prompt}\n") + if not runtime.running and not runtime.has_pending_submission(): + emit_info(f"{user_prompt}\n") - try: - # Use prompt_toolkit for enhanced input with path completion try: - # Windows-specific: Reset terminal state before prompting - reset_windows_terminal_ansi() + # Use prompt_toolkit for enhanced input with path completion + try: + # Windows-specific: Reset terminal state before prompting + reset_windows_terminal_ansi() - submission = await prompt_for_submission( - get_prompt_with_active_model, - history_file=COMMAND_HISTORY_FILE, - erase_when_done=runtime.running, - ) - log_event( - "input_received", - action=submission.action, - text=submission.text, - ) - if submission.text.strip(): - suppress_next_input_cancel_message = False + submission = await prompt_for_submission( + get_prompt_with_active_model, + history_file=COMMAND_HISTORY_FILE, + erase_when_done=runtime.running, + ) + log_event( + "input_received", + action=submission.action, + text=submission.text, + ) + if submission.text.strip(): + suppress_next_input_cancel_message = False - # Windows+uvx: Re-disable Ctrl+C after prompt_toolkit - # (prompt_toolkit restores console mode which re-enables Ctrl+C) - try: - from code_puppy.terminal_utils import ensure_ctrl_c_disabled + # Windows+uvx: Re-disable Ctrl+C after prompt_toolkit + # (prompt_toolkit restores console mode which re-enables Ctrl+C) + try: + from code_puppy.terminal_utils import ensure_ctrl_c_disabled - ensure_ctrl_c_disabled() + ensure_ctrl_c_disabled() + except ImportError: + pass except ImportError: - pass - except ImportError: - # Fall back to basic input if prompt_toolkit is not available - from code_puppy.command_line.prompt_toolkit_completion import ( - PromptSubmission, - ) + # Fall back to basic input if prompt_toolkit is not available + from code_puppy.command_line.prompt_toolkit_completion import ( + PromptSubmission, + ) - submission = PromptSubmission(action="submit", text=input(">>> ")) + submission = PromptSubmission(action="submit", text=input(">>> ")) - except KeyboardInterrupt: - # Handle Ctrl+C - cancel input and continue - # Windows-specific: Reset terminal state after interrupt to prevent - # the terminal from becoming unresponsive (can't type characters) - reset_windows_terminal_full() - from code_puppy.messaging import emit_warning + except KeyboardInterrupt: + # Handle Ctrl+C - cancel input and continue + # Windows-specific: Reset terminal state after interrupt to prevent + # the terminal from becoming unresponsive (can't type characters) + reset_windows_terminal_full() + from code_puppy.messaging import emit_warning - if stop_wiggum_with_notice("\nšŸ© Wiggum loop stopped!"): - runtime.suppress_queue_autodrain() + if stop_wiggum_with_notice("\nšŸ© Wiggum loop stopped!"): + runtime.suppress_queue_autodrain() + continue + if suppress_next_input_cancel_message: + suppress_next_input_cancel_message = False + else: + emit_warning("\nInput cancelled") continue - if suppress_next_input_cancel_message: - suppress_next_input_cancel_message = False - else: - emit_warning("\nInput cancelled") - continue - except EOFError: - # Handle Ctrl+D - exit the application - await shutdown_interactive_session("\nGoodbye! (Ctrl+D)", reason="ctrl_d") - break - - # Shell pass-through: ! executes directly, bypassing the agent. - from code_puppy.command_line.shell_passthrough import ( - execute_shell_passthrough, - is_shell_passthrough, - ) + except EOFError: + # Handle Ctrl+D - exit the application + await shutdown_interactive_session( + "\nGoodbye! (Ctrl+D)", reason="ctrl_d" + ) + break - if is_shell_passthrough(submission.text): - if submission.text.strip(): + # Shell pass-through: ! executes directly, bypassing the agent. + from code_puppy.command_line.shell_passthrough import ( + execute_shell_passthrough, + is_shell_passthrough, + ) + + if is_shell_passthrough(submission.text): + if submission.text.strip(): + runtime.clear_queue_autodrain_suppression() + execute_shell_passthrough(submission.text) + continue + + if submission.text.strip() and runtime.is_queue_autodrain_suppressed(): runtime.clear_queue_autodrain_suppression() - execute_shell_passthrough(submission.text) - continue + log_event( + "queue_autodrain_resumed", + reason="explicit_submission", + text=submission.text.strip(), + ) - if submission.text.strip() and runtime.is_queue_autodrain_suppressed(): - runtime.clear_queue_autodrain_suppression() - log_event( - "queue_autodrain_resumed", - reason="explicit_submission", - text=submission.text.strip(), + outcome = await dispatch_submission( + submission.text, + requested_action=submission.action, + echo_in_transcript=submission.echo_in_transcript, + allow_command_dispatch=submission.allow_command_dispatch, ) - - outcome = await dispatch_submission( - submission.text, - requested_action=submission.action, - echo_in_transcript=submission.echo_in_transcript, - allow_command_dispatch=submission.allow_command_dispatch, - ) - if outcome == "exit": - break - if outcome == "launched": - await asyncio.sleep(0) - clear_active_interactive_runtime(runtime) + if outcome == "exit": + break + if outcome == "launched": + await asyncio.sleep(0) + finally: + clear_active_interactive_runtime(runtime) async def run_prompt_with_attachments( @@ -1856,7 +1861,7 @@ async def execute_single_prompt(prompt: str, message_renderer) -> None: try: # Get agent through runtime manager and use helper for attachments agent = get_current_agent() - response = await run_prompt_with_attachments( + response, _task = await run_prompt_with_attachments( agent, prompt, spinner_console=message_renderer.console, diff --git a/code_puppy/plugins/antigravity_oauth/register_callbacks.py b/code_puppy/plugins/antigravity_oauth/register_callbacks.py index 39adeff73..8e7e742f1 100644 --- a/code_puppy/plugins/antigravity_oauth/register_callbacks.py +++ b/code_puppy/plugins/antigravity_oauth/register_callbacks.py @@ -263,6 +263,7 @@ def _perform_authentication( ) else: emit_warning("Failed to configure models. Try running /antigravity-auth again.") + return False if reload_agent: reload_current_agent() diff --git a/code_puppy/tools/command_runner.py b/code_puppy/tools/command_runner.py index 725d29143..23b0838aa 100644 --- a/code_puppy/tools/command_runner.py +++ b/code_puppy/tools/command_runner.py @@ -292,7 +292,7 @@ def _normalize_shell_cwd(cwd: str | None) -> str | None: normalized = cwd.strip() if not normalized: return None - return normalized + return cwd class ShellCommandOutput(BaseModel): @@ -1216,22 +1216,31 @@ def get_active_interactive_runtime(): # type: ignore[no-redef] return None interactive_runtime = get_active_interactive_runtime() - if interactive_runtime is not None: - interactive_runtime.notify_shell_started() - else: + if interactive_runtime is None: # Acquire shared keyboard context - Ctrl-X/Ctrl-C will kill ALL running commands # This is reference-counted: listener starts on first command, stops on last _acquire_keyboard_context() release_keyboard_context = True try: + if interactive_runtime is not None: + try: + interactive_runtime.notify_shell_started() + except Exception: + interactive_runtime = None + _acquire_keyboard_context() + release_keyboard_context = True return await _run_command_inner(command, cwd, timeout, group_id, silent=silent) finally: - if interactive_runtime is not None: - interactive_runtime.notify_shell_finished() - if release_keyboard_context: - _release_keyboard_context() - resume_all_spinners() + try: + if interactive_runtime is not None: + interactive_runtime.notify_shell_finished() + except Exception: + pass + finally: + if release_keyboard_context: + _release_keyboard_context() + resume_all_spinners() def _run_command_sync( diff --git a/tests/agents/test_event_stream_handler.py b/tests/agents/test_event_stream_handler.py index 39296c8b7..b60c316f7 100644 --- a/tests/agents/test_event_stream_handler.py +++ b/tests/agents/test_event_stream_handler.py @@ -628,6 +628,57 @@ async def event_stream(): runtime.set_prompt_ephemeral_status.assert_not_called() runtime.clear_prompt_ephemeral_status.assert_called_once() + def test_merge_tool_name_ignores_repeated_prefix_delta(self): + from code_puppy.agents.event_stream_handler import _merge_tool_name + + assert ( + _merge_tool_name("agent_share_your_reasoning", "agent_share") + == "agent_share_your_reasoning" + ) + + @pytest.mark.asyncio + async def test_reasoning_tool_partial_prefix_stays_suppressed_on_prompt_surface( + self, mock_ctx + ): + tool_part = ToolCallPart(tool_call_id="tool_1", tool_name="", args={}) + start_event = PartStartEvent(index=0, part=tool_part) + delta_event = PartDeltaEvent( + index=0, + delta=ToolCallPartDelta(tool_name_delta="agent_share", args_delta="{}"), + ) + end_event = PartEndEvent(index=0, part=tool_part, next_part_kind=None) + + async def event_stream(): + yield start_event + yield delta_event + yield end_event + + console = MagicMock(spec=Console) + set_streaming_console(console) + safe_console = MagicMock(spec=Console) + runtime = MagicMock() + runtime.has_prompt_surface.return_value = True + runtime.set_prompt_ephemeral_status = MagicMock() + runtime.clear_prompt_ephemeral_status = MagicMock() + runtime.run_above_prompt_async = AsyncMock(return_value=True) + + with patch( + "code_puppy.agents.event_stream_handler._get_active_prompt_runtime", + return_value=runtime, + ): + with patch( + "code_puppy.agents.event_stream_handler._build_prompt_safe_console", + return_value=safe_console, + ): + with patch("code_puppy.agents.event_stream_handler.pause_all_spinners"): + with patch( + "code_puppy.agents.event_stream_handler.resume_all_spinners" + ): + await event_stream_handler(mock_ctx, event_stream()) + + runtime.set_prompt_ephemeral_status.assert_not_called() + runtime.clear_prompt_ephemeral_status.assert_called_once() + @pytest.mark.asyncio async def test_thinking_prompt_surface_mode_renders_above_prompt(self, mock_ctx): """Prompt-surface mode should show thinking output above the prompt.""" diff --git a/tests/plugins/test_antigravity_callbacks_coverage.py b/tests/plugins/test_antigravity_callbacks_coverage.py index 52caa9677..ece3bee9b 100644 --- a/tests/plugins/test_antigravity_callbacks_coverage.py +++ b/tests/plugins/test_antigravity_callbacks_coverage.py @@ -470,7 +470,7 @@ def test_success_no_email(self): "code_puppy.plugins.antigravity_oauth.register_callbacks.emit_warning" ), ): - assert _perform_authentication(reload_agent=False) is True + assert _perform_authentication(reload_agent=False) is False class TestAntigravityHandleStatus: @@ -704,6 +704,9 @@ def test_auth_failure(self): "code_puppy.plugins.antigravity_oauth.register_callbacks._perform_authentication", return_value=False, ) as mock_auth, + patch( + "code_puppy.plugins.antigravity_oauth.register_callbacks.set_model_and_reload_agent" + ) as mock_set_model, ): result = _handle_custom_command("/antigravity-auth", "antigravity-auth") assert isinstance(result, BackgroundInteractiveCommand) @@ -714,6 +717,7 @@ def test_auth_failure(self): reload_agent=False, cancel_event=cancel_event, ) + mock_set_model.assert_not_called() def test_add(self): from code_puppy.command_line.interactive_command import ( diff --git a/tests/plugins/test_antigravity_register_callbacks.py b/tests/plugins/test_antigravity_register_callbacks.py index 3a69a5351..e06ffe364 100644 --- a/tests/plugins/test_antigravity_register_callbacks.py +++ b/tests/plugins/test_antigravity_register_callbacks.py @@ -427,6 +427,59 @@ def test_perform_authentication_add_account_flag( calls_args = [str(call) for call in mock_emit_success.call_args_list] assert any("Added account" in str(c) for c in calls_args) + @patch("code_puppy.plugins.antigravity_oauth.register_callbacks.emit_warning") + @patch("code_puppy.plugins.antigravity_oauth.register_callbacks.save_tokens") + @patch( + "code_puppy.plugins.antigravity_oauth.register_callbacks.add_models_to_config" + ) + @patch( + "code_puppy.plugins.antigravity_oauth.register_callbacks.exchange_code_for_tokens" + ) + @patch("code_puppy.plugins.antigravity_oauth.register_callbacks._await_callback") + @patch( + "code_puppy.plugins.antigravity_oauth.register_callbacks.prepare_oauth_context" + ) + def test_perform_authentication_model_registration_failure( + self, + mock_prepare_context, + mock_await_callback, + mock_exchange_code, + mock_add_models, + mock_save_tokens, + mock_emit_warning, + ): + """Authentication should fail if model registration fails.""" + from code_puppy.plugins.antigravity_oauth.oauth import TokenExchangeSuccess + + mock_context = MagicMock() + mock_prepare_context.return_value = mock_context + mock_await_callback.return_value = ( + "code_123", + "state_456", + "http://localhost:51121/oauth-callback", + ) + mock_exchange_code.return_value = TokenExchangeSuccess( + access_token="access_token_123", + refresh_token="refresh_token_456", + expires_at=time.time() + 3600, + email="test@example.com", + project_id="project_123", + ) + mock_save_tokens.return_value = True + mock_add_models.return_value = False + + with patch( + "code_puppy.plugins.antigravity_oauth.register_callbacks.AccountManager" + ) as mock_manager_class: + mock_manager = MagicMock() + mock_manager.account_count = 0 + mock_manager_class.load_from_disk.return_value = mock_manager + + result = _perform_authentication(reload_agent=False) + + assert result is False + mock_emit_warning.assert_called_once() + # ============================================================================ # CUSTOM HELP TESTS diff --git a/tests/test_cli_runner_coverage.py b/tests/test_cli_runner_coverage.py index 319499304..79811c71c 100644 --- a/tests/test_cli_runner_coverage.py +++ b/tests/test_cli_runner_coverage.py @@ -290,7 +290,7 @@ async def test_none_response(self): ) as mock_run, patch("code_puppy.cli_runner.emit_info"), ): - mock_run.return_value = None + mock_run.return_value = (None, None) await execute_single_prompt("hello", mock_renderer) @pytest.mark.anyio diff --git a/tests/test_cli_runner_full_coverage.py b/tests/test_cli_runner_full_coverage.py index d5c9b3b0c..e62be8b25 100644 --- a/tests/test_cli_runner_full_coverage.py +++ b/tests/test_cli_runner_full_coverage.py @@ -137,7 +137,7 @@ async def prompt_side_effect(*args, **kwargs): else: value = input_fn - if isinstance(value, PromptSubmission): + if value is None or isinstance(value, PromptSubmission): return value return PromptSubmission(action="submit", text=value) @@ -860,6 +860,43 @@ async def fake_input(*a, **kw): }, ) + @pytest.mark.anyio + async def test_runtime_cleared_after_unhandled_exception(self): + from code_puppy.cli_runner import interactive_mode + from code_puppy.command_line.interactive_runtime import ( + get_active_interactive_runtime, + ) + + renderer = _mock_renderer() + agent = MagicMock() + agent.get_user_prompt.return_value = "task:" + + with ExitStack() as stack: + _apply_patches(stack, _interactive_patches()) + stack.enter_context( + patch( + "code_puppy.command_line.prompt_toolkit_completion.prompt_for_submission", + side_effect=RuntimeError("boom"), + ) + ) + stack.enter_context( + patch( + "code_puppy.command_line.prompt_toolkit_completion.get_prompt_with_active_model", + return_value="> ", + ) + ) + stack.enter_context( + patch( + "code_puppy.agents.agent_manager.get_current_agent", + return_value=agent, + ) + ) + + with pytest.raises(RuntimeError, match="boom"): + await interactive_mode(renderer) + + assert get_active_interactive_runtime() is None + @pytest.mark.anyio async def test_normal_prompt_execution(self): call_count = 0 @@ -1537,6 +1574,141 @@ async def fake_run(*args, **kwargs): ) assert autosave_mock.call_count == 2 + @pytest.mark.anyio + async def test_full_queue_interject_does_not_cancel_active_run(self): + call_count = 0 + first_task_started = asyncio.Event() + queued_prompt_started = asyncio.Event() + first_task_cancelled = asyncio.Event() + started_prompts = [] + warning = MagicMock() + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _submission("first task") + if call_count == 2: + await first_task_started.wait() + return _submission("second queued", action="queue") + if call_count == 3: + return _submission("steer now", action="interject") + await queued_prompt_started.wait() + return _submission("/exit") + + async def fake_run(*args, **kwargs): + prompt = args[1] + started_prompts.append(prompt) + if prompt == "first task": + first_task_started.set() + try: + await asyncio.sleep(0.2) + except asyncio.CancelledError: + first_task_cancelled.set() + raise + if prompt == "second queued": + queued_prompt_started.set() + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.cli_runner.get_queue_limit": MagicMock(return_value=1), + "code_puppy.command_line.interactive_runtime.get_queue_limit": MagicMock( + return_value=1 + ), + "code_puppy.messaging.emit_warning": warning, + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + "code_puppy.tools.command_runner.get_running_shell_process_count": MagicMock( + return_value=0 + ), + "code_puppy.tools.command_runner.kill_all_running_shell_processes": MagicMock(), + }, + ) + + assert started_prompts == ["first task", "second queued"] + assert not first_task_cancelled.is_set() + assert any( + "Cannot interject right now" in call.args[0] + for call in warning.call_args_list + ) + + @pytest.mark.anyio + async def test_queued_background_command_returning_false_skips_completed_lifecycle( + self, + ): + from code_puppy.command_line.interactive_command import ( + BackgroundInteractiveCommand, + ) + + call_count = 0 + first_task_started = asyncio.Event() + background_done = asyncio.Event() + lifecycle = MagicMock() + + async def fake_input(*a, **kw): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _submission("first task") + if call_count == 2: + await first_task_started.wait() + return _submission("/claude-code-auth", action="queue") + await background_done.wait() + return _submission("/exit") + + async def fake_run(*args, **kwargs): + prompt = args[1] + if prompt == "first task": + first_task_started.set() + await asyncio.sleep(0.05) + result = MagicMock(output=f"done: {prompt}") + result.all_messages.return_value = [] + return result, MagicMock() + + def auth_wait(cancel_event): + background_done.set() + return False + + def fake_handle_command(command): + if command == "/claude-code-auth": + return BackgroundInteractiveCommand(run=auth_wait) + return False + + await _run_interactive( + _mock_renderer(), + _interactive_patches(), + fake_input, + extra_patches={ + "code_puppy.cli_runner.run_prompt_with_attachments": fake_run, + "code_puppy.command_line.command_handler.handle_command": MagicMock( + side_effect=fake_handle_command + ), + "code_puppy.cli_runner.parse_prompt_attachments": MagicMock( + side_effect=lambda text: _mock_parse_result(text) + ), + "code_puppy.cli_runner.emit_interject_queue_lifecycle": lifecycle, + "code_puppy.command_line.wiggum_state.is_wiggum_active": MagicMock( + return_value=False + ), + }, + ) + + assert not any( + len(call.args) > 1 and call.args[1] == "completed" + for call in lifecycle.call_args_list + ) + @pytest.mark.anyio async def test_exit_while_running_cancels_runtime_task(self): call_count = 0 @@ -2776,11 +2948,10 @@ class TestRemainingEdgeCases: @pytest.mark.anyio async def test_execute_single_prompt_success_path(self): - """Lines 1005-1015: execute_single_prompt success with .output access.""" + """Lines 1005-1015: execute_single_prompt success with tuple unpack.""" from code_puppy.cli_runner import execute_single_prompt mock_renderer = _mock_renderer() - # response needs .output attribute (not a tuple) mock_response = MagicMock() mock_response.output = "the response" @@ -2790,7 +2961,7 @@ async def test_execute_single_prompt_success_path(self): patch( "code_puppy.cli_runner.run_prompt_with_attachments", new_callable=AsyncMock, - return_value=mock_response, + return_value=(mock_response, MagicMock()), ) ) stack.enter_context(patch("code_puppy.cli_runner.emit_info")) diff --git a/tests/tools/test_command_runner_full_coverage.py b/tests/tools/test_command_runner_full_coverage.py index 763c3e71c..14654514f 100644 --- a/tests/tools/test_command_runner_full_coverage.py +++ b/tests/tools/test_command_runner_full_coverage.py @@ -256,7 +256,7 @@ def test_normalize_shell_cwd(self): assert _normalize_shell_cwd(None) is None assert _normalize_shell_cwd("") is None assert _normalize_shell_cwd(" ") is None - assert _normalize_shell_cwd(" /tmp/work ") == "/tmp/work" + assert _normalize_shell_cwd(" /tmp/work ") == " /tmp/work " # --------------------------------------------------------------------------- @@ -956,6 +956,97 @@ async def test_uses_keyboard_listener_when_no_active_prompt(self): mock_acquire.assert_called_once() mock_release.assert_called_once() + @pytest.mark.asyncio + async def test_runtime_start_notification_failure_falls_back_to_keyboard_listener( + self, + ): + from code_puppy.tools.command_runner import ( + ShellCommandOutput, + _execute_shell_command, + ) + + mock_result = ShellCommandOutput( + success=True, + command="echo hi", + stdout="hi", + stderr="", + exit_code=0, + execution_time=0.1, + ) + + runtime = MagicMock() + runtime.notify_shell_started.side_effect = RuntimeError("boom") + with patch("code_puppy.tools.command_runner.get_message_bus") as mock_bus: + mock_bus.return_value = MagicMock() + with patch("code_puppy.messaging.spinner.pause_all_spinners"): + with patch("code_puppy.messaging.spinner.resume_all_spinners"): + with patch( + "code_puppy.command_line.interactive_runtime.get_active_interactive_runtime", + return_value=runtime, + ): + with patch( + "code_puppy.tools.command_runner._acquire_keyboard_context" + ) as mock_acquire: + with patch( + "code_puppy.tools.command_runner._release_keyboard_context" + ) as mock_release: + with patch( + "code_puppy.tools.command_runner._run_command_inner", + new_callable=AsyncMock, + return_value=mock_result, + ): + result = await _execute_shell_command( + "echo hi", None, 10, "grp" + ) + + assert result.success is True + runtime.notify_shell_started.assert_called_once() + runtime.notify_shell_finished.assert_not_called() + mock_acquire.assert_called_once() + mock_release.assert_called_once() + + @pytest.mark.asyncio + async def test_runtime_finish_notification_failure_does_not_mask_result(self): + from code_puppy.tools.command_runner import ( + ShellCommandOutput, + _execute_shell_command, + ) + + mock_result = ShellCommandOutput( + success=True, + command="echo hi", + stdout="hi", + stderr="", + exit_code=0, + execution_time=0.1, + ) + + runtime = MagicMock() + runtime.notify_shell_finished.side_effect = RuntimeError("boom") + with patch("code_puppy.tools.command_runner.get_message_bus") as mock_bus: + mock_bus.return_value = MagicMock() + with patch("code_puppy.messaging.spinner.pause_all_spinners"): + with patch( + "code_puppy.messaging.spinner.resume_all_spinners" + ) as mock_resume: + with patch( + "code_puppy.command_line.interactive_runtime.get_active_interactive_runtime", + return_value=runtime, + ): + with patch( + "code_puppy.tools.command_runner._run_command_inner", + new_callable=AsyncMock, + return_value=mock_result, + ): + result = await _execute_shell_command( + "echo hi", None, 10, "grp" + ) + + assert result.success is True + runtime.notify_shell_started.assert_called_once() + runtime.notify_shell_finished.assert_called_once() + mock_resume.assert_called_once() + # --------------------------------------------------------------------------- # _run_command_inner exception handling