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
@@ -1,41 +1,67 @@
1
- import os
2
- from pathlib import Path
1
+ import asyncio
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from typing import List
3
4
 
4
5
  from pydantic_ai import Agent
5
6
 
7
+ from code_puppy.config import get_model_name
6
8
  from code_puppy.model_factory import ModelFactory
7
- from code_puppy.tools.common import console
8
9
 
9
- # Environment variables used in this module:
10
- # - MODELS_JSON_PATH: Optional path to a custom models.json configuration file.
11
- # If not set, uses the default file in the package directory.
12
- # - MODEL_NAME: The model to use for code generation. Defaults to "gpt-4o".
13
- # Must match a key in the models.json configuration.
10
+ # Keep a module-level agent reference to avoid rebuilding per call
11
+ _summarization_agent = None
14
12
 
15
- MODELS_JSON_PATH = os.environ.get("MODELS_JSON_PATH", None)
13
+ # Safe sync runner for async agent.run calls
14
+ # Avoids "event loop is already running" by offloading to a separate thread loop when needed
15
+ _thread_pool: ThreadPoolExecutor | None = None
16
+
17
+
18
+ def _ensure_thread_pool():
19
+ global _thread_pool
20
+ if _thread_pool is None:
21
+ _thread_pool = ThreadPoolExecutor(
22
+ max_workers=1, thread_name_prefix="summarizer-loop"
23
+ )
24
+ return _thread_pool
25
+
26
+
27
+ async def _run_agent_async(agent: Agent, prompt: str, message_history: List):
28
+ return await agent.run(prompt, message_history=message_history)
16
29
 
17
- _LAST_MODEL_NAME = None
18
- _summarization_agent = None
30
+
31
+ def run_summarization_sync(prompt: str, message_history: List) -> List:
32
+ agent = get_summarization_agent()
33
+ try:
34
+ # Try to detect if we're already in an event loop
35
+ asyncio.get_running_loop()
36
+
37
+ # We're in an event loop: offload to a dedicated thread with its own loop
38
+ def _worker(prompt_: str):
39
+ return asyncio.run(
40
+ _run_agent_async(agent, prompt_, message_history=message_history)
41
+ )
42
+
43
+ pool = _ensure_thread_pool()
44
+ result = pool.submit(_worker, prompt).result()
45
+ except RuntimeError:
46
+ # No running loop, safe to run directly
47
+ result = asyncio.run(
48
+ _run_agent_async(agent, prompt, message_history=message_history)
49
+ )
50
+ return result.new_messages()
19
51
 
20
52
 
21
53
  def reload_summarization_agent():
22
54
  """Create a specialized agent for summarizing messages when context limit is reached."""
23
- global _summarization_agent, _LAST_MODEL_NAME
24
- from code_puppy.config import get_model_name
25
-
26
- model_name = get_model_name()
27
- console.print(f"[bold cyan]Loading Summarization Model: {model_name}[/bold cyan]")
28
- models_path = (
29
- Path(MODELS_JSON_PATH)
30
- if MODELS_JSON_PATH
31
- else Path(__file__).parent / "models.json"
32
- )
33
- model = ModelFactory.get_model(model_name, ModelFactory.load_config(models_path))
55
+ models_config = ModelFactory.load_config()
56
+ model_name = "gemini-2.5-pro"
57
+ if model_name not in models_config:
58
+ model_name = get_model_name()
59
+ model = ModelFactory.get_model(model_name, models_config)
34
60
 
35
61
  # Specialized instructions for summarization
36
- instructions = """You are a message summarization expert. Your task is to summarize conversation messages
37
- while preserving important context and information. The summaries should be concise but capture the essential
38
- content and intent of the original messages. This is to help manage token usage in a conversation history
62
+ instructions = """You are a message summarization expert. Your task is to summarize conversation messages
63
+ while preserving important context and information. The summaries should be concise but capture the essential
64
+ content and intent of the original messages. This is to help manage token usage in a conversation history
39
65
  while maintaining context for the AI to continue the conversation effectively.
40
66
 
41
67
  When summarizing:
@@ -51,20 +77,15 @@ When summarizing:
51
77
  output_type=str,
52
78
  retries=1, # Fewer retries for summarization
53
79
  )
54
- _summarization_agent = agent
55
- _LAST_MODEL_NAME = model_name
56
- return _summarization_agent
80
+ return agent
57
81
 
58
82
 
59
- def get_summarization_agent(force_reload=False):
83
+ def get_summarization_agent(force_reload=True):
60
84
  """
61
85
  Retrieve the summarization agent with the currently set MODEL_NAME.
62
86
  Forces a reload if the model has changed, or if force_reload is passed.
63
87
  """
64
- global _summarization_agent, _LAST_MODEL_NAME
65
- from code_puppy.config import get_model_name
66
-
67
- model_name = get_model_name()
68
- if _summarization_agent is None or _LAST_MODEL_NAME != model_name or force_reload:
69
- return reload_summarization_agent()
88
+ global _summarization_agent
89
+ if force_reload or _summarization_agent is None:
90
+ _summarization_agent = reload_summarization_agent()
70
91
  return _summarization_agent
code_puppy/token_utils.py CHANGED
@@ -4,14 +4,12 @@ import pydantic
4
4
  from pydantic_ai.messages import ModelMessage
5
5
 
6
6
 
7
- def estimate_tokens(text: str) -> int:
7
+ def estimate_token_count(text: str) -> int:
8
8
  """
9
- Estimate the number of tokens using the len/4 heuristic.
10
- This is a simple approximation that works reasonably well for most text.
9
+ Simple token estimation using len(message) - 4.
10
+ This replaces tiktoken with a much simpler approach.
11
11
  """
12
- if not text:
13
- return 0
14
- return max(1, len(text) // 4)
12
+ return max(1, len(text) - 4)
15
13
 
16
14
 
17
15
  def stringify_message_part(part) -> str:
@@ -56,14 +54,14 @@ def stringify_message_part(part) -> str:
56
54
 
57
55
  def estimate_tokens_for_message(message: ModelMessage) -> int:
58
56
  """
59
- Estimate the number of tokens in a message using the len/4 heuristic.
60
- This is a simple approximation that works reasonably well for most text.
57
+ Estimate the number of tokens in a message using len(message) - 4.
58
+ Simple and fast replacement for tiktoken.
61
59
  """
62
60
  total_tokens = 0
63
61
 
64
62
  for part in message.parts:
65
63
  part_str = stringify_message_part(part)
66
64
  if part_str:
67
- total_tokens += estimate_tokens(part_str)
65
+ total_tokens += estimate_token_count(part_str)
68
66
 
69
67
  return max(1, total_tokens)
@@ -1,13 +1,10 @@
1
- from code_puppy.tools.command_runner import (
2
- register_command_runner_tools,
3
- )
1
+ from code_puppy.tools.command_runner import register_command_runner_tools
4
2
  from code_puppy.tools.file_modifications import register_file_modifications_tools
5
3
  from code_puppy.tools.file_operations import register_file_operations_tools
6
4
 
7
5
 
8
6
  def register_all_tools(agent):
9
7
  """Register all available tools to the provided agent."""
10
-
11
8
  register_file_operations_tools(agent)
12
9
  register_file_modifications_tools(agent)
13
10
  register_command_runner_tools(agent)
@@ -1,10 +1,10 @@
1
1
  import os
2
2
  import signal
3
3
  import subprocess
4
+ import sys
4
5
  import threading
5
6
  import time
6
7
  import traceback
7
- import sys
8
8
  from typing import Set
9
9
 
10
10
  from pydantic import BaseModel
@@ -12,7 +12,15 @@ from pydantic_ai import RunContext
12
12
  from rich.markdown import Markdown
13
13
  from rich.text import Text
14
14
 
15
- from code_puppy.tools.common import console
15
+ from code_puppy.messaging import (
16
+ emit_divider,
17
+ emit_error,
18
+ emit_info,
19
+ emit_system_message,
20
+ emit_warning,
21
+ )
22
+ from code_puppy.state_management import is_tui_mode
23
+ from code_puppy.tools.common import generate_group_id
16
24
 
17
25
  _AWAITING_USER_INPUT = False
18
26
 
@@ -91,7 +99,7 @@ def _kill_process_group(proc: subprocess.Popen) -> None:
91
99
  except Exception:
92
100
  pass
93
101
  except Exception as e:
94
- console.print(f"Kill process error: {e}")
102
+ emit_error(f"Kill process error: {e}")
95
103
 
96
104
 
97
105
  def kill_all_running_shell_processes() -> int:
@@ -114,6 +122,38 @@ def kill_all_running_shell_processes() -> int:
114
122
  return count
115
123
 
116
124
 
125
+ # Function to check if user input is awaited
126
+ def is_awaiting_user_input():
127
+ """Check if command_runner is waiting for user input."""
128
+ global _AWAITING_USER_INPUT
129
+ return _AWAITING_USER_INPUT
130
+
131
+
132
+ # Function to set user input flag
133
+ def set_awaiting_user_input(awaiting=True):
134
+ """Set the flag indicating if user input is awaited."""
135
+ global _AWAITING_USER_INPUT
136
+ _AWAITING_USER_INPUT = awaiting
137
+
138
+ # When we're setting this flag, also pause/resume all active spinners
139
+ if awaiting:
140
+ # Pause all active spinners (imported here to avoid circular imports)
141
+ try:
142
+ from code_puppy.messaging.spinner import pause_all_spinners
143
+
144
+ pause_all_spinners()
145
+ except ImportError:
146
+ pass # Spinner functionality not available
147
+ else:
148
+ # Resume all active spinners
149
+ try:
150
+ from code_puppy.messaging.spinner import resume_all_spinners
151
+
152
+ resume_all_spinners()
153
+ except ImportError:
154
+ pass # Spinner functionality not available
155
+
156
+
117
157
  class ShellCommandOutput(BaseModel):
118
158
  success: bool
119
159
  command: str | None
@@ -127,7 +167,10 @@ class ShellCommandOutput(BaseModel):
127
167
 
128
168
 
129
169
  def run_shell_command_streaming(
130
- process: subprocess.Popen, timeout: int = 60, command: str = ""
170
+ process: subprocess.Popen,
171
+ timeout: int = 60,
172
+ command: str = "",
173
+ group_id: str = None,
131
174
  ):
132
175
  start_time = time.time()
133
176
  last_output_time = [start_time]
@@ -146,7 +189,7 @@ def run_shell_command_streaming(
146
189
  if line:
147
190
  line = line.rstrip("\n\r")
148
191
  stdout_lines.append(line)
149
- console.print(line)
192
+ emit_system_message(line, message_group=group_id)
150
193
  last_output_time[0] = time.time()
151
194
  except Exception:
152
195
  pass
@@ -157,7 +200,7 @@ def run_shell_command_streaming(
157
200
  if line:
158
201
  line = line.rstrip("\n\r")
159
202
  stderr_lines.append(line)
160
- console.print(line)
203
+ emit_system_message(line, message_group=group_id)
161
204
  last_output_time[0] = time.time()
162
205
  except Exception:
163
206
  pass
@@ -188,19 +231,21 @@ def run_shell_command_streaming(
188
231
  if stdout_thread and stdout_thread.is_alive():
189
232
  stdout_thread.join(timeout=3)
190
233
  if stdout_thread.is_alive():
191
- console.print(
192
- f"stdout reader thread failed to terminate after {timeout_type} seconds"
234
+ emit_warning(
235
+ f"stdout reader thread failed to terminate after {timeout_type} timeout",
236
+ message_group=group_id,
193
237
  )
194
238
 
195
239
  if stderr_thread and stderr_thread.is_alive():
196
240
  stderr_thread.join(timeout=3)
197
241
  if stderr_thread.is_alive():
198
- console.print(
199
- f"stderr reader thread failed to terminate after {timeout_type} seconds"
242
+ emit_warning(
243
+ f"stderr reader thread failed to terminate after {timeout_type} timeout",
244
+ message_group=group_id,
200
245
  )
201
246
 
202
247
  except Exception as e:
203
- console.log(f"Error during process cleanup {e}")
248
+ emit_warning(f"Error during process cleanup: {e}", message_group=group_id)
204
249
 
205
250
  execution_time = time.time() - start_time
206
251
  return ShellCommandOutput(
@@ -231,7 +276,7 @@ def run_shell_command_streaming(
231
276
  error_msg.append(
232
277
  "Process killed: inactivity timeout reached", style="bold red"
233
278
  )
234
- console.print(error_msg)
279
+ emit_error(error_msg, message_group=group_id)
235
280
  return cleanup_process_and_threads("absolute")
236
281
 
237
282
  if current_time - last_output_time[0] > timeout:
@@ -239,7 +284,7 @@ def run_shell_command_streaming(
239
284
  error_msg.append(
240
285
  "Process killed: inactivity timeout reached", style="bold red"
241
286
  )
242
- console.print(error_msg)
287
+ emit_error(error_msg, message_group=group_id)
243
288
  return cleanup_process_and_threads("inactivity")
244
289
 
245
290
  time.sleep(0.1)
@@ -265,10 +310,10 @@ def run_shell_command_streaming(
265
310
  _unregister_process(process)
266
311
 
267
312
  if exit_code != 0:
268
- console.print(
269
- f"Command failed with exit code {exit_code}", style="bold red"
313
+ emit_error(
314
+ f"Command failed with exit code {exit_code}", message_group=group_id
270
315
  )
271
- console.print(f"Took {execution_time:.2f}s", style="dim")
316
+ emit_info(f"Took {execution_time:.2f}s", message_group=group_id)
272
317
  time.sleep(1)
273
318
  return ShellCommandOutput(
274
319
  success=False,
@@ -296,7 +341,7 @@ def run_shell_command_streaming(
296
341
  return ShellCommandOutput(
297
342
  success=False,
298
343
  command=command,
299
- error=f"Error durign streaming execution {str(e)}",
344
+ error=f"Error during streaming execution: {str(e)}",
300
345
  stdout="\n".join(stdout_lines[-1000:]),
301
346
  stderr="\n".join(stderr_lines[-1000:]),
302
347
  exit_code=-1,
@@ -308,14 +353,21 @@ def run_shell_command(
308
353
  context: RunContext, command: str, cwd: str = None, timeout: int = 60
309
354
  ) -> ShellCommandOutput:
310
355
  command_displayed = False
356
+
357
+ # Generate unique group_id for this command execution
358
+ group_id = generate_group_id("shell_command", command)
359
+
311
360
  if not command or not command.strip():
312
- console.print("[bold red]Error:[/bold red] Command cannot be empty")
361
+ emit_error("Command cannot be empty", message_group=group_id)
313
362
  return ShellCommandOutput(
314
363
  **{"success": False, "error": "Command cannot be empty"}
315
364
  )
316
- console.print(
317
- f"\n[bold white on blue] SHELL COMMAND [/bold white on blue] \U0001f4c2 [bold green]$ {command}[/bold green]"
365
+
366
+ emit_info(
367
+ f"\n[bold white on blue] SHELL COMMAND [/bold white on blue] 📂 [bold green]$ {command}[/bold green]",
368
+ message_group=group_id,
318
369
  )
370
+
319
371
  from code_puppy.config import get_yolo_mode
320
372
 
321
373
  yolo_mode = get_yolo_mode()
@@ -335,7 +387,11 @@ def run_shell_command(
335
387
  command_displayed = True
336
388
 
337
389
  if cwd:
338
- console.print(f"[dim] Working directory: {cwd} [/dim]")
390
+ emit_info(f"[dim] Working directory: {cwd} [/dim]", message_group=group_id)
391
+
392
+ # Set the flag to indicate we're awaiting user input
393
+ set_awaiting_user_input(True)
394
+
339
395
  time.sleep(0.2)
340
396
  sys.stdout.write("Are you sure you want to run this command? (y(es)/n(o))\n")
341
397
  sys.stdout.flush()
@@ -344,9 +400,11 @@ def run_shell_command(
344
400
  user_input = input()
345
401
  confirmed = user_input.strip().lower() in {"yes", "y"}
346
402
  except (KeyboardInterrupt, EOFError):
347
- console.print("\n Cancelled by user")
403
+ emit_warning("\n Cancelled by user")
348
404
  confirmed = False
349
405
  finally:
406
+ # Clear the flag regardless of the outcome
407
+ set_awaiting_user_input(False)
350
408
  if confirmation_lock_acquired:
351
409
  _CONFIRMATION_LOCK.release()
352
410
 
@@ -357,6 +415,7 @@ def run_shell_command(
357
415
  return result
358
416
  else:
359
417
  start_time = time.time()
418
+
360
419
  try:
361
420
  creationflags = 0
362
421
  preexec_fn = None
@@ -367,6 +426,7 @@ def run_shell_command(
367
426
  creationflags = 0
368
427
  else:
369
428
  preexec_fn = os.setsid if hasattr(os, "setsid") else None
429
+
370
430
  process = subprocess.Popen(
371
431
  command,
372
432
  shell=True,
@@ -382,13 +442,13 @@ def run_shell_command(
382
442
  _register_process(process)
383
443
  try:
384
444
  return run_shell_command_streaming(
385
- process, timeout=timeout, command=command
445
+ process, timeout=timeout, command=command, group_id=group_id
386
446
  )
387
447
  finally:
388
448
  # Ensure unregistration in case streaming returned early or raised
389
449
  _unregister_process(process)
390
450
  except Exception as e:
391
- console.print(traceback.format_exc())
451
+ emit_error(traceback.format_exc(), message_group=group_id)
392
452
  if "stdout" not in locals():
393
453
  stdout = None
394
454
  if "stderr" not in locals():
@@ -411,25 +471,120 @@ class ReasoningOutput(BaseModel):
411
471
  def share_your_reasoning(
412
472
  context: RunContext, reasoning: str, next_steps: str | None = None
413
473
  ) -> ReasoningOutput:
414
- console.print("\n[bold white on purple] AGENT REASONING [/bold white on purple]")
415
- console.print("[bold cyan]Current reasoning:[/bold cyan]")
416
- console.print(Markdown(reasoning))
474
+ # Generate unique group_id for this reasoning session
475
+ group_id = generate_group_id(
476
+ "agent_reasoning", reasoning[:50]
477
+ ) # Use first 50 chars for context
478
+
479
+ if not is_tui_mode():
480
+ emit_divider(message_group=group_id)
481
+ emit_info(
482
+ "\n[bold white on purple] AGENT REASONING [/bold white on purple]",
483
+ message_group=group_id,
484
+ )
485
+ emit_info("[bold cyan]Current reasoning:[/bold cyan]", message_group=group_id)
486
+ emit_system_message(Markdown(reasoning), message_group=group_id)
417
487
  if next_steps is not None and next_steps.strip():
418
- console.print("\n[bold cyan]Planned next steps:[/bold cyan]")
419
- console.print(Markdown(next_steps))
420
- console.print("[dim]" + "-" * 60 + "[/dim]\n")
488
+ emit_info(
489
+ "\n[bold cyan]Planned next steps:[/bold cyan]", message_group=group_id
490
+ )
491
+ emit_system_message(Markdown(next_steps), message_group=group_id)
492
+ emit_info("[dim]" + "-" * 60 + "[/dim]\n", message_group=group_id)
421
493
  return ReasoningOutput(**{"success": True})
422
494
 
423
495
 
424
496
  def register_command_runner_tools(agent):
425
497
  @agent.tool
426
498
  def agent_run_shell_command(
427
- context: RunContext, command: str, cwd: str = None, timeout: int = 60
499
+ context: RunContext, command: str = "", cwd: str = None, timeout: int = 60
428
500
  ) -> ShellCommandOutput:
501
+ """Execute a shell command with comprehensive monitoring and safety features.
502
+
503
+ This tool provides robust shell command execution with streaming output,
504
+ timeout handling, user confirmation (when not in yolo mode), and proper
505
+ process lifecycle management. Commands are executed in a controlled
506
+ environment with cross-platform process group handling.
507
+
508
+ Args:
509
+ context (RunContext): The PydanticAI runtime context for the agent.
510
+ command (str): The shell command to execute. Cannot be empty or whitespace-only.
511
+ cwd (str, optional): Working directory for command execution. If None,
512
+ uses the current working directory. Defaults to None.
513
+ timeout (int, optional): Inactivity timeout in seconds. If no output is
514
+ produced for this duration, the process will be terminated.
515
+ Defaults to 60 seconds.
516
+
517
+ Returns:
518
+ ShellCommandOutput: A structured response containing:
519
+ - success (bool): True if command executed successfully (exit code 0)
520
+ - command (str | None): The executed command string
521
+ - error (str | None): Error message if execution failed
522
+ - stdout (str | None): Standard output from the command (last 1000 lines)
523
+ - stderr (str | None): Standard error from the command (last 1000 lines)
524
+ - exit_code (int | None): Process exit code
525
+ - execution_time (float | None): Total execution time in seconds
526
+ - timeout (bool | None): True if command was terminated due to timeout
527
+ - user_interrupted (bool | None): True if user killed the process
528
+
529
+ Note:
530
+ - In interactive mode (not yolo), user confirmation is required before execution
531
+ - Commands have an absolute timeout of 270 seconds regardless of activity
532
+ - Process groups are properly managed for clean termination
533
+ - Output is streamed in real-time and displayed to the user
534
+ - Large output is truncated to the last 1000 lines for memory efficiency
535
+
536
+ Examples:
537
+ >>> result = agent_run_shell_command(ctx, "ls -la", cwd="/tmp", timeout=30)
538
+ >>> if result.success:
539
+ ... print(f"Command completed in {result.execution_time:.2f}s")
540
+ ... print(result.stdout)
541
+
542
+ Warning:
543
+ This tool can execute arbitrary shell commands. Exercise caution when
544
+ running untrusted commands, especially those that modify system state.
545
+ """
429
546
  return run_shell_command(context, command, cwd, timeout)
430
547
 
431
548
  @agent.tool
432
549
  def agent_share_your_reasoning(
433
- context: RunContext, reasoning: str, next_steps: str | None = None
550
+ context: RunContext, reasoning: str = "", next_steps: str | None = None
434
551
  ) -> ReasoningOutput:
552
+ """Share the agent's current reasoning and planned next steps with the user.
553
+
554
+ This tool provides transparency into the agent's decision-making process
555
+ by displaying the current reasoning and upcoming actions in a formatted,
556
+ user-friendly manner. It's essential for building trust and understanding
557
+ between the agent and user.
558
+
559
+ Args:
560
+ context (RunContext): The PydanticAI runtime context for the agent.
561
+ reasoning (str): The agent's current thought process, analysis, or
562
+ reasoning for the current situation. This should be clear,
563
+ comprehensive, and explain the 'why' behind decisions.
564
+ next_steps (str | None, optional): Planned upcoming actions or steps
565
+ the agent intends to take. Can be None if no specific next steps
566
+ are determined. Defaults to None.
567
+
568
+ Returns:
569
+ ReasoningOutput: A simple response object containing:
570
+ - success (bool): Always True, indicating the reasoning was shared
571
+
572
+ Note:
573
+ - Reasoning is displayed with Markdown formatting for better readability
574
+ - Next steps are only shown if provided and non-empty
575
+ - Output is visually separated with dividers in TUI mode
576
+ - This tool should be called before major actions to explain intent
577
+
578
+ Examples:
579
+ >>> reasoning = "I need to analyze the codebase structure before making changes"
580
+ >>> next_steps = "First, I'll list the directory contents, then read key files"
581
+ >>> result = agent_share_your_reasoning(ctx, reasoning, next_steps)
582
+
583
+ Best Practice:
584
+ Use this tool frequently to maintain transparency. Call it:
585
+ - Before starting complex operations
586
+ - When changing strategy or approach
587
+ - To explain why certain decisions are being made
588
+ - When encountering unexpected situations
589
+ """
435
590
  return share_your_reasoning(context, reasoning, next_steps)
@@ -1,43 +1,27 @@
1
- import os
2
1
  import fnmatch
3
-
2
+ import hashlib
3
+ import os
4
+ import time
5
+ from pathlib import Path
4
6
  from typing import Optional, Tuple
7
+
5
8
  from rapidfuzz.distance import JaroWinkler
6
9
  from rich.console import Console
7
10
 
8
- from pathlib import Path
9
- # get_model_context_length will be imported locally where needed to avoid circular imports
10
-
11
- NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
12
- console = Console(no_color=NO_COLOR)
13
-
14
-
15
- def get_model_context_length() -> int:
16
- """
17
- Get the context length for the currently configured model from models.json
18
- """
19
- # Import locally to avoid circular imports
20
- from code_puppy.model_factory import ModelFactory
21
- from code_puppy.config import get_model_name
22
- import os
23
- from pathlib import Path
24
-
25
- # Load model configuration
26
- models_path = os.environ.get("MODELS_JSON_PATH")
27
- if not models_path:
28
- models_path = Path(__file__).parent.parent / "models.json"
29
- else:
30
- models_path = Path(models_path)
31
-
32
- model_configs = ModelFactory.load_config(str(models_path))
33
- model_name = get_model_name()
34
-
35
- # Get context length from model config
36
- model_config = model_configs.get(model_name, {})
37
- context_length = model_config.get("context_length", 128000) # Default value
11
+ # Import our queue-based console system
12
+ try:
13
+ from code_puppy.messaging import get_queue_console
38
14
 
39
- # Reserve 10% of context for response
40
- return int(context_length)
15
+ # Use queue console by default, but allow fallback
16
+ NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
17
+ _rich_console = Console(no_color=NO_COLOR)
18
+ console = get_queue_console()
19
+ # Set the fallback console for compatibility
20
+ console.fallback_console = _rich_console
21
+ except ImportError:
22
+ # Fallback to regular Rich console if messaging system not available
23
+ NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
24
+ console = Console(no_color=NO_COLOR)
41
25
 
42
26
 
43
27
  # -------------------
@@ -77,7 +61,7 @@ IGNORE_PATTERNS = [
77
61
  "**/.parcel-cache/**",
78
62
  "**/.vite/**",
79
63
  "**/storybook-static/**",
80
- "**/*.tsbuildinfo/*",
64
+ "**/*.tsbuildinfo/**",
81
65
  # Python
82
66
  "**/__pycache__/**",
83
67
  "**/__pycache__",
@@ -104,6 +88,7 @@ IGNORE_PATTERNS = [
104
88
  "**/*.egg-info/**",
105
89
  "**/dist/**",
106
90
  "**/wheels/**",
91
+ "**/pytest-reports/**",
107
92
  # Java (Maven, Gradle, SBT)
108
93
  "**/target/**",
109
94
  "**/target",
@@ -384,3 +369,27 @@ def _find_best_window(
384
369
  console.log(f"Best window: {best_window}")
385
370
  console.log(f"Best score: {best_score}")
386
371
  return best_span, best_score
372
+
373
+
374
+ def generate_group_id(tool_name: str, extra_context: str = "") -> str:
375
+ """Generate a unique group_id for tool output grouping.
376
+
377
+ Args:
378
+ tool_name: Name of the tool (e.g., 'list_files', 'edit_file')
379
+ extra_context: Optional extra context to make group_id more unique
380
+
381
+ Returns:
382
+ A string in format: tool_name_hash
383
+ """
384
+ # Create a unique identifier using timestamp, context, and a random component
385
+ import random
386
+
387
+ timestamp = str(int(time.time() * 1000000)) # microseconds for more uniqueness
388
+ random_component = random.randint(1000, 9999) # Add randomness
389
+ context_string = f"{tool_name}_{timestamp}_{random_component}_{extra_context}"
390
+
391
+ # Generate a short hash
392
+ hash_obj = hashlib.md5(context_string.encode())
393
+ short_hash = hash_obj.hexdigest()[:8]
394
+
395
+ return f"{tool_name}_{short_hash}"