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
@@ -343,45 +343,50 @@ async def _process_tool_calls(
343
343
  f"[bold #00d7ff]{batch_msg}...[/bold #00d7ff]", state_manager
344
344
  )
345
345
 
346
- # Enhanced visual feedback for parallel execution
347
- await ui.muted("\n" + "=" * 60)
348
- await ui.muted(
349
- f"🚀 PARALLEL BATCH #{batch_id}: Executing {len(buffered_tasks)} read-only tools concurrently"
350
- )
351
- await ui.muted("=" * 60)
352
-
353
- # Display details of what's being executed
354
- for idx, (buffered_part, _) in enumerate(buffered_tasks, 1):
355
- tool_desc = f" [{idx}] {buffered_part.tool_name}"
356
- if hasattr(buffered_part, "args") and isinstance(
357
- buffered_part.args, dict
358
- ):
359
- if (
360
- buffered_part.tool_name == "read_file"
361
- and "file_path" in buffered_part.args
362
- ):
363
- tool_desc += f" → {buffered_part.args['file_path']}"
364
- elif (
365
- buffered_part.tool_name == "grep"
366
- and "pattern" in buffered_part.args
346
+ # Enhanced visual feedback for parallel execution (suppress in plan mode)
347
+ if not state_manager.is_plan_mode():
348
+ await ui.muted("\n" + "=" * 60)
349
+ await ui.muted(
350
+ f"🚀 PARALLEL BATCH #{batch_id}: Executing {len(buffered_tasks)} read-only tools concurrently"
351
+ )
352
+ await ui.muted("=" * 60)
353
+
354
+ # Display details of what's being executed
355
+ for idx, (buffered_part, _) in enumerate(buffered_tasks, 1):
356
+ tool_desc = f" [{idx}] {buffered_part.tool_name}"
357
+ if hasattr(buffered_part, "args") and isinstance(
358
+ buffered_part.args, dict
367
359
  ):
368
- tool_desc += f" → pattern: '{buffered_part.args['pattern']}'"
369
- if "include_files" in buffered_part.args:
360
+ if (
361
+ buffered_part.tool_name == "read_file"
362
+ and "file_path" in buffered_part.args
363
+ ):
364
+ tool_desc += f" → {buffered_part.args['file_path']}"
365
+ elif (
366
+ buffered_part.tool_name == "grep"
367
+ and "pattern" in buffered_part.args
368
+ ):
370
369
  tool_desc += (
371
- f", files: '{buffered_part.args['include_files']}'"
370
+ f" → pattern: '{buffered_part.args['pattern']}'"
372
371
  )
373
- elif (
374
- buffered_part.tool_name == "list_dir"
375
- and "directory" in buffered_part.args
376
- ):
377
- tool_desc += f" → {buffered_part.args['directory']}"
378
- elif (
379
- buffered_part.tool_name == "glob"
380
- and "pattern" in buffered_part.args
381
- ):
382
- tool_desc += f" → pattern: '{buffered_part.args['pattern']}'"
383
- await ui.muted(tool_desc)
384
- await ui.muted("=" * 60)
372
+ if "include_files" in buffered_part.args:
373
+ tool_desc += (
374
+ f", files: '{buffered_part.args['include_files']}'"
375
+ )
376
+ elif (
377
+ buffered_part.tool_name == "list_dir"
378
+ and "directory" in buffered_part.args
379
+ ):
380
+ tool_desc += f" → {buffered_part.args['directory']}"
381
+ elif (
382
+ buffered_part.tool_name == "glob"
383
+ and "pattern" in buffered_part.args
384
+ ):
385
+ tool_desc += (
386
+ f" → pattern: '{buffered_part.args['pattern']}'"
387
+ )
388
+ await ui.muted(tool_desc)
389
+ await ui.muted("=" * 60)
385
390
 
386
391
  await execute_tools_parallel(buffered_tasks, tool_callback)
387
392
 
@@ -391,10 +396,11 @@ async def _process_tool_calls(
391
396
  ) # Assume 100ms per tool average
392
397
  speedup = sequential_estimate / elapsed_time if elapsed_time > 0 else 1.0
393
398
 
394
- await ui.muted(
395
- f"✅ Parallel batch completed in {elapsed_time:.0f}ms "
396
- f"(~{speedup:.1f}x faster than sequential)\n"
397
- )
399
+ if not state_manager.is_plan_mode():
400
+ await ui.muted(
401
+ f"✅ Parallel batch completed in {elapsed_time:.0f}ms "
402
+ f"(~{speedup:.1f}x faster than sequential)\n"
403
+ )
398
404
 
399
405
  # Reset spinner message back to thinking
400
406
  from tunacode.constants import UI_THINKING_MESSAGE
@@ -3,6 +3,7 @@
3
3
  import logging
4
4
  import os
5
5
  import threading
6
+ import time
6
7
  from collections import defaultdict
7
8
  from pathlib import Path
8
9
  from typing import Dict, List, Optional, Set
@@ -17,6 +18,10 @@ class CodeIndex:
17
18
  grep searches that can timeout in large repositories.
18
19
  """
19
20
 
21
+ # Singleton instance
22
+ _instance: Optional["CodeIndex"] = None
23
+ _instance_lock = threading.RLock()
24
+
20
25
  # Directories to ignore during indexing
21
26
  IGNORE_DIRS = {
22
27
  ".git",
@@ -121,8 +126,83 @@ class CodeIndex:
121
126
  # Cache for directory contents
122
127
  self._dir_cache: Dict[Path, List[Path]] = {}
123
128
 
129
+ # Cache freshness tracking
130
+ self._cache_timestamps: Dict[Path, float] = {}
131
+ self._cache_ttl = 5.0 # 5 seconds TTL for directory cache
132
+
124
133
  self._indexed = False
125
134
 
135
+ @classmethod
136
+ def get_instance(cls, root_dir: Optional[str] = None) -> "CodeIndex":
137
+ """Get the singleton CodeIndex instance.
138
+
139
+ Args:
140
+ root_dir: Root directory to index. Only used on first call.
141
+
142
+ Returns:
143
+ The singleton CodeIndex instance.
144
+ """
145
+ if cls._instance is None:
146
+ with cls._instance_lock:
147
+ if cls._instance is None:
148
+ cls._instance = cls(root_dir)
149
+ return cls._instance
150
+
151
+ @classmethod
152
+ def reset_instance(cls) -> None:
153
+ """Reset the singleton instance (for testing)."""
154
+ with cls._instance_lock:
155
+ cls._instance = None
156
+
157
+ def get_directory_contents(self, path: Path) -> List[str]:
158
+ """Get cached directory contents if available and fresh.
159
+
160
+ Args:
161
+ path: Directory path to check
162
+
163
+ Returns:
164
+ List of filenames in directory, empty list if not cached/stale
165
+ """
166
+ with self._lock:
167
+ if path not in self._dir_cache:
168
+ return []
169
+
170
+ if not self.is_cache_fresh(path):
171
+ # Remove stale entry
172
+ self._dir_cache.pop(path, None)
173
+ self._cache_timestamps.pop(path, None)
174
+ return []
175
+
176
+ # Return just the filenames, not Path objects
177
+ return [p.name for p in self._dir_cache[path]]
178
+
179
+ def is_cache_fresh(self, path: Path) -> bool:
180
+ """Check if cached directory data is still fresh.
181
+
182
+ Args:
183
+ path: Directory path to check
184
+
185
+ Returns:
186
+ True if cache is fresh, False if stale or missing
187
+ """
188
+ if path not in self._cache_timestamps:
189
+ return False
190
+
191
+ age = time.time() - self._cache_timestamps[path]
192
+ return age < self._cache_ttl
193
+
194
+ def update_directory_cache(self, path: Path, entries: List[str]) -> None:
195
+ """Update the directory cache with fresh data.
196
+
197
+ Args:
198
+ path: Directory path
199
+ entries: List of filenames in the directory
200
+ """
201
+ with self._lock:
202
+ # Convert filenames back to Path objects for internal storage
203
+ self._dir_cache[path] = [Path(path) / entry for entry in entries]
204
+ self._cache_timestamps[path] = time.time()
205
+
126
206
  def build_index(self, force: bool = False) -> None:
127
207
  """Build the file index for the repository.
128
208
 
@@ -152,6 +232,7 @@ class CodeIndex:
152
232
  self._class_definitions.clear()
153
233
  self._function_definitions.clear()
154
234
  self._dir_cache.clear()
235
+ self._cache_timestamps.clear()
155
236
 
156
237
  def _should_ignore_path(self, path: Path) -> bool:
157
238
  """Check if a path should be ignored during indexing."""
@@ -183,8 +264,9 @@ class CodeIndex:
183
264
  self._index_file(entry)
184
265
  file_list.append(entry)
185
266
 
186
- # Cache directory contents
267
+ # Cache directory contents with timestamp
187
268
  self._dir_cache[directory] = file_list
269
+ self._cache_timestamps[directory] = time.time()
188
270
 
189
271
  except PermissionError:
190
272
  logger.debug(f"Permission denied: {directory}")
@@ -352,34 +434,6 @@ class CodeIndex:
352
434
 
353
435
  return sorted(self._all_files)
354
436
 
355
- def get_directory_contents(self, directory: str) -> List[Path]:
356
- """Get cached contents of a directory.
357
-
358
- Args:
359
- directory: Directory path relative to root
360
-
361
- Returns:
362
- List of file paths in the directory.
363
- """
364
- with self._lock:
365
- if not self._indexed:
366
- self.build_index()
367
-
368
- dir_path = self.root_dir / directory
369
- if dir_path in self._dir_cache:
370
- return [p.relative_to(self.root_dir) for p in self._dir_cache[dir_path]]
371
-
372
- # Fallback to scanning if not in cache
373
- results = []
374
- for file_path in self._all_files:
375
- if str(file_path).startswith(directory + os.sep):
376
- # Only include direct children
377
- relative = str(file_path)[len(directory) + 1 :]
378
- if os.sep not in relative:
379
- results.append(file_path)
380
-
381
- return sorted(results)
382
-
383
437
  def find_imports(self, module_name: str) -> List[Path]:
384
438
  """Find files that import a specific module.
385
439
 
tunacode/core/state.py CHANGED
@@ -15,6 +15,8 @@ from tunacode.types import (
15
15
  InputSessions,
16
16
  MessageHistory,
17
17
  ModelName,
18
+ PlanDoc,
19
+ PlanPhase,
18
20
  SessionId,
19
21
  TodoItem,
20
22
  ToolName,
@@ -85,6 +87,12 @@ class SessionState:
85
87
  iteration_budgets: dict[str, int] = field(default_factory=dict)
86
88
  recursive_context_stack: list[dict[str, Any]] = field(default_factory=list)
87
89
 
90
+ # Plan Mode state tracking
91
+ plan_mode: bool = False
92
+ plan_phase: Optional[PlanPhase] = None
93
+ current_plan: Optional[PlanDoc] = None
94
+ plan_approved: bool = False
95
+
88
96
  def update_token_count(self):
89
97
  """Calculates the total token count from messages and files in context."""
90
98
  message_contents = [get_message_content(msg) for msg in self.messages]
@@ -167,3 +175,39 @@ class StateManager:
167
175
  def reset_session(self) -> None:
168
176
  """Reset the session to a fresh state."""
169
177
  self._session = SessionState()
178
+
179
+ # Plan Mode methods
180
+ def enter_plan_mode(self) -> None:
181
+ """Enter plan mode - restricts to read-only operations."""
182
+ self._session.plan_mode = True
183
+ self._session.plan_phase = PlanPhase.PLANNING_RESEARCH
184
+ self._session.current_plan = None
185
+ self._session.plan_approved = False
186
+ # Clear agent cache to force recreation with plan mode tools
187
+ self._session.agents.clear()
188
+
189
+ def exit_plan_mode(self, plan: Optional[PlanDoc] = None) -> None:
190
+ """Exit plan mode with optional plan data."""
191
+ self._session.plan_mode = False
192
+ self._session.plan_phase = None
193
+ self._session.current_plan = plan
194
+ self._session.plan_approved = False
195
+ # Clear agent cache to force recreation without plan mode tools
196
+ self._session.agents.clear()
197
+
198
+ def approve_plan(self) -> None:
199
+ """Mark current plan as approved for execution."""
200
+ self._session.plan_approved = True
201
+ self._session.plan_mode = False
202
+
203
+ def is_plan_mode(self) -> bool:
204
+ """Check if currently in plan mode."""
205
+ return self._session.plan_mode
206
+
207
+ def set_current_plan(self, plan: PlanDoc) -> None:
208
+ """Set the current plan data."""
209
+ self._session.current_plan = plan
210
+
211
+ def get_current_plan(self) -> Optional[PlanDoc]:
212
+ """Get the current plan data."""
213
+ return self._session.current_plan
@@ -37,10 +37,10 @@ class UsageTracker(UsageTrackerProtocol):
37
37
  # 2. Calculate the cost
38
38
  cost = self._calculate_cost(parsed_data)
39
39
 
40
- # 3. Update the session state
40
+ # 3. Update the session state (always done to track totals for session cost display)
41
41
  self._update_state(parsed_data, cost)
42
42
 
43
- # 4. Display the summary if enabled
43
+ # 4. Display detailed per-call summary only if debugging enabled
44
44
  if self.state_manager.session.show_thoughts:
45
45
  await self._display_summary()
46
46
 
@@ -41,6 +41,14 @@ class ToolHandler:
41
41
  Returns:
42
42
  bool: True if confirmation is required, False otherwise.
43
43
  """
44
+ # Never confirm present_plan - it has its own approval flow
45
+ if tool_name == "present_plan":
46
+ return False
47
+
48
+ # Block write tools in plan mode
49
+ if self.is_tool_blocked_in_plan_mode(tool_name):
50
+ return True # Force confirmation for blocked tools
51
+
44
52
  # Skip confirmation for read-only tools
45
53
  if is_read_only_tool(tool_name):
46
54
  return False
@@ -52,6 +60,18 @@ class ToolHandler:
52
60
 
53
61
  return not (self.state.session.yolo or tool_name in self.state.session.tool_ignore)
54
62
 
63
+ def is_tool_blocked_in_plan_mode(self, tool_name: ToolName) -> bool:
64
+ """Check if tool is blocked in plan mode."""
65
+ if not self.state.is_plan_mode():
66
+ return False
67
+
68
+ # Allow present_plan tool to end planning phase
69
+ if tool_name == "present_plan":
70
+ return False
71
+
72
+ # Allow read-only tools
73
+ return not is_read_only_tool(tool_name)
74
+
55
75
  def process_confirmation(self, response: ToolConfirmationResponse, tool_name: ToolName) -> bool:
56
76
  """
57
77
  Process the confirmation response.