tunacode-cli 0.0.56__py3-none-any.whl → 0.0.60__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.

Files changed (45) hide show
  1. tunacode/cli/commands/implementations/plan.py +8 -8
  2. tunacode/cli/commands/registry.py +2 -2
  3. tunacode/cli/repl.py +214 -407
  4. tunacode/cli/repl_components/command_parser.py +37 -4
  5. tunacode/cli/repl_components/error_recovery.py +79 -1
  6. tunacode/cli/repl_components/output_display.py +14 -11
  7. tunacode/cli/repl_components/tool_executor.py +7 -4
  8. tunacode/configuration/defaults.py +8 -0
  9. tunacode/constants.py +8 -2
  10. tunacode/core/agents/agent_components/agent_config.py +128 -65
  11. tunacode/core/agents/agent_components/node_processor.py +6 -2
  12. tunacode/core/code_index.py +83 -29
  13. tunacode/core/state.py +1 -1
  14. tunacode/core/token_usage/usage_tracker.py +2 -2
  15. tunacode/core/tool_handler.py +3 -3
  16. tunacode/prompts/system.md +117 -490
  17. tunacode/services/mcp.py +29 -7
  18. tunacode/tools/base.py +110 -0
  19. tunacode/tools/bash.py +96 -1
  20. tunacode/tools/exit_plan_mode.py +114 -32
  21. tunacode/tools/glob.py +366 -33
  22. tunacode/tools/grep.py +226 -77
  23. tunacode/tools/grep_components/result_formatter.py +98 -4
  24. tunacode/tools/list_dir.py +132 -2
  25. tunacode/tools/present_plan.py +111 -31
  26. tunacode/tools/read_file.py +91 -0
  27. tunacode/tools/run_command.py +99 -0
  28. tunacode/tools/schema_assembler.py +167 -0
  29. tunacode/tools/todo.py +108 -1
  30. tunacode/tools/update_file.py +94 -0
  31. tunacode/tools/write_file.py +86 -0
  32. tunacode/types.py +10 -9
  33. tunacode/ui/input.py +1 -0
  34. tunacode/ui/keybindings.py +1 -0
  35. tunacode/ui/panels.py +49 -27
  36. tunacode/ui/prompt_manager.py +13 -7
  37. tunacode/utils/json_utils.py +206 -0
  38. tunacode/utils/ripgrep.py +332 -9
  39. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/METADATA +7 -2
  40. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/RECORD +44 -43
  41. tunacode/tools/read_file_async_poc.py +0 -196
  42. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/WHEEL +0 -0
  43. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/entry_points.txt +0 -0
  44. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/licenses/LICENSE +0 -0
  45. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.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.exceptions import AgentError, UserAbortError, ValidationError
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,9 +40,6 @@ 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
43
 
57
44
  def _transform_to_implementation_request(original_request: str) -> str:
58
45
  """
@@ -61,12 +48,9 @@ def _transform_to_implementation_request(original_request: str) -> str:
61
48
  This ensures that after plan approval, the agent understands it should
62
49
  implement rather than plan again.
63
50
  """
64
- # Remove plan-related language and add implementation language
65
51
  request = original_request.lower()
66
52
 
67
53
  if "plan" in request:
68
- # Transform "plan a md file" -> "create a md file"
69
- # Transform "plan to implement" -> "implement"
70
54
  request = request.replace("plan a ", "create a ")
71
55
  request = request.replace("plan an ", "create an ")
72
56
  request = request.replace("plan to ", "")
@@ -80,362 +64,225 @@ def _transform_to_implementation_request(original_request: str) -> str:
80
64
 
81
65
  async def _display_plan(plan_doc) -> None:
82
66
  """Display the plan in a formatted way."""
83
-
84
67
  if not plan_doc:
85
68
  await ui.error("⚠️ Error: No plan document found to display")
86
69
  return
87
70
 
88
- output = []
89
- output.append(f"[bold cyan]🎯 {plan_doc.title}[/bold cyan]")
90
- output.append("")
71
+ output = [f"[bold cyan]🎯 {plan_doc.title}[/bold cyan]", ""]
91
72
 
92
73
  if plan_doc.overview:
93
- output.append(f"[bold]📝 Overview:[/bold] {plan_doc.overview}")
94
- output.append("")
95
-
96
- # Files section
97
- if plan_doc.files_to_modify:
98
- output.append("[bold]📝 Files to Modify:[/bold]")
99
- for f in plan_doc.files_to_modify:
100
- output.append(f" {f}")
101
- output.append("")
102
-
103
- if plan_doc.files_to_create:
104
- output.append("[bold]📄 Files to Create:[/bold]")
105
- for f in plan_doc.files_to_create:
106
- output.append(f" • {f}")
107
- output.append("")
108
-
109
- # Implementation steps
74
+ output.extend([f"[bold]📝 Overview:[/bold] {plan_doc.overview}", ""])
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
+
110
92
  output.append("[bold]🔧 Implementation Steps:[/bold]")
111
- for i, step in enumerate(plan_doc.steps, 1):
112
- output.append(f" {i}. {step}")
93
+ output.extend(f" {i}. {step}" for i, step in enumerate(plan_doc.steps, 1))
113
94
  output.append("")
114
95
 
115
- # Testing approach
116
- if plan_doc.tests:
117
- output.append("[bold]🧪 Testing Approach:[/bold]")
118
- for test in plan_doc.tests:
119
- output.append(f" • {test}")
120
- output.append("")
121
-
122
- # Success criteria
123
- if plan_doc.success_criteria:
124
- output.append("[bold]✅ Success Criteria:[/bold]")
125
- for criteria in plan_doc.success_criteria:
126
- output.append(f" • {criteria}")
127
- output.append("")
128
-
129
- # Risks and considerations
130
- if plan_doc.risks:
131
- output.append("[bold]⚠️ Risks & Considerations:[/bold]")
132
- for risk in plan_doc.risks:
133
- output.append(f" • {risk}")
134
- output.append("")
135
-
136
- # Open questions
137
- if plan_doc.open_questions:
138
- output.append("[bold]❓ Open Questions:[/bold]")
139
- for question in plan_doc.open_questions:
140
- output.append(f" • {question}")
141
- output.append("")
142
-
143
- # References
144
- if plan_doc.references:
145
- output.append("[bold]📚 References:[/bold]")
146
- for ref in plan_doc.references:
147
- output.append(f" • {ref}")
148
- output.append("")
149
-
150
- # Rollback plan
151
96
  if plan_doc.rollback:
152
- output.append(f"[bold]🔄 Rollback Plan:[/bold] {plan_doc.rollback}")
153
- output.append("")
97
+ output.extend([f"[bold]🔄 Rollback Plan:[/bold] {plan_doc.rollback}", ""])
154
98
 
155
- # Display the plan in a cyan panel
156
99
  await ui.panel("📋 IMPLEMENTATION PLAN", "\n".join(output), border_style="cyan")
157
100
 
158
101
 
159
102
  async def _detect_and_handle_text_plan(state_manager, agent_response, original_request):
160
- """
161
- Detect if agent presented a plan in text format and handle it.
162
-
163
- This is a fallback for when agents ignore the present_plan tool requirement.
164
- """
103
+ """Detect if agent presented a plan in text format and handle it."""
165
104
  try:
166
105
  # Extract response text
167
106
  response_text = ""
168
- if hasattr(agent_response, 'messages') and agent_response.messages:
169
- latest_msg = agent_response.messages[-1]
170
- if hasattr(latest_msg, 'content'):
171
- response_text = str(latest_msg.content)
172
- elif hasattr(latest_msg, 'text'):
173
- response_text = str(latest_msg.text)
174
- elif hasattr(agent_response, 'result') and hasattr(agent_response.result, 'output'):
175
- response_text = str(agent_response.result.output)
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))
176
112
  else:
177
113
  response_text = str(agent_response)
178
114
 
179
- # Skip if agent just returned TUNACODE_TASK_COMPLETE or showed present_plan as text
180
115
  if "TUNACODE_TASK_COMPLETE" in response_text:
181
- logger.debug("Agent returned TUNACODE_TASK_COMPLETE instead of calling present_plan")
182
- await ui.warning("⚠️ Agent failed to call present_plan tool. Please provide clearer instructions to plan the task.")
116
+ await ui.warning(
117
+ "⚠️ Agent failed to call present_plan tool. Please provide clearer instructions."
118
+ )
183
119
  return
184
120
 
185
121
  if "present_plan(" in response_text:
186
- logger.debug("Agent showed present_plan as text instead of executing it")
187
- await ui.error("❌ Agent showed present_plan as text instead of EXECUTING it as a tool!")
188
- await ui.info("The agent must EXECUTE the present_plan tool, not show it as code.")
122
+ await ui.error(
123
+ "❌ Agent showed present_plan as text instead of EXECUTING it as a tool!"
124
+ )
189
125
  await ui.info("Try again with: 'Execute the present_plan tool to create a plan for...'")
190
126
  return
191
127
 
192
128
  # Check for plan indicators
193
- plan_indicators = [
194
- "plan for", "implementation plan", "here's a plan", "i'll create a plan",
195
- "plan to write", "plan to create", "markdown file", "outline for the",
196
- "plan title", "overview:", "steps:", "file title and introduction",
197
- "main functions", "sections to cover", "structure for", "plan overview"
198
- ]
199
-
200
- has_plan_indicators = any(indicator in response_text.lower() for indicator in plan_indicators)
201
-
202
- # Also check for structured content (numbered lists, bullet points, sections)
203
- has_structure = bool(
204
- ("1." in response_text or "2." in response_text or "3." in response_text) or
205
- ("•" in response_text and response_text.count("") >= 3) or
206
- ("Title:" in response_text and "Overview:" in response_text)
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
207
142
  )
208
143
 
209
- if has_plan_indicators and has_structure:
210
- # Agent presented a text plan - simulate the approval flow
211
- await ui.line()
144
+ if has_plan and has_structure:
212
145
  await ui.info("📋 Plan detected in text format - extracting for review")
213
-
214
- # Create a simple plan from the text
215
146
  from tunacode.types import PlanDoc, PlanPhase
216
147
 
217
- # Extract title (simple heuristic)
218
- title = "TunaCode Functions Overview Markdown File"
219
- if "title:" in response_text.lower():
220
- lines = response_text.split("\n")
221
- for line in lines:
222
- if "title:" in line.lower():
223
- title = line.split(":", 1)[1].strip().strip('"')
224
- break
225
-
226
- # Create basic plan structure from detected text
227
148
  plan_doc = PlanDoc(
228
- title=title,
229
- overview="Create a comprehensive markdown file documenting TunaCode's main functions",
230
- steps=[
231
- "Draft document structure with sections",
232
- "Detail each function with descriptions and examples",
233
- "Add usage guidelines and best practices",
234
- "Review and finalize content"
235
- ],
149
+ title="Implementation Plan",
150
+ overview="Automated plan extraction from text",
151
+ steps=["Review and implement the described functionality"],
236
152
  files_to_modify=[],
237
- files_to_create=["TunaCode_Functions_Overview.md"],
238
- success_criteria=["Clear documentation of all main TunaCode functions"]
153
+ files_to_create=[],
154
+ success_criteria=[],
239
155
  )
240
156
 
241
- # Set plan ready state and trigger approval
242
157
  state_manager.session.plan_phase = PlanPhase.PLAN_READY
243
158
  state_manager.session.current_plan = plan_doc
244
-
245
159
  await _handle_plan_approval(state_manager, original_request)
246
160
 
247
161
  except Exception as e:
248
162
  logger.error(f"Error detecting text plan: {e}")
249
- # If detection fails, just continue normally
250
163
 
251
164
 
252
165
  async def _handle_plan_approval(state_manager, original_request=None):
253
- """
254
- Handle plan approval when a plan has been presented via present_plan tool.
255
-
256
- This function:
257
- 1. Shows the user approval options (approve/modify/reject)
258
- 2. Handles the user's decision appropriately
259
- 3. Continues with implementation if approved
260
- """
166
+ """Handle plan approval when a plan has been presented via present_plan tool."""
261
167
  try:
168
+ import time
169
+
262
170
  from tunacode.types import PlanPhase
171
+ from tunacode.ui.keybindings import create_key_bindings
263
172
 
264
- # Exit plan mode and move to review phase
265
173
  state_manager.session.plan_phase = PlanPhase.REVIEW_DECISION
266
174
  plan_doc = state_manager.session.current_plan
267
175
  state_manager.exit_plan_mode(plan_doc)
268
176
 
269
- await ui.line()
270
177
  await ui.info("📋 Plan has been prepared and Plan Mode exited")
271
- await ui.line()
272
-
273
- # Display the plan content now
274
178
  await _display_plan(plan_doc)
275
179
 
276
- # Display approval options with better styling
277
- await ui.line()
278
- # Create content with exactly 45 characters per line for perfect alignment
279
180
  content = (
280
- "[bold cyan]The implementation plan has been presented. [/bold cyan]\n\n"
281
- "[yellow]Choose your action: [/yellow]\n\n"
282
- " [bold green]a[/bold green] → Approve and proceed with implementation\n"
283
- " [bold yellow]m[/bold yellow] → Modify the plan (return to Plan Mode) \n"
284
- " [bold red]r[/bold red] → Reject and create different approach \n"
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"
285
186
  )
286
187
  await ui.panel("🎯 Plan Review", content, border_style="cyan")
287
- await ui.line()
288
188
 
289
- # Handle double-escape pattern like main REPL
290
- from tunacode.ui.keybindings import create_key_bindings
291
189
  kb = create_key_bindings(state_manager)
292
-
293
190
  while True:
294
191
  try:
295
192
  response = await ui.input(
296
- session_key="plan_approval",
297
- pretext=" → Your choice [a/m/r]: ",
298
- key_bindings=kb,
299
- state_manager=state_manager
193
+ "plan_approval", " → Your choice [a/m/r]: ", kb, state_manager
300
194
  )
301
195
  response = response.strip().lower()
302
-
303
- # Reset abort flags on successful input
304
196
  state_manager.session.approval_abort_pressed = False
305
197
  state_manager.session.approval_last_abort_time = 0.0
306
198
  break
307
-
308
199
  except UserAbortError:
309
- import time
310
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)
311
203
 
312
- # Get current session state
313
- approval_abort_pressed = getattr(state_manager.session, 'approval_abort_pressed', False)
314
- approval_last_abort_time = getattr(state_manager.session, 'approval_last_abort_time', 0.0)
315
-
316
- # Reset if more than 3 seconds have passed
317
- if current_time - approval_last_abort_time > 3.0:
318
- approval_abort_pressed = False
319
- state_manager.session.approval_abort_pressed = False
204
+ if current_time - last_abort > 3.0:
205
+ abort_pressed = False
320
206
 
321
- if approval_abort_pressed:
322
- # Second escape - return to Plan Mode
323
- await ui.line()
324
- await ui.info("🔄 Returning to Plan Mode for further planning")
325
- await ui.line()
207
+ if abort_pressed:
208
+ await ui.info("🔄 Returning to Plan Mode")
326
209
  state_manager.enter_plan_mode()
327
- # Clean up approval flags
328
210
  state_manager.session.approval_abort_pressed = False
329
- state_manager.session.approval_last_abort_time = 0.0
330
211
  return
331
212
 
332
- # First escape - show warning and continue the loop
333
213
  state_manager.session.approval_abort_pressed = True
334
214
  state_manager.session.approval_last_abort_time = current_time
335
- await ui.line()
336
215
  await ui.warning("Hit ESC or Ctrl+C again to return to Plan Mode")
337
- await ui.line()
338
- continue
339
-
340
- if response in ['a', 'approve']:
341
- await ui.line()
342
- await ui.success("✅ Plan approved - proceeding with implementation")
343
- state_manager.approve_plan()
344
- state_manager.session.plan_phase = None
345
-
346
- # Continue processing the original request now that we're in normal mode
347
- if original_request:
348
- await ui.info("🚀 Executing implementation...")
349
- await ui.line()
350
- # Transform the original request to make it clear we want implementation, not more planning
351
- implementation_request = _transform_to_implementation_request(original_request)
352
- await process_request(implementation_request, state_manager, output=True)
353
-
354
- elif response in ['m', 'modify']:
355
- await ui.line()
356
- await ui.info("📝 Returning to Plan Mode for modifications")
357
- state_manager.enter_plan_mode()
358
-
359
- elif response in ['r', 'reject']:
360
- await ui.line()
361
- await ui.warning("🔄 Plan rejected - returning to Plan Mode")
362
- state_manager.enter_plan_mode()
363
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
+ )
364
245
  else:
365
- await ui.line()
366
246
  await ui.warning("⚠️ Invalid choice - please enter a, m, or r")
367
- state_manager.session.plan_phase = None
247
+
248
+ state_manager.session.plan_phase = None
368
249
 
369
250
  except Exception as e:
370
251
  logger.error(f"Error in plan approval: {e}")
371
- # If anything goes wrong, reset plan phase
372
252
  state_manager.session.plan_phase = None
373
253
 
374
254
 
375
- # ============================================================================
376
- # COMMAND SYSTEM
377
- # ============================================================================
378
-
379
255
  _command_registry = CommandRegistry()
380
256
  _command_registry.register_all_default_commands()
381
257
 
382
258
 
383
259
  async def _handle_command(command: str, state_manager: StateManager) -> CommandResult:
384
- """
385
- Handles a command string using the command registry.
386
-
387
- Args:
388
- command: The command string entered by the user.
389
- state_manager: The state manager instance.
390
-
391
- Returns:
392
- Command result (varies by command).
393
- """
260
+ """Handles a command string using the command registry."""
394
261
  context = CommandContext(state_manager=state_manager, process_request=process_request)
395
-
396
262
  try:
397
263
  _command_registry.set_process_request_callback(process_request)
398
-
399
264
  return await _command_registry.execute(command, context)
400
265
  except ValidationError as e:
401
266
  await ui.error(str(e))
402
267
  return None
403
268
 
404
269
 
405
- # The _attempt_tool_recovery function has been moved to repl_components.error_recovery
406
-
407
-
408
- # The _display_agent_output function has been moved to repl_components.output_display
409
-
410
-
411
- # ============================================================================
412
- # MAIN AGENT REQUEST PROCESSING
413
- # ============================================================================
414
-
415
-
416
270
  async def process_request(text: str, state_manager: StateManager, output: bool = True):
417
- """Process input using the agent, handling cancellation safely.
418
-
419
- CLAUDE_ANCHOR[process-request-repl]: REPL's main request processor with error handling
420
- """
271
+ """Process input using the agent, handling cancellation safely."""
421
272
  import uuid
422
273
 
423
- # Generate a unique ID for this request for correlated logging
424
- request_id = str(uuid.uuid4())
425
- logger.debug(
426
- "Processing new request", extra={"request_id": request_id, "input_text": text[:100]}
427
- )
428
- state_manager.session.request_id = request_id
274
+ from tunacode.types import PlanPhase
275
+ from tunacode.utils.text_utils import expand_file_refs
276
+
277
+ state_manager.session.request_id = str(uuid.uuid4())
429
278
 
430
- # Check for cancellation before starting (only if explicitly set to True)
431
- operation_cancelled = getattr(state_manager.session, "operation_cancelled", False)
432
- if operation_cancelled is True:
433
- logger.debug("Operation cancelled before processing started")
279
+ if getattr(state_manager.session, "operation_cancelled", False) is True:
434
280
  raise CancelledError("Operation was cancelled")
435
281
 
436
282
  state_manager.session.spinner = await ui.spinner(
437
283
  True, state_manager.session.spinner, state_manager
438
284
  )
285
+
439
286
  try:
440
287
  patch_tool_messages(MSG_TOOL_INTERRUPTED, state_manager)
441
288
 
@@ -446,161 +293,157 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
446
293
 
447
294
  start_idx = len(state_manager.session.messages)
448
295
 
449
- def tool_callback_with_state(part, _node):
296
+ def tool_callback_with_state(part, _):
450
297
  return tool_handler(part, state_manager)
451
298
 
452
299
  try:
453
- from tunacode.utils.text_utils import expand_file_refs
454
-
455
300
  text, referenced_files = expand_file_refs(text)
456
- for file_path in referenced_files:
457
- state_manager.session.files_in_context.add(file_path)
301
+ state_manager.session.files_in_context.update(referenced_files)
458
302
  except ValueError as e:
459
303
  await ui.error(str(e))
460
304
  return
461
305
 
462
- # Check for cancellation before proceeding with agent call (only if explicitly set to True)
463
- operation_cancelled = getattr(state_manager.session, "operation_cancelled", False)
464
- if operation_cancelled is True:
465
- logger.debug("Operation cancelled before agent processing")
306
+ if getattr(state_manager.session, "operation_cancelled", False) is True:
466
307
  raise CancelledError("Operation was cancelled")
467
308
 
468
309
  enable_streaming = state_manager.session.user_config.get("settings", {}).get(
469
310
  "enable_streaming", True
470
311
  )
471
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
+
472
319
  if enable_streaming:
473
320
  await ui.spinner(False, state_manager.session.spinner, state_manager)
474
-
475
321
  state_manager.session.is_streaming_active = True
476
-
477
322
  streaming_panel = ui.StreamingAgentPanel()
478
323
  await streaming_panel.start()
479
-
480
324
  state_manager.session.streaming_panel = streaming_panel
481
325
 
482
326
  try:
483
-
484
- async def streaming_callback(content: str):
485
- await streaming_panel.update(content)
486
-
487
327
  res = await agent.process_request(
488
328
  text,
489
329
  state_manager.session.current_model,
490
330
  state_manager,
491
331
  tool_callback=tool_callback_with_state,
492
- streaming_callback=streaming_callback,
332
+ streaming_callback=lambda content: streaming_panel.update(content),
333
+ usage_tracker=usage_tracker,
493
334
  )
494
335
  finally:
495
336
  await streaming_panel.stop()
496
337
  state_manager.session.streaming_panel = None
497
338
  state_manager.session.is_streaming_active = False
498
-
499
- # Check if plan is ready for user review OR if agent presented text plan
500
- from tunacode.types import PlanPhase
501
- if hasattr(state_manager.session, 'plan_phase') and state_manager.session.plan_phase == PlanPhase.PLAN_READY:
502
- await _handle_plan_approval(state_manager, text)
503
- elif state_manager.is_plan_mode() and not getattr(state_manager.session, '_continuing_from_plan', False):
504
- # Check if agent presented a text plan instead of using the tool
505
- await _detect_and_handle_text_plan(state_manager, res, text)
506
339
  else:
507
- # Use normal agent processing
508
340
  res = await agent.process_request(
509
341
  text,
510
342
  state_manager.session.current_model,
511
343
  state_manager,
512
344
  tool_callback=tool_callback_with_state,
345
+ usage_tracker=usage_tracker,
513
346
  )
514
347
 
515
- # Check if plan is ready for user review OR if agent presented text plan
516
- from tunacode.types import PlanPhase
517
- if hasattr(state_manager.session, 'plan_phase') and state_manager.session.plan_phase == PlanPhase.PLAN_READY:
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
+ ):
518
353
  await _handle_plan_approval(state_manager, text)
519
- elif state_manager.is_plan_mode() and not getattr(state_manager.session, '_continuing_from_plan', False):
520
- # Check if agent presented a text plan instead of using the tool
354
+ elif state_manager.is_plan_mode() and not getattr(
355
+ state_manager.session, "_continuing_from_plan", False
356
+ ):
521
357
  await _detect_and_handle_text_plan(state_manager, res, text)
522
358
 
523
359
  if output:
524
360
  if state_manager.session.show_thoughts:
525
- new_msgs = state_manager.session.messages[start_idx:]
526
- for msg in new_msgs:
361
+ for msg in state_manager.session.messages[start_idx:]:
527
362
  if isinstance(msg, dict) and "thought" in msg:
528
363
  await ui.muted(f"THOUGHT: {msg['thought']}")
529
-
530
- # Only display result if not streaming (streaming already showed content)
531
- if enable_streaming:
532
- pass # Guard: streaming already showed content
533
- elif (
534
- not hasattr(res, "result")
535
- or res.result is None
536
- or not hasattr(res.result, "output")
537
- ):
538
- # Fallback: show that the request was processed
539
- await ui.muted(MSG_REQUEST_COMPLETED)
540
- else:
541
- # Use the dedicated function for displaying agent output
542
- await display_agent_output(res, enable_streaming, state_manager)
543
-
544
- # 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)
545
373
  if state_manager.session.files_in_context:
546
374
  filenames = [Path(f).name for f in sorted(state_manager.session.files_in_context)]
547
375
  await ui.muted(f"Files in context: {', '.join(filenames)}")
548
376
 
549
- # --- ERROR HANDLING ---
550
377
  except CancelledError:
551
378
  await ui.muted(MSG_REQUEST_CANCELLED)
552
379
  except UserAbortError:
553
380
  await ui.muted(MSG_OPERATION_ABORTED)
554
381
  except UnexpectedModelBehavior as e:
555
- error_message = str(e)
556
- await ui.muted(error_message)
557
- patch_tool_messages(error_message, state_manager)
382
+ await ui.muted(str(e))
383
+ patch_tool_messages(str(e), state_manager)
558
384
  except Exception as e:
559
- # Try tool recovery for tool-related errors
560
- if await attempt_tool_recovery(e, state_manager):
561
- return # Successfully recovered
562
-
563
- agent_error = AgentError(f"Agent processing failed: {str(e)}")
564
- agent_error.__cause__ = e # Preserve the original exception chain
565
- await ui.error(str(e))
385
+ if not await attempt_tool_recovery(e, state_manager):
386
+ await ui.error(str(e))
566
387
  finally:
567
388
  await ui.spinner(False, state_manager.session.spinner, state_manager)
568
389
  state_manager.session.current_task = None
569
- # Reset cancellation flag when task completes (if attribute exists)
570
390
  if hasattr(state_manager.session, "operation_cancelled"):
571
391
  state_manager.session.operation_cancelled = False
572
-
573
392
  if "multiline" in state_manager.session.input_sessions:
574
393
  await run_in_terminal(
575
394
  lambda: state_manager.session.input_sessions["multiline"].app.invalidate()
576
395
  )
577
396
 
578
397
 
579
- # ============================================================================
580
- # MAIN REPL LOOP
581
- # ============================================================================
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}")
582
410
 
583
411
 
584
412
  async def repl(state_manager: StateManager):
585
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
+
586
419
  action = None
587
420
  abort_pressed = False
588
421
  last_abort_time = 0.0
589
422
 
590
- model_name = state_manager.session.current_model
591
423
  max_tokens = (
592
424
  state_manager.session.user_config.get("context_window_size") or DEFAULT_CONTEXT_WINDOW
593
425
  )
594
426
  state_manager.session.max_tokens = max_tokens
595
-
596
427
  state_manager.session.update_token_count()
597
- context_display = get_context_window_display(state_manager.session.total_tokens, max_tokens)
598
428
 
599
- # Only show startup info if thoughts are enabled or on first run
600
- if state_manager.session.show_thoughts or not hasattr(state_manager.session, "_startup_shown"):
601
- await ui.muted(f"• Model: {model_name} • {context_display}")
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"):
602
446
  await ui.success("Ready to assist")
603
- await ui.line()
604
447
  state_manager.session._startup_shown = True
605
448
 
606
449
  instance = agent.get_or_create_agent(state_manager.session.current_model, state_manager)
@@ -610,17 +453,11 @@ async def repl(state_manager: StateManager):
610
453
  try:
611
454
  line = await ui.multiline_input(state_manager, _command_registry)
612
455
  except UserAbortError:
613
- import time
614
-
615
456
  current_time = time.time()
616
-
617
- # Reset if more than 3 seconds have passed
618
457
  if current_time - last_abort_time > 3.0:
619
458
  abort_pressed = False
620
-
621
459
  if abort_pressed:
622
460
  break
623
-
624
461
  abort_pressed = True
625
462
  last_abort_time = current_time
626
463
  await ui.warning(MSG_HIT_ABORT_KEY)
@@ -628,7 +465,6 @@ async def repl(state_manager: StateManager):
628
465
 
629
466
  if not line:
630
467
  continue
631
-
632
468
  abort_pressed = False
633
469
 
634
470
  if line.lower() in ["exit", "quit"]:
@@ -639,54 +475,40 @@ async def repl(state_manager: StateManager):
639
475
  if action == "restart":
640
476
  break
641
477
  elif isinstance(action, str) and action:
642
- # If the command returned a string (e.g., from template shortcut),
643
- # process it as a prompt
644
478
  line = action
645
- # Fall through to process as normal text
646
479
  else:
647
480
  continue
648
481
 
649
482
  if line.startswith("!"):
650
483
  command = line[1:].strip()
651
-
652
- cmd_display = command if command else "Interactive shell"
653
- await ui.panel("Tool(bash)", f"Command: {cmd_display}", border_style="yellow")
484
+ await ui.panel(
485
+ "Tool(bash)",
486
+ f"Command: {command or 'Interactive shell'}",
487
+ border_style="yellow",
488
+ )
654
489
 
655
490
  def run_shell():
656
491
  try:
657
492
  if command:
658
- try:
659
- result = safe_subprocess_run(
660
- command,
661
- shell=True,
662
- validate=True, # Still validate for basic safety
663
- capture_output=False,
664
- )
665
- if result.returncode != 0:
666
- ui.console.print(
667
- f"\nCommand exited with code {result.returncode}"
668
- )
669
- except CommandSecurityError as e:
670
- ui.console.print(f"\nSecurity validation failed: {str(e)}")
671
- ui.console.print(
672
- "If you need to run this command, please ensure it's safe."
673
- )
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}")
674
498
  else:
675
- shell = os.environ.get(SHELL_ENV_VAR, DEFAULT_SHELL)
676
- subprocess.run(shell) # Interactive shell is safe
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)}")
677
502
  except Exception as e:
678
503
  ui.console.print(f"\nShell command failed: {str(e)}")
679
504
 
680
505
  await run_in_terminal(run_shell)
681
- await ui.line()
682
506
  continue
683
507
 
684
- # --- AGENT REQUEST PROCESSING ---
685
508
  if state_manager.session.current_task and not state_manager.session.current_task.done():
686
509
  await ui.muted(MSG_AGENT_BUSY)
687
510
  continue
688
511
 
689
- # Reset cancellation flag for new operations (if attribute exists)
690
512
  if hasattr(state_manager.session, "operation_cancelled"):
691
513
  state_manager.session.operation_cancelled = False
692
514
 
@@ -696,39 +518,24 @@ async def repl(state_manager: StateManager):
696
518
  await state_manager.session.current_task
697
519
 
698
520
  state_manager.session.update_token_count()
699
- context_display = get_context_window_display(
700
- state_manager.session.total_tokens, state_manager.session.max_tokens
701
- )
702
- # Only show model/context info if thoughts are enabled
703
- if state_manager.session.show_thoughts:
704
- await ui.muted(
705
- f"• Model: {state_manager.session.current_model} • {context_display}"
706
- )
521
+ await show_context()
707
522
 
708
- if action == "restart":
709
- await repl(state_manager)
710
- else:
711
- # Show session cost summary if available
712
- session_total = state_manager.session.session_total_usage
713
- if session_total:
714
- try:
715
- prompt = int(session_total.get("prompt_tokens", 0) or 0)
716
- completion = int(session_total.get("completion_tokens", 0) or 0)
717
- total_tokens = prompt + completion
718
- total_cost = float(session_total.get("cost", 0) or 0)
719
-
720
- # Only show summary if we have actual token usage
721
- if state_manager.session.show_thoughts and (total_tokens > 0 or total_cost > 0):
722
- summary = (
723
- f"\n[bold cyan]TunaCode Session Summary[/bold cyan]\n"
724
- f" - Total Tokens: {total_tokens:,}\n"
725
- f" - Prompt Tokens: {prompt:,}\n"
726
- f" - Completion Tokens: {completion:,}\n"
727
- f" - [bold green]Total Session Cost: ${total_cost:.4f}[/bold green]"
728
- )
729
- ui.console.print(summary)
730
- except (TypeError, ValueError) as e:
731
- # Skip displaying summary if values can't be converted to numbers
732
- logger.debug(f"Failed to display token usage summary: {e}")
733
-
734
- 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)