tunacode-cli 0.0.55__py3-none-any.whl → 0.0.78.6__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/__init__.py +2 -2
- tunacode/cli/commands/implementations/__init__.py +2 -3
- tunacode/cli/commands/implementations/command_reload.py +48 -0
- tunacode/cli/commands/implementations/debug.py +2 -2
- tunacode/cli/commands/implementations/development.py +10 -8
- tunacode/cli/commands/implementations/model.py +357 -29
- tunacode/cli/commands/implementations/quickstart.py +43 -0
- tunacode/cli/commands/implementations/system.py +96 -3
- tunacode/cli/commands/implementations/template.py +0 -2
- tunacode/cli/commands/registry.py +139 -5
- tunacode/cli/commands/slash/__init__.py +32 -0
- tunacode/cli/commands/slash/command.py +157 -0
- tunacode/cli/commands/slash/loader.py +135 -0
- tunacode/cli/commands/slash/processor.py +294 -0
- tunacode/cli/commands/slash/types.py +93 -0
- tunacode/cli/commands/slash/validator.py +400 -0
- tunacode/cli/main.py +23 -2
- tunacode/cli/repl.py +217 -190
- tunacode/cli/repl_components/command_parser.py +38 -4
- tunacode/cli/repl_components/error_recovery.py +85 -4
- tunacode/cli/repl_components/output_display.py +12 -1
- tunacode/cli/repl_components/tool_executor.py +1 -1
- tunacode/configuration/defaults.py +12 -3
- tunacode/configuration/key_descriptions.py +284 -0
- tunacode/configuration/settings.py +0 -1
- tunacode/constants.py +12 -40
- tunacode/core/agents/__init__.py +43 -2
- tunacode/core/agents/agent_components/__init__.py +7 -0
- tunacode/core/agents/agent_components/agent_config.py +249 -55
- tunacode/core/agents/agent_components/agent_helpers.py +43 -13
- tunacode/core/agents/agent_components/node_processor.py +179 -139
- tunacode/core/agents/agent_components/response_state.py +123 -6
- tunacode/core/agents/agent_components/state_transition.py +116 -0
- tunacode/core/agents/agent_components/streaming.py +296 -0
- tunacode/core/agents/agent_components/task_completion.py +19 -6
- tunacode/core/agents/agent_components/tool_buffer.py +21 -1
- tunacode/core/agents/agent_components/tool_executor.py +10 -0
- tunacode/core/agents/main.py +522 -370
- tunacode/core/agents/main_legact.py +538 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/utils.py +29 -121
- tunacode/core/code_index.py +83 -29
- tunacode/core/setup/__init__.py +0 -2
- tunacode/core/setup/config_setup.py +110 -20
- tunacode/core/setup/config_wizard.py +230 -0
- tunacode/core/setup/coordinator.py +14 -5
- tunacode/core/state.py +16 -20
- tunacode/core/token_usage/usage_tracker.py +5 -3
- tunacode/core/tool_authorization.py +352 -0
- tunacode/core/tool_handler.py +67 -40
- tunacode/exceptions.py +119 -5
- tunacode/prompts/system.xml +751 -0
- tunacode/services/mcp.py +125 -7
- tunacode/setup.py +5 -25
- tunacode/tools/base.py +163 -0
- tunacode/tools/bash.py +110 -1
- tunacode/tools/glob.py +332 -34
- tunacode/tools/grep.py +179 -82
- tunacode/tools/grep_components/result_formatter.py +98 -4
- tunacode/tools/list_dir.py +132 -2
- tunacode/tools/prompts/bash_prompt.xml +72 -0
- tunacode/tools/prompts/glob_prompt.xml +45 -0
- tunacode/tools/prompts/grep_prompt.xml +98 -0
- tunacode/tools/prompts/list_dir_prompt.xml +31 -0
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/prompts/read_file_prompt.xml +54 -0
- tunacode/tools/prompts/run_command_prompt.xml +64 -0
- tunacode/tools/prompts/update_file_prompt.xml +53 -0
- tunacode/tools/prompts/write_file_prompt.xml +37 -0
- tunacode/tools/react.py +153 -0
- tunacode/tools/read_file.py +91 -0
- tunacode/tools/run_command.py +114 -0
- tunacode/tools/schema_assembler.py +167 -0
- tunacode/tools/update_file.py +94 -0
- tunacode/tools/write_file.py +86 -0
- tunacode/tools/xml_helper.py +83 -0
- tunacode/tutorial/__init__.py +9 -0
- tunacode/tutorial/content.py +98 -0
- tunacode/tutorial/manager.py +182 -0
- tunacode/tutorial/steps.py +124 -0
- tunacode/types.py +20 -27
- tunacode/ui/completers.py +434 -50
- tunacode/ui/config_dashboard.py +585 -0
- tunacode/ui/console.py +63 -11
- tunacode/ui/input.py +20 -3
- tunacode/ui/keybindings.py +7 -4
- tunacode/ui/model_selector.py +395 -0
- tunacode/ui/output.py +40 -19
- tunacode/ui/panels.py +212 -43
- tunacode/ui/path_heuristics.py +91 -0
- tunacode/ui/prompt_manager.py +5 -1
- tunacode/ui/tool_ui.py +33 -10
- tunacode/utils/api_key_validation.py +93 -0
- tunacode/utils/config_comparator.py +340 -0
- tunacode/utils/json_utils.py +206 -0
- tunacode/utils/message_utils.py +14 -4
- tunacode/utils/models_registry.py +593 -0
- tunacode/utils/ripgrep.py +332 -9
- tunacode/utils/text_utils.py +18 -1
- tunacode/utils/user_configuration.py +45 -0
- tunacode_cli-0.0.78.6.dist-info/METADATA +260 -0
- tunacode_cli-0.0.78.6.dist-info/RECORD +158 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +1 -2
- tunacode/cli/commands/implementations/todo.py +0 -217
- tunacode/context.py +0 -71
- tunacode/core/setup/git_safety_setup.py +0 -182
- tunacode/prompts/system.md +0 -731
- tunacode/tools/read_file_async_poc.py +0 -196
- tunacode/tools/todo.py +0 -349
- tunacode_cli-0.0.55.dist-info/METADATA +0 -322
- tunacode_cli-0.0.55.dist-info/RECORD +0 -126
- tunacode_cli-0.0.55.dist-info/top_level.txt +0 -1
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
tunacode/cli/repl.py
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Module: tunacode.cli.repl
|
|
3
|
-
|
|
4
|
-
Interactive REPL (Read-Eval-Print Loop) implementation for TunaCode.
|
|
5
|
-
Handles user input, command processing, and agent interaction in an interactive shell.
|
|
6
|
-
|
|
7
|
-
CLAUDE_ANCHOR[repl-module]: Core REPL loop and user interaction handling
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
# ============================================================================
|
|
11
|
-
# IMPORTS AND DEPENDENCIES
|
|
12
|
-
# ============================================================================
|
|
1
|
+
"""Interactive REPL implementation for TunaCode."""
|
|
13
2
|
|
|
3
|
+
import asyncio
|
|
14
4
|
import logging
|
|
15
5
|
import os
|
|
16
6
|
import subprocess
|
|
@@ -21,20 +11,20 @@ from prompt_toolkit.application import run_in_terminal
|
|
|
21
11
|
from prompt_toolkit.application.current import get_app
|
|
22
12
|
from pydantic_ai.exceptions import UnexpectedModelBehavior
|
|
23
13
|
|
|
14
|
+
from tunacode.configuration.models import ModelRegistry
|
|
24
15
|
from tunacode.constants import DEFAULT_CONTEXT_WINDOW
|
|
25
|
-
from tunacode.core
|
|
26
|
-
from tunacode.core.agents
|
|
27
|
-
from tunacode.
|
|
16
|
+
from tunacode.core import agents as agent
|
|
17
|
+
from tunacode.core.agents import patch_tool_messages
|
|
18
|
+
from tunacode.core.token_usage.api_response_parser import ApiResponseParser
|
|
19
|
+
from tunacode.core.token_usage.cost_calculator import CostCalculator
|
|
20
|
+
from tunacode.core.token_usage.usage_tracker import UsageTracker
|
|
21
|
+
from tunacode.exceptions import UserAbortError, ValidationError
|
|
28
22
|
from tunacode.ui import console as ui
|
|
29
23
|
from tunacode.ui.output import get_context_window_display
|
|
30
24
|
from tunacode.utils.security import CommandSecurityError, safe_subprocess_run
|
|
31
25
|
|
|
32
26
|
from ..types import CommandContext, CommandResult, StateManager
|
|
33
27
|
from .commands import CommandRegistry
|
|
34
|
-
|
|
35
|
-
# ============================================================================
|
|
36
|
-
# MODULE-LEVEL CONSTANTS AND CONFIGURATION
|
|
37
|
-
# ============================================================================
|
|
38
28
|
from .repl_components import attempt_tool_recovery, display_agent_output, tool_handler
|
|
39
29
|
from .repl_components.output_display import MSG_REQUEST_COMPLETED
|
|
40
30
|
|
|
@@ -50,74 +40,81 @@ DEFAULT_SHELL = "bash"
|
|
|
50
40
|
# Configure logging
|
|
51
41
|
logger = logging.getLogger(__name__)
|
|
52
42
|
|
|
53
|
-
# The _parse_args function has been moved to repl_components.command_parser
|
|
54
|
-
# The _tool_handler function has been moved to repl_components.tool_executor
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# ============================================================================
|
|
58
|
-
# COMMAND SYSTEM
|
|
59
|
-
# ============================================================================
|
|
60
43
|
|
|
61
44
|
_command_registry = CommandRegistry()
|
|
62
45
|
_command_registry.register_all_default_commands()
|
|
63
46
|
|
|
64
47
|
|
|
65
48
|
async def _handle_command(command: str, state_manager: StateManager) -> CommandResult:
|
|
66
|
-
"""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
Args:
|
|
70
|
-
command: The command string entered by the user.
|
|
71
|
-
state_manager: The state manager instance.
|
|
72
|
-
|
|
73
|
-
Returns:
|
|
74
|
-
Command result (varies by command).
|
|
75
|
-
"""
|
|
76
|
-
context = CommandContext(state_manager=state_manager, process_request=process_request)
|
|
77
|
-
|
|
49
|
+
"""Handles a command string using the command registry."""
|
|
50
|
+
context = CommandContext(state_manager=state_manager, process_request=execute_repl_request)
|
|
78
51
|
try:
|
|
79
|
-
_command_registry.set_process_request_callback(
|
|
80
|
-
|
|
52
|
+
_command_registry.set_process_request_callback(execute_repl_request)
|
|
81
53
|
return await _command_registry.execute(command, context)
|
|
82
54
|
except ValidationError as e:
|
|
83
55
|
await ui.error(str(e))
|
|
84
56
|
return None
|
|
85
57
|
|
|
86
58
|
|
|
87
|
-
|
|
59
|
+
def _extract_feedback_from_last_message(state_manager: StateManager) -> str | None:
|
|
60
|
+
"""Extract user guidance feedback from recent messages in session.messages.
|
|
61
|
+
|
|
62
|
+
When option 3 is selected with feedback, a message is added with format:
|
|
63
|
+
"Tool '...' execution cancelled before running.\nUser guidance:\n{guidance}\n..."
|
|
88
64
|
|
|
65
|
+
Note: patch_tool_messages() adds "Operation aborted by user." AFTER the feedback,
|
|
66
|
+
so we check the last few messages, not just the last one.
|
|
89
67
|
|
|
90
|
-
|
|
68
|
+
Args:
|
|
69
|
+
state_manager: State manager containing session messages
|
|
91
70
|
|
|
71
|
+
Returns:
|
|
72
|
+
The guidance text if found, None otherwise
|
|
73
|
+
"""
|
|
74
|
+
if not state_manager.session.messages:
|
|
75
|
+
return None
|
|
92
76
|
|
|
93
|
-
#
|
|
94
|
-
|
|
95
|
-
# ============================================================================
|
|
77
|
+
# Check last 3 messages since patch_tool_messages() adds a message after feedback
|
|
78
|
+
messages_to_check = state_manager.session.messages[-3:]
|
|
96
79
|
|
|
80
|
+
for msg in reversed(messages_to_check):
|
|
81
|
+
# Extract content from message parts
|
|
82
|
+
if not hasattr(msg, "parts"):
|
|
83
|
+
continue
|
|
97
84
|
|
|
98
|
-
|
|
99
|
-
|
|
85
|
+
for part in msg.parts:
|
|
86
|
+
if hasattr(part, "content") and isinstance(part.content, str):
|
|
87
|
+
content = part.content
|
|
100
88
|
|
|
101
|
-
|
|
102
|
-
|
|
89
|
+
# Look for "User guidance:" pattern
|
|
90
|
+
if "User guidance:" in content:
|
|
91
|
+
lines = content.split("\n")
|
|
92
|
+
for i, line in enumerate(lines):
|
|
93
|
+
if "User guidance:" in line and i + 1 < len(lines):
|
|
94
|
+
guidance = lines[i + 1].strip()
|
|
95
|
+
# Only return non-empty guidance
|
|
96
|
+
cancelled_msg = "User cancelled without additional instructions."
|
|
97
|
+
if guidance and guidance != cancelled_msg:
|
|
98
|
+
return guidance
|
|
99
|
+
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def execute_repl_request(text: str, state_manager: StateManager, output: bool = True):
|
|
104
|
+
"""Process input using the agent, handling cancellation safely."""
|
|
103
105
|
import uuid
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"Processing new request", extra={"request_id": request_id, "input_text": text[:100]}
|
|
109
|
-
)
|
|
110
|
-
state_manager.session.request_id = request_id
|
|
107
|
+
from tunacode.utils.text_utils import expand_file_refs
|
|
108
|
+
|
|
109
|
+
state_manager.session.request_id = str(uuid.uuid4())
|
|
111
110
|
|
|
112
|
-
|
|
113
|
-
operation_cancelled = getattr(state_manager.session, "operation_cancelled", False)
|
|
114
|
-
if operation_cancelled is True:
|
|
115
|
-
logger.debug("Operation cancelled before processing started")
|
|
111
|
+
if getattr(state_manager.session, "operation_cancelled", False) is True:
|
|
116
112
|
raise CancelledError("Operation was cancelled")
|
|
117
113
|
|
|
118
114
|
state_manager.session.spinner = await ui.spinner(
|
|
119
115
|
True, state_manager.session.spinner, state_manager
|
|
120
116
|
)
|
|
117
|
+
|
|
121
118
|
try:
|
|
122
119
|
patch_tool_messages(MSG_TOOL_INTERRUPTED, state_manager)
|
|
123
120
|
|
|
@@ -128,147 +125,191 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
|
|
|
128
125
|
|
|
129
126
|
start_idx = len(state_manager.session.messages)
|
|
130
127
|
|
|
131
|
-
def tool_callback_with_state(part,
|
|
128
|
+
def tool_callback_with_state(part, _):
|
|
132
129
|
return tool_handler(part, state_manager)
|
|
133
130
|
|
|
134
131
|
try:
|
|
135
|
-
from tunacode.utils.text_utils import expand_file_refs
|
|
136
|
-
|
|
137
132
|
text, referenced_files = expand_file_refs(text)
|
|
138
|
-
|
|
139
|
-
state_manager.session.files_in_context.add(file_path)
|
|
133
|
+
state_manager.session.files_in_context.update(referenced_files)
|
|
140
134
|
except ValueError as e:
|
|
141
135
|
await ui.error(str(e))
|
|
142
136
|
return
|
|
143
137
|
|
|
144
|
-
|
|
145
|
-
operation_cancelled = getattr(state_manager.session, "operation_cancelled", False)
|
|
146
|
-
if operation_cancelled is True:
|
|
147
|
-
logger.debug("Operation cancelled before agent processing")
|
|
138
|
+
if getattr(state_manager.session, "operation_cancelled", False) is True:
|
|
148
139
|
raise CancelledError("Operation was cancelled")
|
|
149
140
|
|
|
150
141
|
enable_streaming = state_manager.session.user_config.get("settings", {}).get(
|
|
151
142
|
"enable_streaming", True
|
|
152
143
|
)
|
|
153
144
|
|
|
145
|
+
# Create UsageTracker to ensure session cost tracking
|
|
146
|
+
model_registry = ModelRegistry()
|
|
147
|
+
parser = ApiResponseParser()
|
|
148
|
+
calculator = CostCalculator(model_registry)
|
|
149
|
+
usage_tracker = UsageTracker(parser, calculator, state_manager)
|
|
150
|
+
|
|
154
151
|
if enable_streaming:
|
|
155
152
|
await ui.spinner(False, state_manager.session.spinner, state_manager)
|
|
156
|
-
|
|
157
153
|
state_manager.session.is_streaming_active = True
|
|
158
|
-
|
|
159
|
-
|
|
154
|
+
streaming_panel = ui.StreamingAgentPanel(
|
|
155
|
+
debug=bool(state_manager.session.show_thoughts)
|
|
156
|
+
)
|
|
160
157
|
await streaming_panel.start()
|
|
161
|
-
|
|
162
158
|
state_manager.session.streaming_panel = streaming_panel
|
|
163
159
|
|
|
164
160
|
try:
|
|
165
|
-
|
|
166
|
-
async def streaming_callback(content: str):
|
|
167
|
-
await streaming_panel.update(content)
|
|
168
|
-
|
|
169
161
|
res = await agent.process_request(
|
|
170
162
|
text,
|
|
171
163
|
state_manager.session.current_model,
|
|
172
164
|
state_manager,
|
|
173
165
|
tool_callback=tool_callback_with_state,
|
|
174
|
-
streaming_callback=
|
|
166
|
+
streaming_callback=lambda content: streaming_panel.update(content),
|
|
167
|
+
usage_tracker=usage_tracker,
|
|
175
168
|
)
|
|
176
169
|
finally:
|
|
177
170
|
await streaming_panel.stop()
|
|
178
171
|
state_manager.session.streaming_panel = None
|
|
179
172
|
state_manager.session.is_streaming_active = False
|
|
173
|
+
# Emit source-side streaming diagnostics if thoughts are enabled
|
|
174
|
+
if state_manager.session.show_thoughts:
|
|
175
|
+
try:
|
|
176
|
+
raw = getattr(state_manager.session, "_debug_raw_stream_accum", "") or ""
|
|
177
|
+
events = getattr(state_manager.session, "_debug_events", []) or []
|
|
178
|
+
raw_first5 = repr(raw[:5])
|
|
179
|
+
await ui.muted(
|
|
180
|
+
f"[debug] raw_stream_first5={raw_first5} total_len={len(raw)}"
|
|
181
|
+
)
|
|
182
|
+
for line in events:
|
|
183
|
+
await ui.muted(line)
|
|
184
|
+
except Exception:
|
|
185
|
+
# Don't let diagnostics break normal flow
|
|
186
|
+
pass
|
|
180
187
|
else:
|
|
181
|
-
# Use normal agent processing
|
|
182
188
|
res = await agent.process_request(
|
|
183
189
|
text,
|
|
184
190
|
state_manager.session.current_model,
|
|
185
191
|
state_manager,
|
|
186
192
|
tool_callback=tool_callback_with_state,
|
|
193
|
+
usage_tracker=usage_tracker,
|
|
187
194
|
)
|
|
188
195
|
|
|
189
196
|
if output:
|
|
190
197
|
if state_manager.session.show_thoughts:
|
|
191
|
-
|
|
192
|
-
for msg in new_msgs:
|
|
198
|
+
for msg in state_manager.session.messages[start_idx:]:
|
|
193
199
|
if isinstance(msg, dict) and "thought" in msg:
|
|
194
200
|
await ui.muted(f"THOUGHT: {msg['thought']}")
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
# Fallback: show that the request was processed
|
|
205
|
-
await ui.muted(MSG_REQUEST_COMPLETED)
|
|
206
|
-
else:
|
|
207
|
-
# Use the dedicated function for displaying agent output
|
|
208
|
-
await display_agent_output(res, enable_streaming)
|
|
209
|
-
|
|
210
|
-
# Always show files in context after agent response
|
|
201
|
+
if not enable_streaming:
|
|
202
|
+
if (
|
|
203
|
+
not hasattr(res, "result")
|
|
204
|
+
or res.result is None
|
|
205
|
+
or not hasattr(res.result, "output")
|
|
206
|
+
):
|
|
207
|
+
await ui.muted(MSG_REQUEST_COMPLETED)
|
|
208
|
+
else:
|
|
209
|
+
await display_agent_output(res, enable_streaming, state_manager)
|
|
211
210
|
if state_manager.session.files_in_context:
|
|
212
211
|
filenames = [Path(f).name for f in sorted(state_manager.session.files_in_context)]
|
|
213
212
|
await ui.muted(f"Files in context: {', '.join(filenames)}")
|
|
214
213
|
|
|
215
|
-
# --- ERROR HANDLING ---
|
|
216
214
|
except CancelledError:
|
|
217
215
|
await ui.muted(MSG_REQUEST_CANCELLED)
|
|
218
216
|
except UserAbortError:
|
|
219
|
-
|
|
217
|
+
# CLAUDE_ANCHOR[7b2c1d4e]: Guided aborts inject user instructions; skip legacy banner.
|
|
218
|
+
# Check if there's feedback to process immediately
|
|
219
|
+
feedback = _extract_feedback_from_last_message(state_manager)
|
|
220
|
+
if feedback:
|
|
221
|
+
# Process the feedback as a new request immediately
|
|
222
|
+
# Stop spinner first to clean up state before recursive call
|
|
223
|
+
await ui.spinner(False, state_manager.session.spinner, state_manager)
|
|
224
|
+
# Clear current_task so recursive call can set its own
|
|
225
|
+
state_manager.session.current_task = None
|
|
226
|
+
try:
|
|
227
|
+
await execute_repl_request(feedback, state_manager, output=output)
|
|
228
|
+
except Exception:
|
|
229
|
+
# If recursive call fails, don't let it bubble up - just continue
|
|
230
|
+
pass
|
|
231
|
+
# Return early to skip the finally block's cleanup (already done above)
|
|
232
|
+
return
|
|
233
|
+
# No feedback, just abort normally
|
|
234
|
+
pass
|
|
220
235
|
except UnexpectedModelBehavior as e:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
patch_tool_messages(error_message, state_manager)
|
|
236
|
+
await ui.muted(str(e))
|
|
237
|
+
patch_tool_messages(str(e), state_manager)
|
|
224
238
|
except Exception as e:
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return # Successfully recovered
|
|
228
|
-
|
|
229
|
-
agent_error = AgentError(f"Agent processing failed: {str(e)}")
|
|
230
|
-
agent_error.__cause__ = e # Preserve the original exception chain
|
|
231
|
-
await ui.error(str(e))
|
|
239
|
+
if not await attempt_tool_recovery(e, state_manager):
|
|
240
|
+
await ui.error(str(e))
|
|
232
241
|
finally:
|
|
233
242
|
await ui.spinner(False, state_manager.session.spinner, state_manager)
|
|
234
243
|
state_manager.session.current_task = None
|
|
235
|
-
# Reset cancellation flag when task completes (if attribute exists)
|
|
236
244
|
if hasattr(state_manager.session, "operation_cancelled"):
|
|
237
245
|
state_manager.session.operation_cancelled = False
|
|
238
|
-
|
|
239
246
|
if "multiline" in state_manager.session.input_sessions:
|
|
240
247
|
await run_in_terminal(
|
|
241
248
|
lambda: state_manager.session.input_sessions["multiline"].app.invalidate()
|
|
242
249
|
)
|
|
243
250
|
|
|
244
251
|
|
|
245
|
-
#
|
|
246
|
-
|
|
247
|
-
|
|
252
|
+
# Backwards compatibility: exported name expected by external integrations/tests
|
|
253
|
+
process_request = execute_repl_request
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def warm_code_index():
|
|
257
|
+
"""Pre-warm the code index in background for faster directory operations."""
|
|
258
|
+
try:
|
|
259
|
+
from tunacode.core.code_index import CodeIndex
|
|
260
|
+
|
|
261
|
+
# Build index in thread to avoid blocking
|
|
262
|
+
index = await asyncio.to_thread(lambda: CodeIndex.get_instance())
|
|
263
|
+
await asyncio.to_thread(index.build_index)
|
|
264
|
+
|
|
265
|
+
logger.debug(f"Code index pre-warmed with {len(index._all_files)} files")
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.debug(f"Failed to pre-warm code index: {e}")
|
|
248
268
|
|
|
249
269
|
|
|
250
270
|
async def repl(state_manager: StateManager):
|
|
251
271
|
"""Main REPL loop that handles user interaction and input processing."""
|
|
272
|
+
import time
|
|
273
|
+
|
|
274
|
+
# Start pre-warming code index in background (non-blocking)
|
|
275
|
+
asyncio.create_task(warm_code_index())
|
|
276
|
+
|
|
252
277
|
action = None
|
|
253
278
|
abort_pressed = False
|
|
254
279
|
last_abort_time = 0.0
|
|
255
280
|
|
|
256
|
-
model_name = state_manager.session.current_model
|
|
257
281
|
max_tokens = (
|
|
258
282
|
state_manager.session.user_config.get("context_window_size") or DEFAULT_CONTEXT_WINDOW
|
|
259
283
|
)
|
|
260
284
|
state_manager.session.max_tokens = max_tokens
|
|
261
|
-
|
|
262
285
|
state_manager.session.update_token_count()
|
|
263
|
-
context_display = get_context_window_display(state_manager.session.total_tokens, max_tokens)
|
|
264
286
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
287
|
+
async def show_context():
|
|
288
|
+
context = get_context_window_display(state_manager.session.total_tokens, max_tokens)
|
|
289
|
+
|
|
290
|
+
# Get session cost for display
|
|
291
|
+
session_cost = 0.0
|
|
292
|
+
if state_manager.session.session_total_usage:
|
|
293
|
+
session_cost = float(state_manager.session.session_total_usage.get("cost", 0.0) or 0.0)
|
|
294
|
+
|
|
295
|
+
# Subtle, unified styling - mostly muted with minimal accent on cost
|
|
296
|
+
await ui.muted(f"• Model: {state_manager.session.current_model} • {context}")
|
|
297
|
+
if session_cost > 0:
|
|
298
|
+
await ui.print(
|
|
299
|
+
f"[dim]• Session Cost:[/dim] [dim #00d7ff]${session_cost:.4f}[/dim #00d7ff]"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Always show context
|
|
303
|
+
await show_context()
|
|
304
|
+
|
|
305
|
+
# Show startup message only once
|
|
306
|
+
if not hasattr(state_manager.session, "_startup_shown"):
|
|
268
307
|
await ui.success("Ready to assist")
|
|
269
|
-
await ui.line()
|
|
270
308
|
state_manager.session._startup_shown = True
|
|
271
309
|
|
|
310
|
+
# Offer tutorial to first-time users
|
|
311
|
+
await _offer_tutorial_if_appropriate(state_manager)
|
|
312
|
+
|
|
272
313
|
instance = agent.get_or_create_agent(state_manager.session.current_model, state_manager)
|
|
273
314
|
|
|
274
315
|
async with instance.run_mcp_servers():
|
|
@@ -276,17 +317,11 @@ async def repl(state_manager: StateManager):
|
|
|
276
317
|
try:
|
|
277
318
|
line = await ui.multiline_input(state_manager, _command_registry)
|
|
278
319
|
except UserAbortError:
|
|
279
|
-
import time
|
|
280
|
-
|
|
281
320
|
current_time = time.time()
|
|
282
|
-
|
|
283
|
-
# Reset if more than 3 seconds have passed
|
|
284
321
|
if current_time - last_abort_time > 3.0:
|
|
285
322
|
abort_pressed = False
|
|
286
|
-
|
|
287
323
|
if abort_pressed:
|
|
288
324
|
break
|
|
289
|
-
|
|
290
325
|
abort_pressed = True
|
|
291
326
|
last_abort_time = current_time
|
|
292
327
|
await ui.warning(MSG_HIT_ABORT_KEY)
|
|
@@ -294,7 +329,6 @@ async def repl(state_manager: StateManager):
|
|
|
294
329
|
|
|
295
330
|
if not line:
|
|
296
331
|
continue
|
|
297
|
-
|
|
298
332
|
abort_pressed = False
|
|
299
333
|
|
|
300
334
|
if line.lower() in ["exit", "quit"]:
|
|
@@ -305,96 +339,89 @@ async def repl(state_manager: StateManager):
|
|
|
305
339
|
if action == "restart":
|
|
306
340
|
break
|
|
307
341
|
elif isinstance(action, str) and action:
|
|
308
|
-
# If the command returned a string (e.g., from template shortcut),
|
|
309
|
-
# process it as a prompt
|
|
310
342
|
line = action
|
|
311
|
-
# Fall through to process as normal text
|
|
312
343
|
else:
|
|
313
344
|
continue
|
|
314
345
|
|
|
315
346
|
if line.startswith("!"):
|
|
316
347
|
command = line[1:].strip()
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
348
|
+
await ui.panel(
|
|
349
|
+
"Tool(bash)",
|
|
350
|
+
f"Command: {command or 'Interactive shell'}",
|
|
351
|
+
border_style="yellow",
|
|
352
|
+
)
|
|
320
353
|
|
|
321
354
|
def run_shell():
|
|
322
355
|
try:
|
|
323
356
|
if command:
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
capture_output=False,
|
|
330
|
-
)
|
|
331
|
-
if result.returncode != 0:
|
|
332
|
-
ui.console.print(
|
|
333
|
-
f"\nCommand exited with code {result.returncode}"
|
|
334
|
-
)
|
|
335
|
-
except CommandSecurityError as e:
|
|
336
|
-
ui.console.print(f"\nSecurity validation failed: {str(e)}")
|
|
337
|
-
ui.console.print(
|
|
338
|
-
"If you need to run this command, please ensure it's safe."
|
|
339
|
-
)
|
|
357
|
+
result = safe_subprocess_run(
|
|
358
|
+
command, shell=True, validate=True, capture_output=False
|
|
359
|
+
)
|
|
360
|
+
if result.returncode != 0:
|
|
361
|
+
ui.console.print(f"\nCommand exited with code {result.returncode}")
|
|
340
362
|
else:
|
|
341
|
-
|
|
342
|
-
|
|
363
|
+
subprocess.run(os.environ.get(SHELL_ENV_VAR, DEFAULT_SHELL))
|
|
364
|
+
except CommandSecurityError as e:
|
|
365
|
+
ui.console.print(f"\nSecurity validation failed: {str(e)}")
|
|
343
366
|
except Exception as e:
|
|
344
367
|
ui.console.print(f"\nShell command failed: {str(e)}")
|
|
345
368
|
|
|
346
369
|
await run_in_terminal(run_shell)
|
|
347
|
-
await ui.line()
|
|
348
370
|
continue
|
|
349
371
|
|
|
350
|
-
# --- AGENT REQUEST PROCESSING ---
|
|
351
372
|
if state_manager.session.current_task and not state_manager.session.current_task.done():
|
|
352
373
|
await ui.muted(MSG_AGENT_BUSY)
|
|
353
374
|
continue
|
|
354
375
|
|
|
355
|
-
# Reset cancellation flag for new operations (if attribute exists)
|
|
356
376
|
if hasattr(state_manager.session, "operation_cancelled"):
|
|
357
377
|
state_manager.session.operation_cancelled = False
|
|
358
378
|
|
|
359
379
|
state_manager.session.current_task = get_app().create_background_task(
|
|
360
|
-
|
|
380
|
+
execute_repl_request(line, state_manager)
|
|
361
381
|
)
|
|
362
382
|
await state_manager.session.current_task
|
|
363
383
|
|
|
364
384
|
state_manager.session.update_token_count()
|
|
365
|
-
|
|
366
|
-
state_manager.session.total_tokens, state_manager.session.max_tokens
|
|
367
|
-
)
|
|
368
|
-
# Only show model/context info if thoughts are enabled
|
|
369
|
-
if state_manager.session.show_thoughts:
|
|
370
|
-
await ui.muted(
|
|
371
|
-
f"• Model: {state_manager.session.current_model} • {context_display}"
|
|
372
|
-
)
|
|
373
|
-
|
|
374
|
-
if action == "restart":
|
|
375
|
-
await repl(state_manager)
|
|
376
|
-
else:
|
|
377
|
-
# Show session cost summary if available
|
|
378
|
-
session_total = state_manager.session.session_total_usage
|
|
379
|
-
if session_total:
|
|
380
|
-
try:
|
|
381
|
-
prompt = int(session_total.get("prompt_tokens", 0) or 0)
|
|
382
|
-
completion = int(session_total.get("completion_tokens", 0) or 0)
|
|
383
|
-
total_tokens = prompt + completion
|
|
384
|
-
total_cost = float(session_total.get("cost", 0) or 0)
|
|
385
|
-
|
|
386
|
-
# Only show summary if we have actual token usage
|
|
387
|
-
if state_manager.session.show_thoughts and (total_tokens > 0 or total_cost > 0):
|
|
388
|
-
summary = (
|
|
389
|
-
f"\n[bold cyan]TunaCode Session Summary[/bold cyan]\n"
|
|
390
|
-
f" - Total Tokens: {total_tokens:,}\n"
|
|
391
|
-
f" - Prompt Tokens: {prompt:,}\n"
|
|
392
|
-
f" - Completion Tokens: {completion:,}\n"
|
|
393
|
-
f" - [bold green]Total Session Cost: ${total_cost:.4f}[/bold green]"
|
|
394
|
-
)
|
|
395
|
-
ui.console.print(summary)
|
|
396
|
-
except (TypeError, ValueError) as e:
|
|
397
|
-
# Skip displaying summary if values can't be converted to numbers
|
|
398
|
-
logger.debug(f"Failed to display token usage summary: {e}")
|
|
385
|
+
await show_context()
|
|
399
386
|
|
|
400
|
-
|
|
387
|
+
if action == "restart":
|
|
388
|
+
await repl(state_manager)
|
|
389
|
+
else:
|
|
390
|
+
session_total = state_manager.session.session_total_usage
|
|
391
|
+
if session_total:
|
|
392
|
+
try:
|
|
393
|
+
total_tokens = int(session_total.get("prompt_tokens", 0) or 0) + int(
|
|
394
|
+
session_total.get("completion_tokens", 0) or 0
|
|
395
|
+
)
|
|
396
|
+
total_cost = float(session_total.get("cost", 0) or 0)
|
|
397
|
+
if total_tokens > 0 or total_cost > 0:
|
|
398
|
+
ui.console.print(
|
|
399
|
+
f"\n[bold cyan]TunaCode Session Summary[/bold cyan]\n"
|
|
400
|
+
f" - Total Tokens: {total_tokens:,}\n"
|
|
401
|
+
f" - Total Cost: ${total_cost:.4f}"
|
|
402
|
+
)
|
|
403
|
+
except (TypeError, ValueError):
|
|
404
|
+
pass
|
|
405
|
+
await ui.info(MSG_SESSION_ENDED)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
async def _offer_tutorial_if_appropriate(state_manager: StateManager) -> None:
|
|
409
|
+
"""Offer tutorial to first-time users if appropriate."""
|
|
410
|
+
try:
|
|
411
|
+
from tunacode.tutorial import TutorialManager
|
|
412
|
+
|
|
413
|
+
tutorial_manager = TutorialManager(state_manager)
|
|
414
|
+
|
|
415
|
+
# Check if we should offer tutorial
|
|
416
|
+
if await tutorial_manager.should_offer_tutorial():
|
|
417
|
+
# Offer tutorial to user
|
|
418
|
+
accepted = await tutorial_manager.offer_tutorial()
|
|
419
|
+
if accepted:
|
|
420
|
+
# Run tutorial
|
|
421
|
+
await tutorial_manager.run_tutorial()
|
|
422
|
+
except ImportError:
|
|
423
|
+
# Tutorial system not available, silently continue
|
|
424
|
+
pass
|
|
425
|
+
except Exception as e:
|
|
426
|
+
# Don't let tutorial errors crash the REPL
|
|
427
|
+
logger.warning(f"Tutorial offer failed: {e}")
|
|
@@ -5,14 +5,24 @@ Command parsing utilities for the REPL.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
|
+
import logging
|
|
8
9
|
|
|
10
|
+
from tunacode.constants import (
|
|
11
|
+
JSON_PARSE_BASE_DELAY,
|
|
12
|
+
JSON_PARSE_MAX_DELAY,
|
|
13
|
+
JSON_PARSE_MAX_RETRIES,
|
|
14
|
+
)
|
|
9
15
|
from tunacode.exceptions import ValidationError
|
|
10
16
|
from tunacode.types import ToolArgs
|
|
17
|
+
from tunacode.utils.json_utils import safe_json_parse
|
|
18
|
+
from tunacode.utils.retry import retry_json_parse
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
11
21
|
|
|
12
22
|
|
|
13
23
|
def parse_args(args) -> ToolArgs:
|
|
14
24
|
"""
|
|
15
|
-
Parse tool arguments from a JSON string or dictionary.
|
|
25
|
+
Parse tool arguments from a JSON string or dictionary with retry logic.
|
|
16
26
|
|
|
17
27
|
Args:
|
|
18
28
|
args (str or dict): A JSON-formatted string or a dictionary containing tool arguments.
|
|
@@ -21,12 +31,36 @@ def parse_args(args) -> ToolArgs:
|
|
|
21
31
|
dict: The parsed arguments.
|
|
22
32
|
|
|
23
33
|
Raises:
|
|
24
|
-
|
|
34
|
+
ValidationError: If 'args' is not a string or dictionary, or if the string
|
|
35
|
+
is not valid JSON.
|
|
25
36
|
"""
|
|
26
37
|
if isinstance(args, str):
|
|
27
38
|
try:
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
# First attempt: Use retry logic for transient failures
|
|
40
|
+
return retry_json_parse(
|
|
41
|
+
args,
|
|
42
|
+
max_retries=JSON_PARSE_MAX_RETRIES,
|
|
43
|
+
base_delay=JSON_PARSE_BASE_DELAY,
|
|
44
|
+
max_delay=JSON_PARSE_MAX_DELAY,
|
|
45
|
+
)
|
|
46
|
+
except json.JSONDecodeError as e:
|
|
47
|
+
# Check if this is an "Extra data" error (concatenated JSON objects)
|
|
48
|
+
if "Extra data" in str(e):
|
|
49
|
+
logger.warning(f"Detected concatenated JSON objects in args: {args[:200]}...")
|
|
50
|
+
try:
|
|
51
|
+
# Use the new safe JSON parser with concatenation support
|
|
52
|
+
result = safe_json_parse(args, allow_concatenated=True)
|
|
53
|
+
if isinstance(result, dict):
|
|
54
|
+
return result
|
|
55
|
+
elif isinstance(result, list) and result:
|
|
56
|
+
# Multiple objects - return first one
|
|
57
|
+
logger.warning("Multiple JSON objects detected, using first object only")
|
|
58
|
+
return result[0]
|
|
59
|
+
except Exception:
|
|
60
|
+
# If safe parsing also fails, fall through to original error
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# Original error - no recovery possible
|
|
30
64
|
raise ValidationError(f"Invalid JSON: {args}")
|
|
31
65
|
elif isinstance(args, dict):
|
|
32
66
|
return args
|