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
|
@@ -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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
buffered_part
|
|
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
|
-
|
|
369
|
-
|
|
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"
|
|
370
|
+
f" → pattern: '{buffered_part.args['pattern']}'"
|
|
372
371
|
)
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
tunacode/core/code_index.py
CHANGED
|
@@ -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
|
|
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
|
|
tunacode/core/tool_handler.py
CHANGED
|
@@ -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.
|