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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- 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
|