code-puppy 0.0.96__py3-none-any.whl → 0.0.118__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 (81) hide show
  1. code_puppy/__init__.py +2 -5
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agent.py +125 -40
  4. code_puppy/agent_prompts.py +30 -24
  5. code_puppy/callbacks.py +152 -0
  6. code_puppy/command_line/command_handler.py +359 -0
  7. code_puppy/command_line/load_context_completion.py +59 -0
  8. code_puppy/command_line/model_picker_completion.py +14 -21
  9. code_puppy/command_line/motd.py +44 -28
  10. code_puppy/command_line/prompt_toolkit_completion.py +42 -23
  11. code_puppy/config.py +266 -26
  12. code_puppy/http_utils.py +122 -0
  13. code_puppy/main.py +570 -383
  14. code_puppy/message_history_processor.py +195 -104
  15. code_puppy/messaging/__init__.py +46 -0
  16. code_puppy/messaging/message_queue.py +288 -0
  17. code_puppy/messaging/queue_console.py +293 -0
  18. code_puppy/messaging/renderers.py +305 -0
  19. code_puppy/messaging/spinner/__init__.py +55 -0
  20. code_puppy/messaging/spinner/console_spinner.py +200 -0
  21. code_puppy/messaging/spinner/spinner_base.py +66 -0
  22. code_puppy/messaging/spinner/textual_spinner.py +97 -0
  23. code_puppy/model_factory.py +73 -105
  24. code_puppy/plugins/__init__.py +32 -0
  25. code_puppy/reopenable_async_client.py +225 -0
  26. code_puppy/state_management.py +60 -21
  27. code_puppy/summarization_agent.py +56 -35
  28. code_puppy/token_utils.py +7 -9
  29. code_puppy/tools/__init__.py +1 -4
  30. code_puppy/tools/command_runner.py +187 -32
  31. code_puppy/tools/common.py +44 -35
  32. code_puppy/tools/file_modifications.py +335 -118
  33. code_puppy/tools/file_operations.py +368 -95
  34. code_puppy/tools/token_check.py +27 -11
  35. code_puppy/tools/tools_content.py +53 -0
  36. code_puppy/tui/__init__.py +10 -0
  37. code_puppy/tui/app.py +1050 -0
  38. code_puppy/tui/components/__init__.py +21 -0
  39. code_puppy/tui/components/chat_view.py +512 -0
  40. code_puppy/tui/components/command_history_modal.py +218 -0
  41. code_puppy/tui/components/copy_button.py +139 -0
  42. code_puppy/tui/components/custom_widgets.py +58 -0
  43. code_puppy/tui/components/input_area.py +167 -0
  44. code_puppy/tui/components/sidebar.py +309 -0
  45. code_puppy/tui/components/status_bar.py +182 -0
  46. code_puppy/tui/messages.py +27 -0
  47. code_puppy/tui/models/__init__.py +8 -0
  48. code_puppy/tui/models/chat_message.py +25 -0
  49. code_puppy/tui/models/command_history.py +89 -0
  50. code_puppy/tui/models/enums.py +24 -0
  51. code_puppy/tui/screens/__init__.py +13 -0
  52. code_puppy/tui/screens/help.py +130 -0
  53. code_puppy/tui/screens/settings.py +256 -0
  54. code_puppy/tui/screens/tools.py +74 -0
  55. code_puppy/tui/tests/__init__.py +1 -0
  56. code_puppy/tui/tests/test_chat_message.py +28 -0
  57. code_puppy/tui/tests/test_chat_view.py +88 -0
  58. code_puppy/tui/tests/test_command_history.py +89 -0
  59. code_puppy/tui/tests/test_copy_button.py +191 -0
  60. code_puppy/tui/tests/test_custom_widgets.py +27 -0
  61. code_puppy/tui/tests/test_disclaimer.py +27 -0
  62. code_puppy/tui/tests/test_enums.py +15 -0
  63. code_puppy/tui/tests/test_file_browser.py +60 -0
  64. code_puppy/tui/tests/test_help.py +38 -0
  65. code_puppy/tui/tests/test_history_file_reader.py +107 -0
  66. code_puppy/tui/tests/test_input_area.py +33 -0
  67. code_puppy/tui/tests/test_settings.py +44 -0
  68. code_puppy/tui/tests/test_sidebar.py +33 -0
  69. code_puppy/tui/tests/test_sidebar_history.py +153 -0
  70. code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
  71. code_puppy/tui/tests/test_status_bar.py +54 -0
  72. code_puppy/tui/tests/test_timestamped_history.py +52 -0
  73. code_puppy/tui/tests/test_tools.py +82 -0
  74. code_puppy/version_checker.py +26 -3
  75. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/METADATA +9 -2
  76. code_puppy-0.0.118.dist-info/RECORD +86 -0
  77. code_puppy-0.0.96.dist-info/RECORD +0 -32
  78. {code_puppy-0.0.96.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
  79. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
  80. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
  81. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/licenses/LICENSE +0 -0
code_puppy/main.py CHANGED
@@ -1,168 +1,383 @@
1
1
  import argparse
2
2
  import asyncio
3
3
  import os
4
+ import subprocess
4
5
  import sys
6
+ import time
7
+ import webbrowser
5
8
 
6
- from dotenv import load_dotenv
7
9
  from rich.console import Console, ConsoleOptions, RenderResult
8
10
  from rich.markdown import CodeBlock, Markdown
9
11
  from rich.syntax import Syntax
10
12
  from rich.text import Text
11
13
 
12
- from code_puppy import __version__, state_management
13
- from code_puppy.agent import get_code_generation_agent
14
+ from code_puppy import __version__, callbacks, plugins, state_management
15
+ from code_puppy.agent import get_code_generation_agent, get_custom_usage_limits
14
16
  from code_puppy.command_line.prompt_toolkit_completion import (
15
17
  get_input_with_combined_completion,
16
18
  get_prompt_with_active_model,
17
19
  )
18
- from code_puppy.config import ensure_config_exists
19
- from code_puppy.state_management import get_message_history, set_message_history
20
- from code_puppy.status_display import StatusDisplay
21
-
22
- # Initialize rich console for pretty output
20
+ from code_puppy.config import (
21
+ COMMAND_HISTORY_FILE,
22
+ ensure_config_exists,
23
+ initialize_command_history_file,
24
+ save_command_to_history,
25
+ )
26
+ from code_puppy.http_utils import find_available_port
27
+ from code_puppy.message_history_processor import (
28
+ message_history_accumulator,
29
+ prune_interrupted_tool_calls,
30
+ )
31
+ from code_puppy.state_management import is_tui_mode, set_tui_mode
23
32
  from code_puppy.tools.common import console
24
- from code_puppy.version_checker import fetch_latest_version
25
- from code_puppy.message_history_processor import message_history_processor
26
-
27
-
28
- # from code_puppy.tools import * # noqa: F403
29
- import logfire
30
-
33
+ from code_puppy.version_checker import default_version_mismatch_behavior
31
34
 
32
- # Define a function to get the secret file path
33
- def get_secret_file_path():
34
- hidden_directory = os.path.join(os.path.expanduser("~"), ".agent_secret")
35
- if not os.path.exists(hidden_directory):
36
- os.makedirs(hidden_directory)
37
- return os.path.join(hidden_directory, "history.txt")
35
+ plugins.load_plugin_callbacks()
38
36
 
39
37
 
40
38
  async def main():
41
- # Ensure the config directory and puppy.cfg with name info exist (prompt user if needed)
42
- logfire.configure(
43
- token="pylf_v1_us_8G5nLznQtHMRsL4hsNG5v3fPWKjyXbysrMgrQ1bV1wRP", console=False
44
- )
45
- logfire.instrument_pydantic_ai()
46
- ensure_config_exists()
47
-
48
- current_version = __version__
49
- latest_version = fetch_latest_version("code-puppy")
50
- console.print(f"Current version: {current_version}")
51
- console.print(f"Latest version: {latest_version}")
52
- if latest_version and latest_version != current_version:
53
- console.print(
54
- f"[bold yellow]A new version of code puppy is available: {latest_version}[/bold yellow]"
55
- )
56
- console.print("[bold green]Please consider updating![/bold green]")
57
- global shutdown_flag
58
- shutdown_flag = False # ensure this is initialized
59
-
60
- # Load environment variables from .env file
61
- load_dotenv()
62
-
63
- # Set up argument parser
64
39
  parser = argparse.ArgumentParser(description="Code Puppy - A code generation agent")
65
40
  parser.add_argument(
66
- "--interactive", "-i", action="store_true", help="Run in interactive mode"
41
+ "--version",
42
+ "-v",
43
+ action="version",
44
+ version=f"{__version__}",
45
+ help="Show version and exit",
46
+ )
47
+ parser.add_argument(
48
+ "--interactive",
49
+ "-i",
50
+ action="store_true",
51
+ help="Run in interactive mode",
52
+ )
53
+ parser.add_argument("--tui", "-t", action="store_true", help="Run in TUI mode")
54
+ parser.add_argument(
55
+ "--web",
56
+ "-w",
57
+ action="store_true",
58
+ help="Run in web mode (serves TUI in browser)",
59
+ )
60
+ parser.add_argument(
61
+ "--prompt",
62
+ "-p",
63
+ type=str,
64
+ help="Execute a single prompt and exit (no interactive mode)",
65
+ )
66
+ parser.add_argument(
67
+ "command", nargs="*", help="Run a single command (deprecated, use -p instead)"
67
68
  )
68
- parser.add_argument("command", nargs="*", help="Run a single command")
69
69
  args = parser.parse_args()
70
70
 
71
- history_file_path = get_secret_file_path()
71
+ if args.tui or args.web:
72
+ set_tui_mode(True)
73
+ elif args.interactive or args.command or args.prompt:
74
+ set_tui_mode(False)
72
75
 
73
- if args.command:
74
- # Join the list of command arguments into a single string command
75
- command = " ".join(args.command)
76
+ message_renderer = None
77
+ if not is_tui_mode():
78
+ from rich.console import Console
79
+
80
+ from code_puppy.messaging import (
81
+ SynchronousInteractiveRenderer,
82
+ get_global_queue,
83
+ )
84
+
85
+ message_queue = get_global_queue()
86
+ display_console = Console() # Separate console for rendering messages
87
+ message_renderer = SynchronousInteractiveRenderer(
88
+ message_queue, display_console
89
+ )
90
+ message_renderer.start()
91
+
92
+ if (
93
+ not args.tui
94
+ and not args.interactive
95
+ and not args.web
96
+ and not args.command
97
+ and not args.prompt
98
+ ):
99
+ pass
100
+
101
+ initialize_command_history_file()
102
+ if args.web:
103
+ from rich.console import Console
104
+
105
+ direct_console = Console()
76
106
  try:
77
- while not shutdown_flag:
78
- agent = get_code_generation_agent()
79
- async with agent.run_mcp_servers():
80
- response = await agent.run(command)
81
- agent_response = response.output
82
- console.print(agent_response)
83
- break
84
- except AttributeError as e:
85
- console.print(f"[bold red]AttributeError:[/bold red] {str(e)}")
86
- console.print(
87
- "[bold yellow]\u26a0 The response might not be in the expected format, missing attributes like 'output_message'."
107
+ # Find an available port for the web server
108
+ available_port = find_available_port()
109
+ if available_port is None:
110
+ direct_console.print(
111
+ "[bold red]Error:[/bold red] No available ports in range 8090-9010!"
112
+ )
113
+ sys.exit(1)
114
+ python_executable = sys.executable
115
+ serve_command = f"{python_executable} -m code_puppy --tui"
116
+ textual_serve_cmd = [
117
+ "textual",
118
+ "serve",
119
+ "-c",
120
+ serve_command,
121
+ "--port",
122
+ str(available_port),
123
+ ]
124
+ direct_console.print(
125
+ "[bold blue]🌐 Starting Code Puppy web interface...[/bold blue]"
126
+ )
127
+ direct_console.print(f"[dim]Running: {' '.join(textual_serve_cmd)}[/dim]")
128
+ web_url = f"http://localhost:{available_port}"
129
+ direct_console.print(
130
+ f"[green]Web interface will be available at: {web_url}[/green]"
88
131
  )
132
+ direct_console.print("[yellow]Press Ctrl+C to stop the server.[/yellow]\n")
133
+ process = subprocess.Popen(textual_serve_cmd)
134
+ time.sleep(2)
135
+ try:
136
+ direct_console.print(
137
+ "[cyan]🚀 Opening web interface in your default browser...[/cyan]"
138
+ )
139
+ webbrowser.open(web_url)
140
+ direct_console.print("[green]✅ Browser opened successfully![/green]\n")
141
+ except Exception as e:
142
+ direct_console.print(
143
+ f"[yellow]⚠️ Could not automatically open browser: {e}[/yellow]"
144
+ )
145
+ direct_console.print(
146
+ f"[yellow]Please manually open: {web_url}[/yellow]\n"
147
+ )
148
+ result = process.wait()
149
+ sys.exit(result)
89
150
  except Exception as e:
90
- console.print(f"[bold red]Unexpected Error:[/bold red] {str(e)}")
91
- elif args.interactive:
92
- await interactive_mode(history_file_path)
151
+ direct_console.print(
152
+ f"[bold red]Error starting web interface:[/bold red] {str(e)}"
153
+ )
154
+ sys.exit(1)
155
+ from code_puppy.messaging import emit_system_message
156
+
157
+ emit_system_message("🐶 Code Puppy is Loading...")
158
+
159
+ available_port = find_available_port()
160
+ if available_port is None:
161
+ error_msg = "Error: No available ports in range 8090-9010!"
162
+ emit_system_message(f"[bold red]{error_msg}[/bold red]")
163
+ return
164
+
165
+ ensure_config_exists()
166
+ current_version = __version__
167
+
168
+ no_version_update = os.getenv("NO_VERSION_UPDATE", "").lower() in (
169
+ "1",
170
+ "true",
171
+ "yes",
172
+ "on",
173
+ )
174
+ if no_version_update:
175
+ version_msg = f"Current version: {current_version}"
176
+ update_disabled_msg = (
177
+ "Update phase disabled because NO_VERSION_UPDATE is set to 1 or true"
178
+ )
179
+ emit_system_message(version_msg)
180
+ emit_system_message(f"[dim]{update_disabled_msg}[/dim]")
93
181
  else:
94
- parser.print_help()
182
+ if len(callbacks.get_callbacks("version_check")):
183
+ await callbacks.on_version_check(current_version)
184
+ else:
185
+ default_version_mismatch_behavior(current_version)
186
+
187
+ await callbacks.on_startup()
188
+
189
+ global shutdown_flag
190
+ shutdown_flag = False
191
+ try:
192
+ initial_command = None
193
+ prompt_only_mode = False
194
+
195
+ if args.prompt:
196
+ initial_command = args.prompt
197
+ prompt_only_mode = True
198
+ elif args.command:
199
+ initial_command = " ".join(args.command)
200
+ prompt_only_mode = False
201
+
202
+ if prompt_only_mode:
203
+ await execute_single_prompt(initial_command, message_renderer)
204
+ elif is_tui_mode():
205
+ try:
206
+ from code_puppy.tui import run_textual_ui
207
+
208
+ await run_textual_ui(initial_command=initial_command)
209
+ except ImportError:
210
+ from code_puppy.messaging import emit_error, emit_warning
211
+
212
+ emit_error(
213
+ "Error: Textual UI not available. Install with: pip install textual"
214
+ )
215
+ emit_warning("Falling back to interactive mode...")
216
+ await interactive_mode(message_renderer)
217
+ except Exception as e:
218
+ from code_puppy.messaging import emit_error, emit_warning
219
+
220
+ emit_error(f"TUI Error: {str(e)}")
221
+ emit_warning("Falling back to interactive mode...")
222
+ await interactive_mode(message_renderer)
223
+ elif args.interactive or initial_command:
224
+ await interactive_mode(message_renderer, initial_command=initial_command)
225
+ else:
226
+ await prompt_then_interactive_mode(message_renderer)
227
+ finally:
228
+ if message_renderer:
229
+ message_renderer.stop()
230
+ await callbacks.on_shutdown()
95
231
 
96
232
 
97
233
  # Add the file handling functionality for interactive mode
98
- async def interactive_mode(history_file_path: str) -> None:
99
- from code_puppy.command_line.meta_command_handler import handle_meta_command
234
+ async def interactive_mode(message_renderer, initial_command: str = None) -> None:
235
+ from code_puppy.command_line.command_handler import handle_command
100
236
 
101
237
  """Run the agent in interactive mode."""
102
- console.print("[bold green]Code Puppy[/bold green] - Interactive Mode")
103
- console.print("Type 'exit' or 'quit' to exit the interactive mode.")
104
- console.print("Type 'clear' to reset the conversation history.")
105
- console.print(
106
- "Type [bold blue]@[/bold blue] for path completion, or [bold blue]~m[/bold blue] to pick a model."
107
- )
238
+ from code_puppy.state_management import clear_message_history, get_message_history
108
239
 
109
- # Show meta commands right at startup - DRY!
110
- from code_puppy.command_line.meta_command_handler import META_COMMANDS_HELP
240
+ clear_message_history()
241
+ display_console = message_renderer.console
242
+ from code_puppy.messaging import emit_info, emit_system_message
111
243
 
112
- console.print(META_COMMANDS_HELP)
113
- # Show MOTD if user hasn't seen it after an update
244
+ emit_info("[bold green]Code Puppy[/bold green] - Interactive Mode")
245
+ emit_system_message("Type '/exit' or '/quit' to exit the interactive mode.")
246
+ emit_system_message("Type 'clear' to reset the conversation history.")
247
+ emit_system_message(
248
+ "Type [bold blue]@[/bold blue] for path completion, or [bold blue]/m[/bold blue] to pick a model. Use [bold blue]Esc+Enter[/bold blue] for multi-line input."
249
+ )
250
+ emit_system_message(
251
+ "Press [bold red]Ctrl+C[/bold red] during processing to cancel the current task or inference."
252
+ )
253
+ from code_puppy.command_line.command_handler import COMMANDS_HELP
254
+
255
+ emit_system_message(COMMANDS_HELP)
114
256
  try:
115
257
  from code_puppy.command_line.motd import print_motd
116
258
 
117
259
  print_motd(console, force=False)
118
260
  except Exception as e:
119
- console.print(f"[yellow]MOTD error: {e}[/yellow]")
261
+ from code_puppy.messaging import emit_warning
262
+
263
+ emit_warning(f"MOTD error: {e}")
264
+ from code_puppy.messaging import emit_info
265
+
266
+ emit_info("[bold cyan]Initializing agent...[/bold cyan]")
267
+ get_code_generation_agent()
268
+ if initial_command:
269
+ from code_puppy.messaging import emit_info, emit_system_message
270
+
271
+ emit_info(
272
+ f"[bold blue]Processing initial command:[/bold blue] {initial_command}"
273
+ )
274
+
275
+ try:
276
+ # Get the agent (already loaded above)
277
+ agent = get_code_generation_agent()
278
+
279
+ # Check if any tool is waiting for user input before showing spinner
280
+ try:
281
+ from code_puppy.tools.command_runner import is_awaiting_user_input
282
+
283
+ awaiting_input = is_awaiting_user_input()
284
+ except ImportError:
285
+ awaiting_input = False
286
+
287
+ # Run with or without spinner based on whether we're awaiting input
288
+ if awaiting_input:
289
+ # No spinner - just run the agent
290
+ try:
291
+ async with agent.run_mcp_servers():
292
+ response = await agent.run(
293
+ initial_command, usage_limits=get_custom_usage_limits()
294
+ )
295
+ except Exception as mcp_error:
296
+ from code_puppy.messaging import emit_warning
297
+
298
+ emit_warning(f"MCP server error: {str(mcp_error)}")
299
+ emit_warning("Running without MCP servers...")
300
+ # Run without MCP servers as fallback
301
+ response = await agent.run(
302
+ initial_command, usage_limits=get_custom_usage_limits()
303
+ )
304
+ else:
305
+ # Use our custom spinner for better compatibility with user input
306
+ from code_puppy.messaging.spinner import ConsoleSpinner
307
+
308
+ with ConsoleSpinner(console=display_console):
309
+ try:
310
+ async with agent.run_mcp_servers():
311
+ response = await agent.run(
312
+ initial_command, usage_limits=get_custom_usage_limits()
313
+ )
314
+ except Exception as mcp_error:
315
+ from code_puppy.messaging import emit_warning
316
+
317
+ emit_warning(f"MCP server error: {str(mcp_error)}")
318
+ emit_warning("Running without MCP servers...")
319
+ # Run without MCP servers as fallback
320
+ response = await agent.run(
321
+ initial_command, usage_limits=get_custom_usage_limits()
322
+ )
323
+
324
+ agent_response = response.output
325
+
326
+ emit_system_message(
327
+ f"\n[bold purple]AGENT RESPONSE: [/bold purple]\n{agent_response.output_message}"
328
+ )
329
+ new_msgs = response.all_messages()
330
+ message_history_accumulator(new_msgs)
331
+
332
+ emit_system_message("\n" + "=" * 50)
333
+ emit_info("[bold green]🐶 Continuing in Interactive Mode[/bold green]")
334
+ emit_system_message(
335
+ "Your command and response are preserved in the conversation history."
336
+ )
337
+ emit_system_message("=" * 50 + "\n")
338
+
339
+ except Exception as e:
340
+ from code_puppy.messaging import emit_error
341
+
342
+ emit_error(f"Error processing initial command: {str(e)}")
120
343
 
121
344
  # Check if prompt_toolkit is installed
122
345
  try:
123
- import prompt_toolkit # noqa: F401
346
+ from code_puppy.messaging import emit_system_message
124
347
 
125
- console.print("[dim]Using prompt_toolkit for enhanced tab completion[/dim]")
126
- except ImportError:
127
- console.print(
128
- "[yellow]Warning: prompt_toolkit not installed. Installing now...[/yellow]"
348
+ emit_system_message(
349
+ "[dim]Using prompt_toolkit for enhanced tab completion[/dim]"
129
350
  )
351
+ except ImportError:
352
+ from code_puppy.messaging import emit_warning
353
+
354
+ emit_warning("Warning: prompt_toolkit not installed. Installing now...")
130
355
  try:
131
356
  import subprocess
132
357
 
133
358
  subprocess.check_call(
134
359
  [sys.executable, "-m", "pip", "install", "prompt_toolkit"]
135
360
  )
136
- console.print("[green]Successfully installed prompt_toolkit[/green]")
137
- except Exception as e:
138
- console.print(f"[bold red]Error installing prompt_toolkit: {e}[/bold red]")
139
- console.print(
140
- "[yellow]Falling back to basic input without tab completion[/yellow]"
141
- )
361
+ from code_puppy.messaging import emit_success
142
362
 
143
- # Set up history file in home directory
144
- history_file_path_prompt = os.path.expanduser("~/.code_puppy_history.txt")
145
- history_dir = os.path.dirname(history_file_path_prompt)
146
-
147
- # Ensure history directory exists
148
- if history_dir and not os.path.exists(history_dir):
149
- try:
150
- os.makedirs(history_dir, exist_ok=True)
363
+ emit_success("Successfully installed prompt_toolkit")
151
364
  except Exception as e:
152
- console.print(
153
- f"[yellow]Warning: Could not create history directory: {e}[/yellow]"
154
- )
365
+ from code_puppy.messaging import emit_error, emit_warning
366
+
367
+ emit_error(f"Error installing prompt_toolkit: {e}")
368
+ emit_warning("Falling back to basic input without tab completion")
155
369
 
156
370
  while True:
157
- console.print("[bold blue]Enter your coding task:[/bold blue]")
371
+ from code_puppy.messaging import emit_info
372
+
373
+ emit_info("[bold blue]Enter your coding task:[/bold blue]")
158
374
 
159
375
  try:
160
376
  # Use prompt_toolkit for enhanced input with path completion
161
377
  try:
162
378
  # Use the async version of get_input_with_combined_completion
163
379
  task = await get_input_with_combined_completion(
164
- get_prompt_with_active_model(),
165
- history_file=history_file_path_prompt,
380
+ get_prompt_with_active_model(), history_file=COMMAND_HISTORY_FILE
166
381
  )
167
382
  except ImportError:
168
383
  # Fall back to basic input if prompt_toolkit is not available
@@ -170,317 +385,184 @@ async def interactive_mode(history_file_path: str) -> None:
170
385
 
171
386
  except (KeyboardInterrupt, EOFError):
172
387
  # Handle Ctrl+C or Ctrl+D
173
- console.print("\n[yellow]Input cancelled[/yellow]")
388
+ from code_puppy.messaging import emit_warning
389
+
390
+ emit_warning("\nInput cancelled")
174
391
  continue
175
392
 
176
- # Check for exit commands
177
- if task.strip().lower() in ["exit", "quit"]:
178
- console.print("[bold green]Goodbye![/bold green]")
393
+ # Check for exit commands (plain text or command form)
394
+ if task.strip().lower() in ["exit", "quit"] or task.strip().lower() in [
395
+ "/exit",
396
+ "/quit",
397
+ ]:
398
+ from code_puppy.messaging import emit_success
399
+
400
+ emit_success("Goodbye!")
401
+ # The renderer is stopped in the finally block of main().
179
402
  break
180
403
 
181
- # Check for clear command (supports both `clear` and `~clear`)
182
- if task.strip().lower() in ("clear", "~clear"):
183
- state_management._message_history = []
184
- console.print("[bold yellow]Conversation history cleared![/bold yellow]")
185
- console.print(
186
- "[dim]The agent will not remember previous interactions.[/dim]\n"
187
- )
404
+ # Check for clear command (supports both `clear` and `/clear`)
405
+ if task.strip().lower() in ("clear", "/clear"):
406
+ clear_message_history()
407
+ from code_puppy.messaging import emit_system_message, emit_warning
408
+
409
+ emit_warning("Conversation history cleared!")
410
+ emit_system_message("The agent will not remember previous interactions.\n")
188
411
  continue
189
412
 
190
- # Handle ~ meta/config commands before anything else
191
- if task.strip().startswith("~"):
192
- if handle_meta_command(task.strip(), console):
413
+ # Handle / commands before anything else
414
+ if task.strip().startswith("/"):
415
+ command_result = handle_command(task.strip())
416
+ if command_result is True:
193
417
  continue
194
- if task.strip():
195
- console.print(f"\n[bold blue]Processing task:[/bold blue] {task}\n")
418
+ elif isinstance(command_result, str):
419
+ # Command returned a prompt to execute
420
+ task = command_result
421
+ elif command_result is False:
422
+ # Command not recognized, continue with normal processing
423
+ pass
196
424
 
197
- # Write to the secret file for permanent history
198
- with open(history_file_path, "a") as f:
199
- f.write(f"{task}\n")
425
+ if task.strip():
426
+ # Write to the secret file for permanent history with timestamp
427
+ save_command_to_history(task)
200
428
 
201
429
  try:
202
430
  prettier_code_blocks()
203
- local_cancelled = False
204
-
205
- # Initialize status display for tokens per second and loading messages
206
- status_display = StatusDisplay(console)
207
431
 
208
- # Print a message indicating we're about to start processing
209
- console.print("\nStarting task processing...")
432
+ # Store agent's full response
433
+ agent_response = None
210
434
 
211
- async def track_tokens_from_messages():
212
- """
213
- Track real token counts from message history.
214
-
215
- This async function runs in the background and periodically checks
216
- the message history for new tokens. When new tokens are detected,
217
- it updates the StatusDisplay with the incremental count to calculate
218
- an accurate tokens-per-second rate.
435
+ # Get the agent (uses cached version from early initialization)
436
+ agent = get_code_generation_agent()
219
437
 
220
- It also looks for SSE stream time_info data to get precise token rate
221
- calculations using the formula: completion_tokens * 1 / completion_time
438
+ # Use our custom spinner for better compatibility with user input
439
+ from code_puppy.messaging import emit_warning
440
+ from code_puppy.messaging.spinner import ConsoleSpinner
222
441
 
223
- The function continues running until status_display.is_active becomes False.
224
- """
225
- from code_puppy.message_history_processor import (
226
- estimate_tokens_for_message,
227
- )
228
- import json
229
- import re
230
-
231
- last_token_total = 0
232
- last_sse_data = None
233
-
234
- while status_display.is_active:
235
- # Get real token count from message history
236
- messages = get_message_history()
237
- if messages:
238
- # Calculate total tokens across all messages
239
- current_token_total = sum(
240
- estimate_tokens_for_message(msg) for msg in messages
241
- )
442
+ # Create a simple flag to track cancellation locally
443
+ local_cancelled = False
242
444
 
243
- # If tokens increased, update the display with the incremental count
244
- if current_token_total > last_token_total:
245
- status_display.update_token_count(
246
- current_token_total - last_token_total
445
+ # Run with spinner
446
+ with ConsoleSpinner(console=display_console):
447
+ # Use a separate asyncio task that we can cancel
448
+ async def run_agent_task():
449
+ try:
450
+ async with agent.run_mcp_servers():
451
+ return await agent.run(
452
+ task,
453
+ message_history=get_message_history(),
454
+ usage_limits=get_custom_usage_limits(),
247
455
  )
248
- last_token_total = current_token_total
249
-
250
- # Try to find SSE stream data in assistant messages
251
- for msg in messages:
252
- # Handle different message types (dict or ModelMessage objects)
253
- if hasattr(msg, "role") and msg.role == "assistant":
254
- # ModelMessage object with role attribute
255
- content = (
256
- msg.content if hasattr(msg, "content") else ""
257
- )
258
- elif (
259
- isinstance(msg, dict)
260
- and msg.get("role") == "assistant"
261
- ):
262
- # Dictionary with 'role' key
263
- content = msg.get("content", "")
264
- # Support for ModelRequest/ModelResponse objects
265
- elif (
266
- hasattr(msg, "message")
267
- and hasattr(msg.message, "role")
268
- and msg.message.role == "assistant"
269
- ):
270
- # Access content through the message attribute
271
- content = (
272
- msg.message.content
273
- if hasattr(msg.message, "content")
274
- else ""
275
- )
276
- else:
277
- # Skip if not an assistant message or unrecognized format
278
- continue
279
-
280
- # Convert content to string if it's not already
281
- if not isinstance(content, str):
282
- try:
283
- content = str(content)
284
- except Exception:
285
- continue
286
-
287
- # Look for SSE usage data pattern in the message content
288
- sse_matches = re.findall(
289
- r'\{\s*"usage".*?"time_info".*?\}',
290
- content,
291
- re.DOTALL,
292
- )
293
- for match in sse_matches:
294
- try:
295
- # Parse the JSON data
296
- sse_data = json.loads(match)
297
- if (
298
- sse_data != last_sse_data
299
- ): # Only process new data
300
- # Check if we have time_info and completion_tokens
301
- if (
302
- "time_info" in sse_data
303
- and "completion_time"
304
- in sse_data["time_info"]
305
- and "usage" in sse_data
306
- and "completion_tokens"
307
- in sse_data["usage"]
308
- ):
309
- completion_time = float(
310
- sse_data["time_info"][
311
- "completion_time"
312
- ]
313
- )
314
- completion_tokens = int(
315
- sse_data["usage"][
316
- "completion_tokens"
317
- ]
318
- )
319
-
320
- # Update rate using the accurate SSE data
321
- if (
322
- completion_time > 0
323
- and completion_tokens > 0
324
- ):
325
- status_display.update_rate_from_sse(
326
- completion_tokens,
327
- completion_time,
328
- )
329
- last_sse_data = sse_data
330
- except (json.JSONDecodeError, KeyError, ValueError):
331
- # Ignore parsing errors and continue
332
- pass
333
-
334
- # Small sleep interval for responsive updates without excessive CPU usage
335
- await asyncio.sleep(0.1)
336
-
337
- async def wrap_agent_run(original_run, *args, **kwargs):
338
- """
339
- Wraps the agent's run method to enable token tracking.
340
-
341
- This wrapper preserves the original functionality while allowing
342
- us to track tokens as they are generated by the model. No additional
343
- logic is needed here since the token tracking happens in a separate task.
344
-
345
- Args:
346
- original_run: The original agent.run method
347
- *args, **kwargs: Arguments to pass to the original run method
348
-
349
- Returns:
350
- The result from the original run method
351
- """
352
- result = await original_run(*args, **kwargs)
353
- return result
354
-
355
- async def run_agent_task():
356
- """
357
- Main task runner for the agent with token tracking.
358
-
359
- This function:
360
- 1. Sets up the agent with token tracking
361
- 2. Starts the status display showing token rate
362
- 3. Runs the agent with the user's task
363
- 4. Ensures proper cleanup of all resources
364
-
365
- Returns the agent's result or raises any exceptions that occurred.
366
- """
367
- # Token tracking task reference for cleanup
368
- token_tracking_task = None
456
+ except Exception as mcp_error:
457
+ # Handle MCP server errors
458
+ emit_warning(f"MCP server error: {str(mcp_error)}")
459
+ emit_warning("Running without MCP servers...")
460
+ # Run without MCP servers as fallback
461
+ return await agent.run(
462
+ task,
463
+ message_history=get_message_history(),
464
+ usage_limits=get_custom_usage_limits(),
465
+ )
369
466
 
370
- try:
371
- # Initialize the agent
372
- agent = get_code_generation_agent()
467
+ # Create the task
468
+ agent_task = asyncio.create_task(run_agent_task())
373
469
 
374
- # Start status display
375
- status_display.start()
470
+ # Set up signal handling for Ctrl+C
471
+ import signal
376
472
 
377
- # Start token tracking
378
- token_tracking_task = asyncio.create_task(
379
- track_tokens_from_messages()
380
- )
381
-
382
- # Create a wrapper for the agent's run method
383
- original_run = agent.run
473
+ from code_puppy.tools.command_runner import (
474
+ kill_all_running_shell_processes,
475
+ )
384
476
 
385
- async def wrapped_run(*args, **kwargs):
386
- return await wrap_agent_run(original_run, *args, **kwargs)
477
+ original_handler = None
478
+
479
+ # Ensure the interrupt handler only acts once per task
480
+ handled = False
481
+
482
+ def keyboard_interrupt_handler(sig, frame):
483
+ nonlocal local_cancelled
484
+ nonlocal handled
485
+ if handled:
486
+ return
487
+ handled = True
488
+ # First, nuke any running shell processes triggered by tools
489
+ try:
490
+ killed = kill_all_running_shell_processes()
491
+ if killed:
492
+ from code_puppy.messaging import emit_warning
493
+
494
+ emit_warning(
495
+ f"Cancelled {killed} running shell process(es)."
496
+ )
497
+ else:
498
+ # Then cancel the agent task
499
+ if not agent_task.done():
500
+ state_management._message_history = (
501
+ prune_interrupted_tool_calls(
502
+ state_management._message_history
503
+ )
504
+ )
505
+ agent_task.cancel()
506
+ local_cancelled = True
507
+ except Exception as e:
508
+ from code_puppy.messaging import emit_warning
387
509
 
388
- agent.run = wrapped_run
510
+ emit_warning(f"Shell kill error: {e}")
511
+ # Don't call the original handler
512
+ # This prevents the application from exiting
389
513
 
390
- # Run the agent with MCP servers
391
- async with agent.run_mcp_servers():
392
- result = await agent.run(
393
- task, message_history=get_message_history()
394
- )
395
- return result
396
- except Exception as e:
397
- console.log("Task failed", e)
398
- raise
514
+ try:
515
+ # Save original handler and set our custom one
516
+ original_handler = signal.getsignal(signal.SIGINT)
517
+ signal.signal(signal.SIGINT, keyboard_interrupt_handler)
518
+
519
+ # Wait for the task to complete or be cancelled
520
+ result = await agent_task
521
+ except asyncio.CancelledError:
522
+ # Task was cancelled by our handler
523
+ pass
399
524
  finally:
400
- # Clean up resources
401
- if status_display.is_active:
402
- status_display.stop()
403
- if token_tracking_task and not token_tracking_task.done():
404
- token_tracking_task.cancel()
405
- if not agent_task.done():
406
- set_message_history(
407
- message_history_processor(get_message_history())
408
- )
409
-
410
- agent_task = asyncio.create_task(run_agent_task())
411
-
412
- import signal
413
- from code_puppy.tools import kill_all_running_shell_processes
525
+ # Restore original signal handler
526
+ if original_handler:
527
+ signal.signal(signal.SIGINT, original_handler)
414
528
 
415
- original_handler = None
529
+ # Check if the task was cancelled
530
+ if local_cancelled:
531
+ emit_warning("\n⚠️ Processing cancelled by user (Ctrl+C)")
532
+ # Skip the rest of this loop iteration
533
+ continue
534
+ # Get the structured response
535
+ agent_response = result.output
536
+ from code_puppy.messaging import emit_info
537
+
538
+ emit_system_message(
539
+ f"\n[bold purple]AGENT RESPONSE: [/bold purple]\n{agent_response}"
540
+ )
416
541
 
417
- # Ensure the interrupt handler only acts once per task
418
- handled = False
542
+ # Update message history - the agent's history processor will handle truncation
543
+ new_msgs = result.all_messages()
544
+ message_history_accumulator(new_msgs)
419
545
 
420
- def keyboard_interrupt_handler(sig, frame):
421
- nonlocal local_cancelled
422
- nonlocal handled
423
- if handled:
424
- return
425
- handled = True
426
- # First, nuke any running shell processes triggered by tools
427
- try:
428
- killed = kill_all_running_shell_processes()
429
- if killed:
430
- console.print(
431
- f"[yellow]Cancelled {killed} running shell process(es).[/yellow]"
432
- )
433
- else:
434
- # Then cancel the agent task
435
- if not agent_task.done():
436
- agent_task.cancel()
437
- local_cancelled = True
438
- except Exception as e:
439
- console.print(f"[dim]Shell kill error: {e}[/dim]")
440
- # On Windows, we need to reset the signal handler to avoid weird terminal behavior
441
- if sys.platform.startswith("win"):
442
- signal.signal(signal.SIGINT, original_handler or signal.SIG_DFL)
546
+ # Show context status
547
+ from code_puppy.messaging import emit_system_message
443
548
 
444
- try:
445
- original_handler = signal.getsignal(signal.SIGINT)
446
- signal.signal(signal.SIGINT, keyboard_interrupt_handler)
447
- result = await agent_task
448
- except asyncio.CancelledError:
449
- pass
450
- except KeyboardInterrupt:
451
- # Handle Ctrl+C from terminal
452
- keyboard_interrupt_handler(signal.SIGINT, None)
453
- raise
454
- finally:
455
- if original_handler:
456
- signal.signal(signal.SIGINT, original_handler)
549
+ emit_system_message(
550
+ f"Context: {len(get_message_history())} messages in history\n"
551
+ )
457
552
 
458
- if local_cancelled:
459
- console.print("Task canceled by user")
460
- # Ensure status display is stopped if canceled
461
- if status_display.is_active:
462
- status_display.stop()
463
- else:
464
- if result is not None and hasattr(result, "output"):
465
- agent_response = result.output
466
- console.print(agent_response)
467
- filtered = message_history_processor(get_message_history())
468
- set_message_history(filtered)
469
- else:
470
- console.print(
471
- "[yellow]No result received from the agent[/yellow]"
472
- )
473
- # Still process history if possible
474
- filtered = message_history_processor(get_message_history())
475
- set_message_history(filtered)
553
+ # Ensure console output is flushed before next prompt
554
+ # This fixes the issue where prompt doesn't appear after agent response
555
+ display_console.file.flush() if hasattr(
556
+ display_console.file, "flush"
557
+ ) else None
558
+ import time
476
559
 
477
- # Show context status
478
- console.print(
479
- f"[dim]Context: {len(get_message_history())} messages in history[/dim]\n"
480
- )
560
+ time.sleep(0.1) # Brief pause to ensure all messages are rendered
481
561
 
482
562
  except Exception:
483
- console.print_exception()
563
+ from code_puppy.messaging.queue_console import get_queue_console
564
+
565
+ get_queue_console().print_exception()
484
566
 
485
567
 
486
568
  def prettier_code_blocks():
@@ -503,9 +585,114 @@ def prettier_code_blocks():
503
585
  Markdown.elements["fence"] = SimpleCodeBlock
504
586
 
505
587
 
588
+ async def execute_single_prompt(prompt: str, message_renderer) -> None:
589
+ """Execute a single prompt and exit (for -p flag)."""
590
+ from code_puppy.messaging import emit_info, emit_system_message
591
+
592
+ emit_info(f"[bold blue]Executing prompt:[/bold blue] {prompt}")
593
+
594
+ try:
595
+ # Get the agent
596
+ agent = get_code_generation_agent()
597
+
598
+ # Use our custom spinner for better compatibility with user input
599
+ from code_puppy.messaging.spinner import ConsoleSpinner
600
+
601
+ display_console = message_renderer.console
602
+ with ConsoleSpinner(console=display_console):
603
+ try:
604
+ async with agent.run_mcp_servers():
605
+ response = await agent.run(
606
+ prompt, usage_limits=get_custom_usage_limits()
607
+ )
608
+ except Exception as mcp_error:
609
+ from code_puppy.messaging import emit_warning
610
+
611
+ emit_warning(f"MCP server error: {str(mcp_error)}")
612
+ emit_warning("Running without MCP servers...")
613
+ # Run without MCP servers as fallback
614
+ response = await agent.run(
615
+ prompt, usage_limits=get_custom_usage_limits()
616
+ )
617
+
618
+ agent_response = response.output
619
+ emit_system_message(
620
+ f"\n[bold purple]AGENT RESPONSE: [/bold purple]\n{agent_response}"
621
+ )
622
+
623
+ except Exception as e:
624
+ from code_puppy.messaging import emit_error
625
+
626
+ emit_error(f"Error executing prompt: {str(e)}")
627
+
628
+
629
+ async def prompt_then_interactive_mode(message_renderer) -> None:
630
+ """Prompt user for input, execute it, then continue in interactive mode."""
631
+ from code_puppy.messaging import emit_info, emit_system_message
632
+
633
+ emit_info("[bold green]🐶 Code Puppy[/bold green] - Enter your request")
634
+ emit_system_message(
635
+ "After processing your request, you'll continue in interactive mode."
636
+ )
637
+
638
+ try:
639
+ # Get user input
640
+ from code_puppy.command_line.prompt_toolkit_completion import (
641
+ get_input_with_combined_completion,
642
+ get_prompt_with_active_model,
643
+ )
644
+ from code_puppy.config import COMMAND_HISTORY_FILE
645
+
646
+ emit_info("[bold blue]What would you like me to help you with?[/bold blue]")
647
+
648
+ try:
649
+ # Use prompt_toolkit for enhanced input with path completion
650
+ user_prompt = await get_input_with_combined_completion(
651
+ get_prompt_with_active_model(), history_file=COMMAND_HISTORY_FILE
652
+ )
653
+ except ImportError:
654
+ # Fall back to basic input if prompt_toolkit is not available
655
+ user_prompt = input(">>> ")
656
+
657
+ if user_prompt.strip():
658
+ # Execute the prompt
659
+ await execute_single_prompt(user_prompt, message_renderer)
660
+
661
+ # Transition to interactive mode
662
+ emit_system_message("\n" + "=" * 50)
663
+ emit_info("[bold green]🐶 Continuing in Interactive Mode[/bold green]")
664
+ emit_system_message(
665
+ "Your request and response are preserved in the conversation history."
666
+ )
667
+ emit_system_message("=" * 50 + "\n")
668
+
669
+ # Continue in interactive mode with the initial command as history
670
+ await interactive_mode(message_renderer, initial_command=user_prompt)
671
+ else:
672
+ # No input provided, just go to interactive mode
673
+ await interactive_mode(message_renderer)
674
+
675
+ except (KeyboardInterrupt, EOFError):
676
+ from code_puppy.messaging import emit_warning
677
+
678
+ emit_warning("\nInput cancelled. Starting interactive mode...")
679
+ await interactive_mode(message_renderer)
680
+ except Exception as e:
681
+ from code_puppy.messaging import emit_error
682
+
683
+ emit_error(f"Error in prompt mode: {str(e)}")
684
+ emit_info("Falling back to interactive mode...")
685
+ await interactive_mode(message_renderer)
686
+
687
+
506
688
  def main_entry():
507
689
  """Entry point for the installed CLI tool."""
508
- asyncio.run(main())
690
+ try:
691
+ asyncio.run(main())
692
+ except KeyboardInterrupt:
693
+ # Just exit gracefully with no error message
694
+ callbacks.on_shutdown()
695
+ return 0
509
696
 
510
697
 
511
698
  if __name__ == "__main__":