zjcode 0.0.1__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.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,890 @@
1
+ """Autocomplete system for @ mentions and / commands.
2
+
3
+ This is a custom implementation that handles trigger-based completion
4
+ for slash commands (/) and file mentions (@).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import os
12
+ import shutil
13
+
14
+ # S404: subprocess is required for git ls-files to get project file list
15
+ import subprocess # noqa: S404
16
+ from difflib import SequenceMatcher
17
+ from enum import StrEnum
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Protocol
20
+
21
+ from deepagents_code.project_utils import find_project_root
22
+ from deepagents_code.unicode_security import sanitize_control_chars
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _get_git_executable() -> str | None:
28
+ """Get full path to git executable using shutil.which().
29
+
30
+ Returns:
31
+ Full path to git executable, or None if not found.
32
+ """
33
+ return shutil.which("git")
34
+
35
+
36
+ if TYPE_CHECKING:
37
+ from textual import events
38
+
39
+ from deepagents_code.command_registry import CommandEntry
40
+
41
+
42
+ class CompletionResult(StrEnum):
43
+ """Result of handling a key event in the completion system."""
44
+
45
+ IGNORED = "ignored" # Key not handled, let default behavior proceed
46
+ HANDLED = "handled" # Key handled, prevent default
47
+ SUBMIT = "submit" # Key triggers submission (e.g., Enter on slash command)
48
+
49
+
50
+ class CompletionView(Protocol):
51
+ """Protocol for views that can display completion suggestions."""
52
+
53
+ def render_completion_suggestions(
54
+ self, suggestions: list[tuple[str, str]], selected_index: int
55
+ ) -> None:
56
+ """Render the completion suggestions popup.
57
+
58
+ Args:
59
+ suggestions: List of (label, description) tuples
60
+ selected_index: Index of currently selected item
61
+ """
62
+ ...
63
+
64
+ def clear_completion_suggestions(self) -> None:
65
+ """Hide/clear the completion suggestions popup."""
66
+ ...
67
+
68
+ def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
69
+ """Replace text in the input from start to end with replacement.
70
+
71
+ Args:
72
+ start: Start index in the input text
73
+ end: End index in the input text
74
+ replacement: Text to insert
75
+ """
76
+ ...
77
+
78
+
79
+ class CompletionController(Protocol):
80
+ """Protocol for completion controllers."""
81
+
82
+ def can_handle(self, text: str, cursor_index: int) -> bool:
83
+ """Check if this controller can handle the current input state."""
84
+ ...
85
+
86
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
87
+ """Called when input text changes."""
88
+ ...
89
+
90
+ def on_key(
91
+ self, event: events.Key, text: str, cursor_index: int
92
+ ) -> CompletionResult:
93
+ """Handle a key event. Returns how the event was handled."""
94
+ ...
95
+
96
+ def reset(self) -> None:
97
+ """Reset/clear the completion state."""
98
+ ...
99
+
100
+
101
+ # ============================================================================
102
+ # Slash Command Completion
103
+ # ============================================================================
104
+
105
+
106
+ MAX_SUGGESTIONS = 10
107
+ """UI cap so the completion popup doesn't get unwieldy."""
108
+
109
+ _MIN_SLASH_FUZZY_SCORE = 25
110
+ """Minimum score for slash-command fuzzy matches."""
111
+
112
+ _MIN_DESC_SEARCH_LEN = 2
113
+ """Minimum query length to search command descriptions (avoids single-char noise)."""
114
+
115
+
116
+ class SlashCommandController:
117
+ """Controller for / slash command completion."""
118
+
119
+ def __init__(
120
+ self,
121
+ commands: list[CommandEntry],
122
+ view: CompletionView,
123
+ ) -> None:
124
+ """Initialize the slash command controller.
125
+
126
+ Args:
127
+ commands: List of `CommandEntry` instances.
128
+ view: View to render suggestions to.
129
+ """
130
+ self._commands = commands
131
+ self._view = view
132
+ self._suggestions: list[tuple[str, str]] = []
133
+ self._selected_index = 0
134
+
135
+ def update_commands(self, commands: list[CommandEntry]) -> None:
136
+ """Replace the commands list and reset suggestions.
137
+
138
+ Used to merge dynamically discovered skill commands with
139
+ the static command registry at runtime.
140
+
141
+ Args:
142
+ commands: New list of `CommandEntry` instances.
143
+ """
144
+ self._commands = commands
145
+ self.reset()
146
+
147
+ @staticmethod
148
+ def can_handle(text: str, cursor_index: int) -> bool: # noqa: ARG004 # Required by AutocompleteProvider interface
149
+ """Handle input that starts with /.
150
+
151
+ Returns:
152
+ True if text starts with slash, indicating a command.
153
+ """
154
+ return text.startswith("/")
155
+
156
+ def reset(self) -> None:
157
+ """Clear suggestions."""
158
+ if self._suggestions:
159
+ self._suggestions.clear()
160
+ self._selected_index = 0
161
+ self._view.clear_completion_suggestions()
162
+
163
+ def name_prefix_matches(self, text: str, cursor_index: int) -> list[CommandEntry]:
164
+ """Return commands whose names start with the current slash query."""
165
+ if cursor_index < 0 or cursor_index > len(text):
166
+ return []
167
+ if not self.can_handle(text, cursor_index):
168
+ return []
169
+
170
+ search = text[1:cursor_index].lower()
171
+ if not search or " " in search:
172
+ return []
173
+
174
+ return [
175
+ entry
176
+ for entry in self._commands
177
+ if entry.name.lstrip("/").lower().startswith(search)
178
+ ]
179
+
180
+ @staticmethod
181
+ def _score_command(search: str, cmd: str, desc: str, keywords: str = "") -> float:
182
+ """Score a command against a search string. Higher = better match.
183
+
184
+ Args:
185
+ search: Lowercase search string (without leading `/`).
186
+ cmd: Command name (e.g. `'/help'`).
187
+ desc: Command description text.
188
+ keywords: Space-separated hidden keywords for matching.
189
+
190
+ Returns:
191
+ Score value where higher indicates better match quality.
192
+ """
193
+ if not search:
194
+ return 0.0
195
+ name = cmd.lstrip("/").lower()
196
+ lower_desc = desc.lower()
197
+ # Prefix match on command name — highest priority
198
+ if name.startswith(search):
199
+ return 200.0
200
+ # Substring match on command name
201
+ if search in name:
202
+ return 150.0
203
+ # Hidden keyword match — treated like a word-boundary description match
204
+ if keywords and len(search) >= _MIN_DESC_SEARCH_LEN:
205
+ for kw in keywords.lower().split():
206
+ if kw.startswith(search) or search in kw:
207
+ return 120.0
208
+ # Substring match on description (require ≥2 chars to avoid single-letter noise)
209
+ if len(search) >= _MIN_DESC_SEARCH_LEN and search in lower_desc:
210
+ idx = lower_desc.find(search)
211
+ # Word-boundary bonus: match at start of description or after a space
212
+ if idx == 0 or lower_desc[idx - 1] == " ":
213
+ return 110.0
214
+ return 90.0
215
+ # Fuzzy match via SequenceMatcher on name + desc
216
+ name_ratio = SequenceMatcher(None, search, name).ratio()
217
+ desc_ratio = SequenceMatcher(None, search, lower_desc).ratio()
218
+ best = max(name_ratio * 60, desc_ratio * 30)
219
+ return best if best >= _MIN_SLASH_FUZZY_SCORE else 0.0
220
+
221
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
222
+ """Update suggestions when text changes."""
223
+ if cursor_index < 0 or cursor_index > len(text):
224
+ self.reset()
225
+ return
226
+
227
+ if not self.can_handle(text, cursor_index):
228
+ self.reset()
229
+ return
230
+
231
+ # Get the search string (text after /)
232
+ search = text[1:cursor_index].lower()
233
+
234
+ # Space means the user finished picking a command — dismiss popup
235
+ if " " in search:
236
+ self.reset()
237
+ return
238
+
239
+ if not search:
240
+ # No search text — show all commands (display only cmd + desc)
241
+ suggestions = [(entry.name, entry.description) for entry in self._commands][
242
+ :MAX_SUGGESTIONS
243
+ ]
244
+ else:
245
+ # Score and filter commands using fuzzy matching
246
+ scored = [
247
+ (score, entry.name, entry.description)
248
+ for entry in self._commands
249
+ if (
250
+ score := self._score_command(
251
+ search, entry.name, entry.description, entry.hidden_keywords
252
+ )
253
+ )
254
+ > 0
255
+ ]
256
+ scored.sort(key=lambda x: -x[0])
257
+ suggestions = [(cmd, desc) for _, cmd, desc in scored[:MAX_SUGGESTIONS]]
258
+
259
+ if suggestions:
260
+ self._suggestions = suggestions
261
+ self._selected_index = 0
262
+ self._view.render_completion_suggestions(
263
+ self._suggestions, self._selected_index
264
+ )
265
+ else:
266
+ self.reset()
267
+
268
+ def on_key(
269
+ self, event: events.Key, _text: str, cursor_index: int
270
+ ) -> CompletionResult:
271
+ """Handle key events for navigation and selection.
272
+
273
+ Returns:
274
+ CompletionResult indicating how the key was handled.
275
+ """
276
+ if not self._suggestions:
277
+ return CompletionResult.IGNORED
278
+
279
+ match event.key:
280
+ case "tab" | "space":
281
+ if self._apply_selected_completion(cursor_index):
282
+ return CompletionResult.HANDLED
283
+ return CompletionResult.IGNORED
284
+ case "enter":
285
+ if self._apply_selected_completion(cursor_index):
286
+ return CompletionResult.SUBMIT
287
+ return CompletionResult.HANDLED
288
+ case "down":
289
+ self._move_selection(1)
290
+ return CompletionResult.HANDLED
291
+ case "up":
292
+ self._move_selection(-1)
293
+ return CompletionResult.HANDLED
294
+ case "escape":
295
+ self.reset()
296
+ return CompletionResult.HANDLED
297
+ case _:
298
+ return CompletionResult.IGNORED
299
+
300
+ def _move_selection(self, delta: int) -> None:
301
+ """Move selection up or down."""
302
+ if not self._suggestions:
303
+ return
304
+ count = len(self._suggestions)
305
+ self._selected_index = (self._selected_index + delta) % count
306
+ self._view.render_completion_suggestions(
307
+ self._suggestions, self._selected_index
308
+ )
309
+
310
+ def _apply_selected_completion(self, cursor_index: int) -> bool:
311
+ """Apply the currently selected completion.
312
+
313
+ Returns:
314
+ True if completion was applied, False if no suggestions.
315
+ """
316
+ if not self._suggestions:
317
+ return False
318
+
319
+ command, _ = self._suggestions[self._selected_index]
320
+ # Replace from start to cursor with the command
321
+ self._view.replace_completion_range(0, cursor_index, command)
322
+ self.reset()
323
+ return True
324
+
325
+ def apply_name_prefix_completion(
326
+ self, match: CommandEntry, cursor_index: int
327
+ ) -> None:
328
+ """Apply a command-name prefix match.
329
+
330
+ Args:
331
+ match: Command entry to apply.
332
+ cursor_index: Cursor index in completion-space coordinates.
333
+ """
334
+ self._view.replace_completion_range(0, cursor_index, match.name)
335
+ self.reset()
336
+
337
+
338
+ # ============================================================================
339
+ # Fuzzy File Completion (scoped to current working directory)
340
+ # ============================================================================
341
+
342
+ # Constants for fuzzy file completion
343
+ _MAX_FALLBACK_FILES = 1000
344
+ """Hard cap on files returned by the non-git glob fallback."""
345
+
346
+ _MIN_FUZZY_SCORE = 15
347
+ """Minimum score to include in file-completion results."""
348
+
349
+ _MIN_FUZZY_RATIO = 0.4
350
+ """SequenceMatcher threshold for filename-only fuzzy matches."""
351
+
352
+ _NOT_A_REPO_MARKER = "not a git repository"
353
+ """Marker in `git ls-files` stderr for a non-repository directory.
354
+
355
+ Running outside a work tree exits 128 and prints a "fatal: not a git
356
+ repository" message. That case intentionally falls back to a glob walk, so it
357
+ is left unlogged to avoid noise.
358
+ """
359
+
360
+ _GIT_STDERR_LOG_LIMIT = 500
361
+ """Max characters of git stderr to include in a diagnostic log line."""
362
+
363
+
364
+ def _run_git_ls_files(
365
+ git_path: str, root: Path, extra_args: list[str]
366
+ ) -> tuple[bool, list[str]]:
367
+ """Run `git ls-files` with the given arguments and return file paths.
368
+
369
+ Args:
370
+ git_path: Full path to the git executable.
371
+ root: Directory to run the command in.
372
+ extra_args: Flags appended after `ls-files`, e.g.
373
+ `["--others", "--exclude-standard"]`.
374
+
375
+ Returns:
376
+ Tuple of success status and relative file paths. Success is `False`
377
+ when git could not be run or exited non-zero, signalling the caller
378
+ to fall back to a glob walk.
379
+ """
380
+ try:
381
+ # S603: git_path validated via shutil.which(); ls-files args are
382
+ # caller-supplied literals.
383
+ result = subprocess.run( # noqa: S603
384
+ [git_path, "ls-files", *extra_args],
385
+ cwd=root,
386
+ capture_output=True,
387
+ text=True,
388
+ timeout=5,
389
+ check=False,
390
+ # Git localizes stderr; use C so the non-repo marker stays stable.
391
+ env={**os.environ, "LC_ALL": "C"},
392
+ )
393
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
394
+ logger.debug("git ls-files %s failed to run", extra_args, exc_info=True)
395
+ return False, []
396
+ if result.returncode != 0:
397
+ stderr = sanitize_control_chars(result.stderr, max_length=_GIT_STDERR_LOG_LIMIT)
398
+ # Running outside a work tree exits 128 with a "not a git repository"
399
+ # fatal message. That is the expected trigger for the glob fallback, so
400
+ # keep it quiet. Everything else is a genuine failure worth logging with
401
+ # enough context (root/cwd, args, exit code, stripped stderr) to debug.
402
+ if _NOT_A_REPO_MARKER not in stderr.lower():
403
+ logger.debug(
404
+ "git ls-files failed: root=%s args=%s exit=%d stderr=%s",
405
+ root,
406
+ extra_args,
407
+ result.returncode,
408
+ stderr,
409
+ )
410
+ return False, []
411
+ return True, [f for f in result.stdout.strip().split("\n") if f]
412
+
413
+
414
+ def _get_project_files(root: Path) -> list[str]:
415
+ """Get project files using git ls-files or fallback to glob.
416
+
417
+ Includes both tracked files and untracked files that are not ignored
418
+ (via `--others --exclude-standard`), so freshly created files surface
419
+ in `@` completion without needing to be committed first.
420
+
421
+ Returns:
422
+ List of relative file paths from project root.
423
+ """
424
+ git_path = _get_git_executable()
425
+ if git_path:
426
+ tracked_ok, tracked = _run_git_ls_files(git_path, root, [])
427
+ if tracked_ok:
428
+ # The untracked scan is optional; if it fails or times out, keep the
429
+ # already-successful tracked list rather than dropping to the glob
430
+ # fallback (which only walks a few levels deep).
431
+ _, untracked = _run_git_ls_files(
432
+ git_path, root, ["--others", "--exclude-standard"]
433
+ )
434
+ seen: set[str] = set()
435
+ files: list[str] = []
436
+ for f in (*tracked, *untracked):
437
+ if f not in seen:
438
+ seen.add(f)
439
+ files.append(f)
440
+ return files
441
+
442
+ # Fallback: simple glob (limited depth to avoid slowness)
443
+ files = []
444
+ try:
445
+ for pattern in ["*", "*/*", "*/*/*", "*/*/*/*"]:
446
+ for p in root.glob(pattern):
447
+ if p.is_file() and not any(part.startswith(".") for part in p.parts):
448
+ files.append(p.relative_to(root).as_posix())
449
+ if len(files) >= _MAX_FALLBACK_FILES:
450
+ break
451
+ if len(files) >= _MAX_FALLBACK_FILES:
452
+ break
453
+ except OSError:
454
+ logger.debug("glob fallback failed for %s", root, exc_info=True)
455
+ return files
456
+
457
+
458
+ def _fuzzy_score(query: str, candidate: str) -> float:
459
+ """Score a candidate against query. Higher = better match.
460
+
461
+ Returns:
462
+ Score value where higher indicates better match quality.
463
+ """
464
+ query_lower = query.lower()
465
+ # Normalize path separators for cross-platform support
466
+ candidate_normalized = candidate.replace("\\", "/")
467
+ candidate_lower = candidate_normalized.lower()
468
+
469
+ # Extract filename for matching (prioritize filename over full path)
470
+ filename = candidate_normalized.rsplit("/", 1)[-1].lower()
471
+ filename_start = candidate_lower.rfind("/") + 1
472
+
473
+ # Check filename first (higher priority)
474
+ if query_lower in filename:
475
+ idx = filename.find(query_lower)
476
+ # Bonus for being at start of filename
477
+ if idx == 0:
478
+ return 150 + (1 / len(candidate))
479
+ # Bonus for word boundary in filename
480
+ if idx > 0 and filename[idx - 1] in "_-.":
481
+ return 120 + (1 / len(candidate))
482
+ return 100 + (1 / len(candidate))
483
+
484
+ # Check full path
485
+ if query_lower in candidate_lower:
486
+ idx = candidate_lower.find(query_lower)
487
+ # At start of filename
488
+ if idx == filename_start:
489
+ return 80 + (1 / len(candidate))
490
+ # At word boundary in path
491
+ if idx == 0 or candidate[idx - 1] in "/_-.":
492
+ return 60 + (1 / len(candidate))
493
+ return 40 + (1 / len(candidate))
494
+
495
+ # Fuzzy match on filename only (more relevant)
496
+ filename_ratio = SequenceMatcher(None, query_lower, filename).ratio()
497
+ if filename_ratio > _MIN_FUZZY_RATIO:
498
+ return filename_ratio * 30
499
+
500
+ # Fallback: fuzzy on full path
501
+ ratio = SequenceMatcher(None, query_lower, candidate_lower).ratio()
502
+ return ratio * 15
503
+
504
+
505
+ def _is_dotpath(path: str) -> bool:
506
+ """Check if path contains dotfiles/dotdirs (e.g., .github/...).
507
+
508
+ Returns:
509
+ True if path contains hidden directories or files.
510
+ """
511
+ return any(part.startswith(".") for part in path.split("/"))
512
+
513
+
514
+ def _path_depth(path: str) -> int:
515
+ """Get depth of path (number of / separators).
516
+
517
+ Returns:
518
+ Number of path separators in the path.
519
+ """
520
+ return path.count("/")
521
+
522
+
523
+ def _fuzzy_search(
524
+ query: str,
525
+ candidates: list[str],
526
+ limit: int = 10,
527
+ *,
528
+ include_dotfiles: bool = False,
529
+ ) -> list[str]:
530
+ """Return top matches sorted by score.
531
+
532
+ Args:
533
+ query: Search query
534
+ candidates: List of file paths to search
535
+ limit: Max results to return
536
+ include_dotfiles: Whether to include dotfiles (default False)
537
+
538
+ Returns:
539
+ List of matching file paths sorted by relevance score.
540
+ """
541
+ # Filter dotfiles unless explicitly searching for them
542
+ filtered = (
543
+ candidates
544
+ if include_dotfiles
545
+ else [c for c in candidates if not _is_dotpath(c)]
546
+ )
547
+
548
+ if not query:
549
+ # Empty query: show root-level files first, sorted by depth then name
550
+ sorted_files = sorted(filtered, key=lambda p: (_path_depth(p), p.lower()))
551
+ return sorted_files[:limit]
552
+
553
+ scored = [
554
+ (score, c)
555
+ for c in filtered
556
+ if (score := _fuzzy_score(query, c)) >= _MIN_FUZZY_SCORE
557
+ ]
558
+ scored.sort(key=lambda x: -x[0])
559
+ return [c for _, c in scored[:limit]]
560
+
561
+
562
+ def _scope_files_to_cwd(files: list[str], project_root: Path, cwd: Path) -> list[str]:
563
+ """Scope a project-root-relative file list to paths under `cwd`.
564
+
565
+ Args:
566
+ files: File paths relative to `project_root` (as produced by
567
+ `_get_project_files`).
568
+ project_root: Directory the `files` paths are relative to.
569
+ cwd: Directory to scope suggestions to.
570
+
571
+ Returns:
572
+ Paths rewritten relative to `cwd`, filtered to that subtree (possibly
573
+ empty), when `cwd` is nested under `project_root`. The input list
574
+ unchanged when `cwd` equals `project_root`. An empty list when `cwd` is
575
+ not under `project_root`: the paths are project-root-relative and would
576
+ resolve to the wrong base from `cwd`, so fail closed rather than offer
577
+ misleading suggestions.
578
+ """
579
+ if cwd == project_root:
580
+ return files
581
+ try:
582
+ relative_cwd = cwd.relative_to(project_root).as_posix()
583
+ except ValueError:
584
+ return []
585
+ prefix = f"{relative_cwd}/"
586
+ return [path[len(prefix) :] for path in files if path.startswith(prefix)]
587
+
588
+
589
+ class FuzzyFileController:
590
+ """Controller for @ file completion with fuzzy matching from current cwd."""
591
+
592
+ def __init__(
593
+ self,
594
+ view: CompletionView,
595
+ cwd: Path | None = None,
596
+ ) -> None:
597
+ """Initialize the fuzzy file controller.
598
+
599
+ Args:
600
+ view: View to render suggestions to
601
+ cwd: Current working directory for file completion scope
602
+ """
603
+ self._view = view
604
+ self._cwd = (cwd or Path.cwd()).resolve()
605
+ self._project_root = find_project_root(self._cwd) or self._cwd
606
+ self._suggestions: list[tuple[str, str]] = []
607
+ self._selected_index = 0
608
+ self._file_cache: list[str] | None = None
609
+ # When True, `_project_root` is a provisional value (set synchronously by
610
+ # `set_cwd`) and the real project root is resolved off the event loop in
611
+ # `warm_cache`. See `set_cwd` for why discovery is deferred.
612
+ self._project_root_pending = False
613
+ self._cache_generation = 0
614
+
615
+ def _get_files(self) -> list[str]:
616
+ """Get cached file list or refresh.
617
+
618
+ Returns:
619
+ List of project file paths.
620
+ """
621
+ if self._file_cache is None:
622
+ files = _get_project_files(self._project_root)
623
+ self._file_cache = _scope_files_to_cwd(files, self._project_root, self._cwd)
624
+ return self._file_cache
625
+
626
+ def refresh_cache(self) -> None:
627
+ """Force refresh of file cache."""
628
+ self._cache_generation += 1
629
+ self._file_cache = None
630
+
631
+ def set_cwd(self, cwd: Path) -> None:
632
+ """Switch completion roots to a new cwd.
633
+
634
+ Roots completion at `cwd` immediately and invalidates the file cache.
635
+ Project-root discovery (`find_project_root`) walks the filesystem, so it
636
+ is deferred to `warm_cache` (which runs in a worker thread) rather than
637
+ run here on the event loop. Until then `cwd` is used as a provisional
638
+ root, which is a safe narrower scope.
639
+ """
640
+ self._cache_generation += 1
641
+ self._cwd = cwd.resolve()
642
+ self._project_root = self._cwd
643
+ self._project_root_pending = True
644
+ self._file_cache = None
645
+ self.reset()
646
+
647
+ async def warm_cache(self, *, force: bool = False) -> None:
648
+ """Pre-populate the file cache off the event loop.
649
+
650
+ Also resolves a project root deferred by `set_cwd`, so the blocking
651
+ filesystem walk runs in a worker thread instead of on the event loop.
652
+
653
+ Warmers are scheduled non-exclusively, so quick cwd/cache invalidations
654
+ can run concurrently. The generation is snapshotted once before the first
655
+ await and re-checked after each await, so a warmer whose generation has
656
+ been superseded drops its results instead of overwriting controller state
657
+ belonging to a newer generation. (Snapshotting again before the second
658
+ await would defeat the guard: it would match the post-supersession
659
+ generation and let a stale-root file walk win.)
660
+
661
+ Args:
662
+ force: Re-walk and swap in a fresh file list even when the cache is
663
+ already populated. Used by the periodic background refresh so
664
+ files created or deleted mid-session surface in `@` completion.
665
+ The existing cache stays visible until the new walk completes.
666
+ """
667
+ cwd = self._cwd
668
+ generation = self._cache_generation
669
+ if self._project_root_pending:
670
+ root = await asyncio.to_thread(find_project_root, cwd)
671
+ if generation != self._cache_generation:
672
+ # A newer cwd/cache invalidation superseded this warmer.
673
+ return
674
+ resolved = root or cwd
675
+ if resolved != self._project_root:
676
+ # The real root differs from the provisional `cwd`; drop any
677
+ # cache built against the narrower scope.
678
+ self._file_cache = None
679
+ self._project_root = resolved
680
+ self._project_root_pending = False
681
+ if not force and self._file_cache is not None:
682
+ return
683
+ project_root = self._project_root
684
+ # Best-effort: on failure the existing cache (if any) stays in place. A
685
+ # cold cache (`_file_cache is None`) is later filled synchronously by
686
+ # `_get_files()`; a force refresh that fails simply leaves the prior
687
+ # list visible. Log at debug so a recurring background refresh failure
688
+ # (the 30s timer) is diagnosable rather than silently stale.
689
+ try:
690
+ files = await asyncio.to_thread(_get_project_files, project_root)
691
+ if generation == self._cache_generation:
692
+ self._file_cache = _scope_files_to_cwd(files, project_root, cwd)
693
+ except Exception: # best-effort refresh; prior cache is the fallback
694
+ logger.debug("File-cache warm failed for %s", project_root, exc_info=True)
695
+
696
+ @staticmethod
697
+ def can_handle(text: str, cursor_index: int) -> bool:
698
+ """Handle input that contains @ not followed by space.
699
+
700
+ Returns:
701
+ True if cursor is after @ and within a file mention context.
702
+ """
703
+ if cursor_index <= 0 or cursor_index > len(text):
704
+ return False
705
+
706
+ before_cursor = text[:cursor_index]
707
+ if "@" not in before_cursor:
708
+ return False
709
+
710
+ at_index = before_cursor.rfind("@")
711
+ if cursor_index <= at_index:
712
+ return False
713
+
714
+ # Fragment from @ to cursor must not contain spaces
715
+ fragment = before_cursor[at_index:cursor_index]
716
+ return bool(fragment) and " " not in fragment
717
+
718
+ def reset(self) -> None:
719
+ """Clear suggestions."""
720
+ if self._suggestions:
721
+ self._suggestions.clear()
722
+ self._selected_index = 0
723
+ self._view.clear_completion_suggestions()
724
+
725
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
726
+ """Update suggestions when text changes."""
727
+ if not self.can_handle(text, cursor_index):
728
+ self.reset()
729
+ return
730
+
731
+ before_cursor = text[:cursor_index]
732
+ at_index = before_cursor.rfind("@")
733
+ search = before_cursor[at_index + 1 :]
734
+
735
+ suggestions = self._get_fuzzy_suggestions(search)
736
+
737
+ if suggestions:
738
+ self._suggestions = suggestions
739
+ self._selected_index = 0
740
+ self._view.render_completion_suggestions(
741
+ self._suggestions, self._selected_index
742
+ )
743
+ else:
744
+ self.reset()
745
+
746
+ def _get_fuzzy_suggestions(self, search: str) -> list[tuple[str, str]]:
747
+ """Get fuzzy file suggestions.
748
+
749
+ Returns:
750
+ List of (label, type_hint) tuples for matching files.
751
+ """
752
+ files = self._get_files()
753
+ # Include dotfiles only if query starts with "."
754
+ include_dots = search.startswith(".")
755
+ matches = _fuzzy_search(
756
+ search, files, limit=MAX_SUGGESTIONS, include_dotfiles=include_dots
757
+ )
758
+
759
+ suggestions: list[tuple[str, str]] = []
760
+ for path in matches:
761
+ # Get file extension for type hint
762
+ ext = Path(path).suffix.lower()
763
+ type_hint = ext[1:] if ext else "file"
764
+ suggestions.append((f"@{path}", type_hint))
765
+
766
+ return suggestions
767
+
768
+ def on_key(
769
+ self, event: events.Key, text: str, cursor_index: int
770
+ ) -> CompletionResult:
771
+ """Handle key events for navigation and selection.
772
+
773
+ Returns:
774
+ CompletionResult indicating how the key was handled.
775
+ """
776
+ if not self._suggestions:
777
+ return CompletionResult.IGNORED
778
+
779
+ match event.key:
780
+ case "tab" | "enter":
781
+ if self._apply_selected_completion(text, cursor_index):
782
+ return CompletionResult.HANDLED
783
+ return CompletionResult.IGNORED
784
+ case "down":
785
+ self._move_selection(1)
786
+ return CompletionResult.HANDLED
787
+ case "up":
788
+ self._move_selection(-1)
789
+ return CompletionResult.HANDLED
790
+ case "escape":
791
+ self.reset()
792
+ return CompletionResult.HANDLED
793
+ case _:
794
+ return CompletionResult.IGNORED
795
+
796
+ def _move_selection(self, delta: int) -> None:
797
+ """Move selection up or down."""
798
+ if not self._suggestions:
799
+ return
800
+ count = len(self._suggestions)
801
+ self._selected_index = (self._selected_index + delta) % count
802
+ self._view.render_completion_suggestions(
803
+ self._suggestions, self._selected_index
804
+ )
805
+
806
+ def _apply_selected_completion(self, text: str, cursor_index: int) -> bool:
807
+ """Apply the currently selected completion.
808
+
809
+ Returns:
810
+ True if completion was applied, False if no suggestions or invalid state.
811
+ """
812
+ if not self._suggestions:
813
+ return False
814
+
815
+ label, _ = self._suggestions[self._selected_index]
816
+ before_cursor = text[:cursor_index]
817
+ at_index = before_cursor.rfind("@")
818
+
819
+ if at_index < 0:
820
+ return False
821
+
822
+ # Replace from @ to cursor with the completion
823
+ self._view.replace_completion_range(at_index, cursor_index, label)
824
+ self.reset()
825
+ return True
826
+
827
+
828
+ # Keep old name as alias for backwards compatibility
829
+ PathCompletionController = FuzzyFileController
830
+
831
+
832
+ # ============================================================================
833
+ # Multi-Completion Manager
834
+ # ============================================================================
835
+
836
+
837
+ class MultiCompletionManager:
838
+ """Manages multiple completion controllers, delegating to the active one."""
839
+
840
+ def __init__(self, controllers: list[CompletionController]) -> None:
841
+ """Initialize with a list of controllers.
842
+
843
+ Args:
844
+ controllers: List of completion controllers (checked in order)
845
+ """
846
+ self._controllers = controllers
847
+ self._active: CompletionController | None = None
848
+
849
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
850
+ """Handle text change, activating the appropriate controller."""
851
+ # Find the first controller that can handle this input
852
+ candidate = None
853
+ for controller in self._controllers:
854
+ if controller.can_handle(text, cursor_index):
855
+ candidate = controller
856
+ break
857
+
858
+ # No controller can handle - reset if we had one active
859
+ if candidate is None:
860
+ if self._active is not None:
861
+ self._active.reset()
862
+ self._active = None
863
+ return
864
+
865
+ # Switch to new controller if different
866
+ if candidate is not self._active:
867
+ if self._active is not None:
868
+ self._active.reset()
869
+ self._active = candidate
870
+
871
+ # Let the active controller process the change
872
+ candidate.on_text_changed(text, cursor_index)
873
+
874
+ def on_key(
875
+ self, event: events.Key, text: str, cursor_index: int
876
+ ) -> CompletionResult:
877
+ """Handle key event, delegating to active controller.
878
+
879
+ Returns:
880
+ CompletionResult from active controller, or IGNORED if none active.
881
+ """
882
+ if self._active is None:
883
+ return CompletionResult.IGNORED
884
+ return self._active.on_key(event, text, cursor_index)
885
+
886
+ def reset(self) -> None:
887
+ """Reset all controllers."""
888
+ if self._active is not None:
889
+ self._active.reset()
890
+ self._active = None