code-puppy 0.0.302__py3-none-any.whl → 0.0.323__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. code_puppy/agents/base_agent.py +373 -46
  2. code_puppy/chatgpt_codex_client.py +283 -0
  3. code_puppy/cli_runner.py +795 -0
  4. code_puppy/command_line/add_model_menu.py +8 -1
  5. code_puppy/command_line/autosave_menu.py +266 -35
  6. code_puppy/command_line/colors_menu.py +515 -0
  7. code_puppy/command_line/command_handler.py +8 -2
  8. code_puppy/command_line/config_commands.py +59 -10
  9. code_puppy/command_line/core_commands.py +19 -7
  10. code_puppy/command_line/mcp/edit_command.py +3 -1
  11. code_puppy/command_line/mcp/handler.py +7 -2
  12. code_puppy/command_line/mcp/install_command.py +8 -3
  13. code_puppy/command_line/mcp/logs_command.py +173 -64
  14. code_puppy/command_line/mcp/restart_command.py +7 -2
  15. code_puppy/command_line/mcp/search_command.py +10 -4
  16. code_puppy/command_line/mcp/start_all_command.py +16 -6
  17. code_puppy/command_line/mcp/start_command.py +3 -1
  18. code_puppy/command_line/mcp/status_command.py +2 -1
  19. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  20. code_puppy/command_line/mcp/stop_command.py +3 -1
  21. code_puppy/command_line/mcp/wizard_utils.py +10 -4
  22. code_puppy/command_line/model_settings_menu.py +53 -7
  23. code_puppy/command_line/prompt_toolkit_completion.py +16 -2
  24. code_puppy/command_line/session_commands.py +11 -4
  25. code_puppy/config.py +103 -15
  26. code_puppy/keymap.py +8 -2
  27. code_puppy/main.py +5 -828
  28. code_puppy/mcp_/__init__.py +17 -0
  29. code_puppy/mcp_/blocking_startup.py +61 -32
  30. code_puppy/mcp_/config_wizard.py +5 -1
  31. code_puppy/mcp_/managed_server.py +23 -3
  32. code_puppy/mcp_/manager.py +65 -0
  33. code_puppy/mcp_/mcp_logs.py +224 -0
  34. code_puppy/messaging/__init__.py +20 -4
  35. code_puppy/messaging/bus.py +64 -0
  36. code_puppy/messaging/markdown_patches.py +57 -0
  37. code_puppy/messaging/messages.py +16 -0
  38. code_puppy/messaging/renderers.py +21 -9
  39. code_puppy/messaging/rich_renderer.py +113 -67
  40. code_puppy/messaging/spinner/console_spinner.py +34 -0
  41. code_puppy/model_factory.py +185 -30
  42. code_puppy/model_utils.py +57 -48
  43. code_puppy/models.json +19 -5
  44. code_puppy/plugins/chatgpt_oauth/config.py +5 -1
  45. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  46. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
  47. code_puppy/plugins/chatgpt_oauth/test_plugin.py +26 -11
  48. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  49. code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
  50. code_puppy/plugins/claude_code_oauth/utils.py +1 -0
  51. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
  52. code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
  53. code_puppy/prompts/codex_system_prompt.md +310 -0
  54. code_puppy/pydantic_patches.py +131 -0
  55. code_puppy/terminal_utils.py +126 -0
  56. code_puppy/tools/agent_tools.py +34 -9
  57. code_puppy/tools/command_runner.py +361 -32
  58. code_puppy/tools/file_operations.py +33 -45
  59. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
  60. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/METADATA +1 -1
  61. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/RECORD +65 -57
  62. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
  63. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
  64. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
  65. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,795 @@
1
+ """CLI runner for Code Puppy.
2
+
3
+ Contains the main application logic, interactive mode, and entry point.
4
+ """
5
+
6
+ # Apply pydantic-ai patches BEFORE any pydantic-ai imports
7
+ from code_puppy.pydantic_patches import apply_all_patches
8
+
9
+ apply_all_patches()
10
+
11
+ import argparse
12
+ import asyncio
13
+ import os
14
+ import sys
15
+ import time
16
+ import traceback
17
+ from pathlib import Path
18
+
19
+ from dbos import DBOS, DBOSConfig
20
+ from rich.console import Console, ConsoleOptions, RenderResult
21
+ from rich.markdown import CodeBlock, Markdown
22
+ from rich.syntax import Syntax
23
+ from rich.text import Text
24
+
25
+ from code_puppy import __version__, callbacks, plugins
26
+ from code_puppy.agents import get_current_agent
27
+ from code_puppy.command_line.attachments import parse_prompt_attachments
28
+ from code_puppy.config import (
29
+ AUTOSAVE_DIR,
30
+ COMMAND_HISTORY_FILE,
31
+ DBOS_DATABASE_URL,
32
+ ensure_config_exists,
33
+ finalize_autosave_session,
34
+ get_use_dbos,
35
+ initialize_command_history_file,
36
+ save_command_to_history,
37
+ )
38
+ from code_puppy.http_utils import find_available_port
39
+ from code_puppy.keymap import (
40
+ KeymapError,
41
+ get_cancel_agent_display_name,
42
+ validate_cancel_agent_key,
43
+ )
44
+ from code_puppy.messaging import emit_info
45
+ from code_puppy.terminal_utils import (
46
+ reset_unix_terminal,
47
+ reset_windows_terminal_ansi,
48
+ reset_windows_terminal_full,
49
+ )
50
+ from code_puppy.tools.common import console
51
+ from code_puppy.version_checker import default_version_mismatch_behavior
52
+
53
+ plugins.load_plugin_callbacks()
54
+
55
+
56
+ async def main():
57
+ """Main async entry point for Code Puppy CLI."""
58
+ parser = argparse.ArgumentParser(description="Code Puppy - A code generation agent")
59
+ parser.add_argument(
60
+ "--version",
61
+ "-v",
62
+ action="version",
63
+ version=f"{__version__}",
64
+ help="Show version and exit",
65
+ )
66
+ parser.add_argument(
67
+ "--interactive",
68
+ "-i",
69
+ action="store_true",
70
+ help="Run in interactive mode",
71
+ )
72
+ parser.add_argument(
73
+ "--prompt",
74
+ "-p",
75
+ type=str,
76
+ help="Execute a single prompt and exit (no interactive mode)",
77
+ )
78
+ parser.add_argument(
79
+ "--agent",
80
+ "-a",
81
+ type=str,
82
+ help="Specify which agent to use (e.g., --agent code-puppy)",
83
+ )
84
+ parser.add_argument(
85
+ "--model",
86
+ "-m",
87
+ type=str,
88
+ help="Specify which model to use (e.g., --model gpt-5)",
89
+ )
90
+ parser.add_argument(
91
+ "command", nargs="*", help="Run a single command (deprecated, use -p instead)"
92
+ )
93
+ args = parser.parse_args()
94
+ from rich.console import Console
95
+
96
+ from code_puppy.messaging import (
97
+ RichConsoleRenderer,
98
+ SynchronousInteractiveRenderer,
99
+ get_global_queue,
100
+ get_message_bus,
101
+ )
102
+
103
+ # Create a shared console for both renderers
104
+ display_console = Console()
105
+
106
+ # Legacy renderer for backward compatibility (emits via get_global_queue)
107
+ message_queue = get_global_queue()
108
+ message_renderer = SynchronousInteractiveRenderer(message_queue, display_console)
109
+ message_renderer.start()
110
+
111
+ # New MessageBus renderer for structured messages (tools emit here)
112
+ message_bus = get_message_bus()
113
+ bus_renderer = RichConsoleRenderer(message_bus, display_console)
114
+ bus_renderer.start()
115
+
116
+ initialize_command_history_file()
117
+ from code_puppy.messaging import emit_error, emit_system_message
118
+
119
+ # Show the awesome Code Puppy logo when entering interactive mode
120
+ # This happens when: no -p flag (prompt-only mode) is used
121
+ # The logo should appear for both `code-puppy` and `code-puppy -i`
122
+ if not args.prompt:
123
+ try:
124
+ import pyfiglet
125
+
126
+ intro_lines = pyfiglet.figlet_format(
127
+ "CODE PUPPY", font="ansi_shadow"
128
+ ).split("\n")
129
+
130
+ # Simple blue to green gradient (top to bottom)
131
+ gradient_colors = ["bright_blue", "bright_cyan", "bright_green"]
132
+ display_console.print("\n")
133
+
134
+ lines = []
135
+ # Apply gradient line by line
136
+ for line_num, line in enumerate(intro_lines):
137
+ if line.strip():
138
+ # Use line position to determine color (top blue, middle cyan, bottom green)
139
+ color_idx = min(line_num // 2, len(gradient_colors) - 1)
140
+ color = gradient_colors[color_idx]
141
+ lines.append(f"[{color}]{line}[/{color}]")
142
+ else:
143
+ lines.append("")
144
+ # Print directly to console to avoid the 'dim' style from emit_system_message
145
+ display_console.print("\n".join(lines))
146
+ except ImportError:
147
+ emit_system_message("🐶 Code Puppy is Loading...")
148
+
149
+ available_port = find_available_port()
150
+ if available_port is None:
151
+ emit_error("No available ports in range 8090-9010!")
152
+ return
153
+
154
+ # Early model setting if specified via command line
155
+ # This happens before ensure_config_exists() to ensure config is set up correctly
156
+ early_model = None
157
+ if args.model:
158
+ early_model = args.model.strip()
159
+ from code_puppy.config import set_model_name
160
+
161
+ set_model_name(early_model)
162
+
163
+ ensure_config_exists()
164
+
165
+ # Validate cancel_agent_key configuration early
166
+ try:
167
+ validate_cancel_agent_key()
168
+ except KeymapError as e:
169
+ from code_puppy.messaging import emit_error
170
+
171
+ emit_error(str(e))
172
+ sys.exit(1)
173
+
174
+ # Load API keys from puppy.cfg into environment variables
175
+ from code_puppy.config import load_api_keys_to_environment
176
+
177
+ load_api_keys_to_environment()
178
+
179
+ # Handle model validation from command line (validation happens here, setting was earlier)
180
+ if args.model:
181
+ from code_puppy.config import _validate_model_exists
182
+
183
+ model_name = args.model.strip()
184
+ try:
185
+ # Validate that the model exists in models.json
186
+ if not _validate_model_exists(model_name):
187
+ from code_puppy.model_factory import ModelFactory
188
+
189
+ models_config = ModelFactory.load_config()
190
+ available_models = list(models_config.keys()) if models_config else []
191
+
192
+ emit_error(f"Model '{model_name}' not found")
193
+ emit_system_message(f"Available models: {', '.join(available_models)}")
194
+ sys.exit(1)
195
+
196
+ # Model is valid, show confirmation (already set earlier)
197
+ emit_system_message(f"🎯 Using model: {model_name}")
198
+ except Exception as e:
199
+ emit_error(f"Error validating model: {str(e)}")
200
+ sys.exit(1)
201
+
202
+ # Handle agent selection from command line
203
+ if args.agent:
204
+ from code_puppy.agents.agent_manager import (
205
+ get_available_agents,
206
+ set_current_agent,
207
+ )
208
+
209
+ agent_name = args.agent.lower()
210
+ try:
211
+ # First check if the agent exists by getting available agents
212
+ available_agents = get_available_agents()
213
+ if agent_name not in available_agents:
214
+ emit_error(f"Agent '{agent_name}' not found")
215
+ emit_system_message(
216
+ f"Available agents: {', '.join(available_agents.keys())}"
217
+ )
218
+ sys.exit(1)
219
+
220
+ # Agent exists, set it
221
+ set_current_agent(agent_name)
222
+ emit_system_message(f"🤖 Using agent: {agent_name}")
223
+ except Exception as e:
224
+ emit_error(f"Error setting agent: {str(e)}")
225
+ sys.exit(1)
226
+
227
+ current_version = __version__
228
+
229
+ no_version_update = os.getenv("NO_VERSION_UPDATE", "").lower() in (
230
+ "1",
231
+ "true",
232
+ "yes",
233
+ "on",
234
+ )
235
+ if no_version_update:
236
+ version_msg = f"Current version: {current_version}"
237
+ update_disabled_msg = (
238
+ "Update phase disabled because NO_VERSION_UPDATE is set to 1 or true"
239
+ )
240
+ emit_system_message(version_msg)
241
+ emit_system_message(update_disabled_msg)
242
+ else:
243
+ if len(callbacks.get_callbacks("version_check")):
244
+ await callbacks.on_version_check(current_version)
245
+ else:
246
+ default_version_mismatch_behavior(current_version)
247
+
248
+ await callbacks.on_startup()
249
+
250
+ # Initialize DBOS if not disabled
251
+ if get_use_dbos():
252
+ # Append a Unix timestamp in ms to the version for uniqueness
253
+ dbos_app_version = os.environ.get(
254
+ "DBOS_APP_VERSION", f"{current_version}-{int(time.time() * 1000)}"
255
+ )
256
+ dbos_config: DBOSConfig = {
257
+ "name": "dbos-code-puppy",
258
+ "system_database_url": DBOS_DATABASE_URL,
259
+ "run_admin_server": False,
260
+ "conductor_key": os.environ.get(
261
+ "DBOS_CONDUCTOR_KEY"
262
+ ), # Optional, if set in env, connect to conductor
263
+ "log_level": os.environ.get(
264
+ "DBOS_LOG_LEVEL", "ERROR"
265
+ ), # Default to ERROR level to suppress verbose logs
266
+ "application_version": dbos_app_version, # Match DBOS app version to Code Puppy version
267
+ }
268
+ try:
269
+ DBOS(config=dbos_config)
270
+ DBOS.launch()
271
+ except Exception as e:
272
+ emit_error(f"Error initializing DBOS: {e}")
273
+ sys.exit(1)
274
+ else:
275
+ pass
276
+
277
+ global shutdown_flag
278
+ shutdown_flag = False
279
+ try:
280
+ initial_command = None
281
+ prompt_only_mode = False
282
+
283
+ if args.prompt:
284
+ initial_command = args.prompt
285
+ prompt_only_mode = True
286
+ elif args.command:
287
+ initial_command = " ".join(args.command)
288
+ prompt_only_mode = False
289
+
290
+ if prompt_only_mode:
291
+ await execute_single_prompt(initial_command, message_renderer)
292
+ else:
293
+ # Default to interactive mode (no args = same as -i)
294
+ await interactive_mode(message_renderer, initial_command=initial_command)
295
+ finally:
296
+ if message_renderer:
297
+ message_renderer.stop()
298
+ if bus_renderer:
299
+ bus_renderer.stop()
300
+ await callbacks.on_shutdown()
301
+ if get_use_dbos():
302
+ DBOS.destroy()
303
+
304
+
305
+ async def interactive_mode(message_renderer, initial_command: str = None) -> None:
306
+ """Run the agent in interactive mode."""
307
+ from code_puppy.command_line.command_handler import handle_command
308
+
309
+ display_console = message_renderer.console
310
+ from code_puppy.messaging import emit_info, emit_system_message
311
+
312
+ emit_system_message("Type '/exit' or '/quit' to exit the interactive mode.")
313
+ emit_system_message("Type 'clear' to reset the conversation history.")
314
+ emit_system_message("Type /help to view all commands")
315
+ emit_system_message(
316
+ "Type @ for path completion, or /model to pick a model. Toggle multiline with Alt+M or F2; newline: Ctrl+J."
317
+ )
318
+ cancel_key = get_cancel_agent_display_name()
319
+ emit_system_message(
320
+ f"Press {cancel_key} during processing to cancel the current task or inference. Use Ctrl+X to interrupt running shell commands."
321
+ )
322
+ emit_system_message(
323
+ "Use /autosave_load to manually load a previous autosave session."
324
+ )
325
+ emit_system_message(
326
+ "Use /diff to configure diff highlighting colors for file changes."
327
+ )
328
+ try:
329
+ from code_puppy.command_line.motd import print_motd
330
+
331
+ print_motd(console, force=False)
332
+ except Exception as e:
333
+ from code_puppy.messaging import emit_warning
334
+
335
+ emit_warning(f"MOTD error: {e}")
336
+
337
+ # Initialize the runtime agent manager
338
+ if initial_command:
339
+ from code_puppy.agents import get_current_agent
340
+ from code_puppy.messaging import emit_info, emit_success, emit_system_message
341
+
342
+ agent = get_current_agent()
343
+ emit_info(f"Processing initial command: {initial_command}")
344
+
345
+ try:
346
+ # Check if any tool is waiting for user input before showing spinner
347
+ try:
348
+ from code_puppy.tools.command_runner import is_awaiting_user_input
349
+
350
+ awaiting_input = is_awaiting_user_input()
351
+ except ImportError:
352
+ awaiting_input = False
353
+
354
+ # Run with or without spinner based on whether we're awaiting input
355
+ response, agent_task = await run_prompt_with_attachments(
356
+ agent,
357
+ initial_command,
358
+ spinner_console=display_console,
359
+ use_spinner=not awaiting_input,
360
+ )
361
+ if response is not None:
362
+ agent_response = response.output
363
+
364
+ # Update the agent's message history with the complete conversation
365
+ # including the final assistant response
366
+ if hasattr(response, "all_messages"):
367
+ agent.set_message_history(list(response.all_messages()))
368
+
369
+ # Emit structured message for proper markdown rendering
370
+ from code_puppy.messaging import get_message_bus
371
+ from code_puppy.messaging.messages import AgentResponseMessage
372
+
373
+ response_msg = AgentResponseMessage(
374
+ content=agent_response,
375
+ is_markdown=True,
376
+ )
377
+ get_message_bus().emit(response_msg)
378
+
379
+ emit_success("🐶 Continuing in Interactive Mode")
380
+ emit_system_message(
381
+ "Your command and response are preserved in the conversation history."
382
+ )
383
+
384
+ except Exception as e:
385
+ from code_puppy.messaging import emit_error
386
+
387
+ emit_error(f"Error processing initial command: {str(e)}")
388
+
389
+ # Check if prompt_toolkit is installed
390
+ try:
391
+ from code_puppy.command_line.prompt_toolkit_completion import (
392
+ get_input_with_combined_completion,
393
+ get_prompt_with_active_model,
394
+ )
395
+ except ImportError:
396
+ from code_puppy.messaging import emit_warning
397
+
398
+ emit_warning("Warning: prompt_toolkit not installed. Installing now...")
399
+ try:
400
+ import subprocess
401
+
402
+ subprocess.check_call(
403
+ [sys.executable, "-m", "pip", "install", "--quiet", "prompt_toolkit"]
404
+ )
405
+ from code_puppy.messaging import emit_success
406
+
407
+ emit_success("Successfully installed prompt_toolkit")
408
+ from code_puppy.command_line.prompt_toolkit_completion import (
409
+ get_input_with_combined_completion,
410
+ get_prompt_with_active_model,
411
+ )
412
+ except Exception as e:
413
+ from code_puppy.messaging import emit_error, emit_warning
414
+
415
+ emit_error(f"Error installing prompt_toolkit: {e}")
416
+ emit_warning("Falling back to basic input without tab completion")
417
+
418
+ # Autosave loading is now manual - use /autosave_load command
419
+
420
+ # Track the current agent task for cancellation on quit
421
+ current_agent_task = None
422
+
423
+ while True:
424
+ from code_puppy.agents.agent_manager import get_current_agent
425
+ from code_puppy.messaging import emit_info
426
+
427
+ # Get the custom prompt from the current agent, or use default
428
+ current_agent = get_current_agent()
429
+ user_prompt = current_agent.get_user_prompt() or "Enter your coding task:"
430
+
431
+ emit_info(f"{user_prompt}\n")
432
+
433
+ try:
434
+ # Use prompt_toolkit for enhanced input with path completion
435
+ try:
436
+ # Windows-specific: Reset terminal state before prompting
437
+ reset_windows_terminal_ansi()
438
+
439
+ # Use the async version of get_input_with_combined_completion
440
+ task = await get_input_with_combined_completion(
441
+ get_prompt_with_active_model(), history_file=COMMAND_HISTORY_FILE
442
+ )
443
+ except ImportError:
444
+ # Fall back to basic input if prompt_toolkit is not available
445
+ task = input(">>> ")
446
+
447
+ except (KeyboardInterrupt, EOFError):
448
+ # Handle Ctrl+C or Ctrl+D
449
+ # Windows-specific: Reset terminal state after interrupt to prevent
450
+ # the terminal from becoming unresponsive (can't type characters)
451
+ reset_windows_terminal_full()
452
+ from code_puppy.messaging import emit_warning
453
+
454
+ emit_warning("\nInput cancelled")
455
+ continue
456
+
457
+ # Check for exit commands (plain text or command form)
458
+ if task.strip().lower() in ["exit", "quit"] or task.strip().lower() in [
459
+ "/exit",
460
+ "/quit",
461
+ ]:
462
+ import asyncio
463
+
464
+ from code_puppy.messaging import emit_success
465
+
466
+ emit_success("Goodbye!")
467
+
468
+ # Cancel any running agent task for clean shutdown
469
+ if current_agent_task and not current_agent_task.done():
470
+ emit_info("Cancelling running agent task...")
471
+ current_agent_task.cancel()
472
+ try:
473
+ await current_agent_task
474
+ except asyncio.CancelledError:
475
+ pass # Expected when cancelling
476
+
477
+ # The renderer is stopped in the finally block of main().
478
+ break
479
+
480
+ # Check for clear command (supports both `clear` and `/clear`)
481
+ if task.strip().lower() in ("clear", "/clear"):
482
+ from code_puppy.messaging import (
483
+ emit_info,
484
+ emit_system_message,
485
+ emit_warning,
486
+ )
487
+
488
+ agent = get_current_agent()
489
+ new_session_id = finalize_autosave_session()
490
+ agent.clear_message_history()
491
+ emit_warning("Conversation history cleared!")
492
+ emit_system_message("The agent will not remember previous interactions.")
493
+ emit_info(f"Auto-save session rotated to: {new_session_id}")
494
+ continue
495
+
496
+ # Parse attachments first so leading paths aren't misread as commands
497
+ processed_for_commands = parse_prompt_attachments(task)
498
+ cleaned_for_commands = (processed_for_commands.prompt or "").strip()
499
+
500
+ # Handle / commands based on cleaned prompt (after stripping attachments)
501
+ if cleaned_for_commands.startswith("/"):
502
+ try:
503
+ command_result = handle_command(cleaned_for_commands)
504
+ except Exception as e:
505
+ from code_puppy.messaging import emit_error
506
+
507
+ emit_error(f"Command error: {e}")
508
+ # Continue interactive loop instead of exiting
509
+ continue
510
+ if command_result is True:
511
+ continue
512
+ elif isinstance(command_result, str):
513
+ if command_result == "__AUTOSAVE_LOAD__":
514
+ # Handle async autosave loading
515
+ try:
516
+ # Check if we're in a real interactive terminal
517
+ # (not pexpect/tests) - interactive picker requires proper TTY
518
+ use_interactive_picker = (
519
+ sys.stdin.isatty() and sys.stdout.isatty()
520
+ )
521
+
522
+ # Allow environment variable override for tests
523
+ if os.getenv("CODE_PUPPY_NO_TUI") == "1":
524
+ use_interactive_picker = False
525
+
526
+ if use_interactive_picker:
527
+ # Use interactive picker for terminal sessions
528
+ from code_puppy.agents.agent_manager import (
529
+ get_current_agent,
530
+ )
531
+ from code_puppy.command_line.autosave_menu import (
532
+ interactive_autosave_picker,
533
+ )
534
+ from code_puppy.config import (
535
+ set_current_autosave_from_session_name,
536
+ )
537
+ from code_puppy.messaging import (
538
+ emit_error,
539
+ emit_success,
540
+ emit_warning,
541
+ )
542
+ from code_puppy.session_storage import (
543
+ load_session,
544
+ restore_autosave_interactively,
545
+ )
546
+
547
+ chosen_session = await interactive_autosave_picker()
548
+
549
+ if not chosen_session:
550
+ emit_warning("Autosave load cancelled")
551
+ continue
552
+
553
+ # Load the session
554
+ base_dir = Path(AUTOSAVE_DIR)
555
+ history = load_session(chosen_session, base_dir)
556
+
557
+ agent = get_current_agent()
558
+ agent.set_message_history(history)
559
+
560
+ # Set current autosave session
561
+ set_current_autosave_from_session_name(chosen_session)
562
+
563
+ total_tokens = sum(
564
+ agent.estimate_tokens_for_message(msg)
565
+ for msg in history
566
+ )
567
+ session_path = base_dir / f"{chosen_session}.pkl"
568
+
569
+ emit_success(
570
+ f"✅ Autosave loaded: {len(history)} messages ({total_tokens} tokens)\n"
571
+ f"📁 From: {session_path}"
572
+ )
573
+ else:
574
+ # Fall back to old text-based picker for tests/non-TTY environments
575
+ await restore_autosave_interactively(Path(AUTOSAVE_DIR))
576
+
577
+ except Exception as e:
578
+ from code_puppy.messaging import emit_error
579
+
580
+ emit_error(f"Failed to load autosave: {e}")
581
+ continue
582
+ else:
583
+ # Command returned a prompt to execute
584
+ task = command_result
585
+ elif command_result is False:
586
+ # Command not recognized, continue with normal processing
587
+ pass
588
+
589
+ if task.strip():
590
+ # Write to the secret file for permanent history with timestamp
591
+ save_command_to_history(task)
592
+
593
+ try:
594
+ prettier_code_blocks()
595
+
596
+ # No need to get agent directly - use manager's run methods
597
+
598
+ # Use our custom helper to enable attachment handling with spinner support
599
+ result, current_agent_task = await run_prompt_with_attachments(
600
+ current_agent,
601
+ task,
602
+ spinner_console=message_renderer.console,
603
+ )
604
+ # Check if the task was cancelled (but don't show message if we just killed processes)
605
+ if result is None:
606
+ # Windows-specific: Reset terminal state after cancellation
607
+ reset_windows_terminal_ansi()
608
+ continue
609
+ # Get the structured response
610
+ agent_response = result.output
611
+
612
+ # Emit structured message for proper markdown rendering
613
+ from code_puppy.messaging import get_message_bus
614
+ from code_puppy.messaging.messages import AgentResponseMessage
615
+
616
+ response_msg = AgentResponseMessage(
617
+ content=agent_response,
618
+ is_markdown=True,
619
+ )
620
+ get_message_bus().emit(response_msg)
621
+
622
+ # Update the agent's message history with the complete conversation
623
+ # including the final assistant response. The history_processors callback
624
+ # may not capture the final message, so we use result.all_messages()
625
+ # to ensure the autosave includes the complete conversation.
626
+ if hasattr(result, "all_messages"):
627
+ current_agent.set_message_history(list(result.all_messages()))
628
+
629
+ # Ensure console output is flushed before next prompt
630
+ # This fixes the issue where prompt doesn't appear after agent response
631
+ display_console.file.flush() if hasattr(
632
+ display_console.file, "flush"
633
+ ) else None
634
+ import time
635
+
636
+ time.sleep(0.1) # Brief pause to ensure all messages are rendered
637
+
638
+ except Exception:
639
+ from code_puppy.messaging.queue_console import get_queue_console
640
+
641
+ get_queue_console().print_exception()
642
+
643
+ # Auto-save session if enabled (moved outside the try block to avoid being swallowed)
644
+ from code_puppy.config import auto_save_session_if_enabled
645
+
646
+ auto_save_session_if_enabled()
647
+
648
+
649
+ def prettier_code_blocks():
650
+ """Configure Rich to use prettier code block rendering."""
651
+
652
+ class SimpleCodeBlock(CodeBlock):
653
+ def __rich_console__(
654
+ self, console: Console, options: ConsoleOptions
655
+ ) -> RenderResult:
656
+ code = str(self.text).rstrip()
657
+ yield Text(self.lexer_name, style="dim")
658
+ syntax = Syntax(
659
+ code,
660
+ self.lexer_name,
661
+ theme=self.theme,
662
+ background_color="default",
663
+ line_numbers=True,
664
+ )
665
+ yield syntax
666
+ yield Text(f"/{self.lexer_name}", style="dim")
667
+
668
+ Markdown.elements["fence"] = SimpleCodeBlock
669
+
670
+
671
+ async def run_prompt_with_attachments(
672
+ agent,
673
+ raw_prompt: str,
674
+ *,
675
+ spinner_console=None,
676
+ use_spinner: bool = True,
677
+ ):
678
+ """Run the agent after parsing CLI attachments for image/document support.
679
+
680
+ Returns:
681
+ tuple: (result, task) where result is the agent response and task is the asyncio task
682
+ """
683
+ import asyncio
684
+
685
+ from code_puppy.messaging import emit_system_message, emit_warning
686
+
687
+ processed_prompt = parse_prompt_attachments(raw_prompt)
688
+
689
+ for warning in processed_prompt.warnings:
690
+ emit_warning(warning)
691
+
692
+ summary_parts = []
693
+ if processed_prompt.attachments:
694
+ summary_parts.append(f"binary files: {len(processed_prompt.attachments)}")
695
+ if processed_prompt.link_attachments:
696
+ summary_parts.append(f"urls: {len(processed_prompt.link_attachments)}")
697
+ if summary_parts:
698
+ emit_system_message("Attachments detected -> " + ", ".join(summary_parts))
699
+
700
+ if not processed_prompt.prompt:
701
+ emit_warning(
702
+ "Prompt is empty after removing attachments; add instructions and retry."
703
+ )
704
+ return None, None
705
+
706
+ attachments = [attachment.content for attachment in processed_prompt.attachments]
707
+ link_attachments = [link.url_part for link in processed_prompt.link_attachments]
708
+
709
+ # IMPORTANT: Set the shared console on the agent so that streaming output
710
+ # uses the same console as the spinner. This prevents Live display conflicts
711
+ # that cause line duplication during markdown streaming.
712
+ if spinner_console is not None:
713
+ agent._console = spinner_console
714
+
715
+ # Create the agent task first so we can track and cancel it
716
+ agent_task = asyncio.create_task(
717
+ agent.run_with_mcp(
718
+ processed_prompt.prompt,
719
+ attachments=attachments,
720
+ link_attachments=link_attachments,
721
+ )
722
+ )
723
+
724
+ if use_spinner and spinner_console is not None:
725
+ from code_puppy.messaging.spinner import ConsoleSpinner
726
+
727
+ with ConsoleSpinner(console=spinner_console):
728
+ try:
729
+ result = await agent_task
730
+ return result, agent_task
731
+ except asyncio.CancelledError:
732
+ emit_info("Agent task cancelled")
733
+ return None, agent_task
734
+ else:
735
+ try:
736
+ result = await agent_task
737
+ return result, agent_task
738
+ except asyncio.CancelledError:
739
+ emit_info("Agent task cancelled")
740
+ return None, agent_task
741
+
742
+
743
+ async def execute_single_prompt(prompt: str, message_renderer) -> None:
744
+ """Execute a single prompt and exit (for -p flag)."""
745
+ from code_puppy.messaging import emit_info
746
+
747
+ emit_info(f"Executing prompt: {prompt}")
748
+
749
+ try:
750
+ # Get agent through runtime manager and use helper for attachments
751
+ agent = get_current_agent()
752
+ response = await run_prompt_with_attachments(
753
+ agent,
754
+ prompt,
755
+ spinner_console=message_renderer.console,
756
+ )
757
+ if response is None:
758
+ return
759
+
760
+ agent_response = response.output
761
+
762
+ # Emit structured message for proper markdown rendering
763
+ from code_puppy.messaging import get_message_bus
764
+ from code_puppy.messaging.messages import AgentResponseMessage
765
+
766
+ response_msg = AgentResponseMessage(
767
+ content=agent_response,
768
+ is_markdown=True,
769
+ )
770
+ get_message_bus().emit(response_msg)
771
+
772
+ except asyncio.CancelledError:
773
+ from code_puppy.messaging import emit_warning
774
+
775
+ emit_warning("Execution cancelled by user")
776
+ except Exception as e:
777
+ from code_puppy.messaging import emit_error
778
+
779
+ emit_error(f"Error executing prompt: {str(e)}")
780
+
781
+
782
+ def main_entry():
783
+ """Entry point for the installed CLI tool."""
784
+ try:
785
+ asyncio.run(main())
786
+ except KeyboardInterrupt:
787
+ # Note: Using sys.stderr for crash output - messaging system may not be available
788
+ sys.stderr.write(traceback.format_exc())
789
+ if get_use_dbos():
790
+ DBOS.destroy()
791
+ return 0
792
+ finally:
793
+ # Reset terminal on all platforms for clean state
794
+ reset_windows_terminal_full() # Safe no-op on non-Windows
795
+ reset_unix_terminal()