tunacode-cli 0.0.29__py3-none-any.whl → 0.0.31__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.
- api/auth.py +13 -0
- api/users.py +8 -0
- tunacode/cli/commands.py +115 -233
- tunacode/cli/repl.py +53 -63
- tunacode/cli/textual_bridge.py +4 -1
- tunacode/constants.py +10 -1
- tunacode/core/agents/__init__.py +0 -4
- tunacode/core/agents/main.py +454 -49
- tunacode/core/code_index.py +479 -0
- tunacode/core/setup/git_safety_setup.py +7 -9
- tunacode/core/state.py +5 -0
- tunacode/core/tool_handler.py +18 -0
- tunacode/exceptions.py +13 -0
- tunacode/prompts/system.md +269 -30
- tunacode/tools/glob.py +288 -0
- tunacode/tools/grep.py +168 -195
- tunacode/tools/list_dir.py +190 -0
- tunacode/tools/read_file.py +9 -3
- tunacode/tools/read_file_async_poc.py +188 -0
- tunacode/utils/text_utils.py +14 -5
- tunacode/utils/token_counter.py +23 -0
- {tunacode_cli-0.0.29.dist-info → tunacode_cli-0.0.31.dist-info}/METADATA +16 -7
- {tunacode_cli-0.0.29.dist-info → tunacode_cli-0.0.31.dist-info}/RECORD +27 -24
- {tunacode_cli-0.0.29.dist-info → tunacode_cli-0.0.31.dist-info}/top_level.txt +1 -0
- tunacode/core/agents/orchestrator.py +0 -213
- tunacode/core/agents/planner_schema.py +0 -9
- tunacode/core/agents/readonly.py +0 -65
- tunacode/core/llm/planner.py +0 -62
- {tunacode_cli-0.0.29.dist-info → tunacode_cli-0.0.31.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.29.dist-info → tunacode_cli-0.0.31.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.29.dist-info → tunacode_cli-0.0.31.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Fast in-memory code index for efficient file lookups."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional, Set
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodeIndex:
|
|
14
|
+
"""Fast in-memory code index for repository file lookups.
|
|
15
|
+
|
|
16
|
+
This index provides efficient file discovery without relying on
|
|
17
|
+
grep searches that can timeout in large repositories.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# Directories to ignore during indexing
|
|
21
|
+
IGNORE_DIRS = {
|
|
22
|
+
".git",
|
|
23
|
+
".hg",
|
|
24
|
+
".svn",
|
|
25
|
+
".bzr",
|
|
26
|
+
"__pycache__",
|
|
27
|
+
".pytest_cache",
|
|
28
|
+
".mypy_cache",
|
|
29
|
+
"node_modules",
|
|
30
|
+
"bower_components",
|
|
31
|
+
".venv",
|
|
32
|
+
"venv",
|
|
33
|
+
"env",
|
|
34
|
+
".env",
|
|
35
|
+
"build",
|
|
36
|
+
"dist",
|
|
37
|
+
"_build",
|
|
38
|
+
"target",
|
|
39
|
+
".idea",
|
|
40
|
+
".vscode",
|
|
41
|
+
".vs",
|
|
42
|
+
"htmlcov",
|
|
43
|
+
".coverage",
|
|
44
|
+
".tox",
|
|
45
|
+
".eggs",
|
|
46
|
+
"*.egg-info",
|
|
47
|
+
".bundle",
|
|
48
|
+
"vendor",
|
|
49
|
+
".terraform",
|
|
50
|
+
".serverless",
|
|
51
|
+
".next",
|
|
52
|
+
".nuxt",
|
|
53
|
+
"coverage",
|
|
54
|
+
"tmp",
|
|
55
|
+
"temp",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# File extensions to index
|
|
59
|
+
INDEXED_EXTENSIONS = {
|
|
60
|
+
".py",
|
|
61
|
+
".js",
|
|
62
|
+
".jsx",
|
|
63
|
+
".ts",
|
|
64
|
+
".tsx",
|
|
65
|
+
".java",
|
|
66
|
+
".c",
|
|
67
|
+
".cpp",
|
|
68
|
+
".cc",
|
|
69
|
+
".cxx",
|
|
70
|
+
".h",
|
|
71
|
+
".hpp",
|
|
72
|
+
".rs",
|
|
73
|
+
".go",
|
|
74
|
+
".rb",
|
|
75
|
+
".php",
|
|
76
|
+
".cs",
|
|
77
|
+
".swift",
|
|
78
|
+
".kt",
|
|
79
|
+
".scala",
|
|
80
|
+
".sh",
|
|
81
|
+
".bash",
|
|
82
|
+
".zsh",
|
|
83
|
+
".json",
|
|
84
|
+
".yaml",
|
|
85
|
+
".yml",
|
|
86
|
+
".toml",
|
|
87
|
+
".xml",
|
|
88
|
+
".md",
|
|
89
|
+
".rst",
|
|
90
|
+
".txt",
|
|
91
|
+
".html",
|
|
92
|
+
".css",
|
|
93
|
+
".scss",
|
|
94
|
+
".sass",
|
|
95
|
+
".sql",
|
|
96
|
+
".graphql",
|
|
97
|
+
".dockerfile",
|
|
98
|
+
".containerfile",
|
|
99
|
+
".gitignore",
|
|
100
|
+
".env.example",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def __init__(self, root_dir: Optional[str] = None):
|
|
104
|
+
"""Initialize the code index.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
root_dir: Root directory to index. Defaults to current directory.
|
|
108
|
+
"""
|
|
109
|
+
self.root_dir = Path(root_dir or os.getcwd()).resolve()
|
|
110
|
+
self._lock = threading.RLock()
|
|
111
|
+
|
|
112
|
+
# Primary indices
|
|
113
|
+
self._basename_to_paths: Dict[str, List[Path]] = defaultdict(list)
|
|
114
|
+
self._path_to_imports: Dict[Path, Set[str]] = {}
|
|
115
|
+
self._all_files: Set[Path] = set()
|
|
116
|
+
|
|
117
|
+
# Symbol indices for common patterns
|
|
118
|
+
self._class_definitions: Dict[str, List[Path]] = defaultdict(list)
|
|
119
|
+
self._function_definitions: Dict[str, List[Path]] = defaultdict(list)
|
|
120
|
+
|
|
121
|
+
# Cache for directory contents
|
|
122
|
+
self._dir_cache: Dict[Path, List[Path]] = {}
|
|
123
|
+
|
|
124
|
+
self._indexed = False
|
|
125
|
+
|
|
126
|
+
def build_index(self, force: bool = False) -> None:
|
|
127
|
+
"""Build the file index for the repository.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
force: Force rebuild even if already indexed.
|
|
131
|
+
"""
|
|
132
|
+
with self._lock:
|
|
133
|
+
if self._indexed and not force:
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
logger.info(f"Building code index for {self.root_dir}")
|
|
137
|
+
self._clear_indices()
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
self._scan_directory(self.root_dir)
|
|
141
|
+
self._indexed = True
|
|
142
|
+
logger.info(f"Indexed {len(self._all_files)} files")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(f"Error building index: {e}")
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
def _clear_indices(self) -> None:
|
|
148
|
+
"""Clear all indices."""
|
|
149
|
+
self._basename_to_paths.clear()
|
|
150
|
+
self._path_to_imports.clear()
|
|
151
|
+
self._all_files.clear()
|
|
152
|
+
self._class_definitions.clear()
|
|
153
|
+
self._function_definitions.clear()
|
|
154
|
+
self._dir_cache.clear()
|
|
155
|
+
|
|
156
|
+
def _should_ignore_path(self, path: Path) -> bool:
|
|
157
|
+
"""Check if a path should be ignored during indexing."""
|
|
158
|
+
# Check against ignore patterns
|
|
159
|
+
parts = path.parts
|
|
160
|
+
for part in parts:
|
|
161
|
+
if part in self.IGNORE_DIRS:
|
|
162
|
+
return True
|
|
163
|
+
if part.startswith(".") and part != ".":
|
|
164
|
+
# Skip hidden directories except current directory
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
def _scan_directory(self, directory: Path) -> None:
|
|
170
|
+
"""Recursively scan a directory and index files."""
|
|
171
|
+
if self._should_ignore_path(directory):
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
entries = list(directory.iterdir())
|
|
176
|
+
file_list = []
|
|
177
|
+
|
|
178
|
+
for entry in entries:
|
|
179
|
+
if entry.is_dir():
|
|
180
|
+
self._scan_directory(entry)
|
|
181
|
+
elif entry.is_file():
|
|
182
|
+
if self._should_index_file(entry):
|
|
183
|
+
self._index_file(entry)
|
|
184
|
+
file_list.append(entry)
|
|
185
|
+
|
|
186
|
+
# Cache directory contents
|
|
187
|
+
self._dir_cache[directory] = file_list
|
|
188
|
+
|
|
189
|
+
except PermissionError:
|
|
190
|
+
logger.debug(f"Permission denied: {directory}")
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.warning(f"Error scanning {directory}: {e}")
|
|
193
|
+
|
|
194
|
+
def _should_index_file(self, file_path: Path) -> bool:
|
|
195
|
+
"""Check if a file should be indexed."""
|
|
196
|
+
# Check extension
|
|
197
|
+
if file_path.suffix.lower() not in self.INDEXED_EXTENSIONS:
|
|
198
|
+
# Also index files with no extension if they might be scripts
|
|
199
|
+
if file_path.suffix == "":
|
|
200
|
+
# Check for shebang or common script names
|
|
201
|
+
name = file_path.name.lower()
|
|
202
|
+
if name in {"makefile", "dockerfile", "jenkinsfile", "rakefile"}:
|
|
203
|
+
return True
|
|
204
|
+
# Try to detect shebang
|
|
205
|
+
try:
|
|
206
|
+
with open(file_path, "rb") as f:
|
|
207
|
+
first_bytes = f.read(2)
|
|
208
|
+
if first_bytes == b"#!":
|
|
209
|
+
return True
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
# Skip very large files
|
|
215
|
+
try:
|
|
216
|
+
if file_path.stat().st_size > 10 * 1024 * 1024: # 10MB
|
|
217
|
+
return False
|
|
218
|
+
except Exception:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
def _index_file(self, file_path: Path) -> None:
|
|
224
|
+
"""Index a single file."""
|
|
225
|
+
relative_path = file_path.relative_to(self.root_dir)
|
|
226
|
+
|
|
227
|
+
# Add to all files set
|
|
228
|
+
self._all_files.add(relative_path)
|
|
229
|
+
|
|
230
|
+
# Index by basename
|
|
231
|
+
basename = file_path.name
|
|
232
|
+
self._basename_to_paths[basename].append(relative_path)
|
|
233
|
+
|
|
234
|
+
# For Python files, extract additional information
|
|
235
|
+
if file_path.suffix == ".py":
|
|
236
|
+
self._index_python_file(file_path, relative_path)
|
|
237
|
+
|
|
238
|
+
def _index_python_file(self, file_path: Path, relative_path: Path) -> None:
|
|
239
|
+
"""Extract Python-specific information from a file."""
|
|
240
|
+
try:
|
|
241
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
242
|
+
content = f.read()
|
|
243
|
+
|
|
244
|
+
imports = set()
|
|
245
|
+
|
|
246
|
+
# Quick regex-free parsing for common patterns
|
|
247
|
+
for line in content.splitlines():
|
|
248
|
+
line = line.strip()
|
|
249
|
+
|
|
250
|
+
# Import statements
|
|
251
|
+
if line.startswith("import ") or line.startswith("from "):
|
|
252
|
+
parts = line.split()
|
|
253
|
+
if len(parts) >= 2:
|
|
254
|
+
if parts[0] == "import":
|
|
255
|
+
imports.add(parts[1].split(".")[0])
|
|
256
|
+
elif parts[0] == "from" and len(parts) >= 3:
|
|
257
|
+
imports.add(parts[1].split(".")[0])
|
|
258
|
+
|
|
259
|
+
# Class definitions
|
|
260
|
+
if line.startswith("class ") and ":" in line:
|
|
261
|
+
class_name = line[6:].split("(")[0].split(":")[0].strip()
|
|
262
|
+
if class_name:
|
|
263
|
+
self._class_definitions[class_name].append(relative_path)
|
|
264
|
+
|
|
265
|
+
# Function definitions
|
|
266
|
+
if line.startswith("def ") and "(" in line:
|
|
267
|
+
func_name = line[4:].split("(")[0].strip()
|
|
268
|
+
if func_name:
|
|
269
|
+
self._function_definitions[func_name].append(relative_path)
|
|
270
|
+
|
|
271
|
+
if imports:
|
|
272
|
+
self._path_to_imports[relative_path] = imports
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
logger.debug(f"Error indexing Python file {file_path}: {e}")
|
|
276
|
+
|
|
277
|
+
def lookup(self, query: str, file_type: Optional[str] = None) -> List[Path]:
|
|
278
|
+
"""Look up files matching a query.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
query: Search query (basename, partial path, or symbol)
|
|
282
|
+
file_type: Optional file extension filter (e.g., '.py')
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
List of matching file paths relative to root directory.
|
|
286
|
+
"""
|
|
287
|
+
with self._lock:
|
|
288
|
+
if not self._indexed:
|
|
289
|
+
self.build_index()
|
|
290
|
+
|
|
291
|
+
results = set()
|
|
292
|
+
|
|
293
|
+
# Exact basename match
|
|
294
|
+
if query in self._basename_to_paths:
|
|
295
|
+
results.update(self._basename_to_paths[query])
|
|
296
|
+
|
|
297
|
+
# Partial basename match
|
|
298
|
+
query_lower = query.lower()
|
|
299
|
+
for basename, paths in self._basename_to_paths.items():
|
|
300
|
+
if query_lower in basename.lower():
|
|
301
|
+
results.update(paths)
|
|
302
|
+
|
|
303
|
+
# Path component match
|
|
304
|
+
for file_path in self._all_files:
|
|
305
|
+
if query_lower in str(file_path).lower():
|
|
306
|
+
results.add(file_path)
|
|
307
|
+
|
|
308
|
+
# Symbol matches (classes and functions)
|
|
309
|
+
if query in self._class_definitions:
|
|
310
|
+
results.update(self._class_definitions[query])
|
|
311
|
+
if query in self._function_definitions:
|
|
312
|
+
results.update(self._function_definitions[query])
|
|
313
|
+
|
|
314
|
+
# Filter by file type if specified
|
|
315
|
+
if file_type:
|
|
316
|
+
if not file_type.startswith("."):
|
|
317
|
+
file_type = "." + file_type
|
|
318
|
+
results = {p for p in results if p.suffix == file_type}
|
|
319
|
+
|
|
320
|
+
# Sort results by relevance
|
|
321
|
+
sorted_results = sorted(
|
|
322
|
+
results,
|
|
323
|
+
key=lambda p: (
|
|
324
|
+
# Exact basename matches first
|
|
325
|
+
0 if p.name == query else 1,
|
|
326
|
+
# Then shorter paths
|
|
327
|
+
len(str(p)),
|
|
328
|
+
# Then alphabetically
|
|
329
|
+
str(p),
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return sorted_results
|
|
334
|
+
|
|
335
|
+
def get_all_files(self, file_type: Optional[str] = None) -> List[Path]:
|
|
336
|
+
"""Get all indexed files.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
file_type: Optional file extension filter (e.g., '.py')
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
List of all file paths relative to root directory.
|
|
343
|
+
"""
|
|
344
|
+
with self._lock:
|
|
345
|
+
if not self._indexed:
|
|
346
|
+
self.build_index()
|
|
347
|
+
|
|
348
|
+
if file_type:
|
|
349
|
+
if not file_type.startswith("."):
|
|
350
|
+
file_type = "." + file_type
|
|
351
|
+
return sorted([p for p in self._all_files if p.suffix == file_type])
|
|
352
|
+
|
|
353
|
+
return sorted(self._all_files)
|
|
354
|
+
|
|
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
|
+
def find_imports(self, module_name: str) -> List[Path]:
|
|
384
|
+
"""Find files that import a specific module.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
module_name: Name of the module to search for
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
List of file paths that import the module.
|
|
391
|
+
"""
|
|
392
|
+
with self._lock:
|
|
393
|
+
if not self._indexed:
|
|
394
|
+
self.build_index()
|
|
395
|
+
|
|
396
|
+
results = []
|
|
397
|
+
for file_path, imports in self._path_to_imports.items():
|
|
398
|
+
if module_name in imports:
|
|
399
|
+
results.append(file_path)
|
|
400
|
+
|
|
401
|
+
return sorted(results)
|
|
402
|
+
|
|
403
|
+
def refresh(self, path: Optional[str] = None) -> None:
|
|
404
|
+
"""Refresh the index for a specific path or the entire repository.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
path: Optional specific path to refresh. If None, refreshes everything.
|
|
408
|
+
"""
|
|
409
|
+
with self._lock:
|
|
410
|
+
if path:
|
|
411
|
+
# Refresh a specific file or directory
|
|
412
|
+
target_path = Path(path)
|
|
413
|
+
if not target_path.is_absolute():
|
|
414
|
+
target_path = self.root_dir / target_path
|
|
415
|
+
|
|
416
|
+
if target_path.is_file():
|
|
417
|
+
# Re-index single file
|
|
418
|
+
relative_path = target_path.relative_to(self.root_dir)
|
|
419
|
+
|
|
420
|
+
# Remove from indices
|
|
421
|
+
self._remove_from_indices(relative_path)
|
|
422
|
+
|
|
423
|
+
# Re-index if it should be indexed
|
|
424
|
+
if self._should_index_file(target_path):
|
|
425
|
+
self._index_file(target_path)
|
|
426
|
+
|
|
427
|
+
elif target_path.is_dir():
|
|
428
|
+
# Remove all files under this directory
|
|
429
|
+
prefix = str(target_path.relative_to(self.root_dir))
|
|
430
|
+
to_remove = [p for p in self._all_files if str(p).startswith(prefix)]
|
|
431
|
+
for p in to_remove:
|
|
432
|
+
self._remove_from_indices(p)
|
|
433
|
+
|
|
434
|
+
# Re-scan directory
|
|
435
|
+
self._scan_directory(target_path)
|
|
436
|
+
else:
|
|
437
|
+
# Full refresh
|
|
438
|
+
self.build_index(force=True)
|
|
439
|
+
|
|
440
|
+
def _remove_from_indices(self, relative_path: Path) -> None:
|
|
441
|
+
"""Remove a file from all indices."""
|
|
442
|
+
# Remove from all files
|
|
443
|
+
self._all_files.discard(relative_path)
|
|
444
|
+
|
|
445
|
+
# Remove from basename index
|
|
446
|
+
basename = relative_path.name
|
|
447
|
+
if basename in self._basename_to_paths:
|
|
448
|
+
self._basename_to_paths[basename] = [
|
|
449
|
+
p for p in self._basename_to_paths[basename] if p != relative_path
|
|
450
|
+
]
|
|
451
|
+
if not self._basename_to_paths[basename]:
|
|
452
|
+
del self._basename_to_paths[basename]
|
|
453
|
+
|
|
454
|
+
# Remove from import index
|
|
455
|
+
if relative_path in self._path_to_imports:
|
|
456
|
+
del self._path_to_imports[relative_path]
|
|
457
|
+
|
|
458
|
+
# Remove from symbol indices
|
|
459
|
+
for symbol_dict in [self._class_definitions, self._function_definitions]:
|
|
460
|
+
for symbol, paths in list(symbol_dict.items()):
|
|
461
|
+
symbol_dict[symbol] = [p for p in paths if p != relative_path]
|
|
462
|
+
if not symbol_dict[symbol]:
|
|
463
|
+
del symbol_dict[symbol]
|
|
464
|
+
|
|
465
|
+
def get_stats(self) -> Dict[str, int]:
|
|
466
|
+
"""Get indexing statistics.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Dictionary with index statistics.
|
|
470
|
+
"""
|
|
471
|
+
with self._lock:
|
|
472
|
+
return {
|
|
473
|
+
"total_files": len(self._all_files),
|
|
474
|
+
"unique_basenames": len(self._basename_to_paths),
|
|
475
|
+
"python_files": len(self._path_to_imports),
|
|
476
|
+
"classes_indexed": len(self._class_definitions),
|
|
477
|
+
"functions_indexed": len(self._function_definitions),
|
|
478
|
+
"directories_cached": len(self._dir_cache),
|
|
479
|
+
}
|
|
@@ -47,7 +47,7 @@ class GitSafetySetup(BaseSetup):
|
|
|
47
47
|
|
|
48
48
|
if result.returncode != 0:
|
|
49
49
|
await panel(
|
|
50
|
-
"
|
|
50
|
+
" Git Not Found",
|
|
51
51
|
"Git is not installed or not in PATH. TunaCode will modify files directly.\n"
|
|
52
52
|
"It's strongly recommended to install Git for safety.",
|
|
53
53
|
border_style="yellow",
|
|
@@ -65,7 +65,7 @@ class GitSafetySetup(BaseSetup):
|
|
|
65
65
|
|
|
66
66
|
if result.returncode != 0:
|
|
67
67
|
await panel(
|
|
68
|
-
"
|
|
68
|
+
" Not a Git Repository",
|
|
69
69
|
"This directory is not a Git repository. TunaCode will modify files directly.\n"
|
|
70
70
|
"Consider initializing a Git repository for safety: git init",
|
|
71
71
|
border_style="yellow",
|
|
@@ -81,7 +81,7 @@ class GitSafetySetup(BaseSetup):
|
|
|
81
81
|
if not current_branch:
|
|
82
82
|
# Detached HEAD state
|
|
83
83
|
await panel(
|
|
84
|
-
"
|
|
84
|
+
" Detached HEAD State",
|
|
85
85
|
"You're in a detached HEAD state. TunaCode will continue without creating a branch.",
|
|
86
86
|
border_style="yellow",
|
|
87
87
|
)
|
|
@@ -109,16 +109,14 @@ class GitSafetySetup(BaseSetup):
|
|
|
109
109
|
)
|
|
110
110
|
|
|
111
111
|
if has_changes:
|
|
112
|
-
message +=
|
|
113
|
-
"\n⚠️ You have uncommitted changes that will be brought to the new branch."
|
|
114
|
-
)
|
|
112
|
+
message += "\n You have uncommitted changes that will be brought to the new branch."
|
|
115
113
|
|
|
116
114
|
create_branch = await yes_no_prompt(f"{message}\n\nCreate safety branch?", default=True)
|
|
117
115
|
|
|
118
116
|
if not create_branch:
|
|
119
117
|
# User declined - show warning
|
|
120
118
|
await panel(
|
|
121
|
-
"
|
|
119
|
+
" Working Without Safety Branch",
|
|
122
120
|
"You've chosen to work directly on your current branch.\n"
|
|
123
121
|
"TunaCode will modify files in place. Make sure you have backups!",
|
|
124
122
|
border_style="red",
|
|
@@ -153,7 +151,7 @@ class GitSafetySetup(BaseSetup):
|
|
|
153
151
|
|
|
154
152
|
except subprocess.CalledProcessError as e:
|
|
155
153
|
await panel(
|
|
156
|
-
"
|
|
154
|
+
" Failed to Create Branch",
|
|
157
155
|
f"Could not create branch '{new_branch}': {str(e)}\n"
|
|
158
156
|
"Continuing on current branch.",
|
|
159
157
|
border_style="red",
|
|
@@ -162,7 +160,7 @@ class GitSafetySetup(BaseSetup):
|
|
|
162
160
|
except Exception as e:
|
|
163
161
|
# Non-fatal error - just warn the user
|
|
164
162
|
await panel(
|
|
165
|
-
"
|
|
163
|
+
" Git Safety Setup Failed",
|
|
166
164
|
f"Could not set up Git safety: {str(e)}\n"
|
|
167
165
|
"TunaCode will continue without branch protection.",
|
|
168
166
|
border_style="yellow",
|
tunacode/core/state.py
CHANGED
|
@@ -30,6 +30,11 @@ class SessionState:
|
|
|
30
30
|
device_id: Optional[DeviceId] = None
|
|
31
31
|
input_sessions: InputSessions = field(default_factory=dict)
|
|
32
32
|
current_task: Optional[Any] = None
|
|
33
|
+
# Enhanced tracking for thoughts display
|
|
34
|
+
files_in_context: set[str] = field(default_factory=set)
|
|
35
|
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
36
|
+
iteration_count: int = 0
|
|
37
|
+
current_iteration: int = 0
|
|
33
38
|
|
|
34
39
|
|
|
35
40
|
class StateManager:
|
tunacode/core/tool_handler.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Tool handling business logic, separated from UI concerns.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from tunacode.constants import READ_ONLY_TOOLS
|
|
5
6
|
from tunacode.core.state import StateManager
|
|
6
7
|
from tunacode.types import ToolArgs, ToolConfirmationRequest, ToolConfirmationResponse, ToolName
|
|
7
8
|
|
|
@@ -22,6 +23,10 @@ class ToolHandler:
|
|
|
22
23
|
Returns:
|
|
23
24
|
bool: True if confirmation is required, False otherwise.
|
|
24
25
|
"""
|
|
26
|
+
# Skip confirmation for read-only tools
|
|
27
|
+
if is_read_only_tool(tool_name):
|
|
28
|
+
return False
|
|
29
|
+
|
|
25
30
|
return not (self.state.session.yolo or tool_name in self.state.session.tool_ignore)
|
|
26
31
|
|
|
27
32
|
def process_confirmation(self, response: ToolConfirmationResponse, tool_name: ToolName) -> bool:
|
|
@@ -55,3 +60,16 @@ class ToolHandler:
|
|
|
55
60
|
"""
|
|
56
61
|
filepath = args.get("filepath")
|
|
57
62
|
return ToolConfirmationRequest(tool_name=tool_name, args=args, filepath=filepath)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_read_only_tool(tool_name: str) -> bool:
|
|
66
|
+
"""
|
|
67
|
+
Check if a tool is read-only (safe to execute without confirmation).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
tool_name: Name of the tool to check.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
bool: True if the tool is read-only, False otherwise.
|
|
74
|
+
"""
|
|
75
|
+
return tool_name in READ_ONLY_TOOLS
|
tunacode/exceptions.py
CHANGED
|
@@ -101,3 +101,16 @@ class FileOperationError(TunaCodeError):
|
|
|
101
101
|
self.path = path
|
|
102
102
|
self.original_error = original_error
|
|
103
103
|
super().__init__(f"File {operation} failed for '{path}': {message}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TooBroadPatternError(ToolExecutionError):
|
|
107
|
+
"""Raised when a search pattern is too broad and times out."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, pattern: str, timeout_seconds: float):
|
|
110
|
+
self.pattern = pattern
|
|
111
|
+
self.timeout_seconds = timeout_seconds
|
|
112
|
+
super().__init__(
|
|
113
|
+
"grep",
|
|
114
|
+
f"Pattern '{pattern}' is too broad - no matches found within {timeout_seconds}s. "
|
|
115
|
+
"Please use a more specific pattern.",
|
|
116
|
+
)
|