tunacode-cli 0.1.21__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 (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,432 @@
1
+ """Fast in-memory code index for efficient file lookups."""
2
+
3
+ import os
4
+ import threading
5
+ import time
6
+ from collections import defaultdict
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from tunacode.indexing.constants import (
11
+ IGNORE_DIRS,
12
+ INDEXED_EXTENSIONS,
13
+ PRIORITY_DIRS,
14
+ QUICK_INDEX_THRESHOLD,
15
+ )
16
+
17
+
18
+ class CodeIndex:
19
+ """Fast in-memory code index for repository file lookups.
20
+
21
+ This index provides efficient file discovery without relying on
22
+ grep searches that can timeout in large repositories.
23
+ """
24
+
25
+ _instance: Optional["CodeIndex"] = None
26
+ _instance_lock = threading.RLock()
27
+
28
+ def __init__(self, root_dir: str | None = None):
29
+ """Initialize the code index.
30
+
31
+ Args:
32
+ root_dir: Root directory to index. Defaults to current directory.
33
+ """
34
+ self.root_dir = Path(root_dir or os.getcwd()).resolve()
35
+ self._lock = threading.RLock()
36
+
37
+ # Primary indices
38
+ self._basename_to_paths: dict[str, list[Path]] = defaultdict(list)
39
+ self._path_to_imports: dict[Path, set[str]] = {}
40
+ self._all_files: set[Path] = set()
41
+
42
+ # Symbol indices for common patterns
43
+ self._class_definitions: dict[str, list[Path]] = defaultdict(list)
44
+ self._function_definitions: dict[str, list[Path]] = defaultdict(list)
45
+
46
+ # Cache for directory contents
47
+ self._dir_cache: dict[Path, list[Path]] = {}
48
+
49
+ # Cache freshness tracking
50
+ self._cache_timestamps: dict[Path, float] = {}
51
+ self._cache_ttl = 5.0 # 5 seconds TTL for directory cache
52
+
53
+ self._indexed = False
54
+ self._partial_indexed = False
55
+
56
+ @classmethod
57
+ def get_instance(cls, root_dir: str | None = None) -> "CodeIndex":
58
+ """Get the singleton CodeIndex instance.
59
+
60
+ Args:
61
+ root_dir: Root directory to index. Only used on first call.
62
+
63
+ Returns:
64
+ The singleton CodeIndex instance.
65
+ """
66
+ if cls._instance is None:
67
+ with cls._instance_lock:
68
+ if cls._instance is None:
69
+ cls._instance = cls(root_dir)
70
+ return cls._instance
71
+
72
+ @classmethod
73
+ def reset_instance(cls) -> None:
74
+ """Reset the singleton instance (for testing)."""
75
+ with cls._instance_lock:
76
+ cls._instance = None
77
+
78
+ def get_directory_contents(self, path: Path) -> list[str]:
79
+ """Get cached directory contents if available and fresh.
80
+
81
+ Args:
82
+ path: Directory path to check
83
+
84
+ Returns:
85
+ List of filenames in directory, empty list if not cached/stale
86
+ """
87
+ with self._lock:
88
+ if path not in self._dir_cache:
89
+ return []
90
+
91
+ if not self.is_cache_fresh(path):
92
+ # Remove stale entry
93
+ self._dir_cache.pop(path, None)
94
+ self._cache_timestamps.pop(path, None)
95
+ return []
96
+
97
+ # Return just the filenames, not Path objects
98
+ return [p.name for p in self._dir_cache[path]]
99
+
100
+ def is_cache_fresh(self, path: Path) -> bool:
101
+ """Check if cached directory data is still fresh.
102
+
103
+ Args:
104
+ path: Directory path to check
105
+
106
+ Returns:
107
+ True if cache is fresh, False if stale or missing
108
+ """
109
+ if path not in self._cache_timestamps:
110
+ return False
111
+
112
+ age = time.time() - self._cache_timestamps[path]
113
+ return age < self._cache_ttl
114
+
115
+ def update_directory_cache(self, path: Path, entries: list[str]) -> None:
116
+ """Update the directory cache with fresh data.
117
+
118
+ Args:
119
+ path: Directory path
120
+ entries: List of filenames in the directory
121
+ """
122
+ with self._lock:
123
+ # Convert filenames back to Path objects for internal storage
124
+ self._dir_cache[path] = [Path(path) / entry for entry in entries]
125
+ self._cache_timestamps[path] = time.time()
126
+
127
+ def build_index(self, force: bool = False) -> None:
128
+ """Build the file index for the repository.
129
+
130
+ Args:
131
+ force: Force rebuild even if already indexed.
132
+ """
133
+ with self._lock:
134
+ if self._indexed and not force:
135
+ return
136
+
137
+ self._clear_indices()
138
+
139
+ self._scan_directory(self.root_dir)
140
+ self._indexed = True
141
+
142
+ def _clear_indices(self) -> None:
143
+ """Clear all indices."""
144
+ self._basename_to_paths.clear()
145
+ self._path_to_imports.clear()
146
+ self._all_files.clear()
147
+ self._class_definitions.clear()
148
+ self._function_definitions.clear()
149
+ self._dir_cache.clear()
150
+ self._cache_timestamps.clear()
151
+
152
+ def quick_count(self) -> int:
153
+ """Fast file count without full indexing.
154
+
155
+ Uses os.scandir for speed and exits early at threshold.
156
+ Does not acquire lock - read-only filesystem scan.
157
+
158
+ Returns:
159
+ File count, capped at QUICK_INDEX_THRESHOLD + 1.
160
+ """
161
+ count = 0
162
+ stack = [self.root_dir]
163
+
164
+ while stack and count <= QUICK_INDEX_THRESHOLD:
165
+ current = stack.pop()
166
+ try:
167
+ for entry in os.scandir(current):
168
+ if entry.is_dir(follow_symlinks=False):
169
+ if entry.name not in IGNORE_DIRS and not entry.name.startswith("."):
170
+ stack.append(Path(entry.path))
171
+ elif entry.is_file(follow_symlinks=False):
172
+ ext = Path(entry.name).suffix.lower()
173
+ if ext in INDEXED_EXTENSIONS:
174
+ count += 1
175
+ if count > QUICK_INDEX_THRESHOLD:
176
+ break
177
+ except (PermissionError, OSError):
178
+ continue
179
+
180
+ return count
181
+
182
+ def build_priority_index(self) -> int:
183
+ """Build index for priority directories only.
184
+
185
+ Indexes top-level files and PRIORITY_DIRS subdirectories.
186
+ Sets _partial_indexed = True to indicate background expansion needed.
187
+
188
+ Returns:
189
+ Number of files indexed.
190
+ """
191
+ with self._lock:
192
+ self._clear_indices()
193
+
194
+ # Index top-level files only (not subdirectories)
195
+ for entry in os.scandir(self.root_dir):
196
+ if entry.is_file(follow_symlinks=False):
197
+ file_path = Path(entry.path)
198
+ if self._should_index_file(file_path):
199
+ self._index_file(file_path)
200
+
201
+ # Index priority subdirectories fully
202
+ for name in PRIORITY_DIRS:
203
+ priority_path = self.root_dir / name
204
+ if priority_path.is_dir():
205
+ self._scan_directory(priority_path)
206
+
207
+ self._partial_indexed = True
208
+ self._indexed = False
209
+ return len(self._all_files)
210
+
211
+ def expand_index(self) -> None:
212
+ """Expand partial index to full index.
213
+
214
+ Safe to call in background. Only runs if _partial_indexed is True.
215
+ Scans remaining non-priority directories.
216
+ """
217
+ with self._lock:
218
+ if not self._partial_indexed:
219
+ return
220
+
221
+ # Scan remaining directories (non-priority)
222
+ for entry in os.scandir(self.root_dir):
223
+ if entry.is_dir(follow_symlinks=False):
224
+ dir_name = entry.name
225
+ if dir_name in IGNORE_DIRS or dir_name.startswith("."):
226
+ continue
227
+ if dir_name not in PRIORITY_DIRS:
228
+ self._scan_directory(Path(entry.path))
229
+
230
+ self._partial_indexed = False
231
+ self._indexed = True
232
+
233
+ def _should_ignore_path(self, path: Path) -> bool:
234
+ """Check if a path should be ignored during indexing."""
235
+ # Check against ignore patterns
236
+ parts = path.parts
237
+ for part in parts:
238
+ if part in IGNORE_DIRS:
239
+ return True
240
+ if part.startswith(".") and part != ".":
241
+ # Skip hidden directories except current directory
242
+ return True
243
+
244
+ return False
245
+
246
+ def _scan_directory(self, directory: Path) -> None:
247
+ """Recursively scan a directory and index files."""
248
+ if self._should_ignore_path(directory):
249
+ return
250
+
251
+ try:
252
+ entries = list(directory.iterdir())
253
+ file_list = []
254
+
255
+ for entry in entries:
256
+ if entry.is_dir():
257
+ self._scan_directory(entry)
258
+ elif entry.is_file() and self._should_index_file(entry):
259
+ self._index_file(entry)
260
+ file_list.append(entry)
261
+
262
+ # Cache directory contents with timestamp
263
+ self._dir_cache[directory] = file_list
264
+ self._cache_timestamps[directory] = time.time()
265
+
266
+ except PermissionError:
267
+ pass
268
+ except Exception:
269
+ pass
270
+
271
+ def _should_index_file(self, file_path: Path) -> bool:
272
+ """Check if a file should be indexed."""
273
+ # Check extension
274
+ if file_path.suffix.lower() not in INDEXED_EXTENSIONS:
275
+ # Also index files with no extension if they might be scripts
276
+ if file_path.suffix == "":
277
+ # Check for shebang or common script names
278
+ name = file_path.name.lower()
279
+ if name in {"makefile", "dockerfile", "jenkinsfile", "rakefile"}:
280
+ return True
281
+ # Try to detect shebang
282
+ try:
283
+ with open(file_path, "rb") as f:
284
+ first_bytes = f.read(2)
285
+ if first_bytes == b"#!":
286
+ return True
287
+ except Exception:
288
+ pass
289
+ return False
290
+
291
+ # Skip very large files
292
+ try:
293
+ if file_path.stat().st_size > 10 * 1024 * 1024: # 10MB
294
+ return False
295
+ except Exception:
296
+ return False
297
+
298
+ return True
299
+
300
+ def _index_file(self, file_path: Path) -> None:
301
+ """Index a single file."""
302
+ relative_path = file_path.relative_to(self.root_dir)
303
+
304
+ # Add to all files set
305
+ self._all_files.add(relative_path)
306
+
307
+ # Index by basename
308
+ basename = file_path.name
309
+ self._basename_to_paths[basename].append(relative_path)
310
+
311
+ # For Python files, extract additional information
312
+ if file_path.suffix == ".py":
313
+ self._index_python_file(file_path, relative_path)
314
+
315
+ def _index_python_file(self, file_path: Path, relative_path: Path) -> None:
316
+ """Extract Python-specific information from a file."""
317
+ try:
318
+ with open(file_path, encoding="utf-8", errors="ignore") as f:
319
+ content = f.read()
320
+
321
+ imports = set()
322
+
323
+ # Quick regex-free parsing for common patterns
324
+ for line in content.splitlines():
325
+ line = line.strip()
326
+
327
+ # Import statements
328
+ if line.startswith("import ") or line.startswith("from "):
329
+ parts = line.split()
330
+ if len(parts) >= 2: # noqa: SIM102
331
+ if parts[0] == "import" or parts[0] == "from" and len(parts) >= 3:
332
+ imports.add(parts[1].split(".")[0])
333
+
334
+ # Class definitions
335
+ if line.startswith("class ") and ":" in line:
336
+ class_name = line[6:].split("(")[0].split(":")[0].strip()
337
+ if class_name:
338
+ self._class_definitions[class_name].append(relative_path)
339
+
340
+ # Function definitions
341
+ if line.startswith("def ") and "(" in line:
342
+ func_name = line[4:].split("(")[0].strip()
343
+ if func_name:
344
+ self._function_definitions[func_name].append(relative_path)
345
+
346
+ if imports:
347
+ self._path_to_imports[relative_path] = imports
348
+
349
+ except Exception:
350
+ pass
351
+
352
+ def get_all_files(self, file_type: str | None = None) -> list[Path]:
353
+ """Get all indexed files.
354
+
355
+ Args:
356
+ file_type: Optional file extension filter (e.g., '.py')
357
+
358
+ Returns:
359
+ List of all file paths relative to root directory.
360
+ """
361
+ with self._lock:
362
+ if not self._indexed:
363
+ self.build_index()
364
+
365
+ if file_type:
366
+ if not file_type.startswith("."):
367
+ file_type = "." + file_type
368
+ return sorted([p for p in self._all_files if p.suffix == file_type])
369
+
370
+ return sorted(self._all_files)
371
+
372
+ def refresh(self, path: str | None = None) -> None:
373
+ """Refresh the index for a specific path or the entire repository.
374
+
375
+ Args:
376
+ path: Optional specific path to refresh. If None, refreshes everything.
377
+ """
378
+ with self._lock:
379
+ if path:
380
+ # Refresh a specific file or directory
381
+ target_path = Path(path)
382
+ if not target_path.is_absolute():
383
+ target_path = self.root_dir / target_path
384
+
385
+ if target_path.is_file():
386
+ # Re-index single file
387
+ relative_path = target_path.relative_to(self.root_dir)
388
+
389
+ # Remove from indices
390
+ self._remove_from_indices(relative_path)
391
+
392
+ # Re-index if it should be indexed
393
+ if self._should_index_file(target_path):
394
+ self._index_file(target_path)
395
+
396
+ elif target_path.is_dir():
397
+ # Remove all files under this directory
398
+ prefix = str(target_path.relative_to(self.root_dir))
399
+ to_remove = [p for p in self._all_files if str(p).startswith(prefix)]
400
+ for p in to_remove:
401
+ self._remove_from_indices(p)
402
+
403
+ # Re-scan directory
404
+ self._scan_directory(target_path)
405
+ else:
406
+ # Full refresh
407
+ self.build_index(force=True)
408
+
409
+ def _remove_from_indices(self, relative_path: Path) -> None:
410
+ """Remove a file from all indices."""
411
+ # Remove from all files
412
+ self._all_files.discard(relative_path)
413
+
414
+ # Remove from basename index
415
+ basename = relative_path.name
416
+ if basename in self._basename_to_paths:
417
+ self._basename_to_paths[basename] = [
418
+ p for p in self._basename_to_paths[basename] if p != relative_path
419
+ ]
420
+ if not self._basename_to_paths[basename]:
421
+ del self._basename_to_paths[basename]
422
+
423
+ # Remove from import index
424
+ if relative_path in self._path_to_imports:
425
+ del self._path_to_imports[relative_path]
426
+
427
+ # Remove from symbol indices
428
+ for symbol_dict in [self._class_definitions, self._function_definitions]:
429
+ for symbol, paths in list(symbol_dict.items()):
430
+ symbol_dict[symbol] = [p for p in paths if p != relative_path]
431
+ if not symbol_dict[symbol]:
432
+ del symbol_dict[symbol]
@@ -0,0 +1,86 @@
1
+ """Configuration constants for code indexing."""
2
+
3
+ IGNORE_DIRS = {
4
+ ".git",
5
+ ".hg",
6
+ ".svn",
7
+ ".bzr",
8
+ "__pycache__",
9
+ ".pytest_cache",
10
+ ".mypy_cache",
11
+ "node_modules",
12
+ "bower_components",
13
+ ".venv",
14
+ "venv",
15
+ "env",
16
+ ".env",
17
+ "build",
18
+ "dist",
19
+ "_build",
20
+ "target",
21
+ ".idea",
22
+ ".vscode",
23
+ ".vs",
24
+ "htmlcov",
25
+ ".coverage",
26
+ ".tox",
27
+ ".eggs",
28
+ ".egg-info",
29
+ ".bundle",
30
+ "vendor",
31
+ ".terraform",
32
+ ".serverless",
33
+ ".next",
34
+ ".nuxt",
35
+ "coverage",
36
+ "tmp",
37
+ "temp",
38
+ }
39
+
40
+ QUICK_INDEX_THRESHOLD = 1000
41
+
42
+ PRIORITY_DIRS = {"src", "lib", "app", "packages", "core", "internal"}
43
+
44
+ INDEXED_EXTENSIONS = {
45
+ ".py",
46
+ ".js",
47
+ ".jsx",
48
+ ".ts",
49
+ ".tsx",
50
+ ".java",
51
+ ".c",
52
+ ".cpp",
53
+ ".cc",
54
+ ".cxx",
55
+ ".h",
56
+ ".hpp",
57
+ ".rs",
58
+ ".go",
59
+ ".rb",
60
+ ".php",
61
+ ".cs",
62
+ ".swift",
63
+ ".kt",
64
+ ".scala",
65
+ ".sh",
66
+ ".bash",
67
+ ".zsh",
68
+ ".json",
69
+ ".yaml",
70
+ ".yml",
71
+ ".toml",
72
+ ".xml",
73
+ ".md",
74
+ ".rst",
75
+ ".txt",
76
+ ".html",
77
+ ".css",
78
+ ".scss",
79
+ ".sass",
80
+ ".sql",
81
+ ".graphql",
82
+ ".dockerfile",
83
+ ".containerfile",
84
+ ".gitignore",
85
+ ".env.example",
86
+ }
@@ -0,0 +1,112 @@
1
+ """LSP client orchestrator for diagnostic feedback.
2
+
3
+ This module provides the public API for getting diagnostics from language servers.
4
+ It manages server lifecycle and provides formatted diagnostic output.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ from tunacode.lsp.client import Diagnostic, LSPClient
10
+ from tunacode.lsp.diagnostics import truncate_diagnostic_message
11
+ from tunacode.lsp.servers import get_server_command
12
+
13
+ __all__ = ["get_diagnostics", "format_diagnostics"]
14
+
15
+
16
+ _clients: dict[str, LSPClient] = {}
17
+
18
+ WORKSPACE_MARKERS: tuple[str, ...] = (
19
+ ".git",
20
+ "pyproject.toml",
21
+ "setup.cfg",
22
+ "setup.py",
23
+ "requirements.txt",
24
+ "Pipfile",
25
+ "package.json",
26
+ "Cargo.toml",
27
+ "go.mod",
28
+ )
29
+
30
+
31
+ def _resolve_workspace_root(path: Path) -> Path:
32
+ start_dir = path if path.is_dir() else path.parent
33
+
34
+ for candidate in (start_dir, *start_dir.parents):
35
+ if any((candidate / marker).exists() for marker in WORKSPACE_MARKERS):
36
+ return candidate
37
+
38
+ return start_dir
39
+
40
+
41
+ async def get_diagnostics(filepath: Path | str, timeout: float = 5.0) -> list[Diagnostic]:
42
+ """Get diagnostics for a file from the appropriate language server.
43
+
44
+ Args:
45
+ filepath: Path to the file to check
46
+ timeout: Maximum time to wait for diagnostics in seconds
47
+
48
+ Returns:
49
+ List of diagnostics, empty if server unavailable or no errors
50
+ """
51
+ path = Path(filepath).resolve()
52
+
53
+ if not path.exists():
54
+ return []
55
+
56
+ root = _resolve_workspace_root(path)
57
+ command = get_server_command(path)
58
+ if command is None:
59
+ return []
60
+
61
+ command_key = " ".join(command)
62
+ client_key = f"{root}::{command_key}"
63
+
64
+ if client_key not in _clients:
65
+ client = LSPClient(command=command, root=root)
66
+ started = await client.start()
67
+ if not started:
68
+ return []
69
+ _clients[client_key] = client
70
+
71
+ client = _clients[client_key]
72
+ return await client.get_diagnostics(path, timeout=timeout)
73
+
74
+
75
+ MAX_DIAGNOSTICS_COUNT = 10
76
+
77
+
78
+ def format_diagnostics(diagnostics: list[Diagnostic]) -> str:
79
+ """Format diagnostics as XML block for tool output.
80
+
81
+ Args:
82
+ diagnostics: List of diagnostics to format
83
+
84
+ Returns:
85
+ Formatted XML string or empty string if no diagnostics
86
+ """
87
+ if not diagnostics:
88
+ return ""
89
+
90
+ errors = sum(1 for d in diagnostics if d.severity == "error")
91
+ warnings = sum(1 for d in diagnostics if d.severity == "warning")
92
+
93
+ lines: list[str] = ["<file_diagnostics>"]
94
+
95
+ if errors > 0:
96
+ lines.append(f"ACTION REQUIRED: {errors} error(s) found - fix before continuing")
97
+ if warnings > 0:
98
+ lines.append(f"Additional: {warnings} warning(s)")
99
+ else:
100
+ lines.append(f"Summary: {warnings} warning(s)")
101
+
102
+ for diag in diagnostics[:MAX_DIAGNOSTICS_COUNT]:
103
+ severity = diag.severity.capitalize()
104
+ line = diag.line
105
+ message = truncate_diagnostic_message(diag.message)
106
+ lines.append(f"{severity} (line {line}): {message}")
107
+
108
+ lines.append("</file_diagnostics>")
109
+
110
+ return "\n".join(lines)
111
+
112
+