|
11 | 11 | import sys |
12 | 12 | import time |
13 | 13 | from collections import deque |
14 | | -from collections.abc import Awaitable, Callable, Iterable, Sequence |
| 14 | +from collections.abc import Awaitable, Callable, Sequence |
15 | 15 | from dataclasses import dataclass |
16 | 16 | from datetime import datetime |
17 | 17 | from enum import Enum |
18 | 18 | from hashlib import md5 |
19 | 19 | from pathlib import Path |
20 | | -from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, override, runtime_checkable |
| 20 | +from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, runtime_checkable |
21 | 21 |
|
22 | 22 | from prompt_toolkit import PromptSession |
23 | 23 | from prompt_toolkit.application import Application |
24 | 24 | from prompt_toolkit.application.current import get_app_or_none |
25 | 25 | from prompt_toolkit.buffer import Buffer |
26 | 26 | from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard |
27 | | -from prompt_toolkit.completion import ( |
28 | | - CompleteEvent, |
29 | | - Completer, |
30 | | - Completion, |
31 | | - FuzzyCompleter, |
32 | | - WordCompleter, |
33 | | - merge_completers, |
34 | | -) |
| 27 | +from prompt_toolkit.completion import Completion, merge_completers |
35 | 28 | from prompt_toolkit.data_structures import Point |
36 | 29 | from prompt_toolkit.document import Document |
37 | 30 | from prompt_toolkit.filters import Condition, has_completions |
|
59 | 52 | from prompt_toolkit.patch_stdout import patch_stdout |
60 | 53 | from prompt_toolkit.utils import get_cwidth |
61 | 54 | from pydantic import BaseModel, ValidationError |
| 55 | +from pythinker_host import get_current_host |
62 | 56 | from pythinker_host.path import HostPath |
63 | 57 |
|
64 | 58 | from pythinker_code.config import StatusLineConfig |
|
97 | 91 | command_name_set, |
98 | 92 | discard_slash_command, |
99 | 93 | ) |
| 94 | +from pythinker_code.ui.shell.prompting.completion.workspace import ( |
| 95 | + HostFileMentionCompleter, |
| 96 | + WorkspaceIndex, |
| 97 | +) |
100 | 98 | from pythinker_code.ui.shell.prompting.lifecycle import PromptLifecycle |
101 | 99 | from pythinker_code.ui.shell.prompting.state import ( |
102 | 100 | BufferObserved, |
@@ -1163,183 +1161,7 @@ def _render_count_line( |
1163 | 1161 | return fragments |
1164 | 1162 |
|
1165 | 1163 |
|
1166 | | -class LocalFileMentionCompleter(Completer): |
1167 | | - """Offer fuzzy `@` path completion by indexing workspace files. |
1168 | | -
|
1169 | | - File discovery and ignore rules are delegated to |
1170 | | - :mod:`pythinker_code.utils.file_filter` so that the web backend can reuse |
1171 | | - them. |
1172 | | - """ |
1173 | | - |
1174 | | - _FRAGMENT_PATTERN = re.compile(r"[^\s@]+") |
1175 | | - |
1176 | | - def __init__( |
1177 | | - self, |
1178 | | - root: Path, |
1179 | | - *, |
1180 | | - refresh_interval: float = 2.0, |
1181 | | - limit: int = 1000, |
1182 | | - ) -> None: |
1183 | | - self._root = root |
1184 | | - self._refresh_interval = refresh_interval |
1185 | | - self._limit = limit |
1186 | | - self._cache_time: float = 0.0 |
1187 | | - self._cached_paths: list[str] = [] |
1188 | | - self._cache_scope: str | None = None |
1189 | | - self._top_cache_time: float = 0.0 |
1190 | | - self._top_cached_paths: list[str] = [] |
1191 | | - self._fragment_hint: str | None = None |
1192 | | - self._is_git: bool | None = None # lazily detected |
1193 | | - self._git_index_mtime: float | None = None |
1194 | | - |
1195 | | - self._word_completer = WordCompleter( |
1196 | | - self._get_paths, |
1197 | | - WORD=False, |
1198 | | - pattern=self._FRAGMENT_PATTERN, |
1199 | | - ) |
1200 | | - |
1201 | | - self._fuzzy = FuzzyCompleter( |
1202 | | - self._word_completer, |
1203 | | - WORD=False, |
1204 | | - pattern=r"^[^\s@]*", |
1205 | | - ) |
1206 | | - |
1207 | | - def _get_paths(self) -> list[str]: |
1208 | | - fragment = self._fragment_hint or "" |
1209 | | - if "/" not in fragment and len(fragment) < 3: |
1210 | | - return self._get_top_level_paths() |
1211 | | - return self._get_deep_paths() |
1212 | | - |
1213 | | - def _get_top_level_paths(self) -> list[str]: |
1214 | | - from pythinker_code.utils.file_filter import is_ignored |
1215 | | - |
1216 | | - now = time.monotonic() |
1217 | | - if now - self._top_cache_time <= self._refresh_interval: |
1218 | | - return self._top_cached_paths |
1219 | | - |
1220 | | - entries: list[str] = [] |
1221 | | - try: |
1222 | | - for entry in sorted(self._root.iterdir(), key=lambda p: p.name): |
1223 | | - name = entry.name |
1224 | | - if is_ignored(name): |
1225 | | - continue |
1226 | | - entries.append(f"{name}/" if entry.is_dir() else name) |
1227 | | - if len(entries) >= self._limit: |
1228 | | - break |
1229 | | - except OSError: |
1230 | | - return self._top_cached_paths |
1231 | | - |
1232 | | - self._top_cached_paths = entries |
1233 | | - self._top_cache_time = now |
1234 | | - return self._top_cached_paths |
1235 | | - |
1236 | | - def _get_deep_paths(self) -> list[str]: |
1237 | | - from pythinker_code.utils.file_filter import ( |
1238 | | - detect_git, |
1239 | | - git_index_mtime, |
1240 | | - list_files_git, |
1241 | | - list_files_walk, |
1242 | | - ) |
1243 | | - |
1244 | | - fragment = self._fragment_hint or "" |
1245 | | - |
1246 | | - scope: str | None = None |
1247 | | - if "/" in fragment: |
1248 | | - scope = fragment.rsplit("/", 1)[0] |
1249 | | - |
1250 | | - now = time.monotonic() |
1251 | | - cache_valid = ( |
1252 | | - now - self._cache_time <= self._refresh_interval and self._cache_scope == scope |
1253 | | - ) |
1254 | | - |
1255 | | - # Invalidate on .git/index mtime change. |
1256 | | - if cache_valid and self._is_git: |
1257 | | - mtime = git_index_mtime(self._root) |
1258 | | - if mtime != self._git_index_mtime: |
1259 | | - cache_valid = False |
1260 | | - |
1261 | | - if cache_valid: |
1262 | | - return self._cached_paths |
1263 | | - |
1264 | | - if self._is_git is None: |
1265 | | - self._is_git = detect_git(self._root) |
1266 | | - |
1267 | | - paths: list[str] | None = None |
1268 | | - if self._is_git: |
1269 | | - paths = list_files_git(self._root, scope) |
1270 | | - self._git_index_mtime = git_index_mtime(self._root) |
1271 | | - if paths is None: |
1272 | | - paths = list_files_walk(self._root, scope, limit=self._limit) |
1273 | | - |
1274 | | - self._cached_paths = paths |
1275 | | - self._cache_scope = scope |
1276 | | - self._cache_time = now |
1277 | | - return self._cached_paths |
1278 | | - |
1279 | | - @staticmethod |
1280 | | - def should_complete(document: Document) -> bool: |
1281 | | - """Return whether `@` file completion should be active for the buffer.""" |
1282 | | - context = parse_completion_context(document, allow_slash=False) |
1283 | | - return context.kind is CompletionKind.FILE |
1284 | | - |
1285 | | - def _is_completed_file(self, fragment: str) -> bool: |
1286 | | - candidate = fragment.rstrip("/") |
1287 | | - if not candidate: |
1288 | | - return False |
1289 | | - try: |
1290 | | - return (self._root / candidate).is_file() |
1291 | | - except OSError: |
1292 | | - return False |
1293 | | - |
1294 | | - @override |
1295 | | - def get_completions( |
1296 | | - self, document: Document, complete_event: CompleteEvent |
1297 | | - ) -> Iterable[Completion]: |
1298 | | - context = parse_completion_context(document, allow_slash=False) |
1299 | | - if context.kind is not CompletionKind.FILE: |
1300 | | - return |
1301 | | - fragment = context.token |
1302 | | - if self._is_completed_file(fragment): |
1303 | | - return |
1304 | | - |
1305 | | - mention_doc = Document(text=fragment, cursor_position=len(fragment)) |
1306 | | - self._fragment_hint = fragment |
1307 | | - try: |
1308 | | - # First, ask the fuzzy completer for candidates. |
1309 | | - candidates = list(self._fuzzy.get_completions(mention_doc, complete_event)) |
1310 | | - |
1311 | | - # re-rank: prefer basename matches |
1312 | | - frag_lower = fragment.lower() |
1313 | | - |
1314 | | - def _rank(c: Completion) -> tuple[int, ...]: |
1315 | | - path = c.text |
1316 | | - base = path.rstrip("/").split("/")[-1].lower() |
1317 | | - if base.startswith(frag_lower): |
1318 | | - cat = 0 |
1319 | | - elif frag_lower in base: |
1320 | | - cat = 1 |
1321 | | - else: |
1322 | | - cat = 2 |
1323 | | - test_penalty = int(any("test" in segment.lower() for segment in path.split("/"))) |
1324 | | - # preserve original FuzzyCompleter's order in the same category |
1325 | | - return (cat, test_penalty) |
1326 | | - |
1327 | | - candidates.sort(key=_rank) |
1328 | | - if not context.quoted: |
1329 | | - yield from candidates |
1330 | | - return |
1331 | | - for candidate in candidates: |
1332 | | - escaped = candidate.text.replace("\\", "\\\\").replace('"', '\\"') |
1333 | | - yield Completion( |
1334 | | - text=f'"{escaped}"', |
1335 | | - start_position=context.start_position, |
1336 | | - display=candidate.display, |
1337 | | - display_meta=candidate.display_meta, |
1338 | | - style=candidate.style, |
1339 | | - selected_style=candidate.selected_style, |
1340 | | - ) |
1341 | | - finally: |
1342 | | - self._fragment_hint = None |
| 1164 | +LocalFileMentionCompleter = HostFileMentionCompleter |
1343 | 1165 |
|
1344 | 1166 |
|
1345 | 1167 | class _HistoryEntry(BaseModel): |
@@ -1959,11 +1781,19 @@ def __init__( |
1959 | 1781 | is_task_running=lambda: self._running_prompt_delegate is not None, |
1960 | 1782 | arg_suggestions=self._slash_arg_suggestions, |
1961 | 1783 | ) |
| 1784 | + self._workspace_root = HostPath.cwd() |
| 1785 | + self._workspace_index = WorkspaceIndex( |
| 1786 | + get_current_host(), |
| 1787 | + self._lifecycle, |
| 1788 | + self._workspace_root, |
| 1789 | + on_publish=self._on_workspace_snapshot_published, |
| 1790 | + ) |
| 1791 | + self._lifecycle.register_closer("workspace index", self._workspace_index.aclose) |
| 1792 | + self._file_mention_completer = HostFileMentionCompleter(self._workspace_index) |
1962 | 1793 | self._agent_mode_completer = merge_completers( |
1963 | 1794 | [ |
1964 | 1795 | self._agent_slash_completer, |
1965 | | - # TODO(host): we need an async HostFileMentionCompleter |
1966 | | - LocalFileMentionCompleter(HostPath.cwd().unsafe_to_local_path()), |
| 1796 | + self._file_mention_completer, |
1967 | 1797 | ], |
1968 | 1798 | deduplicate=True, |
1969 | 1799 | ) |
@@ -3825,7 +3655,25 @@ def running_prompt_accepts_submission(self) -> bool: |
3825 | 3655 | return False |
3826 | 3656 | return delegate.running_prompt_accepts_submission() |
3827 | 3657 |
|
| 3658 | + def _on_workspace_snapshot_published(self) -> None: |
| 3659 | + """Re-run file completion when a fresh workspace snapshot lands mid-menu.""" |
| 3660 | + app = self._session.app |
| 3661 | + if not app.is_running: |
| 3662 | + return |
| 3663 | + buffer = self._session.default_buffer |
| 3664 | + if not HostFileMentionCompleter.should_complete(buffer.document): |
| 3665 | + return |
| 3666 | + buffer.start_completion(select_first=False) |
| 3667 | + app.invalidate() |
| 3668 | + |
3828 | 3669 | async def _prompt_once(self, *, append_history: bool | None) -> UserInput: |
| 3670 | + workspace_index = getattr(self, "_workspace_index", None) |
| 3671 | + if workspace_index is not None: |
| 3672 | + workspace_root = HostPath.cwd() |
| 3673 | + if workspace_root != getattr(self, "_workspace_root", None): |
| 3674 | + self._workspace_root = workspace_root |
| 3675 | + workspace_index.set_root(workspace_root) |
| 3676 | + workspace_index.request_refresh("") |
3829 | 3677 | placeholder = None |
3830 | 3678 | if (delegate := self._active_prompt_delegate()) is not None: |
3831 | 3679 | placeholder = delegate.running_prompt_placeholder() |
|
0 commit comments