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.

@@ -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
- "⚠️ Git Not Found",
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
- "⚠️ Not a Git Repository",
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
- "⚠️ Detached HEAD State",
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
- "⚠️ Working Without Safety Branch",
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
- " Failed to Create Branch",
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
- "⚠️ Git Safety Setup Failed",
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:
@@ -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
+ )