perf(code): background refresh for @ file completion cache (#3911)

Closes #1409

`@` file-mention completion now refreshes in the background so newly
created or deleted files appear without restarting or switching
directories.

---

`@` file-mention completion already pre-warms its index on mount and
re-warms on cwd switches, with the results cached for the session. The
remaining gap was freshness: files created or deleted mid-session stayed
stale until the next cwd switch.

This adds a periodic background refresh of the file-completion cache
(every 30s). `FuzzyFileController.warm_cache` gains a keyword-only
`force` flag so a refresh re-walks the project even when the cache is
already populated. The walk runs off the event loop in a worker thread
and atomically swaps in the new list — the existing cache stays visible
until the fresh one is ready, so typing is never blocked. The existing
generation guard still drops results from a refresh that a cwd switch
has superseded.

Made by [Open SWE](https://openswe.vercel.app)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-06-28 19:46:44 -04:00
committed by GitHub
parent 49b0cce656
commit aa22d6b6d5
4 changed files with 264 additions and 14 deletions
@@ -7,7 +7,6 @@ for slash commands (/) and file mentions (@).
from __future__ import annotations
import asyncio
import contextlib
import logging
import shutil
@@ -618,7 +617,7 @@ class FuzzyFileController:
self._file_cache = None
self.reset()
async def warm_cache(self) -> None:
async def warm_cache(self, *, force: bool = False) -> None:
"""Pre-populate the file cache off the event loop.
Also resolves a project root deferred by `set_cwd`, so the blocking
@@ -631,6 +630,12 @@ class FuzzyFileController:
belonging to a newer generation. (Snapshotting again before the second
await would defeat the guard: it would match the post-supersession
generation and let a stale-root file walk win.)
Args:
force: Re-walk and swap in a fresh file list even when the cache is
already populated. Used by the periodic background refresh so
files created or deleted mid-session surface in `@` completion.
The existing cache stays visible until the new walk completes.
"""
cwd = self._cwd
generation = self._cache_generation
@@ -646,14 +651,20 @@ class FuzzyFileController:
self._file_cache = None
self._project_root = resolved
self._project_root_pending = False
if self._file_cache is not None:
if not force and self._file_cache is not None:
return
project_root = self._project_root
# Best-effort; _get_files() falls back to sync on failure.
with contextlib.suppress(Exception):
# Best-effort: on failure the existing cache (if any) stays in place. A
# cold cache (`_file_cache is None`) is later filled synchronously by
# `_get_files()`; a force refresh that fails simply leaves the prior
# list visible. Log at debug so a recurring background refresh failure
# (the 30s timer) is diagnosable rather than silently stale.
try:
files = await asyncio.to_thread(_get_project_files, project_root)
if generation == self._cache_generation:
self._file_cache = _scope_files_to_cwd(files, project_root, cwd)
except Exception: # best-effort refresh; prior cache is the fallback
logger.debug("File-cache warm failed for %s", project_root, exc_info=True)
@staticmethod
def can_handle(text: str, cursor_index: int) -> bool:
@@ -91,6 +91,9 @@ Keeps multi-line pastes grouped as one input even when newlines arrive as
terminal read boundaries), instead of submitting mid-paste.
"""
_FILE_CACHE_WORKER_GROUP = "file-cache"
"""Textual worker group for all `@` file-completion cache warmers."""
_BACKSLASH_ENTER_GAP_SECONDS = 0.15
"""Maximum gap between a `\\` key and a following `enter` key to treat the
pair as a terminal-emitted shift+enter sequence.
@@ -118,6 +121,14 @@ comfortably covers the FocusIn-to-mouse-report latency while staying below a
deliberate click-pause-click interaction.
"""
_FILE_CACHE_REFRESH_INTERVAL_SECONDS = 30.0
"""How often to refresh the `@` file-completion cache in the background.
The cache is pre-warmed on mount and re-warmed on cwd switches, but files
created or deleted mid-session would otherwise stay stale until the next switch.
A periodic refresh keeps `@` suggestions current; the walk runs off the event
loop and swaps in atomically, so it never blocks typing."""
if TYPE_CHECKING:
from textual import events
from textual.app import ComposeResult
@@ -1646,13 +1657,42 @@ class ChatInput(Vertical):
self._rebuild_argument_hints(SLASH_COMMANDS)
self.run_worker(
self._file_controller.warm_cache(),
exclusive=False,
exit_on_error=False,
self._warm_file_cache()
self.set_interval(
_FILE_CACHE_REFRESH_INTERVAL_SECONDS,
self._refresh_file_cache,
)
self._text_area.focus()
def _warm_file_cache(self, *, force: bool = False, exclusive: bool = False) -> None:
"""Schedule an `@` file-completion cache warmer.
No-ops before `on_mount` wires up the file controller (the periodic
refresh interval can fire during teardown or a partial mount).
Args:
force: Re-walk even when the cache is already populated. The prior
cache stays visible until the new walk completes.
exclusive: Cancel any other in-flight warmer in the shared worker
group before starting, so a slow walk is superseded by the next
tick rather than stacking overlapping walks. Used by the
periodic refresh; the on-mount/cwd-switch warmers run
non-exclusively so a quick invalidation can warm concurrently.
"""
file_controller = getattr(self, "_file_controller", None)
if file_controller is None:
return
self.run_worker(
file_controller.warm_cache(force=force),
exclusive=exclusive,
group=_FILE_CACHE_WORKER_GROUP,
exit_on_error=False,
)
def _refresh_file_cache(self) -> None:
"""Re-warm the `@` file-completion cache off the event loop."""
self._warm_file_cache(force=True, exclusive=True)
def set_cwd(self, cwd: str | Path) -> None:
"""Update file completion to use a new cwd.
@@ -1663,11 +1703,7 @@ class ChatInput(Vertical):
file_controller = getattr(self, "_file_controller", None)
if file_controller is not None:
file_controller.set_cwd(self._cwd)
self.run_worker(
file_controller.warm_cache(),
exclusive=False,
exit_on_error=False,
)
self._warm_file_cache()
def update_slash_commands(self, commands: list[CommandEntry]) -> None:
"""Update the slash command controller's command list.
@@ -790,6 +790,69 @@ class TestFuzzyFileControllerWarmCacheRace:
assert controller._project_root_pending is False
assert controller._file_cache == ["a/new.py"]
async def test_force_warmer_does_not_overwrite_newer_cwd(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A force refresh superseded mid-walk must not clobber a newer cwd.
`force=True` is a new way to reach the file-walk-and-swap block on an
already-populated cache (a populated cache otherwise short-circuits), so
a background refresh racing a `set_cwd` is a genuinely new scenario.
"""
sub_a = tmp_path / "a"
sub_a.mkdir()
sub_b = tmp_path / "b"
sub_b.mkdir()
entered_force = threading.Event()
release_force = threading.Event()
lock = threading.Lock()
a_calls = 0
def fake_files(root: Path) -> list[str]:
nonlocal a_calls
if root == sub_a:
with lock:
a_calls += 1
call = a_calls
if call == 1:
# Initial warm populates the cache and returns immediately.
return ["a/file.py"]
# The forced background refresh; block it mid-walk so a newer
# cwd switch can supersede it.
entered_force.set()
release_force.wait(timeout=5)
return ["a/stale.py"]
return ["b/file.py"]
monkeypatch.setattr(autocomplete_module, "find_project_root", lambda _: None)
monkeypatch.setattr(autocomplete_module, "_get_project_files", fake_files)
controller = FuzzyFileController(MagicMock(), cwd=tmp_path)
controller.set_cwd(sub_a)
await controller.warm_cache()
assert controller._file_cache == ["a/file.py"]
force_task = asyncio.create_task(controller.warm_cache(force=True))
await asyncio.to_thread(entered_force.wait, 5)
# The prior cache stays visible while the forced walk is in flight.
assert controller._file_cache == ["a/file.py"]
# A newer cwd switch supersedes the in-flight forced refresh.
controller.set_cwd(sub_b)
await controller.warm_cache()
assert controller._file_cache == ["b/file.py"]
release_force.set()
await force_task
# The stale forced walk finished last but dropped its result.
assert controller._project_root == sub_b
assert controller._project_root_pending is False
assert controller._file_cache == ["b/file.py"]
class TestGetProjectFiles:
"""Tests for _get_project_files."""
@@ -1081,6 +1144,29 @@ class TestFuzzyFileControllerScope:
assert controller._file_cache == ["main.py"]
async def test_warm_cache_force_refreshes_populated_cache(
self, mock_view, monkeypatch, tmp_path
):
"""warm_cache(force=True) re-walks and swaps in a fresh list."""
project_root = tmp_path
(project_root / ".git").mkdir()
files = ["main.py"]
monkeypatch.setattr(
autocomplete_module, "_get_project_files", lambda _root: list(files)
)
controller = FuzzyFileController(mock_view, cwd=project_root)
await controller.warm_cache()
assert controller._file_cache == ["main.py"]
files.append("added.py")
await controller.warm_cache()
assert controller._file_cache == ["main.py"]
await controller.warm_cache(force=True)
assert controller._file_cache == ["main.py", "added.py"]
def test_excludes_sibling_with_shared_prefix(
self, mock_view, monkeypatch, tmp_path
):
@@ -27,6 +27,7 @@ from deepagents_code.widgets.chat_input import (
)
if TYPE_CHECKING:
from collections.abc import Coroutine
from pathlib import Path
from textual.pilot import Pilot
@@ -228,6 +229,122 @@ class _RecordingApp(App[None]):
self.submitted.append(event)
async def _noop() -> None:
pass
class _RefreshController:
def __init__(self) -> None:
self.cwd_values: list[Path] = []
self.force_values: list[bool] = []
def set_cwd(self, cwd: Path) -> None:
self.cwd_values.append(cwd)
def warm_cache(self, *, force: bool = False) -> Coroutine[object, object, None]:
self.force_values.append(force)
return _noop()
class TestChatInputFileCacheRefresh:
def test_refresh_file_cache_uses_exclusive_worker(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
chat = ChatInput()
controller = _RefreshController()
worker_calls: list[dict[str, object]] = []
def fake_run_worker(
work: Coroutine[object, object, None], **kwargs: object
) -> None:
work.close()
worker_calls.append(kwargs)
monkeypatch.setattr(chat, "_file_controller", controller, raising=False)
monkeypatch.setattr(chat, "run_worker", fake_run_worker)
chat._refresh_file_cache()
assert controller.force_values == [True]
assert worker_calls == [
{
"exclusive": True,
"group": chat_input_module._FILE_CACHE_WORKER_GROUP,
"exit_on_error": False,
}
]
def test_set_cwd_uses_file_cache_worker_group(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
chat = ChatInput()
controller = _RefreshController()
worker_calls: list[dict[str, object]] = []
def fake_run_worker(
work: Coroutine[object, object, None], **kwargs: object
) -> None:
work.close()
worker_calls.append(kwargs)
monkeypatch.setattr(chat, "_file_controller", controller, raising=False)
monkeypatch.setattr(chat, "run_worker", fake_run_worker)
chat.set_cwd(tmp_path)
assert controller.cwd_values == [tmp_path]
assert controller.force_values == [False]
assert worker_calls == [
{
"exclusive": False,
"group": chat_input_module._FILE_CACHE_WORKER_GROUP,
"exit_on_error": False,
}
]
def test_refresh_file_cache_noop_without_controller(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Refresh is a no-op before the file controller is wired up."""
chat = ChatInput()
worker_calls: list[object] = []
monkeypatch.setattr(
chat,
"run_worker",
lambda *args, **kwargs: worker_calls.append((args, kwargs)),
)
assert getattr(chat, "_file_controller", None) is None
chat._refresh_file_cache()
assert worker_calls == []
async def test_on_mount_schedules_file_cache_refresh(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`on_mount` schedules the periodic `@` file-cache refresh.
Without this the cache would only warm on mount and cwd switches, so
files created or deleted mid-session would never surface.
"""
interval_calls: list[tuple[float, object]] = []
def fake_set_interval(
_self: ChatInput, interval: float, callback: object, **_kwargs: object
) -> None:
interval_calls.append((interval, callback))
monkeypatch.setattr(ChatInput, "set_interval", fake_set_interval)
app = _ChatInputTestApp()
async with app.run_test():
chat = app.query_one(ChatInput)
assert (
chat_input_module._FILE_CACHE_REFRESH_INTERVAL_SECONDS,
chat._refresh_file_cache,
) in interval_calls
class TestChatInputScrollbar:
"""Regression tests for the chat input's vertical scrollbar behavior.