Skip to content

Commit bb6d13c

Browse files
committed
feat(tui): index workspace mentions asynchronously
1 parent e8fc28d commit bb6d13c

6 files changed

Lines changed: 1004 additions & 251 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
1717

1818
- Freeze the public shell prompt compatibility contract with constructor and rendering coverage.
1919
- Unify slash and file-mention completion behind one canonical completion context, adding quoted `@"path with spaces"` file mentions.
20+
- Index workspace file mentions asynchronously with a cwd-aware, generation-owned snapshot index so completion never blocks on disk or Git scans.
2021
- Keep the interactive prompt scene within the terminal height with a priority-ordered row allocator, and shut prompt background tasks/processes down with an awaited lifecycle.
2122
- Add xAI Grok OAuth login (browser loopback and device-code).
2223
- Add GitHub Copilot device-code OAuth login for individual github.com accounts.

‎src/pythinker_code/ui/shell/prompt.py‎

Lines changed: 37 additions & 189 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,20 @@
1111
import sys
1212
import time
1313
from collections import deque
14-
from collections.abc import Awaitable, Callable, Iterable, Sequence
14+
from collections.abc import Awaitable, Callable, Sequence
1515
from dataclasses import dataclass
1616
from datetime import datetime
1717
from enum import Enum
1818
from hashlib import md5
1919
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
2121

2222
from prompt_toolkit import PromptSession
2323
from prompt_toolkit.application import Application
2424
from prompt_toolkit.application.current import get_app_or_none
2525
from prompt_toolkit.buffer import Buffer
2626
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
3528
from prompt_toolkit.data_structures import Point
3629
from prompt_toolkit.document import Document
3730
from prompt_toolkit.filters import Condition, has_completions
@@ -59,6 +52,7 @@
5952
from prompt_toolkit.patch_stdout import patch_stdout
6053
from prompt_toolkit.utils import get_cwidth
6154
from pydantic import BaseModel, ValidationError
55+
from pythinker_host import get_current_host
6256
from pythinker_host.path import HostPath
6357

6458
from pythinker_code.config import StatusLineConfig
@@ -97,6 +91,10 @@
9791
command_name_set,
9892
discard_slash_command,
9993
)
94+
from pythinker_code.ui.shell.prompting.completion.workspace import (
95+
HostFileMentionCompleter,
96+
WorkspaceIndex,
97+
)
10098
from pythinker_code.ui.shell.prompting.lifecycle import PromptLifecycle
10199
from pythinker_code.ui.shell.prompting.state import (
102100
BufferObserved,
@@ -1163,183 +1161,7 @@ def _render_count_line(
11631161
return fragments
11641162

11651163

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
13431165

13441166

13451167
class _HistoryEntry(BaseModel):
@@ -1959,11 +1781,19 @@ def __init__(
19591781
is_task_running=lambda: self._running_prompt_delegate is not None,
19601782
arg_suggestions=self._slash_arg_suggestions,
19611783
)
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)
19621793
self._agent_mode_completer = merge_completers(
19631794
[
19641795
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,
19671797
],
19681798
deduplicate=True,
19691799
)
@@ -3825,7 +3655,25 @@ def running_prompt_accepts_submission(self) -> bool:
38253655
return False
38263656
return delegate.running_prompt_accepts_submission()
38273657

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+
38283669
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("")
38293677
placeholder = None
38303678
if (delegate := self._active_prompt_delegate()) is not None:
38313679
placeholder = delegate.running_prompt_placeholder()

‎src/pythinker_code/ui/shell/prompting/completion/__init__.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,24 @@
1010
SlashCommandAutoSuggest,
1111
SlashCommandCompleter,
1212
)
13+
from pythinker_code.ui.shell.prompting.completion.workspace import (
14+
HostFileMentionCompleter,
15+
LocalFileMentionCompleter,
16+
WorkspaceEntry,
17+
WorkspaceIndex,
18+
WorkspaceSnapshot,
19+
)
1320

1421
__all__ = (
1522
"CompletionContext",
1623
"CompletionKind",
24+
"HostFileMentionCompleter",
1725
"InputHighlightLexer",
26+
"LocalFileMentionCompleter",
1827
"SlashCommandAutoSuggest",
1928
"SlashCommandCompleter",
29+
"WorkspaceEntry",
30+
"WorkspaceIndex",
31+
"WorkspaceSnapshot",
2032
"parse_completion_context",
2133
)

0 commit comments

Comments
 (0)