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.

Files changed (47) hide show
  1. tunacode/cli/commands/implementations/plan.py +50 -0
  2. tunacode/cli/commands/registry.py +3 -0
  3. tunacode/cli/repl.py +327 -186
  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 +21 -1
  7. tunacode/cli/repl_components/tool_executor.py +12 -0
  8. tunacode/configuration/defaults.py +8 -0
  9. tunacode/constants.py +10 -2
  10. tunacode/core/agents/agent_components/agent_config.py +212 -22
  11. tunacode/core/agents/agent_components/node_processor.py +46 -40
  12. tunacode/core/code_index.py +83 -29
  13. tunacode/core/state.py +44 -0
  14. tunacode/core/token_usage/usage_tracker.py +2 -2
  15. tunacode/core/tool_handler.py +20 -0
  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 +273 -0
  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 +288 -0
  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 +58 -0
  33. tunacode/ui/input.py +14 -2
  34. tunacode/ui/keybindings.py +25 -4
  35. tunacode/ui/panels.py +53 -8
  36. tunacode/ui/prompt_manager.py +25 -2
  37. tunacode/ui/tool_ui.py +3 -2
  38. tunacode/utils/json_utils.py +206 -0
  39. tunacode/utils/message_utils.py +14 -4
  40. tunacode/utils/ripgrep.py +332 -9
  41. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/METADATA +8 -3
  42. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/RECORD +46 -42
  43. tunacode/tools/read_file_async_poc.py +0 -196
  44. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/WHEEL +0 -0
  45. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/entry_points.txt +0 -0
  46. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.57.dist-info}/licenses/LICENSE +0 -0
  47. {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.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,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
- # COMMAND SYSTEM
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
- _command_registry = CommandRegistry()
62
- _command_registry.register_all_default_commands()
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
- async def _handle_command(command: str, state_manager: StateManager) -> CommandResult:
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
- Returns:
74
- Command result (varies by command).
75
- """
76
- context = CommandContext(state_manager=state_manager, process_request=process_request)
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
- _command_registry.set_process_request_callback(process_request)
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
- return await _command_registry.execute(command, context)
82
- except ValidationError as e:
83
- await ui.error(str(e))
84
- return None
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
- # The _attempt_tool_recovery function has been moved to repl_components.error_recovery
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
- # The _display_agent_output function has been moved to repl_components.output_display
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
- async def process_request(text: str, state_manager: StateManager, output: bool = True):
99
- """Process input using the agent, handling cancellation safely.
170
+ from tunacode.types import PlanPhase
171
+ from tunacode.ui.keybindings import create_key_bindings
100
172
 
101
- CLAUDE_ANCHOR[process-request-repl]: REPL's main request processor with error handling
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
- # Generate a unique ID for this request for correlated logging
106
- request_id = str(uuid.uuid4())
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
- # Check for cancellation before starting (only if explicitly set to True)
113
- operation_cancelled = getattr(state_manager.session, "operation_cancelled", False)
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, _node):
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
- for file_path in referenced_files:
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
- # Check for cancellation before proceeding with agent call (only if explicitly set to True)
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=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
- new_msgs = state_manager.session.messages[start_idx:]
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
- # Only display result if not streaming (streaming already showed content)
197
- if enable_streaming:
198
- pass # Guard: streaming already showed content
199
- elif (
200
- not hasattr(res, "result")
201
- or res.result is None
202
- or not hasattr(res.result, "output")
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
- error_message = str(e)
222
- await ui.muted(error_message)
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
- # Try tool recovery for tool-related errors
226
- if await attempt_tool_recovery(e, state_manager):
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
- # MAIN REPL LOOP
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
- # Only show startup info if thoughts are enabled or on first run
266
- if state_manager.session.show_thoughts or not hasattr(state_manager.session, "_startup_shown"):
267
- 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"):
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
- cmd_display = command if command else "Interactive shell"
319
- 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
+ )
320
489
 
321
490
  def run_shell():
322
491
  try:
323
492
  if command:
324
- try:
325
- result = safe_subprocess_run(
326
- command,
327
- shell=True,
328
- validate=True, # Still validate for basic safety
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
- shell = os.environ.get(SHELL_ENV_VAR, DEFAULT_SHELL)
342
- 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)}")
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
- context_display = get_context_window_display(
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
- 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}")
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)