soothe-cli 0.1.0__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 (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,666 @@
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 contextlib
11
+ import shutil
12
+
13
+ # S404: subprocess is required for git ls-files to get project file list
14
+ import subprocess # noqa: S404
15
+ from difflib import SequenceMatcher
16
+ from enum import StrEnum
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Protocol
19
+
20
+ from soothe_cli.tui.project_utils import find_project_root
21
+
22
+
23
+ def _get_git_executable() -> str | None:
24
+ """Get full path to git executable using shutil.which().
25
+
26
+ Returns:
27
+ Full path to git executable, or None if not found.
28
+ """
29
+ return shutil.which("git")
30
+
31
+
32
+ if TYPE_CHECKING:
33
+ from textual import events
34
+
35
+
36
+ class CompletionResult(StrEnum):
37
+ """Result of handling a key event in the completion system."""
38
+
39
+ IGNORED = "ignored" # Key not handled, let default behavior proceed
40
+ HANDLED = "handled" # Key handled, prevent default
41
+ SUBMIT = "submit" # Key triggers submission (e.g., Enter on slash command)
42
+
43
+
44
+ class CompletionView(Protocol):
45
+ """Protocol for views that can display completion suggestions."""
46
+
47
+ def render_completion_suggestions(
48
+ self, suggestions: list[tuple[str, str]], selected_index: int
49
+ ) -> None:
50
+ """Render the completion suggestions popup.
51
+
52
+ Args:
53
+ suggestions: List of (label, description) tuples
54
+ selected_index: Index of currently selected item
55
+ """
56
+ ...
57
+
58
+ def clear_completion_suggestions(self) -> None:
59
+ """Hide/clear the completion suggestions popup."""
60
+ ...
61
+
62
+ def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
63
+ """Replace text in the input from start to end with replacement.
64
+
65
+ Args:
66
+ start: Start index in the input text
67
+ end: End index in the input text
68
+ replacement: Text to insert
69
+ """
70
+ ...
71
+
72
+
73
+ class CompletionController(Protocol):
74
+ """Protocol for completion controllers."""
75
+
76
+ def can_handle(self, text: str, cursor_index: int) -> bool:
77
+ """Check if this controller can handle the current input state."""
78
+ ...
79
+
80
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
81
+ """Called when input text changes."""
82
+ ...
83
+
84
+ def on_key(self, event: events.Key, text: str, cursor_index: int) -> CompletionResult:
85
+ """Handle a key event. Returns how the event was handled."""
86
+ ...
87
+
88
+ def reset(self) -> None:
89
+ """Reset/clear the completion state."""
90
+ ...
91
+
92
+
93
+ # ============================================================================
94
+ # Slash Command Completion
95
+ # ============================================================================
96
+
97
+
98
+ MAX_SUGGESTIONS = 10
99
+ """UI cap so the completion popup doesn't get unwieldy."""
100
+
101
+ _MIN_SLASH_FUZZY_SCORE = 25
102
+ """Minimum score for slash-command fuzzy matches."""
103
+
104
+ _MIN_DESC_SEARCH_LEN = 2
105
+ """Minimum query length to search command descriptions (avoids single-char noise)."""
106
+
107
+
108
+ class SlashCommandController:
109
+ """Controller for / slash command completion."""
110
+
111
+ def __init__(
112
+ self,
113
+ commands: list[tuple[str, str, str]],
114
+ view: CompletionView,
115
+ ) -> None:
116
+ """Initialize the slash command controller.
117
+
118
+ Args:
119
+ commands: List of `(command, description, hidden_keywords)` tuples.
120
+ view: View to render suggestions to.
121
+ """
122
+ self._commands = commands
123
+ self._view = view
124
+ self._suggestions: list[tuple[str, str]] = []
125
+ self._selected_index = 0
126
+
127
+ def update_commands(self, commands: list[tuple[str, str, str]]) -> None:
128
+ """Replace the commands list and reset suggestions.
129
+
130
+ Used to merge dynamically discovered skill commands with
131
+ the static command registry at runtime.
132
+
133
+ Args:
134
+ commands: New list of `(command, description, hidden_keywords)` tuples.
135
+ """
136
+ self._commands = commands
137
+ self.reset()
138
+
139
+ @staticmethod
140
+ def can_handle(text: str, cursor_index: int) -> bool: # noqa: ARG004 # Required by AutocompleteProvider interface
141
+ """Handle input that starts with /.
142
+
143
+ Returns:
144
+ True if text starts with slash, indicating a command.
145
+ """
146
+ return text.startswith("/")
147
+
148
+ def reset(self) -> None:
149
+ """Clear suggestions."""
150
+ if self._suggestions:
151
+ self._suggestions.clear()
152
+ self._selected_index = 0
153
+ self._view.clear_completion_suggestions()
154
+
155
+ @staticmethod
156
+ def _score_command(search: str, cmd: str, desc: str, keywords: str = "") -> float:
157
+ """Score a command against a search string. Higher = better match.
158
+
159
+ Args:
160
+ search: Lowercase search string (without leading `/`).
161
+ cmd: Command name (e.g. `'/help'`).
162
+ desc: Command description text.
163
+ keywords: Space-separated hidden keywords for matching.
164
+
165
+ Returns:
166
+ Score value where higher indicates better match quality.
167
+ """
168
+ if not search:
169
+ return 0.0
170
+ name = cmd.lstrip("/").lower()
171
+ lower_desc = desc.lower()
172
+ # Prefix match on command name — highest priority
173
+ if name.startswith(search):
174
+ return 200.0
175
+ # Substring match on command name
176
+ if search in name:
177
+ return 150.0
178
+ # Hidden keyword match — treated like a word-boundary description match
179
+ if keywords and len(search) >= _MIN_DESC_SEARCH_LEN:
180
+ for kw in keywords.lower().split():
181
+ if kw.startswith(search) or search in kw:
182
+ return 120.0
183
+ # Substring match on description (require ≥2 chars to avoid single-letter noise)
184
+ if len(search) >= _MIN_DESC_SEARCH_LEN and search in lower_desc:
185
+ idx = lower_desc.find(search)
186
+ # Word-boundary bonus: match at start of description or after a space
187
+ if idx == 0 or lower_desc[idx - 1] == " ":
188
+ return 110.0
189
+ return 90.0
190
+ # Fuzzy match via SequenceMatcher on name + desc
191
+ name_ratio = SequenceMatcher(None, search, name).ratio()
192
+ desc_ratio = SequenceMatcher(None, search, lower_desc).ratio()
193
+ best = max(name_ratio * 60, desc_ratio * 30)
194
+ return best if best >= _MIN_SLASH_FUZZY_SCORE else 0.0
195
+
196
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
197
+ """Update suggestions when text changes."""
198
+ if cursor_index < 0 or cursor_index > len(text):
199
+ self.reset()
200
+ return
201
+
202
+ if not self.can_handle(text, cursor_index):
203
+ self.reset()
204
+ return
205
+
206
+ # Get the search string (text after /)
207
+ search = text[1:cursor_index].lower()
208
+
209
+ # Space means the user finished picking a command — dismiss popup
210
+ if " " in search:
211
+ self.reset()
212
+ return
213
+
214
+ if not search:
215
+ # No search text — show all commands (display only cmd + desc)
216
+ suggestions = [(cmd, desc) for cmd, desc, _ in self._commands][:MAX_SUGGESTIONS]
217
+ else:
218
+ # Score and filter commands using fuzzy matching
219
+ scored = [
220
+ (score, cmd, desc)
221
+ for cmd, desc, kw in self._commands
222
+ if (score := self._score_command(search, cmd, desc, kw)) > 0
223
+ ]
224
+ scored.sort(key=lambda x: -x[0])
225
+ suggestions = [(cmd, desc) for _, cmd, desc in scored[:MAX_SUGGESTIONS]]
226
+
227
+ if suggestions:
228
+ self._suggestions = suggestions
229
+ self._selected_index = 0
230
+ self._view.render_completion_suggestions(self._suggestions, self._selected_index)
231
+ else:
232
+ self.reset()
233
+
234
+ def on_key(self, event: events.Key, _text: str, cursor_index: int) -> CompletionResult:
235
+ """Handle key events for navigation and selection.
236
+
237
+ Returns:
238
+ CompletionResult indicating how the key was handled.
239
+ """
240
+ if not self._suggestions:
241
+ return CompletionResult.IGNORED
242
+
243
+ match event.key:
244
+ case "tab":
245
+ if self._apply_selected_completion(cursor_index):
246
+ return CompletionResult.HANDLED
247
+ return CompletionResult.IGNORED
248
+ case "enter":
249
+ if self._apply_selected_completion(cursor_index):
250
+ return CompletionResult.SUBMIT
251
+ return CompletionResult.HANDLED
252
+ case "down":
253
+ self._move_selection(1)
254
+ return CompletionResult.HANDLED
255
+ case "up":
256
+ self._move_selection(-1)
257
+ return CompletionResult.HANDLED
258
+ case "escape":
259
+ self.reset()
260
+ return CompletionResult.HANDLED
261
+ case _:
262
+ return CompletionResult.IGNORED
263
+
264
+ def _move_selection(self, delta: int) -> None:
265
+ """Move selection up or down."""
266
+ if not self._suggestions:
267
+ return
268
+ count = len(self._suggestions)
269
+ self._selected_index = (self._selected_index + delta) % count
270
+ self._view.render_completion_suggestions(self._suggestions, self._selected_index)
271
+
272
+ def _apply_selected_completion(self, cursor_index: int) -> bool:
273
+ """Apply the currently selected completion.
274
+
275
+ Returns:
276
+ True if completion was applied, False if no suggestions.
277
+ """
278
+ if not self._suggestions:
279
+ return False
280
+
281
+ command, _ = self._suggestions[self._selected_index]
282
+ # Replace from start to cursor with the command
283
+ self._view.replace_completion_range(0, cursor_index, command)
284
+ self.reset()
285
+ return True
286
+
287
+
288
+ # ============================================================================
289
+ # Fuzzy File Completion (from project root)
290
+ # ============================================================================
291
+
292
+ # Constants for fuzzy file completion
293
+ _MAX_FALLBACK_FILES = 1000
294
+ """Hard cap on files returned by the non-git glob fallback."""
295
+
296
+ _MIN_FUZZY_SCORE = 15
297
+ """Minimum score to include in file-completion results."""
298
+
299
+ _MIN_FUZZY_RATIO = 0.4
300
+ """SequenceMatcher threshold for filename-only fuzzy matches."""
301
+
302
+
303
+ def _get_project_files(root: Path) -> list[str]:
304
+ """Get project files using git ls-files or fallback to glob.
305
+
306
+ Returns:
307
+ List of relative file paths from project root.
308
+ """
309
+ git_path = _get_git_executable()
310
+ if git_path:
311
+ try:
312
+ # S603: git_path is validated via shutil.which(), args are hardcoded
313
+ result = subprocess.run( # noqa: S603
314
+ [git_path, "ls-files"],
315
+ cwd=root,
316
+ capture_output=True,
317
+ text=True,
318
+ timeout=5,
319
+ check=False,
320
+ )
321
+ if result.returncode == 0:
322
+ files = result.stdout.strip().split("\n")
323
+ return [f for f in files if f] # Filter empty strings
324
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
325
+ pass
326
+
327
+ # Fallback: simple glob (limited depth to avoid slowness)
328
+ files = []
329
+ try:
330
+ for pattern in ["*", "*/*", "*/*/*", "*/*/*/*"]:
331
+ for p in root.glob(pattern):
332
+ if p.is_file() and not any(part.startswith(".") for part in p.parts):
333
+ files.append(p.relative_to(root).as_posix())
334
+ if len(files) >= _MAX_FALLBACK_FILES:
335
+ break
336
+ if len(files) >= _MAX_FALLBACK_FILES:
337
+ break
338
+ except OSError:
339
+ pass
340
+ return files
341
+
342
+
343
+ def _fuzzy_score(query: str, candidate: str) -> float:
344
+ """Score a candidate against query. Higher = better match.
345
+
346
+ Returns:
347
+ Score value where higher indicates better match quality.
348
+ """
349
+ query_lower = query.lower()
350
+ # Normalize path separators for cross-platform support
351
+ candidate_normalized = candidate.replace("\\", "/")
352
+ candidate_lower = candidate_normalized.lower()
353
+
354
+ # Extract filename for matching (prioritize filename over full path)
355
+ filename = candidate_normalized.rsplit("/", 1)[-1].lower()
356
+ filename_start = candidate_lower.rfind("/") + 1
357
+
358
+ # Check filename first (higher priority)
359
+ if query_lower in filename:
360
+ idx = filename.find(query_lower)
361
+ # Bonus for being at start of filename
362
+ if idx == 0:
363
+ return 150 + (1 / len(candidate))
364
+ # Bonus for word boundary in filename
365
+ if idx > 0 and filename[idx - 1] in "_-.":
366
+ return 120 + (1 / len(candidate))
367
+ return 100 + (1 / len(candidate))
368
+
369
+ # Check full path
370
+ if query_lower in candidate_lower:
371
+ idx = candidate_lower.find(query_lower)
372
+ # At start of filename
373
+ if idx == filename_start:
374
+ return 80 + (1 / len(candidate))
375
+ # At word boundary in path
376
+ if idx == 0 or candidate[idx - 1] in "/_-.":
377
+ return 60 + (1 / len(candidate))
378
+ return 40 + (1 / len(candidate))
379
+
380
+ # Fuzzy match on filename only (more relevant)
381
+ filename_ratio = SequenceMatcher(None, query_lower, filename).ratio()
382
+ if filename_ratio > _MIN_FUZZY_RATIO:
383
+ return filename_ratio * 30
384
+
385
+ # Fallback: fuzzy on full path
386
+ ratio = SequenceMatcher(None, query_lower, candidate_lower).ratio()
387
+ return ratio * 15
388
+
389
+
390
+ def _is_dotpath(path: str) -> bool:
391
+ """Check if path contains dotfiles/dotdirs (e.g., .github/...).
392
+
393
+ Returns:
394
+ True if path contains hidden directories or files.
395
+ """
396
+ return any(part.startswith(".") for part in path.split("/"))
397
+
398
+
399
+ def _path_depth(path: str) -> int:
400
+ """Get depth of path (number of / separators).
401
+
402
+ Returns:
403
+ Number of path separators in the path.
404
+ """
405
+ return path.count("/")
406
+
407
+
408
+ def _fuzzy_search(
409
+ query: str,
410
+ candidates: list[str],
411
+ limit: int = 10,
412
+ *,
413
+ include_dotfiles: bool = False,
414
+ ) -> list[str]:
415
+ """Return top matches sorted by score.
416
+
417
+ Args:
418
+ query: Search query
419
+ candidates: List of file paths to search
420
+ limit: Max results to return
421
+ include_dotfiles: Whether to include dotfiles (default False)
422
+
423
+ Returns:
424
+ List of matching file paths sorted by relevance score.
425
+ """
426
+ # Filter dotfiles unless explicitly searching for them
427
+ filtered = candidates if include_dotfiles else [c for c in candidates if not _is_dotpath(c)]
428
+
429
+ if not query:
430
+ # Empty query: show root-level files first, sorted by depth then name
431
+ sorted_files = sorted(filtered, key=lambda p: (_path_depth(p), p.lower()))
432
+ return sorted_files[:limit]
433
+
434
+ scored = [(score, c) for c in filtered if (score := _fuzzy_score(query, c)) >= _MIN_FUZZY_SCORE]
435
+ scored.sort(key=lambda x: -x[0])
436
+ return [c for _, c in scored[:limit]]
437
+
438
+
439
+ class FuzzyFileController:
440
+ """Controller for @ file completion with fuzzy matching from project root."""
441
+
442
+ def __init__(
443
+ self,
444
+ view: CompletionView,
445
+ cwd: Path | None = None,
446
+ ) -> None:
447
+ """Initialize the fuzzy file controller.
448
+
449
+ Args:
450
+ view: View to render suggestions to
451
+ cwd: Starting directory to find project root from
452
+ """
453
+ self._view = view
454
+ self._cwd = cwd or Path.cwd()
455
+ self._project_root = find_project_root(self._cwd) or self._cwd
456
+ self._suggestions: list[tuple[str, str]] = []
457
+ self._selected_index = 0
458
+ self._file_cache: list[str] | None = None
459
+
460
+ def _get_files(self) -> list[str]:
461
+ """Get cached file list or refresh.
462
+
463
+ Returns:
464
+ List of project file paths.
465
+ """
466
+ if self._file_cache is None:
467
+ self._file_cache = _get_project_files(self._project_root)
468
+ return self._file_cache
469
+
470
+ def refresh_cache(self) -> None:
471
+ """Force refresh of file cache."""
472
+ self._file_cache = None
473
+
474
+ async def warm_cache(self) -> None:
475
+ """Pre-populate the file cache off the event loop."""
476
+ if self._file_cache is not None:
477
+ return
478
+ # Best-effort; _get_files() falls back to sync on failure.
479
+ with contextlib.suppress(Exception):
480
+ self._file_cache = await asyncio.to_thread(_get_project_files, self._project_root)
481
+
482
+ @staticmethod
483
+ def can_handle(text: str, cursor_index: int) -> bool:
484
+ """Handle input that contains @ not followed by space.
485
+
486
+ Returns:
487
+ True if cursor is after @ and within a file mention context.
488
+ """
489
+ if cursor_index <= 0 or cursor_index > len(text):
490
+ return False
491
+
492
+ before_cursor = text[:cursor_index]
493
+ if "@" not in before_cursor:
494
+ return False
495
+
496
+ at_index = before_cursor.rfind("@")
497
+ if cursor_index <= at_index:
498
+ return False
499
+
500
+ # Fragment from @ to cursor must not contain spaces
501
+ fragment = before_cursor[at_index:cursor_index]
502
+ return bool(fragment) and " " not in fragment
503
+
504
+ def reset(self) -> None:
505
+ """Clear suggestions."""
506
+ if self._suggestions:
507
+ self._suggestions.clear()
508
+ self._selected_index = 0
509
+ self._view.clear_completion_suggestions()
510
+
511
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
512
+ """Update suggestions when text changes."""
513
+ if not self.can_handle(text, cursor_index):
514
+ self.reset()
515
+ return
516
+
517
+ before_cursor = text[:cursor_index]
518
+ at_index = before_cursor.rfind("@")
519
+ search = before_cursor[at_index + 1 :]
520
+
521
+ suggestions = self._get_fuzzy_suggestions(search)
522
+
523
+ if suggestions:
524
+ self._suggestions = suggestions
525
+ self._selected_index = 0
526
+ self._view.render_completion_suggestions(self._suggestions, self._selected_index)
527
+ else:
528
+ self.reset()
529
+
530
+ def _get_fuzzy_suggestions(self, search: str) -> list[tuple[str, str]]:
531
+ """Get fuzzy file suggestions.
532
+
533
+ Returns:
534
+ List of (label, type_hint) tuples for matching files.
535
+ """
536
+ files = self._get_files()
537
+ # Include dotfiles only if query starts with "."
538
+ include_dots = search.startswith(".")
539
+ matches = _fuzzy_search(search, files, limit=MAX_SUGGESTIONS, include_dotfiles=include_dots)
540
+
541
+ suggestions: list[tuple[str, str]] = []
542
+ for path in matches:
543
+ # Get file extension for type hint
544
+ ext = Path(path).suffix.lower()
545
+ type_hint = ext[1:] if ext else "file"
546
+ suggestions.append((f"@{path}", type_hint))
547
+
548
+ return suggestions
549
+
550
+ def on_key(self, event: events.Key, text: str, cursor_index: int) -> CompletionResult:
551
+ """Handle key events for navigation and selection.
552
+
553
+ Returns:
554
+ CompletionResult indicating how the key was handled.
555
+ """
556
+ if not self._suggestions:
557
+ return CompletionResult.IGNORED
558
+
559
+ match event.key:
560
+ case "tab" | "enter":
561
+ if self._apply_selected_completion(text, cursor_index):
562
+ return CompletionResult.HANDLED
563
+ return CompletionResult.IGNORED
564
+ case "down":
565
+ self._move_selection(1)
566
+ return CompletionResult.HANDLED
567
+ case "up":
568
+ self._move_selection(-1)
569
+ return CompletionResult.HANDLED
570
+ case "escape":
571
+ self.reset()
572
+ return CompletionResult.HANDLED
573
+ case _:
574
+ return CompletionResult.IGNORED
575
+
576
+ def _move_selection(self, delta: int) -> None:
577
+ """Move selection up or down."""
578
+ if not self._suggestions:
579
+ return
580
+ count = len(self._suggestions)
581
+ self._selected_index = (self._selected_index + delta) % count
582
+ self._view.render_completion_suggestions(self._suggestions, self._selected_index)
583
+
584
+ def _apply_selected_completion(self, text: str, cursor_index: int) -> bool:
585
+ """Apply the currently selected completion.
586
+
587
+ Returns:
588
+ True if completion was applied, False if no suggestions or invalid state.
589
+ """
590
+ if not self._suggestions:
591
+ return False
592
+
593
+ label, _ = self._suggestions[self._selected_index]
594
+ before_cursor = text[:cursor_index]
595
+ at_index = before_cursor.rfind("@")
596
+
597
+ if at_index < 0:
598
+ return False
599
+
600
+ # Replace from @ to cursor with the completion
601
+ self._view.replace_completion_range(at_index, cursor_index, label)
602
+ self.reset()
603
+ return True
604
+
605
+
606
+ # Keep old name as alias for backwards compatibility
607
+ PathCompletionController = FuzzyFileController
608
+
609
+
610
+ # ============================================================================
611
+ # Multi-Completion Manager
612
+ # ============================================================================
613
+
614
+
615
+ class MultiCompletionManager:
616
+ """Manages multiple completion controllers, delegating to the active one."""
617
+
618
+ def __init__(self, controllers: list[CompletionController]) -> None:
619
+ """Initialize with a list of controllers.
620
+
621
+ Args:
622
+ controllers: List of completion controllers (checked in order)
623
+ """
624
+ self._controllers = controllers
625
+ self._active: CompletionController | None = None
626
+
627
+ def on_text_changed(self, text: str, cursor_index: int) -> None:
628
+ """Handle text change, activating the appropriate controller."""
629
+ # Find the first controller that can handle this input
630
+ candidate = None
631
+ for controller in self._controllers:
632
+ if controller.can_handle(text, cursor_index):
633
+ candidate = controller
634
+ break
635
+
636
+ # No controller can handle - reset if we had one active
637
+ if candidate is None:
638
+ if self._active is not None:
639
+ self._active.reset()
640
+ self._active = None
641
+ return
642
+
643
+ # Switch to new controller if different
644
+ if candidate is not self._active:
645
+ if self._active is not None:
646
+ self._active.reset()
647
+ self._active = candidate
648
+
649
+ # Let the active controller process the change
650
+ candidate.on_text_changed(text, cursor_index)
651
+
652
+ def on_key(self, event: events.Key, text: str, cursor_index: int) -> CompletionResult:
653
+ """Handle key event, delegating to active controller.
654
+
655
+ Returns:
656
+ CompletionResult from active controller, or IGNORED if none active.
657
+ """
658
+ if self._active is None:
659
+ return CompletionResult.IGNORED
660
+ return self._active.on_key(event, text, cursor_index)
661
+
662
+ def reset(self) -> None:
663
+ """Reset all controllers."""
664
+ if self._active is not None:
665
+ self._active.reset()
666
+ self._active = None