code-puppy 0.0.123__py3-none-any.whl → 0.0.125__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.
- code_puppy/agent.py +20 -4
- code_puppy/agents/__init__.py +25 -0
- code_puppy/{agent_prompts.py → agents/agent_code_puppy.py} +46 -13
- code_puppy/agents/agent_creator_agent.py +446 -0
- code_puppy/agents/agent_manager.py +211 -0
- code_puppy/agents/base_agent.py +60 -0
- code_puppy/agents/json_agent.py +129 -0
- code_puppy/callbacks.py +24 -0
- code_puppy/command_line/command_handler.py +126 -10
- code_puppy/config.py +68 -10
- code_puppy/main.py +13 -4
- code_puppy/message_history_processor.py +54 -7
- code_puppy/tools/__init__.py +60 -7
- code_puppy/tools/command_runner.py +100 -1
- code_puppy/tools/file_modifications.py +179 -11
- code_puppy/tools/file_operations.py +171 -1
- code_puppy/tui/app.py +22 -160
- code_puppy/tui/components/status_bar.py +4 -4
- code_puppy/tui/screens/settings.py +53 -18
- code_puppy/tui/tests/test_agent_command.py +72 -0
- code_puppy-0.0.125.dist-info/METADATA +634 -0
- {code_puppy-0.0.123.dist-info → code_puppy-0.0.125.dist-info}/RECORD +26 -20
- code_puppy-0.0.123.dist-info/METADATA +0 -192
- {code_puppy-0.0.123.data → code_puppy-0.0.125.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.123.dist-info → code_puppy-0.0.125.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.123.dist-info → code_puppy-0.0.125.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.123.dist-info → code_puppy-0.0.125.dist-info}/licenses/LICENSE +0 -0
|
@@ -20,6 +20,7 @@ import json_repair
|
|
|
20
20
|
from pydantic import BaseModel
|
|
21
21
|
from pydantic_ai import RunContext
|
|
22
22
|
|
|
23
|
+
from code_puppy.callbacks import on_delete_file, on_edit_file
|
|
23
24
|
from code_puppy.messaging import emit_error, emit_info, emit_warning
|
|
24
25
|
from code_puppy.tools.common import _find_best_window, generate_group_id
|
|
25
26
|
|
|
@@ -346,16 +347,19 @@ def _edit_file(
|
|
|
346
347
|
{"content": "full file contents", "overwrite": true}
|
|
347
348
|
{"replacements": [ {"old_str": "foo", "new_str": "bar"}, ... ] }
|
|
348
349
|
{"delete_snippet": "text to remove"}
|
|
350
|
+
|
|
349
351
|
The function auto-detects the payload type and routes to the appropriate internal helper.
|
|
350
352
|
"""
|
|
353
|
+
# Extract file_path from payload
|
|
354
|
+
file_path = os.path.abspath(payload.file_path)
|
|
355
|
+
|
|
351
356
|
# Use provided group_id or generate one if not provided
|
|
352
357
|
if group_id is None:
|
|
353
|
-
group_id = generate_group_id("edit_file",
|
|
358
|
+
group_id = generate_group_id("edit_file", file_path)
|
|
354
359
|
|
|
355
360
|
emit_info(
|
|
356
361
|
"\n[bold white on blue] EDIT FILE [/bold white on blue]", message_group=group_id
|
|
357
362
|
)
|
|
358
|
-
file_path = os.path.abspath(payload.file_path)
|
|
359
363
|
try:
|
|
360
364
|
if isinstance(payload, DeleteSnippetPayload):
|
|
361
365
|
return delete_snippet_from_file(
|
|
@@ -448,9 +452,7 @@ def register_file_modifications_tools(agent):
|
|
|
448
452
|
"""Attach file-editing tools to *agent* with mandatory diff rendering."""
|
|
449
453
|
|
|
450
454
|
@agent.tool(retries=5)
|
|
451
|
-
def edit_file(
|
|
452
|
-
context: RunContext, payload: EditFilePayload | str = ""
|
|
453
|
-
) -> Dict[str, Any]:
|
|
455
|
+
def edit_file(context: RunContext, payload: EditFilePayload) -> Dict[str, Any]:
|
|
454
456
|
"""Comprehensive file editing tool supporting multiple modification strategies.
|
|
455
457
|
|
|
456
458
|
This is the primary file modification tool that supports three distinct editing
|
|
@@ -476,8 +478,9 @@ def register_file_modifications_tools(agent):
|
|
|
476
478
|
DeleteSnippetPayload:
|
|
477
479
|
- delete_snippet (str): Exact text snippet to remove from file
|
|
478
480
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
+
file_path (str): Path to the target file. Can be relative or absolute.
|
|
482
|
+
File will be created if it doesn't exist (for ContentPayload).
|
|
483
|
+
|
|
481
484
|
Returns:
|
|
482
485
|
Dict[str, Any]: Operation result containing:
|
|
483
486
|
- success (bool): True if operation completed successfully
|
|
@@ -497,16 +500,16 @@ def register_file_modifications_tools(agent):
|
|
|
497
500
|
Examples:
|
|
498
501
|
>>> # Create new file
|
|
499
502
|
>>> payload = ContentPayload(file_path="foo.py", content="print('Hello World')")
|
|
500
|
-
>>> result = edit_file(payload)
|
|
503
|
+
>>> result = edit_file(context, payload)
|
|
501
504
|
|
|
502
505
|
>>> # Replace specific text
|
|
503
506
|
>>> replacements = [Replacement(old_str="foo", new_str="bar")]
|
|
504
507
|
>>> payload = ReplacementsPayload(file_path="foo.py", replacements=replacements)
|
|
505
|
-
>>> result = edit_file(payload)
|
|
508
|
+
>>> result = edit_file(context, payload)
|
|
506
509
|
|
|
507
510
|
>>> # Delete code block
|
|
508
511
|
>>> payload = DeleteSnippetPayload(file_path="foo.py", delete_snippet="# TODO: remove this")
|
|
509
|
-
>>> result = edit_file(payload)
|
|
512
|
+
>>> result = edit_file(context, payload)
|
|
510
513
|
|
|
511
514
|
Warning:
|
|
512
515
|
- Always verify file contents after modification
|
|
@@ -542,12 +545,13 @@ def register_file_modifications_tools(agent):
|
|
|
542
545
|
}
|
|
543
546
|
group_id = generate_group_id("edit_file", payload.file_path)
|
|
544
547
|
result = _edit_file(context, payload, group_id)
|
|
548
|
+
on_edit_file(result)
|
|
545
549
|
if "diff" in result:
|
|
546
550
|
del result["diff"]
|
|
547
551
|
return result
|
|
548
552
|
|
|
549
553
|
@agent.tool(retries=5)
|
|
550
|
-
def delete_file(context: RunContext, file_path: str
|
|
554
|
+
def delete_file(context: RunContext, file_path: str) -> Dict[str, Any]:
|
|
551
555
|
"""Safely delete files with comprehensive logging and diff generation.
|
|
552
556
|
|
|
553
557
|
This tool provides safe file deletion with automatic diff generation to show
|
|
@@ -600,6 +604,170 @@ def register_file_modifications_tools(agent):
|
|
|
600
604
|
# Generate group_id for delete_file tool execution
|
|
601
605
|
group_id = generate_group_id("delete_file", file_path)
|
|
602
606
|
result = _delete_file(context, file_path, message_group=group_id)
|
|
607
|
+
on_delete_file(result)
|
|
608
|
+
if "diff" in result:
|
|
609
|
+
del result["diff"]
|
|
610
|
+
return result
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def register_edit_file(agent):
|
|
614
|
+
"""Register only the edit_file tool."""
|
|
615
|
+
|
|
616
|
+
@agent.tool(strict=False)
|
|
617
|
+
def edit_file(
|
|
618
|
+
context: RunContext,
|
|
619
|
+
payload: EditFilePayload | str = "",
|
|
620
|
+
) -> Dict[str, Any]:
|
|
621
|
+
"""Comprehensive file editing tool supporting multiple modification strategies.
|
|
622
|
+
|
|
623
|
+
This is the primary file modification tool that supports three distinct editing
|
|
624
|
+
approaches: full content replacement, targeted text replacements, and snippet
|
|
625
|
+
deletion. It provides robust diff generation, error handling, and automatic
|
|
626
|
+
retry capabilities for reliable file operations.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
630
|
+
payload: One of three payload types:
|
|
631
|
+
|
|
632
|
+
ContentPayload:
|
|
633
|
+
- content (str): Full file content to write
|
|
634
|
+
- overwrite (bool, optional): Whether to overwrite existing files.
|
|
635
|
+
Defaults to False (safe mode).
|
|
636
|
+
|
|
637
|
+
ReplacementsPayload:
|
|
638
|
+
- replacements (List[Replacement]): List of text replacements where
|
|
639
|
+
each Replacement contains:
|
|
640
|
+
- old_str (str): Exact text to find and replace
|
|
641
|
+
- new_str (str): Replacement text
|
|
642
|
+
|
|
643
|
+
DeleteSnippetPayload:
|
|
644
|
+
- delete_snippet (str): Exact text snippet to remove from file
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
Dict[str, Any]: Operation result containing:
|
|
648
|
+
- success (bool): True if operation completed successfully
|
|
649
|
+
- path (str): Absolute path to the modified file
|
|
650
|
+
- message (str): Human-readable description of changes
|
|
651
|
+
- changed (bool): True if file content was actually modified
|
|
652
|
+
- diff (str, optional): Unified diff showing changes made
|
|
653
|
+
- error (str, optional): Error message if operation failed
|
|
654
|
+
|
|
655
|
+
Examples:
|
|
656
|
+
>>> # Create new file with content
|
|
657
|
+
>>> payload = {"file_path": "hello.py", "content": "print('Hello!')"}
|
|
658
|
+
>>> result = edit_file(ctx, payload)
|
|
659
|
+
|
|
660
|
+
>>> # Replace text in existing file
|
|
661
|
+
>>> payload = {
|
|
662
|
+
... "file_path": "config.py",
|
|
663
|
+
... "replacements": [
|
|
664
|
+
... {"old_str": "debug = False", "new_str": "debug = True"}
|
|
665
|
+
... ]
|
|
666
|
+
... }
|
|
667
|
+
>>> result = edit_file(ctx, payload)
|
|
668
|
+
|
|
669
|
+
>>> # Delete snippet from file
|
|
670
|
+
>>> payload = {
|
|
671
|
+
... "file_path": "main.py",
|
|
672
|
+
... "delete_snippet": "# TODO: remove this comment"
|
|
673
|
+
... }
|
|
674
|
+
>>> result = edit_file(ctx, payload)
|
|
675
|
+
|
|
676
|
+
Best Practices:
|
|
677
|
+
- Use replacements for targeted changes (most efficient)
|
|
678
|
+
- Use content payload only for new files or complete rewrites
|
|
679
|
+
- Always check the 'success' field before assuming changes worked
|
|
680
|
+
- Review the 'diff' field to understand what changed
|
|
681
|
+
- Use delete_snippet for removing specific code blocks
|
|
682
|
+
"""
|
|
683
|
+
# Handle string payload parsing (for models that send JSON strings)
|
|
684
|
+
if isinstance(payload, str):
|
|
685
|
+
# Fallback for weird models that just can't help but send json strings...
|
|
686
|
+
payload = json.loads(json_repair.repair_json(payload))
|
|
687
|
+
if "replacements" in payload and "file_path" in payload:
|
|
688
|
+
payload = ReplacementsPayload(**payload)
|
|
689
|
+
elif "delete_snippet" in payload and "file_path" in payload:
|
|
690
|
+
payload = DeleteSnippetPayload(**payload)
|
|
691
|
+
elif "content" in payload and "file_path" in payload:
|
|
692
|
+
payload = ContentPayload(**payload)
|
|
693
|
+
else:
|
|
694
|
+
file_path = "Unknown"
|
|
695
|
+
if "file_path" in payload:
|
|
696
|
+
file_path = payload["file_path"]
|
|
697
|
+
# Diagnose what's missing
|
|
698
|
+
missing = []
|
|
699
|
+
if "file_path" not in payload:
|
|
700
|
+
missing.append("file_path")
|
|
701
|
+
|
|
702
|
+
payload_type = "unknown"
|
|
703
|
+
if "content" in payload:
|
|
704
|
+
payload_type = "content"
|
|
705
|
+
elif "replacements" in payload:
|
|
706
|
+
payload_type = "replacements"
|
|
707
|
+
elif "delete_snippet" in payload:
|
|
708
|
+
payload_type = "delete_snippet"
|
|
709
|
+
else:
|
|
710
|
+
missing.append("content/replacements/delete_snippet")
|
|
711
|
+
|
|
712
|
+
missing_str = ", ".join(missing) if missing else "none"
|
|
713
|
+
return {
|
|
714
|
+
"success": False,
|
|
715
|
+
"path": file_path,
|
|
716
|
+
"message": f"Invalid payload for {payload_type} operation. Missing required fields: {missing_str}. Payload keys: {list(payload.keys())}",
|
|
717
|
+
"changed": False,
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
# Call _edit_file which will extract file_path from payload and handle group_id generation
|
|
721
|
+
result = _edit_file(context, payload)
|
|
722
|
+
if "diff" in result:
|
|
723
|
+
del result["diff"]
|
|
724
|
+
return result
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def register_delete_file(agent):
|
|
728
|
+
"""Register only the delete_file tool."""
|
|
729
|
+
|
|
730
|
+
@agent.tool(strict=False)
|
|
731
|
+
def delete_file(context: RunContext, file_path: str = "") -> Dict[str, Any]:
|
|
732
|
+
"""Safely delete files with comprehensive logging and diff generation.
|
|
733
|
+
|
|
734
|
+
This tool provides safe file deletion with automatic diff generation to show
|
|
735
|
+
exactly what content was removed. It includes proper error handling and
|
|
736
|
+
automatic retry capabilities for reliable operation.
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
740
|
+
file_path (str): Path to the file to delete. Can be relative or absolute.
|
|
741
|
+
Must be an existing regular file (not a directory).
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
Dict[str, Any]: Operation result containing:
|
|
745
|
+
- success (bool): True if file was successfully deleted
|
|
746
|
+
- path (str): Absolute path to the deleted file
|
|
747
|
+
- message (str): Human-readable description of the operation
|
|
748
|
+
- changed (bool): True if file was actually removed
|
|
749
|
+
- error (str, optional): Error message if deletion failed
|
|
750
|
+
|
|
751
|
+
Examples:
|
|
752
|
+
>>> # Delete a specific file
|
|
753
|
+
>>> result = delete_file(ctx, "temp_file.txt")
|
|
754
|
+
>>> if result['success']:
|
|
755
|
+
... print(f"Deleted: {result['path']}")
|
|
756
|
+
|
|
757
|
+
>>> # Handle deletion errors
|
|
758
|
+
>>> result = delete_file(ctx, "missing.txt")
|
|
759
|
+
>>> if not result['success']:
|
|
760
|
+
... print(f"Error: {result.get('error', 'Unknown error')}")
|
|
761
|
+
|
|
762
|
+
Best Practices:
|
|
763
|
+
- Always verify file exists before attempting deletion
|
|
764
|
+
- Check 'success' field to confirm operation completed
|
|
765
|
+
- Use list_files first to confirm file paths
|
|
766
|
+
- Cannot delete directories (use shell commands for that)
|
|
767
|
+
"""
|
|
768
|
+
# Generate group_id for delete_file tool execution
|
|
769
|
+
group_id = generate_group_id("delete_file", file_path)
|
|
770
|
+
result = _delete_file(context, file_path, message_group=group_id)
|
|
603
771
|
if "diff" in result:
|
|
604
772
|
del result["diff"]
|
|
605
773
|
return result
|
|
@@ -173,13 +173,14 @@ def _list_files(
|
|
|
173
173
|
if rel_path == ".":
|
|
174
174
|
rel_path = ""
|
|
175
175
|
if rel_path:
|
|
176
|
-
os.path.join(directory, rel_path)
|
|
176
|
+
dir_path = os.path.join(directory, rel_path)
|
|
177
177
|
results.append(
|
|
178
178
|
ListedFile(
|
|
179
179
|
**{
|
|
180
180
|
"path": rel_path,
|
|
181
181
|
"type": "directory",
|
|
182
182
|
"size": 0,
|
|
183
|
+
"full_path": dir_path,
|
|
183
184
|
"depth": depth,
|
|
184
185
|
}
|
|
185
186
|
)
|
|
@@ -187,6 +188,7 @@ def _list_files(
|
|
|
187
188
|
folder_structure[rel_path] = {
|
|
188
189
|
"path": rel_path,
|
|
189
190
|
"depth": depth,
|
|
191
|
+
"full_path": dir_path,
|
|
190
192
|
}
|
|
191
193
|
for file in files:
|
|
192
194
|
file_path = os.path.join(root, file)
|
|
@@ -199,6 +201,7 @@ def _list_files(
|
|
|
199
201
|
"path": rel_file_path,
|
|
200
202
|
"type": "file",
|
|
201
203
|
"size": size,
|
|
204
|
+
"full_path": file_path,
|
|
202
205
|
"depth": depth,
|
|
203
206
|
}
|
|
204
207
|
results.append(ListedFile(**file_info))
|
|
@@ -621,3 +624,170 @@ def register_file_operations_tools(agent):
|
|
|
621
624
|
- For case-insensitive search, try multiple variants manually
|
|
622
625
|
"""
|
|
623
626
|
return _grep(context, search_string, directory)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def register_list_files(agent):
|
|
630
|
+
"""Register only the list_files tool."""
|
|
631
|
+
|
|
632
|
+
@agent.tool(strict=False)
|
|
633
|
+
def list_files(
|
|
634
|
+
context: RunContext, directory: str = ".", recursive: bool = True
|
|
635
|
+
) -> ListFileOutput:
|
|
636
|
+
"""List files and directories with intelligent filtering and safety features.
|
|
637
|
+
|
|
638
|
+
This tool provides comprehensive directory listing with smart home directory
|
|
639
|
+
detection, project-aware recursion, and token-safe output. It automatically
|
|
640
|
+
ignores common build artifacts, cache directories, and other noise while
|
|
641
|
+
providing rich file metadata and visual formatting.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
645
|
+
directory (str, optional): Path to the directory to list. Can be relative
|
|
646
|
+
or absolute. Defaults to "." (current directory).
|
|
647
|
+
recursive (bool, optional): Whether to recursively list subdirectories.
|
|
648
|
+
Automatically disabled for home directories unless they contain
|
|
649
|
+
project indicators. Defaults to True.
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
ListFileOutput: A structured response containing:
|
|
653
|
+
- files (List[ListedFile]): List of files and directories found, where
|
|
654
|
+
each ListedFile contains:
|
|
655
|
+
- path (str | None): Relative path from the listing directory
|
|
656
|
+
- type (str | None): "file" or "directory"
|
|
657
|
+
- size (int): File size in bytes (0 for directories)
|
|
658
|
+
- full_path (str | None): Absolute path to the item
|
|
659
|
+
- depth (int | None): Nesting depth from the root directory
|
|
660
|
+
- error (str | None): Error message if listing failed
|
|
661
|
+
|
|
662
|
+
Examples:
|
|
663
|
+
>>> # List current directory
|
|
664
|
+
>>> result = list_files(ctx)
|
|
665
|
+
>>> for file in result.files:
|
|
666
|
+
... print(f"{file.type}: {file.path} ({file.size} bytes)")
|
|
667
|
+
|
|
668
|
+
>>> # List specific directory non-recursively
|
|
669
|
+
>>> result = list_files(ctx, "/path/to/project", recursive=False)
|
|
670
|
+
>>> print(f"Found {len(result.files)} items")
|
|
671
|
+
|
|
672
|
+
>>> # Handle potential errors
|
|
673
|
+
>>> result = list_files(ctx, "/nonexistent/path")
|
|
674
|
+
>>> if result.error:
|
|
675
|
+
... print(f"Error: {result.error}")
|
|
676
|
+
|
|
677
|
+
Best Practices:
|
|
678
|
+
- Always use this before reading/modifying files
|
|
679
|
+
- Use non-recursive for quick directory overviews
|
|
680
|
+
- Check for errors in the response
|
|
681
|
+
- Combine with grep to find specific file patterns
|
|
682
|
+
"""
|
|
683
|
+
return _list_files(context, directory, recursive)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def register_read_file(agent):
|
|
687
|
+
"""Register only the read_file tool."""
|
|
688
|
+
|
|
689
|
+
@agent.tool(strict=False)
|
|
690
|
+
def read_file(
|
|
691
|
+
context: RunContext,
|
|
692
|
+
file_path: str = "",
|
|
693
|
+
start_line: int | None = None,
|
|
694
|
+
num_lines: int | None = None,
|
|
695
|
+
) -> ReadFileOutput:
|
|
696
|
+
"""Read file contents with optional line-range selection and token safety.
|
|
697
|
+
|
|
698
|
+
This tool provides safe file reading with automatic token counting and
|
|
699
|
+
optional line-range selection for handling large files efficiently.
|
|
700
|
+
It protects against reading excessively large files that could overwhelm
|
|
701
|
+
the agent's context window.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
705
|
+
file_path (str): Path to the file to read. Can be relative or absolute.
|
|
706
|
+
Cannot be empty.
|
|
707
|
+
start_line (int | None, optional): Starting line number for partial reads
|
|
708
|
+
(1-based indexing). If specified, num_lines must also be provided.
|
|
709
|
+
Defaults to None (read entire file).
|
|
710
|
+
num_lines (int | None, optional): Number of lines to read starting from
|
|
711
|
+
start_line. Must be specified if start_line is provided.
|
|
712
|
+
Defaults to None (read to end of file).
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
ReadFileOutput: A structured response containing:
|
|
716
|
+
- content (str | None): The file contents or error message
|
|
717
|
+
- num_tokens (int): Estimated token count (constrained to < 10,000)
|
|
718
|
+
- error (str | None): Error message if reading failed
|
|
719
|
+
|
|
720
|
+
Examples:
|
|
721
|
+
>>> # Read entire file
|
|
722
|
+
>>> result = read_file(ctx, "example.py")
|
|
723
|
+
>>> print(f"Read {result.num_tokens} tokens")
|
|
724
|
+
>>> print(result.content)
|
|
725
|
+
|
|
726
|
+
>>> # Read specific line range
|
|
727
|
+
>>> result = read_file(ctx, "large_file.py", start_line=10, num_lines=20)
|
|
728
|
+
>>> print("Lines 10-29:", result.content)
|
|
729
|
+
|
|
730
|
+
>>> # Handle errors
|
|
731
|
+
>>> result = read_file(ctx, "missing.txt")
|
|
732
|
+
>>> if result.error:
|
|
733
|
+
... print(f"Error: {result.error}")
|
|
734
|
+
|
|
735
|
+
Best Practices:
|
|
736
|
+
- Always check for errors before using content
|
|
737
|
+
- Use line ranges for large files to avoid token limits
|
|
738
|
+
- Monitor num_tokens to stay within context limits
|
|
739
|
+
- Combine with list_files to find files first
|
|
740
|
+
"""
|
|
741
|
+
return _read_file(context, file_path, start_line, num_lines)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def register_grep(agent):
|
|
745
|
+
"""Register only the grep tool."""
|
|
746
|
+
|
|
747
|
+
@agent.tool(strict=False)
|
|
748
|
+
def grep(
|
|
749
|
+
context: RunContext, search_string: str = "", directory: str = "."
|
|
750
|
+
) -> GrepOutput:
|
|
751
|
+
"""Recursively search for text patterns across files with intelligent filtering.
|
|
752
|
+
|
|
753
|
+
This tool provides powerful text searching across directory trees with
|
|
754
|
+
automatic filtering of irrelevant files, binary detection, and match limiting
|
|
755
|
+
for performance. It's essential for code exploration and finding specific
|
|
756
|
+
patterns or references.
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
760
|
+
search_string (str): The text pattern to search for. Performs exact
|
|
761
|
+
string matching (not regex). Cannot be empty.
|
|
762
|
+
directory (str, optional): Root directory to start the recursive search.
|
|
763
|
+
Can be relative or absolute. Defaults to "." (current directory).
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
GrepOutput: A structured response containing:
|
|
767
|
+
- matches (List[MatchInfo]): List of matches found, where each
|
|
768
|
+
MatchInfo contains:
|
|
769
|
+
- file_path (str | None): Absolute path to the file containing the match
|
|
770
|
+
- line_number (int | None): Line number where match was found (1-based)
|
|
771
|
+
- line_content (str | None): Full line content containing the match
|
|
772
|
+
|
|
773
|
+
Examples:
|
|
774
|
+
>>> # Search for function definitions
|
|
775
|
+
>>> result = grep(ctx, "def my_function")
|
|
776
|
+
>>> for match in result.matches:
|
|
777
|
+
... print(f"{match.file_path}:{match.line_number}: {match.line_content}")
|
|
778
|
+
|
|
779
|
+
>>> # Search in specific directory
|
|
780
|
+
>>> result = grep(ctx, "TODO", "/path/to/project/src")
|
|
781
|
+
>>> print(f"Found {len(result.matches)} TODO items")
|
|
782
|
+
|
|
783
|
+
>>> # Search for imports
|
|
784
|
+
>>> result = grep(ctx, "import pandas")
|
|
785
|
+
>>> files_using_pandas = {match.file_path for match in result.matches}
|
|
786
|
+
|
|
787
|
+
Best Practices:
|
|
788
|
+
- Use specific search terms to avoid too many results
|
|
789
|
+
- Search is case-sensitive; try variations if needed
|
|
790
|
+
- Combine with read_file to examine matches in detail
|
|
791
|
+
- For case-insensitive search, try multiple variants manually
|
|
792
|
+
"""
|
|
793
|
+
return _grep(context, search_string, directory)
|
code_puppy/tui/app.py
CHANGED
|
@@ -10,7 +10,7 @@ from textual.binding import Binding
|
|
|
10
10
|
from textual.containers import Container
|
|
11
11
|
from textual.events import Resize
|
|
12
12
|
from textual.reactive import reactive
|
|
13
|
-
from textual.widgets import Footer,
|
|
13
|
+
from textual.widgets import Footer, ListView
|
|
14
14
|
|
|
15
15
|
from code_puppy.agent import get_code_generation_agent, get_custom_usage_limits
|
|
16
16
|
from code_puppy.command_line.command_handler import handle_command
|
|
@@ -27,7 +27,11 @@ from code_puppy.message_history_processor import (
|
|
|
27
27
|
|
|
28
28
|
# Import our message queue system
|
|
29
29
|
from code_puppy.messaging import TUIRenderer, get_global_queue
|
|
30
|
-
from code_puppy.state_management import
|
|
30
|
+
from code_puppy.state_management import (
|
|
31
|
+
clear_message_history,
|
|
32
|
+
get_message_history,
|
|
33
|
+
set_message_history,
|
|
34
|
+
)
|
|
31
35
|
from code_puppy.tui.components import (
|
|
32
36
|
ChatView,
|
|
33
37
|
CustomTextArea,
|
|
@@ -404,6 +408,15 @@ class CodePuppyTUI(App):
|
|
|
404
408
|
self.action_clear_chat()
|
|
405
409
|
return
|
|
406
410
|
|
|
411
|
+
# Let the command handler process all /agent commands
|
|
412
|
+
# result will be handled by the command handler directly through messaging system
|
|
413
|
+
if message.strip().startswith("/agent"):
|
|
414
|
+
# The command handler will emit messages directly to our messaging system
|
|
415
|
+
handle_command(message.strip())
|
|
416
|
+
# Refresh our agent instance after potential change
|
|
417
|
+
self.agent = get_code_generation_agent()
|
|
418
|
+
return
|
|
419
|
+
|
|
407
420
|
# Handle exit commands
|
|
408
421
|
if message.strip().lower() in ("/exit", "/quit"):
|
|
409
422
|
self.add_system_message("Goodbye!")
|
|
@@ -412,36 +425,11 @@ class CodePuppyTUI(App):
|
|
|
412
425
|
return
|
|
413
426
|
|
|
414
427
|
# Use the existing command handler
|
|
428
|
+
# The command handler directly uses the messaging system, so we don't need to capture stdout
|
|
415
429
|
try:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
from code_puppy.tools.common import console as rich_console
|
|
420
|
-
|
|
421
|
-
# Capture the output from the command handler
|
|
422
|
-
old_stdout = sys.stdout
|
|
423
|
-
captured_output = StringIO()
|
|
424
|
-
sys.stdout = captured_output
|
|
425
|
-
|
|
426
|
-
# Also capture Rich console output
|
|
427
|
-
rich_console.file = captured_output
|
|
428
|
-
|
|
429
|
-
try:
|
|
430
|
-
# Call the existing command handler
|
|
431
|
-
result = handle_command(message.strip())
|
|
432
|
-
if result: # Command was handled
|
|
433
|
-
output = captured_output.getvalue()
|
|
434
|
-
if output.strip():
|
|
435
|
-
self.add_system_message(output.strip())
|
|
436
|
-
else:
|
|
437
|
-
self.add_system_message(f"Command '{message}' executed")
|
|
438
|
-
else:
|
|
439
|
-
self.add_system_message(f"Unknown command: {message}")
|
|
440
|
-
finally:
|
|
441
|
-
# Restore stdout and console
|
|
442
|
-
sys.stdout = old_stdout
|
|
443
|
-
rich_console.file = sys.__stdout__
|
|
444
|
-
|
|
430
|
+
result = handle_command(message.strip())
|
|
431
|
+
if not result:
|
|
432
|
+
self.add_system_message(f"Unknown command: {message}")
|
|
445
433
|
except Exception as e:
|
|
446
434
|
self.add_error_message(f"Error executing command: {str(e)}")
|
|
447
435
|
return
|
|
@@ -499,7 +487,9 @@ class CodePuppyTUI(App):
|
|
|
499
487
|
# Handle regular exceptions
|
|
500
488
|
self.add_error_message(f"MCP/Agent error: {str(eg)}")
|
|
501
489
|
finally:
|
|
502
|
-
set_message_history(
|
|
490
|
+
set_message_history(
|
|
491
|
+
prune_interrupted_tool_calls(get_message_history())
|
|
492
|
+
)
|
|
503
493
|
except Exception as agent_error:
|
|
504
494
|
# Handle any other errors in agent processing
|
|
505
495
|
self.add_error_message(
|
|
@@ -662,134 +652,6 @@ class CodePuppyTUI(App):
|
|
|
662
652
|
# Automatically submit the message
|
|
663
653
|
self.action_send_message()
|
|
664
654
|
|
|
665
|
-
# History management methods
|
|
666
|
-
def load_history_list(self) -> None:
|
|
667
|
-
"""Load session history into the history tab."""
|
|
668
|
-
try:
|
|
669
|
-
from datetime import datetime, timezone
|
|
670
|
-
|
|
671
|
-
history_list = self.query_one("#history-list", ListView)
|
|
672
|
-
|
|
673
|
-
# Get history from session memory
|
|
674
|
-
if self.session_memory:
|
|
675
|
-
# Get recent history (last 24 hours by default)
|
|
676
|
-
recent_history = self.session_memory.get_history(within_minutes=24 * 60)
|
|
677
|
-
|
|
678
|
-
if not recent_history:
|
|
679
|
-
# No history available
|
|
680
|
-
history_list.append(
|
|
681
|
-
ListItem(Label("No recent history", classes="history-empty"))
|
|
682
|
-
)
|
|
683
|
-
return
|
|
684
|
-
|
|
685
|
-
# Filter out model loading entries and group history by type, display most recent first
|
|
686
|
-
filtered_history = [
|
|
687
|
-
entry
|
|
688
|
-
for entry in recent_history
|
|
689
|
-
if not entry.get("description", "").startswith("Agent loaded")
|
|
690
|
-
]
|
|
691
|
-
|
|
692
|
-
# Get sidebar width for responsive text truncation
|
|
693
|
-
try:
|
|
694
|
-
sidebar_width = (
|
|
695
|
-
self.query_one("Sidebar").size.width
|
|
696
|
-
if hasattr(self.query_one("Sidebar"), "size")
|
|
697
|
-
else 30
|
|
698
|
-
)
|
|
699
|
-
except Exception:
|
|
700
|
-
sidebar_width = 30
|
|
701
|
-
|
|
702
|
-
# Adjust text length based on sidebar width
|
|
703
|
-
if sidebar_width >= 35:
|
|
704
|
-
max_text_length = 45
|
|
705
|
-
time_format = "%H:%M:%S"
|
|
706
|
-
elif sidebar_width >= 25:
|
|
707
|
-
max_text_length = 30
|
|
708
|
-
time_format = "%H:%M"
|
|
709
|
-
else:
|
|
710
|
-
max_text_length = 20
|
|
711
|
-
time_format = "%H:%M"
|
|
712
|
-
|
|
713
|
-
for entry in reversed(filtered_history[-20:]): # Show last 20 entries
|
|
714
|
-
timestamp_str = entry.get("timestamp", "")
|
|
715
|
-
description = entry.get("description", "Unknown task")
|
|
716
|
-
|
|
717
|
-
# Parse timestamp for display with safe parsing
|
|
718
|
-
def parse_timestamp_safely_for_display(timestamp_str: str) -> str:
|
|
719
|
-
"""Parse timestamp string safely for display purposes."""
|
|
720
|
-
try:
|
|
721
|
-
# Handle 'Z' suffix (common UTC format)
|
|
722
|
-
cleaned_timestamp = timestamp_str.replace("Z", "+00:00")
|
|
723
|
-
parsed_dt = datetime.fromisoformat(cleaned_timestamp)
|
|
724
|
-
|
|
725
|
-
# If the datetime is naive (no timezone), assume UTC
|
|
726
|
-
if parsed_dt.tzinfo is None:
|
|
727
|
-
parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
|
|
728
|
-
|
|
729
|
-
return parsed_dt.strftime(time_format)
|
|
730
|
-
except (ValueError, AttributeError, TypeError):
|
|
731
|
-
# Handle invalid timestamp formats gracefully
|
|
732
|
-
fallback = (
|
|
733
|
-
timestamp_str[:5]
|
|
734
|
-
if sidebar_width < 25
|
|
735
|
-
else timestamp_str[:8]
|
|
736
|
-
)
|
|
737
|
-
return "??:??" if len(fallback) < 5 else fallback
|
|
738
|
-
|
|
739
|
-
time_display = parse_timestamp_safely_for_display(timestamp_str)
|
|
740
|
-
|
|
741
|
-
# Format description for display with responsive truncation
|
|
742
|
-
if description.startswith("Interactive task:"):
|
|
743
|
-
task_text = description[
|
|
744
|
-
17:
|
|
745
|
-
].strip() # Remove "Interactive task: "
|
|
746
|
-
truncated = task_text[:max_text_length] + (
|
|
747
|
-
"..." if len(task_text) > max_text_length else ""
|
|
748
|
-
)
|
|
749
|
-
display_text = f"[{time_display}] 💬 {truncated}"
|
|
750
|
-
css_class = "history-interactive"
|
|
751
|
-
elif description.startswith("TUI interaction:"):
|
|
752
|
-
task_text = description[
|
|
753
|
-
16:
|
|
754
|
-
].strip() # Remove "TUI interaction: "
|
|
755
|
-
truncated = task_text[:max_text_length] + (
|
|
756
|
-
"..." if len(task_text) > max_text_length else ""
|
|
757
|
-
)
|
|
758
|
-
display_text = f"[{time_display}] 🖥️ {truncated}"
|
|
759
|
-
css_class = "history-tui"
|
|
760
|
-
elif description.startswith("Command executed"):
|
|
761
|
-
cmd_text = description[
|
|
762
|
-
18:
|
|
763
|
-
].strip() # Remove "Command executed: "
|
|
764
|
-
truncated = cmd_text[: max_text_length - 5] + (
|
|
765
|
-
"..." if len(cmd_text) > max_text_length - 5 else ""
|
|
766
|
-
)
|
|
767
|
-
display_text = f"[{time_display}] ⚡ {truncated}"
|
|
768
|
-
css_class = "history-command"
|
|
769
|
-
else:
|
|
770
|
-
# Generic entry
|
|
771
|
-
truncated = description[:max_text_length] + (
|
|
772
|
-
"..." if len(description) > max_text_length else ""
|
|
773
|
-
)
|
|
774
|
-
display_text = f"[{time_display}] 📝 {truncated}"
|
|
775
|
-
css_class = "history-generic"
|
|
776
|
-
|
|
777
|
-
label = Label(display_text, classes=css_class)
|
|
778
|
-
history_item = ListItem(label)
|
|
779
|
-
history_item.history_entry = (
|
|
780
|
-
entry # Store full entry for detail view
|
|
781
|
-
)
|
|
782
|
-
history_list.append(history_item)
|
|
783
|
-
else:
|
|
784
|
-
history_list.append(
|
|
785
|
-
ListItem(
|
|
786
|
-
Label("Session memory not available", classes="history-error")
|
|
787
|
-
)
|
|
788
|
-
)
|
|
789
|
-
|
|
790
|
-
except Exception as e:
|
|
791
|
-
self.add_error_message(f"Failed to load history: {e}")
|
|
792
|
-
|
|
793
655
|
def show_history_details(self, history_entry: dict) -> None:
|
|
794
656
|
"""Show detailed information about a selected history entry."""
|
|
795
657
|
try:
|