tunacode-cli 0.0.55__py3-none-any.whl → 0.0.57__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/implementations/plan.py +50 -0
- tunacode/cli/commands/registry.py +3 -0
- tunacode/cli/repl.py +327 -186
- tunacode/cli/repl_components/command_parser.py +37 -4
- tunacode/cli/repl_components/error_recovery.py +79 -1
- tunacode/cli/repl_components/output_display.py +21 -1
- tunacode/cli/repl_components/tool_executor.py +12 -0
- tunacode/configuration/defaults.py +8 -0
- tunacode/constants.py +10 -2
- tunacode/core/agents/agent_components/agent_config.py +212 -22
- tunacode/core/agents/agent_components/node_processor.py +46 -40
- tunacode/core/code_index.py +83 -29
- tunacode/core/state.py +44 -0
- tunacode/core/token_usage/usage_tracker.py +2 -2
- tunacode/core/tool_handler.py +20 -0
- tunacode/prompts/system.md +117 -490
- tunacode/services/mcp.py +29 -7
- tunacode/tools/base.py +110 -0
- tunacode/tools/bash.py +96 -1
- tunacode/tools/exit_plan_mode.py +273 -0
- tunacode/tools/glob.py +366 -33
- tunacode/tools/grep.py +226 -77
- tunacode/tools/grep_components/result_formatter.py +98 -4
- tunacode/tools/list_dir.py +132 -2
- tunacode/tools/present_plan.py +288 -0
- tunacode/tools/read_file.py +91 -0
- tunacode/tools/run_command.py +99 -0
- tunacode/tools/schema_assembler.py +167 -0
- tunacode/tools/todo.py +108 -1
- tunacode/tools/update_file.py +94 -0
- tunacode/tools/write_file.py +86 -0
- tunacode/types.py +58 -0
- tunacode/ui/input.py +14 -2
- tunacode/ui/keybindings.py +25 -4
- tunacode/ui/panels.py +53 -8
- tunacode/ui/prompt_manager.py +25 -2
- tunacode/ui/tool_ui.py +3 -2
- tunacode/utils/json_utils.py +206 -0
- tunacode/utils/message_utils.py +14 -4
- tunacode/utils/ripgrep.py +332 -9
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/METADATA +8 -3
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/RECORD +46 -42
- tunacode/tools/read_file_async_poc.py +0 -196
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/licenses/LICENSE +0 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/top_level.txt +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
16
|
from tunacode.core.agents import main as agent
|
|
26
17
|
from tunacode.core.agents.main import patch_tool_messages
|
|
27
|
-
from tunacode.
|
|
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,249 @@ 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
43
|
|
|
44
|
+
def _transform_to_implementation_request(original_request: str) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Transform a planning request into an implementation request.
|
|
56
47
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
This ensures that after plan approval, the agent understands it should
|
|
49
|
+
implement rather than plan again.
|
|
50
|
+
"""
|
|
51
|
+
request = original_request.lower()
|
|
60
52
|
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
if "plan" in request:
|
|
54
|
+
request = request.replace("plan a ", "create a ")
|
|
55
|
+
request = request.replace("plan an ", "create an ")
|
|
56
|
+
request = request.replace("plan to ", "")
|
|
57
|
+
request = request.replace("plan ", "create ")
|
|
63
58
|
|
|
59
|
+
# Add clear implementation instruction
|
|
60
|
+
implementation_request = f"{request}\n\nIMPORTANT: Actually implement and create the file(s) - do not just plan or outline. The plan has been approved, now execute the implementation."
|
|
64
61
|
|
|
65
|
-
|
|
66
|
-
"""
|
|
67
|
-
Handles a command string using the command registry.
|
|
62
|
+
return implementation_request
|
|
68
63
|
|
|
69
|
-
Args:
|
|
70
|
-
command: The command string entered by the user.
|
|
71
|
-
state_manager: The state manager instance.
|
|
72
64
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
65
|
+
async def _display_plan(plan_doc) -> None:
|
|
66
|
+
"""Display the plan in a formatted way."""
|
|
67
|
+
if not plan_doc:
|
|
68
|
+
await ui.error("⚠️ Error: No plan document found to display")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
output = [f"[bold cyan]🎯 {plan_doc.title}[/bold cyan]", ""]
|
|
72
|
+
|
|
73
|
+
if plan_doc.overview:
|
|
74
|
+
output.extend([f"[bold]📝 Overview:[/bold] {plan_doc.overview}", ""])
|
|
77
75
|
|
|
76
|
+
sections = [
|
|
77
|
+
("📝 Files to Modify:", plan_doc.files_to_modify, "•"),
|
|
78
|
+
("📄 Files to Create:", plan_doc.files_to_create, "•"),
|
|
79
|
+
("🧪 Testing Approach:", plan_doc.tests, "•"),
|
|
80
|
+
("✅ Success Criteria:", plan_doc.success_criteria, "•"),
|
|
81
|
+
("⚠️ Risks & Considerations:", plan_doc.risks, "•"),
|
|
82
|
+
("❓ Open Questions:", plan_doc.open_questions, "•"),
|
|
83
|
+
("📚 References:", plan_doc.references, "•"),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
for title, items, prefix in sections:
|
|
87
|
+
if items:
|
|
88
|
+
output.append(f"[bold]{title}[/bold]")
|
|
89
|
+
output.extend(f" {prefix} {item}" for item in items)
|
|
90
|
+
output.append("")
|
|
91
|
+
|
|
92
|
+
output.append("[bold]🔧 Implementation Steps:[/bold]")
|
|
93
|
+
output.extend(f" {i}. {step}" for i, step in enumerate(plan_doc.steps, 1))
|
|
94
|
+
output.append("")
|
|
95
|
+
|
|
96
|
+
if plan_doc.rollback:
|
|
97
|
+
output.extend([f"[bold]🔄 Rollback Plan:[/bold] {plan_doc.rollback}", ""])
|
|
98
|
+
|
|
99
|
+
await ui.panel("📋 IMPLEMENTATION PLAN", "\n".join(output), border_style="cyan")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _detect_and_handle_text_plan(state_manager, agent_response, original_request):
|
|
103
|
+
"""Detect if agent presented a plan in text format and handle it."""
|
|
78
104
|
try:
|
|
79
|
-
|
|
105
|
+
# Extract response text
|
|
106
|
+
response_text = ""
|
|
107
|
+
if hasattr(agent_response, "messages") and agent_response.messages:
|
|
108
|
+
msg = agent_response.messages[-1]
|
|
109
|
+
response_text = str(getattr(msg, "content", getattr(msg, "text", msg)))
|
|
110
|
+
elif hasattr(agent_response, "result"):
|
|
111
|
+
response_text = str(getattr(agent_response.result, "output", agent_response.result))
|
|
112
|
+
else:
|
|
113
|
+
response_text = str(agent_response)
|
|
80
114
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
115
|
+
if "TUNACODE_TASK_COMPLETE" in response_text:
|
|
116
|
+
await ui.warning(
|
|
117
|
+
"⚠️ Agent failed to call present_plan tool. Please provide clearer instructions."
|
|
118
|
+
)
|
|
119
|
+
return
|
|
85
120
|
|
|
121
|
+
if "present_plan(" in response_text:
|
|
122
|
+
await ui.error(
|
|
123
|
+
"❌ Agent showed present_plan as text instead of EXECUTING it as a tool!"
|
|
124
|
+
)
|
|
125
|
+
await ui.info("Try again with: 'Execute the present_plan tool to create a plan for...'")
|
|
126
|
+
return
|
|
86
127
|
|
|
87
|
-
#
|
|
128
|
+
# Check for plan indicators
|
|
129
|
+
plan_indicators = {
|
|
130
|
+
"plan for",
|
|
131
|
+
"implementation plan",
|
|
132
|
+
"here's a plan",
|
|
133
|
+
"i'll create a plan",
|
|
134
|
+
"plan to",
|
|
135
|
+
"outline for",
|
|
136
|
+
"overview:",
|
|
137
|
+
"steps:",
|
|
138
|
+
}
|
|
139
|
+
has_plan = any(ind in response_text.lower() for ind in plan_indicators)
|
|
140
|
+
has_structure = (
|
|
141
|
+
any(x in response_text for x in ["1.", "2.", "•"]) and response_text.count("\n") > 5
|
|
142
|
+
)
|
|
88
143
|
|
|
144
|
+
if has_plan and has_structure:
|
|
145
|
+
await ui.info("📋 Plan detected in text format - extracting for review")
|
|
146
|
+
from tunacode.types import PlanDoc, PlanPhase
|
|
147
|
+
|
|
148
|
+
plan_doc = PlanDoc(
|
|
149
|
+
title="Implementation Plan",
|
|
150
|
+
overview="Automated plan extraction from text",
|
|
151
|
+
steps=["Review and implement the described functionality"],
|
|
152
|
+
files_to_modify=[],
|
|
153
|
+
files_to_create=[],
|
|
154
|
+
success_criteria=[],
|
|
155
|
+
)
|
|
89
156
|
|
|
90
|
-
|
|
157
|
+
state_manager.session.plan_phase = PlanPhase.PLAN_READY
|
|
158
|
+
state_manager.session.current_plan = plan_doc
|
|
159
|
+
await _handle_plan_approval(state_manager, original_request)
|
|
91
160
|
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.error(f"Error detecting text plan: {e}")
|
|
92
163
|
|
|
93
|
-
# ============================================================================
|
|
94
|
-
# MAIN AGENT REQUEST PROCESSING
|
|
95
|
-
# ============================================================================
|
|
96
164
|
|
|
165
|
+
async def _handle_plan_approval(state_manager, original_request=None):
|
|
166
|
+
"""Handle plan approval when a plan has been presented via present_plan tool."""
|
|
167
|
+
try:
|
|
168
|
+
import time
|
|
97
169
|
|
|
98
|
-
|
|
99
|
-
|
|
170
|
+
from tunacode.types import PlanPhase
|
|
171
|
+
from tunacode.ui.keybindings import create_key_bindings
|
|
100
172
|
|
|
101
|
-
|
|
102
|
-
|
|
173
|
+
state_manager.session.plan_phase = PlanPhase.REVIEW_DECISION
|
|
174
|
+
plan_doc = state_manager.session.current_plan
|
|
175
|
+
state_manager.exit_plan_mode(plan_doc)
|
|
176
|
+
|
|
177
|
+
await ui.info("📋 Plan has been prepared and Plan Mode exited")
|
|
178
|
+
await _display_plan(plan_doc)
|
|
179
|
+
|
|
180
|
+
content = (
|
|
181
|
+
"[bold cyan]The implementation plan has been presented.[/bold cyan]\n\n"
|
|
182
|
+
"[yellow]Choose your action:[/yellow]\n\n"
|
|
183
|
+
" [bold green]a[/bold green] → Approve and proceed\n"
|
|
184
|
+
" [bold yellow]m[/bold yellow] → Modify the plan\n"
|
|
185
|
+
" [bold red]r[/bold red] → Reject and recreate\n"
|
|
186
|
+
)
|
|
187
|
+
await ui.panel("🎯 Plan Review", content, border_style="cyan")
|
|
188
|
+
|
|
189
|
+
kb = create_key_bindings(state_manager)
|
|
190
|
+
while True:
|
|
191
|
+
try:
|
|
192
|
+
response = await ui.input(
|
|
193
|
+
"plan_approval", " → Your choice [a/m/r]: ", kb, state_manager
|
|
194
|
+
)
|
|
195
|
+
response = response.strip().lower()
|
|
196
|
+
state_manager.session.approval_abort_pressed = False
|
|
197
|
+
state_manager.session.approval_last_abort_time = 0.0
|
|
198
|
+
break
|
|
199
|
+
except UserAbortError:
|
|
200
|
+
current_time = time.time()
|
|
201
|
+
abort_pressed = getattr(state_manager.session, "approval_abort_pressed", False)
|
|
202
|
+
last_abort = getattr(state_manager.session, "approval_last_abort_time", 0.0)
|
|
203
|
+
|
|
204
|
+
if current_time - last_abort > 3.0:
|
|
205
|
+
abort_pressed = False
|
|
206
|
+
|
|
207
|
+
if abort_pressed:
|
|
208
|
+
await ui.info("🔄 Returning to Plan Mode")
|
|
209
|
+
state_manager.enter_plan_mode()
|
|
210
|
+
state_manager.session.approval_abort_pressed = False
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
state_manager.session.approval_abort_pressed = True
|
|
214
|
+
state_manager.session.approval_last_abort_time = current_time
|
|
215
|
+
await ui.warning("Hit ESC or Ctrl+C again to return to Plan Mode")
|
|
216
|
+
|
|
217
|
+
actions = {
|
|
218
|
+
"a": (
|
|
219
|
+
"✅ Plan approved - proceeding with implementation",
|
|
220
|
+
lambda: state_manager.approve_plan(),
|
|
221
|
+
),
|
|
222
|
+
"m": (
|
|
223
|
+
"📝 Returning to Plan Mode for modifications",
|
|
224
|
+
lambda: state_manager.enter_plan_mode(),
|
|
225
|
+
),
|
|
226
|
+
"r": (
|
|
227
|
+
"🔄 Plan rejected - returning to Plan Mode",
|
|
228
|
+
lambda: state_manager.enter_plan_mode(),
|
|
229
|
+
),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if response in actions or response in ["approve", "modify", "reject"]:
|
|
233
|
+
key = response[0] if len(response) > 1 else response
|
|
234
|
+
msg, action = actions.get(key, (None, None))
|
|
235
|
+
if msg:
|
|
236
|
+
await ui.info(msg) if key == "a" else await ui.warning(msg)
|
|
237
|
+
action()
|
|
238
|
+
if key == "a" and original_request:
|
|
239
|
+
await ui.info("🚀 Executing implementation...")
|
|
240
|
+
await process_request(
|
|
241
|
+
_transform_to_implementation_request(original_request),
|
|
242
|
+
state_manager,
|
|
243
|
+
output=True,
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
await ui.warning("⚠️ Invalid choice - please enter a, m, or r")
|
|
247
|
+
|
|
248
|
+
state_manager.session.plan_phase = None
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
logger.error(f"Error in plan approval: {e}")
|
|
252
|
+
state_manager.session.plan_phase = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
_command_registry = CommandRegistry()
|
|
256
|
+
_command_registry.register_all_default_commands()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def _handle_command(command: str, state_manager: StateManager) -> CommandResult:
|
|
260
|
+
"""Handles a command string using the command registry."""
|
|
261
|
+
context = CommandContext(state_manager=state_manager, process_request=process_request)
|
|
262
|
+
try:
|
|
263
|
+
_command_registry.set_process_request_callback(process_request)
|
|
264
|
+
return await _command_registry.execute(command, context)
|
|
265
|
+
except ValidationError as e:
|
|
266
|
+
await ui.error(str(e))
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def process_request(text: str, state_manager: StateManager, output: bool = True):
|
|
271
|
+
"""Process input using the agent, handling cancellation safely."""
|
|
103
272
|
import uuid
|
|
104
273
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
logger.debug(
|
|
108
|
-
"Processing new request", extra={"request_id": request_id, "input_text": text[:100]}
|
|
109
|
-
)
|
|
110
|
-
state_manager.session.request_id = request_id
|
|
274
|
+
from tunacode.types import PlanPhase
|
|
275
|
+
from tunacode.utils.text_utils import expand_file_refs
|
|
111
276
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if operation_cancelled is True:
|
|
115
|
-
logger.debug("Operation cancelled before processing started")
|
|
277
|
+
state_manager.session.request_id = str(uuid.uuid4())
|
|
278
|
+
|
|
279
|
+
if getattr(state_manager.session, "operation_cancelled", False) is True:
|
|
116
280
|
raise CancelledError("Operation was cancelled")
|
|
117
281
|
|
|
118
282
|
state_manager.session.spinner = await ui.spinner(
|
|
119
283
|
True, state_manager.session.spinner, state_manager
|
|
120
284
|
)
|
|
285
|
+
|
|
121
286
|
try:
|
|
122
287
|
patch_tool_messages(MSG_TOOL_INTERRUPTED, state_manager)
|
|
123
288
|
|
|
@@ -128,145 +293,157 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
|
|
|
128
293
|
|
|
129
294
|
start_idx = len(state_manager.session.messages)
|
|
130
295
|
|
|
131
|
-
def tool_callback_with_state(part,
|
|
296
|
+
def tool_callback_with_state(part, _):
|
|
132
297
|
return tool_handler(part, state_manager)
|
|
133
298
|
|
|
134
299
|
try:
|
|
135
|
-
from tunacode.utils.text_utils import expand_file_refs
|
|
136
|
-
|
|
137
300
|
text, referenced_files = expand_file_refs(text)
|
|
138
|
-
|
|
139
|
-
state_manager.session.files_in_context.add(file_path)
|
|
301
|
+
state_manager.session.files_in_context.update(referenced_files)
|
|
140
302
|
except ValueError as e:
|
|
141
303
|
await ui.error(str(e))
|
|
142
304
|
return
|
|
143
305
|
|
|
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")
|
|
306
|
+
if getattr(state_manager.session, "operation_cancelled", False) is True:
|
|
148
307
|
raise CancelledError("Operation was cancelled")
|
|
149
308
|
|
|
150
309
|
enable_streaming = state_manager.session.user_config.get("settings", {}).get(
|
|
151
310
|
"enable_streaming", True
|
|
152
311
|
)
|
|
153
312
|
|
|
313
|
+
# Create UsageTracker to ensure session cost tracking
|
|
314
|
+
model_registry = ModelRegistry()
|
|
315
|
+
parser = ApiResponseParser()
|
|
316
|
+
calculator = CostCalculator(model_registry)
|
|
317
|
+
usage_tracker = UsageTracker(parser, calculator, state_manager)
|
|
318
|
+
|
|
154
319
|
if enable_streaming:
|
|
155
320
|
await ui.spinner(False, state_manager.session.spinner, state_manager)
|
|
156
|
-
|
|
157
321
|
state_manager.session.is_streaming_active = True
|
|
158
|
-
|
|
159
322
|
streaming_panel = ui.StreamingAgentPanel()
|
|
160
323
|
await streaming_panel.start()
|
|
161
|
-
|
|
162
324
|
state_manager.session.streaming_panel = streaming_panel
|
|
163
325
|
|
|
164
326
|
try:
|
|
165
|
-
|
|
166
|
-
async def streaming_callback(content: str):
|
|
167
|
-
await streaming_panel.update(content)
|
|
168
|
-
|
|
169
327
|
res = await agent.process_request(
|
|
170
328
|
text,
|
|
171
329
|
state_manager.session.current_model,
|
|
172
330
|
state_manager,
|
|
173
331
|
tool_callback=tool_callback_with_state,
|
|
174
|
-
streaming_callback=
|
|
332
|
+
streaming_callback=lambda content: streaming_panel.update(content),
|
|
333
|
+
usage_tracker=usage_tracker,
|
|
175
334
|
)
|
|
176
335
|
finally:
|
|
177
336
|
await streaming_panel.stop()
|
|
178
337
|
state_manager.session.streaming_panel = None
|
|
179
338
|
state_manager.session.is_streaming_active = False
|
|
180
339
|
else:
|
|
181
|
-
# Use normal agent processing
|
|
182
340
|
res = await agent.process_request(
|
|
183
341
|
text,
|
|
184
342
|
state_manager.session.current_model,
|
|
185
343
|
state_manager,
|
|
186
344
|
tool_callback=tool_callback_with_state,
|
|
345
|
+
usage_tracker=usage_tracker,
|
|
187
346
|
)
|
|
188
347
|
|
|
348
|
+
# Handle plan approval or detection
|
|
349
|
+
if (
|
|
350
|
+
hasattr(state_manager.session, "plan_phase")
|
|
351
|
+
and state_manager.session.plan_phase == PlanPhase.PLAN_READY
|
|
352
|
+
):
|
|
353
|
+
await _handle_plan_approval(state_manager, text)
|
|
354
|
+
elif state_manager.is_plan_mode() and not getattr(
|
|
355
|
+
state_manager.session, "_continuing_from_plan", False
|
|
356
|
+
):
|
|
357
|
+
await _detect_and_handle_text_plan(state_manager, res, text)
|
|
358
|
+
|
|
189
359
|
if output:
|
|
190
360
|
if state_manager.session.show_thoughts:
|
|
191
|
-
|
|
192
|
-
for msg in new_msgs:
|
|
361
|
+
for msg in state_manager.session.messages[start_idx:]:
|
|
193
362
|
if isinstance(msg, dict) and "thought" in msg:
|
|
194
363
|
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
|
|
364
|
+
if not enable_streaming:
|
|
365
|
+
if (
|
|
366
|
+
not hasattr(res, "result")
|
|
367
|
+
or res.result is None
|
|
368
|
+
or not hasattr(res.result, "output")
|
|
369
|
+
):
|
|
370
|
+
await ui.muted(MSG_REQUEST_COMPLETED)
|
|
371
|
+
else:
|
|
372
|
+
await display_agent_output(res, enable_streaming, state_manager)
|
|
211
373
|
if state_manager.session.files_in_context:
|
|
212
374
|
filenames = [Path(f).name for f in sorted(state_manager.session.files_in_context)]
|
|
213
375
|
await ui.muted(f"Files in context: {', '.join(filenames)}")
|
|
214
376
|
|
|
215
|
-
# --- ERROR HANDLING ---
|
|
216
377
|
except CancelledError:
|
|
217
378
|
await ui.muted(MSG_REQUEST_CANCELLED)
|
|
218
379
|
except UserAbortError:
|
|
219
380
|
await ui.muted(MSG_OPERATION_ABORTED)
|
|
220
381
|
except UnexpectedModelBehavior as e:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
patch_tool_messages(error_message, state_manager)
|
|
382
|
+
await ui.muted(str(e))
|
|
383
|
+
patch_tool_messages(str(e), state_manager)
|
|
224
384
|
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))
|
|
385
|
+
if not await attempt_tool_recovery(e, state_manager):
|
|
386
|
+
await ui.error(str(e))
|
|
232
387
|
finally:
|
|
233
388
|
await ui.spinner(False, state_manager.session.spinner, state_manager)
|
|
234
389
|
state_manager.session.current_task = None
|
|
235
|
-
# Reset cancellation flag when task completes (if attribute exists)
|
|
236
390
|
if hasattr(state_manager.session, "operation_cancelled"):
|
|
237
391
|
state_manager.session.operation_cancelled = False
|
|
238
|
-
|
|
239
392
|
if "multiline" in state_manager.session.input_sessions:
|
|
240
393
|
await run_in_terminal(
|
|
241
394
|
lambda: state_manager.session.input_sessions["multiline"].app.invalidate()
|
|
242
395
|
)
|
|
243
396
|
|
|
244
397
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
398
|
+
async def warm_code_index():
|
|
399
|
+
"""Pre-warm the code index in background for faster directory operations."""
|
|
400
|
+
try:
|
|
401
|
+
from tunacode.core.code_index import CodeIndex
|
|
402
|
+
|
|
403
|
+
# Build index in thread to avoid blocking
|
|
404
|
+
index = await asyncio.to_thread(lambda: CodeIndex.get_instance())
|
|
405
|
+
await asyncio.to_thread(index.build_index)
|
|
406
|
+
|
|
407
|
+
logger.debug(f"Code index pre-warmed with {len(index._all_files)} files")
|
|
408
|
+
except Exception as e:
|
|
409
|
+
logger.debug(f"Failed to pre-warm code index: {e}")
|
|
248
410
|
|
|
249
411
|
|
|
250
412
|
async def repl(state_manager: StateManager):
|
|
251
413
|
"""Main REPL loop that handles user interaction and input processing."""
|
|
414
|
+
import time
|
|
415
|
+
|
|
416
|
+
# Start pre-warming code index in background (non-blocking)
|
|
417
|
+
asyncio.create_task(warm_code_index())
|
|
418
|
+
|
|
252
419
|
action = None
|
|
253
420
|
abort_pressed = False
|
|
254
421
|
last_abort_time = 0.0
|
|
255
422
|
|
|
256
|
-
model_name = state_manager.session.current_model
|
|
257
423
|
max_tokens = (
|
|
258
424
|
state_manager.session.user_config.get("context_window_size") or DEFAULT_CONTEXT_WINDOW
|
|
259
425
|
)
|
|
260
426
|
state_manager.session.max_tokens = max_tokens
|
|
261
|
-
|
|
262
427
|
state_manager.session.update_token_count()
|
|
263
|
-
context_display = get_context_window_display(state_manager.session.total_tokens, max_tokens)
|
|
264
428
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
429
|
+
async def show_context():
|
|
430
|
+
context = get_context_window_display(state_manager.session.total_tokens, max_tokens)
|
|
431
|
+
|
|
432
|
+
# Get session cost for display
|
|
433
|
+
session_cost = 0.0
|
|
434
|
+
if state_manager.session.session_total_usage:
|
|
435
|
+
session_cost = float(state_manager.session.session_total_usage.get("cost", 0.0) or 0.0)
|
|
436
|
+
|
|
437
|
+
await ui.muted(f"• Model: {state_manager.session.current_model} • {context}")
|
|
438
|
+
if session_cost > 0:
|
|
439
|
+
await ui.muted(f"• Session Cost: ${session_cost:.4f}")
|
|
440
|
+
|
|
441
|
+
# Always show context
|
|
442
|
+
await show_context()
|
|
443
|
+
|
|
444
|
+
# Show startup message only once
|
|
445
|
+
if not hasattr(state_manager.session, "_startup_shown"):
|
|
268
446
|
await ui.success("Ready to assist")
|
|
269
|
-
await ui.line()
|
|
270
447
|
state_manager.session._startup_shown = True
|
|
271
448
|
|
|
272
449
|
instance = agent.get_or_create_agent(state_manager.session.current_model, state_manager)
|
|
@@ -276,17 +453,11 @@ async def repl(state_manager: StateManager):
|
|
|
276
453
|
try:
|
|
277
454
|
line = await ui.multiline_input(state_manager, _command_registry)
|
|
278
455
|
except UserAbortError:
|
|
279
|
-
import time
|
|
280
|
-
|
|
281
456
|
current_time = time.time()
|
|
282
|
-
|
|
283
|
-
# Reset if more than 3 seconds have passed
|
|
284
457
|
if current_time - last_abort_time > 3.0:
|
|
285
458
|
abort_pressed = False
|
|
286
|
-
|
|
287
459
|
if abort_pressed:
|
|
288
460
|
break
|
|
289
|
-
|
|
290
461
|
abort_pressed = True
|
|
291
462
|
last_abort_time = current_time
|
|
292
463
|
await ui.warning(MSG_HIT_ABORT_KEY)
|
|
@@ -294,7 +465,6 @@ async def repl(state_manager: StateManager):
|
|
|
294
465
|
|
|
295
466
|
if not line:
|
|
296
467
|
continue
|
|
297
|
-
|
|
298
468
|
abort_pressed = False
|
|
299
469
|
|
|
300
470
|
if line.lower() in ["exit", "quit"]:
|
|
@@ -305,54 +475,40 @@ async def repl(state_manager: StateManager):
|
|
|
305
475
|
if action == "restart":
|
|
306
476
|
break
|
|
307
477
|
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
478
|
line = action
|
|
311
|
-
# Fall through to process as normal text
|
|
312
479
|
else:
|
|
313
480
|
continue
|
|
314
481
|
|
|
315
482
|
if line.startswith("!"):
|
|
316
483
|
command = line[1:].strip()
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
484
|
+
await ui.panel(
|
|
485
|
+
"Tool(bash)",
|
|
486
|
+
f"Command: {command or 'Interactive shell'}",
|
|
487
|
+
border_style="yellow",
|
|
488
|
+
)
|
|
320
489
|
|
|
321
490
|
def run_shell():
|
|
322
491
|
try:
|
|
323
492
|
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
|
-
)
|
|
493
|
+
result = safe_subprocess_run(
|
|
494
|
+
command, shell=True, validate=True, capture_output=False
|
|
495
|
+
)
|
|
496
|
+
if result.returncode != 0:
|
|
497
|
+
ui.console.print(f"\nCommand exited with code {result.returncode}")
|
|
340
498
|
else:
|
|
341
|
-
|
|
342
|
-
|
|
499
|
+
subprocess.run(os.environ.get(SHELL_ENV_VAR, DEFAULT_SHELL))
|
|
500
|
+
except CommandSecurityError as e:
|
|
501
|
+
ui.console.print(f"\nSecurity validation failed: {str(e)}")
|
|
343
502
|
except Exception as e:
|
|
344
503
|
ui.console.print(f"\nShell command failed: {str(e)}")
|
|
345
504
|
|
|
346
505
|
await run_in_terminal(run_shell)
|
|
347
|
-
await ui.line()
|
|
348
506
|
continue
|
|
349
507
|
|
|
350
|
-
# --- AGENT REQUEST PROCESSING ---
|
|
351
508
|
if state_manager.session.current_task and not state_manager.session.current_task.done():
|
|
352
509
|
await ui.muted(MSG_AGENT_BUSY)
|
|
353
510
|
continue
|
|
354
511
|
|
|
355
|
-
# Reset cancellation flag for new operations (if attribute exists)
|
|
356
512
|
if hasattr(state_manager.session, "operation_cancelled"):
|
|
357
513
|
state_manager.session.operation_cancelled = False
|
|
358
514
|
|
|
@@ -362,39 +518,24 @@ async def repl(state_manager: StateManager):
|
|
|
362
518
|
await state_manager.session.current_task
|
|
363
519
|
|
|
364
520
|
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
|
-
)
|
|
521
|
+
await show_context()
|
|
373
522
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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}")
|
|
399
|
-
|
|
400
|
-
await ui.info(MSG_SESSION_ENDED)
|
|
523
|
+
if action == "restart":
|
|
524
|
+
await repl(state_manager)
|
|
525
|
+
else:
|
|
526
|
+
session_total = state_manager.session.session_total_usage
|
|
527
|
+
if session_total:
|
|
528
|
+
try:
|
|
529
|
+
total_tokens = int(session_total.get("prompt_tokens", 0) or 0) + int(
|
|
530
|
+
session_total.get("completion_tokens", 0) or 0
|
|
531
|
+
)
|
|
532
|
+
total_cost = float(session_total.get("cost", 0) or 0)
|
|
533
|
+
if total_tokens > 0 or total_cost > 0:
|
|
534
|
+
ui.console.print(
|
|
535
|
+
f"\n[bold cyan]TunaCode Session Summary[/bold cyan]\n"
|
|
536
|
+
f" - Total Tokens: {total_tokens:,}\n"
|
|
537
|
+
f" - Total Cost: ${total_cost:.4f}"
|
|
538
|
+
)
|
|
539
|
+
except (TypeError, ValueError):
|
|
540
|
+
pass
|
|
541
|
+
await ui.info(MSG_SESSION_ENDED)
|