tunacode-cli 0.0.51__py3-none-any.whl → 0.0.53__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.
Potentially problematic release.
This version of tunacode-cli might be problematic. Click here for more details.
- tunacode/cli/commands/base.py +2 -2
- tunacode/cli/commands/implementations/__init__.py +7 -1
- tunacode/cli/commands/implementations/conversation.py +1 -1
- tunacode/cli/commands/implementations/debug.py +1 -1
- tunacode/cli/commands/implementations/development.py +4 -1
- tunacode/cli/commands/implementations/template.py +132 -0
- tunacode/cli/commands/registry.py +28 -1
- tunacode/cli/commands/template_shortcut.py +93 -0
- tunacode/cli/main.py +6 -0
- tunacode/cli/repl.py +29 -174
- tunacode/cli/repl_components/__init__.py +10 -0
- tunacode/cli/repl_components/command_parser.py +34 -0
- tunacode/cli/repl_components/error_recovery.py +88 -0
- tunacode/cli/repl_components/output_display.py +33 -0
- tunacode/cli/repl_components/tool_executor.py +84 -0
- tunacode/configuration/defaults.py +2 -2
- tunacode/configuration/settings.py +11 -14
- tunacode/constants.py +57 -23
- tunacode/context.py +0 -14
- tunacode/core/agents/agent_components/__init__.py +27 -0
- tunacode/core/agents/agent_components/agent_config.py +109 -0
- tunacode/core/agents/agent_components/json_tool_parser.py +109 -0
- tunacode/core/agents/agent_components/message_handler.py +100 -0
- tunacode/core/agents/agent_components/node_processor.py +480 -0
- tunacode/core/agents/agent_components/response_state.py +13 -0
- tunacode/core/agents/agent_components/result_wrapper.py +50 -0
- tunacode/core/agents/agent_components/task_completion.py +28 -0
- tunacode/core/agents/agent_components/tool_buffer.py +24 -0
- tunacode/core/agents/agent_components/tool_executor.py +49 -0
- tunacode/core/agents/main.py +421 -778
- tunacode/core/agents/utils.py +42 -2
- tunacode/core/background/manager.py +3 -3
- tunacode/core/logging/__init__.py +4 -3
- tunacode/core/logging/config.py +1 -1
- tunacode/core/logging/formatters.py +1 -1
- tunacode/core/logging/handlers.py +41 -7
- tunacode/core/setup/__init__.py +2 -0
- tunacode/core/setup/agent_setup.py +2 -2
- tunacode/core/setup/base.py +2 -2
- tunacode/core/setup/config_setup.py +10 -6
- tunacode/core/setup/git_safety_setup.py +13 -2
- tunacode/core/setup/template_setup.py +75 -0
- tunacode/core/state.py +13 -2
- tunacode/core/token_usage/api_response_parser.py +6 -2
- tunacode/core/token_usage/usage_tracker.py +37 -7
- tunacode/core/tool_handler.py +24 -1
- tunacode/prompts/system.md +289 -4
- tunacode/setup.py +2 -0
- tunacode/templates/__init__.py +9 -0
- tunacode/templates/loader.py +210 -0
- tunacode/tools/glob.py +3 -3
- tunacode/tools/grep.py +26 -276
- tunacode/tools/grep_components/__init__.py +9 -0
- tunacode/tools/grep_components/file_filter.py +93 -0
- tunacode/tools/grep_components/pattern_matcher.py +152 -0
- tunacode/tools/grep_components/result_formatter.py +45 -0
- tunacode/tools/grep_components/search_result.py +35 -0
- tunacode/tools/todo.py +27 -21
- tunacode/types.py +19 -4
- tunacode/ui/completers.py +6 -1
- tunacode/ui/decorators.py +2 -2
- tunacode/ui/keybindings.py +1 -1
- tunacode/ui/panels.py +13 -5
- tunacode/ui/prompt_manager.py +1 -1
- tunacode/ui/tool_ui.py +8 -2
- tunacode/utils/bm25.py +4 -4
- tunacode/utils/file_utils.py +2 -2
- tunacode/utils/message_utils.py +3 -1
- tunacode/utils/system.py +0 -4
- tunacode/utils/text_utils.py +1 -1
- tunacode/utils/token_counter.py +2 -2
- {tunacode_cli-0.0.51.dist-info → tunacode_cli-0.0.53.dist-info}/METADATA +146 -1
- tunacode_cli-0.0.53.dist-info/RECORD +123 -0
- {tunacode_cli-0.0.51.dist-info → tunacode_cli-0.0.53.dist-info}/top_level.txt +0 -1
- api/auth.py +0 -13
- api/users.py +0 -8
- tunacode/core/recursive/__init__.py +0 -18
- tunacode/core/recursive/aggregator.py +0 -467
- tunacode/core/recursive/budget.py +0 -414
- tunacode/core/recursive/decomposer.py +0 -398
- tunacode/core/recursive/executor.py +0 -470
- tunacode/core/recursive/hierarchy.py +0 -488
- tunacode/ui/recursive_progress.py +0 -380
- tunacode_cli-0.0.51.dist-info/RECORD +0 -107
- {tunacode_cli-0.0.51.dist-info → tunacode_cli-0.0.53.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.51.dist-info → tunacode_cli-0.0.53.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.51.dist-info → tunacode_cli-0.0.53.dist-info}/licenses/LICENSE +0 -0
tunacode/cli/commands/base.py
CHANGED
|
@@ -44,12 +44,12 @@ class Command(ABC):
|
|
|
44
44
|
return CommandCategory.SYSTEM
|
|
45
45
|
|
|
46
46
|
@abstractmethod
|
|
47
|
-
async def execute(self,
|
|
47
|
+
async def execute(self, _args: CommandArgs, context: CommandContext) -> CommandResult:
|
|
48
48
|
"""
|
|
49
49
|
Execute the command.
|
|
50
50
|
|
|
51
51
|
Args:
|
|
52
|
-
|
|
52
|
+
_args: Command arguments (excluding the command name)
|
|
53
53
|
context: Execution context with state and config
|
|
54
54
|
|
|
55
55
|
Returns:
|
|
@@ -12,7 +12,13 @@ from .debug import (
|
|
|
12
12
|
)
|
|
13
13
|
from .development import BranchCommand, InitCommand
|
|
14
14
|
from .model import ModelCommand
|
|
15
|
-
from .system import
|
|
15
|
+
from .system import (
|
|
16
|
+
ClearCommand,
|
|
17
|
+
HelpCommand,
|
|
18
|
+
RefreshConfigCommand,
|
|
19
|
+
StreamingCommand,
|
|
20
|
+
UpdateCommand,
|
|
21
|
+
)
|
|
16
22
|
from .todo import TodoCommand
|
|
17
23
|
|
|
18
24
|
__all__ = [
|
|
@@ -40,7 +40,7 @@ class CompactCommand(SimpleCommand):
|
|
|
40
40
|
result = await process_request(
|
|
41
41
|
summary_prompt,
|
|
42
42
|
context.state_manager,
|
|
43
|
-
|
|
43
|
+
False, # We'll handle the output ourselves
|
|
44
44
|
)
|
|
45
45
|
|
|
46
46
|
# Extract summary text from result
|
|
@@ -169,7 +169,7 @@ class ParseToolsCommand(SimpleCommand):
|
|
|
169
169
|
# Create tool callback
|
|
170
170
|
from tunacode.cli.repl import _tool_handler
|
|
171
171
|
|
|
172
|
-
def tool_callback_with_state(part,
|
|
172
|
+
def tool_callback_with_state(part, _node):
|
|
173
173
|
return _tool_handler(part, context.state_manager)
|
|
174
174
|
|
|
175
175
|
try:
|
|
@@ -72,6 +72,9 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (
|
|
|
72
72
|
make sure to include them."""
|
|
73
73
|
|
|
74
74
|
# Call the agent to analyze and create/update the file
|
|
75
|
-
|
|
75
|
+
if context.process_request:
|
|
76
|
+
await context.process_request(
|
|
77
|
+
prompt, context.state_manager.session.current_model, context.state_manager
|
|
78
|
+
)
|
|
76
79
|
|
|
77
80
|
return None
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Template management commands for TunaCode CLI."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from ....templates.loader import TemplateLoader
|
|
6
|
+
from ....types import CommandArgs, CommandContext
|
|
7
|
+
from ....ui import console as ui
|
|
8
|
+
from ..base import CommandCategory, CommandSpec, SimpleCommand
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TemplateCommand(SimpleCommand):
|
|
12
|
+
"""Manage and use templates for pre-approved tools."""
|
|
13
|
+
|
|
14
|
+
spec = CommandSpec(
|
|
15
|
+
name="template",
|
|
16
|
+
aliases=["/template", "/tpl"],
|
|
17
|
+
description="Manage and use templates",
|
|
18
|
+
category=CommandCategory.SYSTEM,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async def execute(self, args: CommandArgs, context: CommandContext) -> Optional[str]:
|
|
22
|
+
"""Execute template command with subcommands."""
|
|
23
|
+
if not args:
|
|
24
|
+
await self._show_help()
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
subcommand = args[0]
|
|
28
|
+
|
|
29
|
+
if subcommand == "list":
|
|
30
|
+
await self._list_templates()
|
|
31
|
+
elif subcommand == "load":
|
|
32
|
+
if len(args) < 2:
|
|
33
|
+
await ui.error("Please specify a template name")
|
|
34
|
+
await ui.muted("Usage: /template load <template-name>")
|
|
35
|
+
return None
|
|
36
|
+
await self._load_template(args[1], context)
|
|
37
|
+
elif subcommand == "create":
|
|
38
|
+
await self._create_template()
|
|
39
|
+
elif subcommand == "clear":
|
|
40
|
+
await self._clear_template(context)
|
|
41
|
+
else:
|
|
42
|
+
await ui.error(f"Unknown subcommand: {subcommand}")
|
|
43
|
+
await self._show_help()
|
|
44
|
+
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
async def _show_help(self) -> None:
|
|
48
|
+
"""Show help for template command."""
|
|
49
|
+
await ui.info("Template management commands:")
|
|
50
|
+
await ui.muted(" /template list - List available templates")
|
|
51
|
+
await ui.muted(" /template load <name> - Load and activate a template")
|
|
52
|
+
await ui.muted(" /template create - Create a new template interactively")
|
|
53
|
+
await ui.muted(" /template clear - Clear the active template")
|
|
54
|
+
|
|
55
|
+
async def _list_templates(self) -> None:
|
|
56
|
+
"""List all available templates."""
|
|
57
|
+
loader = TemplateLoader()
|
|
58
|
+
template_names = loader.list_templates()
|
|
59
|
+
|
|
60
|
+
if not template_names:
|
|
61
|
+
await ui.info("No templates found.")
|
|
62
|
+
await ui.muted("Create one with: /template create")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
await ui.info("Available templates:")
|
|
66
|
+
for name in template_names:
|
|
67
|
+
template = loader.load_template(name)
|
|
68
|
+
if template:
|
|
69
|
+
tools_count = len(template.allowed_tools) if template.allowed_tools else 0
|
|
70
|
+
shortcut_info = f" ({template.shortcut})" if template.shortcut else ""
|
|
71
|
+
await ui.muted(
|
|
72
|
+
f" • {name}{shortcut_info}: {template.description} ({tools_count} tools)"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def _load_template(self, name: str, context: CommandContext) -> None:
|
|
76
|
+
"""Load and activate a template."""
|
|
77
|
+
loader = TemplateLoader()
|
|
78
|
+
template = loader.load_template(name)
|
|
79
|
+
|
|
80
|
+
if not template:
|
|
81
|
+
await ui.error(f"Template '{name}' not found")
|
|
82
|
+
await ui.muted("Use '/template list' to see available templates")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Set active template in tool handler
|
|
86
|
+
if hasattr(context.state_manager, "tool_handler") and context.state_manager.tool_handler:
|
|
87
|
+
context.state_manager.tool_handler.set_active_template(template)
|
|
88
|
+
else:
|
|
89
|
+
await ui.error("Tool handler not initialized. Please restart TunaCode.")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
await ui.success(f"Loaded template: {template.name}")
|
|
93
|
+
await ui.muted(f"Description: {template.description}")
|
|
94
|
+
|
|
95
|
+
if template.shortcut:
|
|
96
|
+
await ui.muted(f"Shortcut: {template.shortcut}")
|
|
97
|
+
|
|
98
|
+
if template.allowed_tools:
|
|
99
|
+
await ui.info(f"Allowed tools ({len(template.allowed_tools)}):")
|
|
100
|
+
tools_str = ", ".join(template.allowed_tools)
|
|
101
|
+
await ui.muted(f" {tools_str}")
|
|
102
|
+
|
|
103
|
+
# Execute the prompt if one is defined
|
|
104
|
+
if template.prompt:
|
|
105
|
+
await ui.info("Template has a default prompt:")
|
|
106
|
+
await ui.muted(f" {template.prompt}")
|
|
107
|
+
await ui.muted("Submit this prompt to execute it")
|
|
108
|
+
|
|
109
|
+
async def _create_template(self) -> None:
|
|
110
|
+
"""Create a new template interactively."""
|
|
111
|
+
await ui.info("Interactive template creation is not yet implemented")
|
|
112
|
+
await ui.muted("Please create templates manually as JSON files in:")
|
|
113
|
+
await ui.muted(" ~/.config/tunacode/templates/")
|
|
114
|
+
await ui.muted("")
|
|
115
|
+
await ui.muted("Example template format:")
|
|
116
|
+
await ui.muted("{")
|
|
117
|
+
await ui.muted(' "name": "debug",')
|
|
118
|
+
await ui.muted(' "description": "Debugging and analysis",')
|
|
119
|
+
await ui.muted(' "shortcut": "/debug",')
|
|
120
|
+
await ui.muted(' "prompt": "Debug the following issue: {argument}",')
|
|
121
|
+
await ui.muted(' "allowed_tools": ["read_file", "grep", "list_dir", "run_command"]')
|
|
122
|
+
await ui.muted("}")
|
|
123
|
+
|
|
124
|
+
# TODO: Implement interactive creation when proper input handling is available
|
|
125
|
+
|
|
126
|
+
async def _clear_template(self, context: CommandContext) -> None:
|
|
127
|
+
"""Clear the currently active template."""
|
|
128
|
+
if hasattr(context.state_manager, "tool_handler") and context.state_manager.tool_handler:
|
|
129
|
+
context.state_manager.tool_handler.set_active_template(None)
|
|
130
|
+
await ui.success("Cleared active template")
|
|
131
|
+
else:
|
|
132
|
+
await ui.error("Unable to clear template - tool handler not found")
|
|
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|
|
4
4
|
from typing import Any, Dict, List, Optional, Type
|
|
5
5
|
|
|
6
6
|
from ...exceptions import ValidationError
|
|
7
|
+
from ...templates.loader import TemplateLoader
|
|
7
8
|
from ...types import CommandArgs, CommandContext, ProcessRequestCallback
|
|
8
9
|
from .base import Command, CommandCategory
|
|
9
10
|
|
|
@@ -26,7 +27,9 @@ from .implementations.system import (
|
|
|
26
27
|
StreamingCommand,
|
|
27
28
|
UpdateCommand,
|
|
28
29
|
)
|
|
30
|
+
from .implementations.template import TemplateCommand
|
|
29
31
|
from .implementations.todo import TodoCommand
|
|
32
|
+
from .template_shortcut import TemplateShortcutCommand
|
|
30
33
|
|
|
31
34
|
|
|
32
35
|
@dataclass
|
|
@@ -71,6 +74,7 @@ class CommandRegistry:
|
|
|
71
74
|
}
|
|
72
75
|
self._factory = factory or CommandFactory()
|
|
73
76
|
self._discovered = False
|
|
77
|
+
self._shortcuts_loaded = False
|
|
74
78
|
|
|
75
79
|
# Set registry reference in factory dependencies
|
|
76
80
|
self._factory.update_dependencies(command_registry=self)
|
|
@@ -120,12 +124,13 @@ class CommandRegistry:
|
|
|
120
124
|
CompactCommand,
|
|
121
125
|
ModelCommand,
|
|
122
126
|
InitCommand,
|
|
127
|
+
TemplateCommand,
|
|
123
128
|
TodoCommand,
|
|
124
129
|
]
|
|
125
130
|
|
|
126
131
|
# Register all discovered commands
|
|
127
132
|
for command_class in command_classes:
|
|
128
|
-
self.register_command_class(command_class)
|
|
133
|
+
self.register_command_class(command_class) # type: ignore[arg-type]
|
|
129
134
|
|
|
130
135
|
self._discovered = True
|
|
131
136
|
|
|
@@ -133,6 +138,26 @@ class CommandRegistry:
|
|
|
133
138
|
"""Register all default commands (backward compatibility)."""
|
|
134
139
|
self.discover_commands()
|
|
135
140
|
|
|
141
|
+
def load_template_shortcuts(self) -> None:
|
|
142
|
+
"""Load and register template shortcuts dynamically."""
|
|
143
|
+
if self._shortcuts_loaded:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
loader = TemplateLoader()
|
|
148
|
+
shortcuts = loader.get_templates_with_shortcuts()
|
|
149
|
+
|
|
150
|
+
for shortcut, template in shortcuts.items():
|
|
151
|
+
# Create a template shortcut command instance
|
|
152
|
+
shortcut_command = TemplateShortcutCommand(template)
|
|
153
|
+
self.register(shortcut_command)
|
|
154
|
+
|
|
155
|
+
self._shortcuts_loaded = True
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
# Don't fail if templates can't be loaded
|
|
159
|
+
print(f"Warning: Failed to load template shortcuts: {str(e)}")
|
|
160
|
+
|
|
136
161
|
def set_process_request_callback(self, callback: ProcessRequestCallback) -> None:
|
|
137
162
|
"""Set the process_request callback for commands that need it."""
|
|
138
163
|
# Only update if callback has changed
|
|
@@ -161,6 +186,8 @@ class CommandRegistry:
|
|
|
161
186
|
"""
|
|
162
187
|
# Ensure commands are discovered
|
|
163
188
|
self.discover_commands()
|
|
189
|
+
# Load template shortcuts
|
|
190
|
+
self.load_template_shortcuts()
|
|
164
191
|
|
|
165
192
|
parts = command_text.split()
|
|
166
193
|
if not parts:
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Template shortcut command base class for dynamic template-based commands."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from ...templates.loader import Template
|
|
6
|
+
from ...types import CommandArgs, CommandContext
|
|
7
|
+
from ...ui import console as ui
|
|
8
|
+
from .base import Command, CommandCategory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TemplateShortcutCommand(Command):
|
|
12
|
+
"""Base class for template shortcut commands that auto-load templates and process arguments."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, template: Template):
|
|
15
|
+
"""Initialize with a specific template.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
template: The template instance this shortcut represents
|
|
19
|
+
"""
|
|
20
|
+
self.template = template
|
|
21
|
+
self._name = template.shortcut.lstrip("/") if template.shortcut else template.name
|
|
22
|
+
self._aliases = [template.shortcut] if template.shortcut else []
|
|
23
|
+
self._description = f"{template.description} (Template: {template.name})"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def name(self) -> str:
|
|
27
|
+
"""The primary name of the command."""
|
|
28
|
+
return self._name
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def aliases(self) -> List[str]:
|
|
32
|
+
"""Alternative names for the command."""
|
|
33
|
+
return self._aliases
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def description(self) -> str:
|
|
37
|
+
"""Description of what the command does."""
|
|
38
|
+
return self._description
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def category(self) -> CommandCategory:
|
|
42
|
+
"""Category this command belongs to."""
|
|
43
|
+
return CommandCategory.DEVELOPMENT
|
|
44
|
+
|
|
45
|
+
async def execute(self, args: CommandArgs, context: CommandContext) -> Optional[str]:
|
|
46
|
+
"""Execute the template shortcut command.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
args: Command arguments passed by the user
|
|
50
|
+
context: Command execution context
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Optional string result
|
|
54
|
+
"""
|
|
55
|
+
# Set the template as active in the tool handler
|
|
56
|
+
if hasattr(context.state_manager, "tool_handler") and context.state_manager.tool_handler:
|
|
57
|
+
context.state_manager.tool_handler.set_active_template(self.template)
|
|
58
|
+
else:
|
|
59
|
+
await ui.error("Tool handler not initialized. Please restart TunaCode.")
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
# Show template activation
|
|
63
|
+
await ui.success(f"Activated template: {self.template.name}")
|
|
64
|
+
if self.template.allowed_tools:
|
|
65
|
+
await ui.muted(f"Auto-approved tools: {', '.join(self.template.allowed_tools)}")
|
|
66
|
+
|
|
67
|
+
# Process the prompt with arguments
|
|
68
|
+
if self.template.prompt:
|
|
69
|
+
# Join all arguments as a single string
|
|
70
|
+
argument = " ".join(args) if args else ""
|
|
71
|
+
|
|
72
|
+
# Substitute {argument} placeholder in the prompt
|
|
73
|
+
prompt = self.template.prompt.replace("{argument}", argument)
|
|
74
|
+
|
|
75
|
+
# If there are additional parameters, substitute them too
|
|
76
|
+
if self.template.parameters:
|
|
77
|
+
for key, value in self.template.parameters.items():
|
|
78
|
+
placeholder = f"{{{key}}}"
|
|
79
|
+
prompt = prompt.replace(placeholder, value)
|
|
80
|
+
|
|
81
|
+
# Show the expanded prompt
|
|
82
|
+
await ui.info("Executing prompt:")
|
|
83
|
+
await ui.muted(f" {prompt}")
|
|
84
|
+
|
|
85
|
+
# Execute the prompt via process_request
|
|
86
|
+
if context.process_request:
|
|
87
|
+
await context.process_request(
|
|
88
|
+
prompt, context.state_manager.session.current_model, context.state_manager
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
await ui.muted("Template activated. No default prompt defined.")
|
|
92
|
+
|
|
93
|
+
return None
|
tunacode/cli/main.py
CHANGED
|
@@ -12,6 +12,7 @@ import typer
|
|
|
12
12
|
from tunacode.cli.repl import repl
|
|
13
13
|
from tunacode.configuration.settings import ApplicationSettings
|
|
14
14
|
from tunacode.core.state import StateManager
|
|
15
|
+
from tunacode.core.tool_handler import ToolHandler
|
|
15
16
|
from tunacode.exceptions import UserAbortError
|
|
16
17
|
from tunacode.setup import setup
|
|
17
18
|
from tunacode.ui import console as ui
|
|
@@ -60,6 +61,11 @@ def main(
|
|
|
60
61
|
|
|
61
62
|
try:
|
|
62
63
|
await setup(run_setup, state_manager, cli_config)
|
|
64
|
+
|
|
65
|
+
# Initialize ToolHandler after setup
|
|
66
|
+
tool_handler = ToolHandler(state_manager)
|
|
67
|
+
state_manager.set_tool_handler(tool_handler)
|
|
68
|
+
|
|
63
69
|
await repl(state_manager)
|
|
64
70
|
except (KeyboardInterrupt, UserAbortError):
|
|
65
71
|
update_task.cancel()
|
tunacode/cli/repl.py
CHANGED
|
@@ -9,7 +9,6 @@ Handles user input, command processing, and agent interaction in an interactive
|
|
|
9
9
|
# IMPORTS AND DEPENDENCIES
|
|
10
10
|
# ============================================================================
|
|
11
11
|
|
|
12
|
-
import json
|
|
13
12
|
import logging
|
|
14
13
|
import os
|
|
15
14
|
import subprocess
|
|
@@ -23,28 +22,23 @@ from pydantic_ai.exceptions import UnexpectedModelBehavior
|
|
|
23
22
|
from tunacode.constants import DEFAULT_CONTEXT_WINDOW
|
|
24
23
|
from tunacode.core.agents import main as agent
|
|
25
24
|
from tunacode.core.agents.main import patch_tool_messages
|
|
26
|
-
from tunacode.core.tool_handler import ToolHandler
|
|
27
25
|
from tunacode.exceptions import AgentError, UserAbortError, ValidationError
|
|
28
26
|
from tunacode.ui import console as ui
|
|
29
27
|
from tunacode.ui.output import get_context_window_display
|
|
30
|
-
from tunacode.ui.tool_ui import ToolUI
|
|
31
28
|
from tunacode.utils.security import CommandSecurityError, safe_subprocess_run
|
|
32
29
|
|
|
33
|
-
from ..types import CommandContext, CommandResult, StateManager
|
|
30
|
+
from ..types import CommandContext, CommandResult, StateManager
|
|
34
31
|
from .commands import CommandRegistry
|
|
35
32
|
|
|
36
33
|
# ============================================================================
|
|
37
34
|
# MODULE-LEVEL CONSTANTS AND CONFIGURATION
|
|
38
35
|
# ============================================================================
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
from .repl_components import attempt_tool_recovery, display_agent_output, tool_handler
|
|
37
|
+
from .repl_components.output_display import MSG_REQUEST_COMPLETED
|
|
41
38
|
|
|
42
39
|
MSG_OPERATION_ABORTED = "Operation aborted."
|
|
43
|
-
MSG_OPERATION_ABORTED_BY_USER = "Operation aborted by user."
|
|
44
40
|
MSG_TOOL_INTERRUPTED = "Tool execution was interrupted"
|
|
45
41
|
MSG_REQUEST_CANCELLED = "Request cancelled"
|
|
46
|
-
MSG_REQUEST_COMPLETED = "Request completed"
|
|
47
|
-
MSG_JSON_RECOVERY = "Recovered using JSON tool parsing"
|
|
48
42
|
MSG_SESSION_ENDED = "Session ended. Happy coding!"
|
|
49
43
|
MSG_AGENT_BUSY = "Agent is busy, press Ctrl+C to interrupt."
|
|
50
44
|
MSG_HIT_CTRL_C = "Hit Ctrl+C again to exit"
|
|
@@ -54,92 +48,8 @@ DEFAULT_SHELL = "bash"
|
|
|
54
48
|
# Configure logging
|
|
55
49
|
logger = logging.getLogger(__name__)
|
|
56
50
|
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
# ============================================================================
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _parse_args(args) -> ToolArgs:
|
|
63
|
-
"""
|
|
64
|
-
Parse tool arguments from a JSON string or dictionary.
|
|
65
|
-
|
|
66
|
-
Args:
|
|
67
|
-
args (str or dict): A JSON-formatted string or a dictionary containing tool arguments.
|
|
68
|
-
|
|
69
|
-
Returns:
|
|
70
|
-
dict: The parsed arguments.
|
|
71
|
-
|
|
72
|
-
Raises:
|
|
73
|
-
ValueError: If 'args' is not a string or dictionary, or if the string is not valid JSON.
|
|
74
|
-
"""
|
|
75
|
-
if isinstance(args, str):
|
|
76
|
-
try:
|
|
77
|
-
return json.loads(args)
|
|
78
|
-
except json.JSONDecodeError:
|
|
79
|
-
raise ValidationError(f"Invalid JSON: {args}")
|
|
80
|
-
elif isinstance(args, dict):
|
|
81
|
-
return args
|
|
82
|
-
else:
|
|
83
|
-
raise ValidationError(f"Invalid args type: {type(args)}")
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
# ============================================================================
|
|
87
|
-
# TOOL EXECUTION AND CONFIRMATION HANDLERS
|
|
88
|
-
# ============================================================================
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
async def _tool_handler(part, state_manager: StateManager):
|
|
92
|
-
"""Handle tool execution with separated business logic and UI."""
|
|
93
|
-
# Check for cancellation before tool execution (only if explicitly set to True)
|
|
94
|
-
operation_cancelled = getattr(state_manager.session, "operation_cancelled", False)
|
|
95
|
-
if operation_cancelled is True:
|
|
96
|
-
logger.debug("Tool execution cancelled")
|
|
97
|
-
raise CancelledError("Operation was cancelled")
|
|
98
|
-
|
|
99
|
-
tool_handler = ToolHandler(state_manager)
|
|
100
|
-
|
|
101
|
-
if tool_handler.should_confirm(part.tool_name):
|
|
102
|
-
await ui.info(f"Tool({part.tool_name})")
|
|
103
|
-
|
|
104
|
-
if not state_manager.session.is_streaming_active and state_manager.session.spinner:
|
|
105
|
-
state_manager.session.spinner.stop()
|
|
106
|
-
|
|
107
|
-
streaming_panel = None
|
|
108
|
-
if state_manager.session.is_streaming_active and hasattr(
|
|
109
|
-
state_manager.session, "streaming_panel"
|
|
110
|
-
):
|
|
111
|
-
streaming_panel = state_manager.session.streaming_panel
|
|
112
|
-
if streaming_panel and tool_handler.should_confirm(part.tool_name):
|
|
113
|
-
await streaming_panel.stop()
|
|
114
|
-
|
|
115
|
-
try:
|
|
116
|
-
args = _parse_args(part.args)
|
|
117
|
-
|
|
118
|
-
def confirm_func():
|
|
119
|
-
if not tool_handler.should_confirm(part.tool_name):
|
|
120
|
-
return False
|
|
121
|
-
request = tool_handler.create_confirmation_request(part.tool_name, args)
|
|
122
|
-
|
|
123
|
-
response = _tool_ui.show_sync_confirmation(request)
|
|
124
|
-
|
|
125
|
-
if not tool_handler.process_confirmation(response, part.tool_name):
|
|
126
|
-
return True # Abort
|
|
127
|
-
return False # Continue
|
|
128
|
-
|
|
129
|
-
should_abort = await run_in_terminal(confirm_func)
|
|
130
|
-
|
|
131
|
-
if should_abort:
|
|
132
|
-
raise UserAbortError("User aborted.")
|
|
133
|
-
|
|
134
|
-
except UserAbortError:
|
|
135
|
-
patch_tool_messages(MSG_OPERATION_ABORTED_BY_USER, state_manager)
|
|
136
|
-
raise
|
|
137
|
-
finally:
|
|
138
|
-
if streaming_panel and tool_handler.should_confirm(part.tool_name):
|
|
139
|
-
await streaming_panel.start()
|
|
140
|
-
|
|
141
|
-
if not state_manager.session.is_streaming_active and state_manager.session.spinner:
|
|
142
|
-
state_manager.session.spinner.start()
|
|
51
|
+
# The _parse_args function has been moved to repl_components.command_parser
|
|
52
|
+
# The _tool_handler function has been moved to repl_components.tool_executor
|
|
143
53
|
|
|
144
54
|
|
|
145
55
|
# ============================================================================
|
|
@@ -169,82 +79,13 @@ async def _handle_command(command: str, state_manager: StateManager) -> CommandR
|
|
|
169
79
|
return await _command_registry.execute(command, context)
|
|
170
80
|
except ValidationError as e:
|
|
171
81
|
await ui.error(str(e))
|
|
82
|
+
return None
|
|
172
83
|
|
|
173
84
|
|
|
174
|
-
#
|
|
175
|
-
# ERROR RECOVERY
|
|
176
|
-
# ============================================================================
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
async def _attempt_tool_recovery(e: Exception, state_manager: StateManager) -> bool:
|
|
180
|
-
"""
|
|
181
|
-
Attempt to recover from tool calling failures using guard clauses.
|
|
85
|
+
# The _attempt_tool_recovery function has been moved to repl_components.error_recovery
|
|
182
86
|
|
|
183
|
-
Returns:
|
|
184
|
-
bool: True if recovery was successful, False otherwise
|
|
185
|
-
"""
|
|
186
|
-
error_str = str(e).lower()
|
|
187
|
-
tool_keywords = ["tool", "function", "call", "schema"]
|
|
188
|
-
if not any(keyword in error_str for keyword in tool_keywords):
|
|
189
|
-
return False
|
|
190
87
|
|
|
191
|
-
|
|
192
|
-
return False
|
|
193
|
-
|
|
194
|
-
last_msg = state_manager.session.messages[-1]
|
|
195
|
-
if not hasattr(last_msg, "parts"):
|
|
196
|
-
return False
|
|
197
|
-
|
|
198
|
-
for part in last_msg.parts:
|
|
199
|
-
if not hasattr(part, "content") or not isinstance(part.content, str):
|
|
200
|
-
continue
|
|
201
|
-
|
|
202
|
-
try:
|
|
203
|
-
from tunacode.core.agents.main import extract_and_execute_tool_calls
|
|
204
|
-
|
|
205
|
-
def tool_callback_with_state(part, node):
|
|
206
|
-
return _tool_handler(part, state_manager)
|
|
207
|
-
|
|
208
|
-
await extract_and_execute_tool_calls(
|
|
209
|
-
part.content, tool_callback_with_state, state_manager
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
await ui.warning(f" {MSG_JSON_RECOVERY}")
|
|
213
|
-
return True
|
|
214
|
-
|
|
215
|
-
except Exception as e:
|
|
216
|
-
logger.debug(f"Failed to check triple quotes: {e}")
|
|
217
|
-
continue
|
|
218
|
-
|
|
219
|
-
return False
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
# ============================================================================
|
|
223
|
-
# AGENT OUTPUT DISPLAY
|
|
224
|
-
# ============================================================================
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
async def _display_agent_output(res, enable_streaming: bool) -> None:
|
|
228
|
-
"""Display agent output using guard clauses to flatten nested conditionals."""
|
|
229
|
-
if enable_streaming:
|
|
230
|
-
return
|
|
231
|
-
|
|
232
|
-
if not hasattr(res, "result") or res.result is None or not hasattr(res.result, "output"):
|
|
233
|
-
await ui.muted(MSG_REQUEST_COMPLETED)
|
|
234
|
-
return
|
|
235
|
-
|
|
236
|
-
output = res.result.output
|
|
237
|
-
|
|
238
|
-
if not isinstance(output, str):
|
|
239
|
-
return
|
|
240
|
-
|
|
241
|
-
if output.strip().startswith('{"thought"'):
|
|
242
|
-
return
|
|
243
|
-
|
|
244
|
-
if '"tool_uses"' in output:
|
|
245
|
-
return
|
|
246
|
-
|
|
247
|
-
await ui.agent(output)
|
|
88
|
+
# The _display_agent_output function has been moved to repl_components.output_display
|
|
248
89
|
|
|
249
90
|
|
|
250
91
|
# ============================================================================
|
|
@@ -254,6 +95,14 @@ async def _display_agent_output(res, enable_streaming: bool) -> None:
|
|
|
254
95
|
|
|
255
96
|
async def process_request(text: str, state_manager: StateManager, output: bool = True):
|
|
256
97
|
"""Process input using the agent, handling cancellation safely."""
|
|
98
|
+
import uuid
|
|
99
|
+
|
|
100
|
+
# Generate a unique ID for this request for correlated logging
|
|
101
|
+
request_id = str(uuid.uuid4())
|
|
102
|
+
logger.debug(
|
|
103
|
+
"Processing new request", extra={"request_id": request_id, "input_text": text[:100]}
|
|
104
|
+
)
|
|
105
|
+
state_manager.session.request_id = request_id
|
|
257
106
|
|
|
258
107
|
# Check for cancellation before starting (only if explicitly set to True)
|
|
259
108
|
operation_cancelled = getattr(state_manager.session, "operation_cancelled", False)
|
|
@@ -274,8 +123,8 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
|
|
|
274
123
|
|
|
275
124
|
start_idx = len(state_manager.session.messages)
|
|
276
125
|
|
|
277
|
-
def tool_callback_with_state(part,
|
|
278
|
-
return
|
|
126
|
+
def tool_callback_with_state(part, _node):
|
|
127
|
+
return tool_handler(part, state_manager)
|
|
279
128
|
|
|
280
129
|
try:
|
|
281
130
|
from tunacode.utils.text_utils import expand_file_refs
|
|
@@ -313,8 +162,8 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
|
|
|
313
162
|
await streaming_panel.update(content)
|
|
314
163
|
|
|
315
164
|
res = await agent.process_request(
|
|
316
|
-
state_manager.session.current_model,
|
|
317
165
|
text,
|
|
166
|
+
state_manager.session.current_model,
|
|
318
167
|
state_manager,
|
|
319
168
|
tool_callback=tool_callback_with_state,
|
|
320
169
|
streaming_callback=streaming_callback,
|
|
@@ -326,8 +175,8 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
|
|
|
326
175
|
else:
|
|
327
176
|
# Use normal agent processing
|
|
328
177
|
res = await agent.process_request(
|
|
329
|
-
state_manager.session.current_model,
|
|
330
178
|
text,
|
|
179
|
+
state_manager.session.current_model,
|
|
331
180
|
state_manager,
|
|
332
181
|
tool_callback=tool_callback_with_state,
|
|
333
182
|
)
|
|
@@ -351,7 +200,7 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
|
|
|
351
200
|
await ui.muted(MSG_REQUEST_COMPLETED)
|
|
352
201
|
else:
|
|
353
202
|
# Use the dedicated function for displaying agent output
|
|
354
|
-
await
|
|
203
|
+
await display_agent_output(res, enable_streaming)
|
|
355
204
|
|
|
356
205
|
# Always show files in context after agent response
|
|
357
206
|
if state_manager.session.files_in_context:
|
|
@@ -369,7 +218,7 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
|
|
|
369
218
|
patch_tool_messages(error_message, state_manager)
|
|
370
219
|
except Exception as e:
|
|
371
220
|
# Try tool recovery for tool-related errors
|
|
372
|
-
if await
|
|
221
|
+
if await attempt_tool_recovery(e, state_manager):
|
|
373
222
|
return # Successfully recovered
|
|
374
223
|
|
|
375
224
|
agent_error = AgentError(f"Agent processing failed: {str(e)}")
|
|
@@ -439,7 +288,13 @@ async def repl(state_manager: StateManager):
|
|
|
439
288
|
action = await _handle_command(line, state_manager)
|
|
440
289
|
if action == "restart":
|
|
441
290
|
break
|
|
442
|
-
|
|
291
|
+
elif isinstance(action, str) and action:
|
|
292
|
+
# If the command returned a string (e.g., from template shortcut),
|
|
293
|
+
# process it as a prompt
|
|
294
|
+
line = action
|
|
295
|
+
# Fall through to process as normal text
|
|
296
|
+
else:
|
|
297
|
+
continue
|
|
443
298
|
|
|
444
299
|
if line.startswith("!"):
|
|
445
300
|
command = line[1:].strip()
|