Skip to content

refactor(tui): extract PrefetchEngine and LazyMounter into fnd/tui/preview - #51

Merged
ben-dev-au merged 7 commits into
mainfrom
refactor/preview-prefetch-lazy
Jun 11, 2026
Merged

refactor(tui): extract PrefetchEngine and LazyMounter into fnd/tui/preview#51
ben-dev-au merged 7 commits into
mainfrom
refactor/preview-prefetch-lazy

Conversation

@ben-dev-au

Copy link
Copy Markdown
Owner

Sixth step of the FNDApp decomposition (stacks on #50).

PrefetchEngine (fnd/tui/preview/prefetch.py) owns the top-N warming pipeline: the decode worker fan-out, the main-thread record sinks, the hidden pre-mount jobs, and the single-consumer sink queue + drainer. The drainer task is still created from the app's on_mount (via PrefetchEngine.start) so task timing matches the app lifecycle; worker closures keep calling through the app so user-side state always wins, and the exclusive worker group preview-prefetch is unchanged.

LazyMounter (fnd/tui/preview/lazy_mount.py) owns the scroll-driven mount: the debounced boundary check, batch mounts in both directions (including the settle-then-compensate upward path), and the cancel invoked on file switch / query change.

Mount-window tunables stay defined in app.py and are read off the app module at call time, preserving the existing monkeypatch point. FNDApp keeps one-line delegators for every entry point the remaining mount machinery uses, plus a read/write property for the in-flight lazy task.

Verification: ruff format/check, pyright strict, full suite run twice (1660 passed, 4 skipped, both runs) — test suite byte-unchanged. Perf harnesses re-run: medians within noise of the baseline.

Move the pre-FNDApp module region (~1,100 lines) into dedicated modules:

- fnd/tui/widgets/markdown.py: the FNDMarkdown widget family, highlight
  span helpers, and the legacy-block markdown fallback
- fnd/tui/widgets/results_tree.py: ResultsTree
- fnd/tui/widgets/preview_container.py: PreviewContainer, PreviewCache,
  the open-with Hit adapter, and the preview-cache tunables
- fnd/tui/results_labels.py: row-label and score formatting

Pure relocation; every moved name keeps its spelling and app.py
re-exports the externally-imported surface, so sibling modules and the
test suite are unaffected. The lazy-mount/prune tunables that are read
at call time stay defined in app.py.
Move search-scope state (collections / sources / filters), the sidebar
panel rendering and toggle handlers, and UI-state persistence into
ScopeController (fnd/tui/scope_panel.py). The filter presentation
tables move with their only consumer.

FNDApp keeps its existing surface: the seven scope fields are exposed
as read/write properties delegating to the controller, the persistence
and panel-refresh entry points stay as one-line delegators, and the
@on tree handlers remain bound to the app class as thin forwards.
Cross-concern calls from scope code (query rerun, status refresh,
ranking-profile resolution) route back through the app.
Move the background-indexer lifecycle — the async task, cancel event,
event queue, run-generation counter, and the update-all chain
bookkeeping — into IndexerService (fnd/tui/indexer_service.py),
together with the start/resume/reindex entry points.

FNDApp keeps its existing surface: start_indexer remains a real app
method with an identical signature (chain continuations re-enter
through it, so a patched app method keeps intercepting starts), the
17 _indexer_* fields become read/write properties, and the resume /
reindex helpers stay as one-line delegators. indexer_modal.py and the
settings screens are untouched — they keep reading and writing the
app's _indexer_* accessors.
SearchController (fnd/tui/search_controller.py) owns the searcher
handle, active query + match spec, result groups, search trace,
synonyms, and ranking profile; run() is the single query entry point
and _PrefixingSearcher moves with it. The preview-cache invalidation
inside run()/clear_results() still reaches through the app until the
preview subsystem is extracted.

ResultsView (fnd/tui/results_view.py) renders and relabels the results
tree; it owns no state.

FNDApp keeps its existing surface: the nine search fields become
read/write properties, query entry points and helpers used by sibling
modules stay as one-line delegators, and @on handlers / actions remain
bound to the app class.
FlatBufferView (fnd/tui/preview/flat_view.py) owns the flat preview
path: the shared LineBufferPreview widget, the per-file rendered-
document cache, the installed-key bookkeeping, and the install /
activate / reset lifecycle. First module of the fnd/tui/preview
package; the structural mount machinery follows separately.

FNDApp keeps its surface: the four flat-buffer fields become
read/write properties and the six entry points stay as one-line
delegators (the dispatch/prefetch code that calls them is still on
the app until its own extraction).
…eview

PrefetchEngine (fnd/tui/preview/prefetch.py) owns the top-N warming
pipeline: the decode worker fan-out, the main-thread record sinks, the
hidden pre-mount jobs, and the single-consumer sink queue + drainer.
The drainer task is still created from the app's on_mount (via
PrefetchEngine.start) so task timing matches the app lifecycle, and
worker closures keep calling through the app so user-side state always
wins.

LazyMounter (fnd/tui/preview/lazy_mount.py) owns the scroll-driven
mount: the debounced boundary check, the batch mounts in both
directions, and the cancel path invoked on file switch / query change.

Mount-window tunables stay defined in app.py and are read off the app
module at call time, preserving their existing override point. FNDApp
keeps one-line delegators for every entry point still used by the
remaining mount machinery, plus a read/write property for the
in-flight lazy task.
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8cfbe5e8-8652-4202-8c58-00586fb61dea

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/preview-prefetch-lazy

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the preview subsystem by extracting scroll-driven lazy mounting and background prefetching logic from the main FNDApp class into two new dedicated classes: LazyMounter and PrefetchEngine. This significantly simplifies fnd/tui/app.py. The reviewer feedback focuses on improving type safety and code cleanliness in the newly created files. Specifically, the reviewer suggests moving standard library imports (like asyncio and contextlib) to the top of the files, which allows for more precise type annotations (e.g., using asyncio.Task and Timer instead of object or Any) and the removal of redundant local imports and # type: ignore comments.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +9 to +23
from __future__ import annotations

from typing import TYPE_CHECKING

from textual.containers import VerticalScroll
from textual.widget import Widget

from fnd.tui.widgets.markdown import FNDMarkdown

if TYPE_CHECKING:
from fnd.query import FileChunk
from fnd.tui.app import FNDApp
from fnd.tui.widgets.preview_container import PreviewContainer

__all__ = ["LazyMounter"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To adhere to PEP 8 and improve type safety, standard library modules like asyncio and contextlib should be imported at the top of the file. Additionally, we can import Any from typing and Timer from textual.timer to type the class attributes more precisely.

from __future__ import annotations

import asyncio
import contextlib
from typing import TYPE_CHECKING, Any

from textual.containers import VerticalScroll
from textual.timer import Timer
from textual.widget import Widget

from fnd.tui.widgets.markdown import FNDMarkdown

if TYPE_CHECKING:
    from fnd.query import FileChunk
    from fnd.tui.app import FNDApp
    from fnd.tui.widgets.preview_container import PreviewContainer

__all__ = ["LazyMounter"]

Comment on lines +34 to +42
self.task: object | None = None
# Monotonic-time gate. Programmatic scrolls (navigation anchor,
# finalize reveal) push this forward so the watcher doesn't
# interpret their own scroll changes as user intent and fire a
# competing mount that yanks the focused chunk off-screen.
# Debounce timer so rapid scroll bursts collapse to a single
# check at the tail end — protects programmatic intermediate
# scrolls AND smooths user wheel/key scroll bursts.
self.check_timer: object | None = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

With asyncio and Timer imported at the top of the file, we can type self.task as asyncio.Task[Any] | None and self.check_timer as Timer | None. This avoids using the overly generic object type and eliminates the need for several # type: ignore comments throughout the class.

Suggested change
self.task: object | None = None
# Monotonic-time gate. Programmatic scrolls (navigation anchor,
# finalize reveal) push this forward so the watcher doesn't
# interpret their own scroll changes as user intent and fire a
# competing mount that yanks the focused chunk off-screen.
# Debounce timer so rapid scroll bursts collapse to a single
# check at the tail end — protects programmatic intermediate
# scrolls AND smooths user wheel/key scroll bursts.
self.check_timer: object | None = None
# In-flight lazy-mount task (driven by scroll). One at a time;
# cleared on file switch alongside ``_preview_mount_task``.
self.task: asyncio.Task[Any] | None = None
# Monotonic-time gate. Programmatic scrolls (navigation anchor,
# finalize reveal) push this forward so the watcher doesn't
# interpret their own scroll changes as user intent and fire a
# competing mount that yanks the focused chunk off-screen.
# Debounce timer so rapid scroll bursts collapse to a single
# check at the tail end — protects programmatic intermediate
# scrolls AND smooths user wheel/key scroll bursts.
self.check_timer: Timer | None = None

Comment on lines +48 to +63
import contextlib

if self.check_timer is not None:
with contextlib.suppress(Exception):
self.check_timer.stop() # type: ignore[attr-defined]
self.check_timer = None
task = self.task
if task is None:
return
try:
done = task.done() # type: ignore[attr-defined]
except Exception:
done = True
if not done:
with contextlib.suppress(Exception):
task.cancel() # type: ignore[attr-defined]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since contextlib is now imported at the top of the file and self.task and self.check_timer are properly typed, we can remove the redundant local import and the # type: ignore[attr-defined] comments.

Suggested change
import contextlib
if self.check_timer is not None:
with contextlib.suppress(Exception):
self.check_timer.stop() # type: ignore[attr-defined]
self.check_timer = None
task = self.task
if task is None:
return
try:
done = task.done() # type: ignore[attr-defined]
except Exception:
done = True
if not done:
with contextlib.suppress(Exception):
task.cancel() # type: ignore[attr-defined]
if self.check_timer is not None:
with contextlib.suppress(Exception):
self.check_timer.stop()
self.check_timer = None
task = self.task
if task is None:
return
try:
done = task.done()
except Exception:
done = True
if not done:
with contextlib.suppress(Exception):
task.cancel()

Comment on lines +86 to +88
if self.check_timer is not None:
with contextlib.suppress(Exception):
self.check_timer.stop() # type: ignore[attr-defined]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since contextlib is imported at the top of the file and self.check_timer is properly typed as Timer, we can remove the redundant local import and the # type: ignore comment.

Suggested change
if self.check_timer is not None:
with contextlib.suppress(Exception):
self.check_timer.stop() # type: ignore[attr-defined]
if self.check_timer is not None:
with contextlib.suppress(Exception):
self.check_timer.stop()

Comment on lines +122 to +128
task = self.task
if task is not None:
try:
if not task.done(): # type: ignore[attr-defined]
return
except Exception:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since self.task is properly typed as asyncio.Task[Any], we can remove the # type: ignore[attr-defined] comment.

Suggested change
task = self.task
if task is not None:
try:
if not task.done(): # type: ignore[attr-defined]
return
except Exception:
pass
task = self.task
if task is not None:
try:
if not task.done():
return
except Exception:
pass

Comment on lines +48 to +49
import asyncio
import contextlib

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The local imports of asyncio and contextlib are redundant because they are already imported at the top of the file.

# busy across navigation.
q = self.sink_queue
if q is not None:
import contextlib as _contextlib

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The local import of contextlib as _contextlib is redundant because contextlib is already imported at the top of the file. We can use contextlib.suppress directly.

Comment on lines +88 to +89
with _contextlib.suppress(Exception):
q.task_done()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use contextlib.suppress directly instead of the local alias _contextlib.suppress.

Suggested change
with _contextlib.suppress(Exception):
q.task_done()
with contextlib.suppress(Exception):
q.task_done()

Comment on lines +497 to +498
import asyncio
import contextlib

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The local imports of asyncio and contextlib are redundant because they are already imported at the top of the file.

Comment thread fnd/tui/app.py
Comment on lines +2140 to +2146
@property
def _lazy_mount_task(self) -> object | None:
return self._lazy.task

async def _cancel_prefetch_task_on(self, container: PreviewContainer) -> None:
"""Cancel + await any background prefetch task on ``container``
so the user-side mount doesn't race it and trip MountError."""
import asyncio
import contextlib
@_lazy_mount_task.setter
def _lazy_mount_task(self, value: object | None) -> None:
self._lazy.task = value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We can type _lazy_mount_task more precisely as asyncio.Task[Any] | None instead of object | None to improve type safety and match the type of LazyMounter.task.

Suggested change
@property
def _lazy_mount_task(self) -> object | None:
return self._lazy.task
async def _cancel_prefetch_task_on(self, container: PreviewContainer) -> None:
"""Cancel + await any background prefetch task on ``container``
so the user-side mount doesn't race it and trip MountError."""
import asyncio
import contextlib
@_lazy_mount_task.setter
def _lazy_mount_task(self, value: object | None) -> None:
self._lazy.task = value
@property
def _lazy_mount_task(self) -> asyncio.Task[Any] | None:
return self._lazy.task
@_lazy_mount_task.setter
def _lazy_mount_task(self, value: asyncio.Task[Any] | None) -> None:
self._lazy.task = value

@ben-dev-au
ben-dev-au changed the base branch from refactor/preview-flat-view to main June 11, 2026 10:10

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@fnd/tui/indexer_service.py`:
- Around line 331-340: The resume logic uses the hard-coded default index root;
change the saved-state lookup and the resumed start to use the app’s configured
index root (self._app._index_dir) instead of default_index_dir() or
state_file_for("default"). In practice, pass self._app._index_dir into the state
file lookup (replace state_file_for("default") usage) and call
self._app.start_indexer(...) with index_dir=self._app._index_dir (replace
index_dir=default_index_dir()), so both is_state_resumable/load_state and
start_indexer operate on the same index root.
- Around line 127-128: When re-opening the running modal the code path that
pushes IndexerScreen uses only the collection name and loses multi-collection
progress; update the branch that runs when open_modal and _bump_seq to pass the
existing chain context (chain_total and chain_index) into IndexerScreen (the
same way the fresh-start flow does), ensuring you read the current chain_total
and chain_index variables and forward them to IndexerScreen(self.collection or
collection, chain_total=..., chain_index=...).

In `@fnd/tui/preview/prefetch.py`:
- Around line 123-138: The prefetch filter only checks self._app._preview_cache
so files that already have flat renders in self._app._flat_buffer_cache still
get re-targeted; update the same conditional that computes in_preview to also
check the flat buffer (e.g., treat warmed_flat =
self._app._flat_buffer_cache.get(g.parent_id, query_sig_for_filter) is not None)
and consider a result warmed if either warmed_flat or in_preview is true before
appending to already_cached/continuing, so build_rendered_document isn't re-run
for already-prefetched flat results; adjust the logic around
in_preview/is_active/targets to use this combined check.
- Around line 467-492: The finally block may insert a stale partially-mounted
container into _preview_cache; before calling
self._app._preview_cache.put(container, ...), verify the container still belongs
to the active query by comparing its query signature to the current signature
(i.e., only cache when container.query_sig (or container.sig if that is the
existing attribute you use to store the query signature) equals the current
signature from the mounting context such as parent_id/current query signature),
and skip caching (and call container.remove()) when they differ; update
_mount_chunk_loop()/container creation to set/propagate the signature if it does
not already exist so the check can be performed.

In `@fnd/tui/scope_panel.py`:
- Around line 331-350: The pruning logic in the currently_full/remove-collection
branch uses only self.active_sources and cannot tell why an id is active
(collection inheritance vs explicit user selection), so replace the flat-id
removal with provenance-aware logic: introduce a per-source provenance structure
(e.g. self.source_provenance: mapping from source_id -> set of owners or a
refcount plus a flag for explicit user selection) and update it whenever
collections are added/removed and when the user explicitly toggles sources; then
in the block around currently_full / collection_source_ids use that provenance
to remove only those source ids whose provenance no longer contains any owner
(and are not explicitly selected), updating self.active_sources accordingly;
update collection_source_ids, collection add/remove paths, and any
explicit-toggle handlers to maintain the provenance mapping.

In `@fnd/tui/search_controller.py`:
- Around line 156-162: The error-handling paths after QueryPlan.from_user_text
failure currently only clear self.groups and refresh the tree, leaving stale
preview/cache and trace; update both error blocks (the one around
QueryPlan.from_user_text and the other at lines 215-219) to call the
controller's clear_results() method and set self.latest_trace = None (or reset
latest_trace) before returning, while still invoking self._show_query_notice(e)
so the UI fully clears previews/traces and rebuilds clean state.

In `@fnd/tui/widgets/markdown.py`:
- Around line 667-689: The update method must reset per-render state so callers
can't observe a stale build_done or first_match_block; at the start of
Markdown.update (the override in this class) reinitialize self.build_done to a
new _asyncio.Event() and set self._first_match_block = None (so the
first_match_block property is cleared) before calling super().update(markdown),
then keep the existing aw._future.add_done_callback(...) behavior to set the new
build_done when the parse+mount completes.
- Around line 416-422: The current compose path stores header-only matches
returned by _find_first_match_coord_in_table() (which returns (0, col) for TH
hits) into dt._fnd_match_coord, causing DataTable scrolling to target a body
row; change the logic so after calling _find_first_match_coord_in_table(headers,
rows, spec) you still set md._first_match_block = self for header hits but only
assign dt._fnd_match_coord = Coordinate(*match_coord) when match_coord is not
None and match_coord[0] > 0 (i.e., an actual body row); apply the same fix in
the other compose location (the block around lines 571-580) and add a regression
test that runs a query matching only a table header to assert the table wrapper
(not a body cell) is the scroll target.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 49aabcd9-12e3-4e7a-bc34-923c8f8991cf

📥 Commits

Reviewing files that changed from the base of the PR and between 519252f and ee07c28.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • fnd/tui/app.py
  • fnd/tui/indexer_service.py
  • fnd/tui/preview/__init__.py
  • fnd/tui/preview/flat_view.py
  • fnd/tui/preview/lazy_mount.py
  • fnd/tui/preview/prefetch.py
  • fnd/tui/results_labels.py
  • fnd/tui/results_view.py
  • fnd/tui/scope_panel.py
  • fnd/tui/search_controller.py
  • fnd/tui/widgets/markdown.py
  • fnd/tui/widgets/preview_container.py
  • fnd/tui/widgets/results_tree.py

Comment on lines +127 to +128
if open_modal and _bump_seq:
self._app.push_screen(IndexerScreen(self.collection or collection))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve chain progress when re-opening the running modal.

Lines 127-128 rebuild IndexerScreen with only the collection name, but the fresh-start path on Lines 221-229 also passes chain_total and chain_index. During an update-all run, re-opening progress will therefore lose the current (N of M) context and render as a single-collection screen.

Proposed fix
                 if open_modal and _bump_seq:
-                    self._app.push_screen(IndexerScreen(self.collection or collection))
+                    chain_total = getattr(self, "chain_total", 1) or 1
+                    chain_pending = getattr(self, "chain_remaining", None) or []
+                    chain_index = max(1, chain_total - len(chain_pending))
+                    self._app.push_screen(
+                        IndexerScreen(
+                            self.collection or collection,
+                            chain_total=chain_total,
+                            chain_index=chain_index,
+                        )
+                    )
                 return False
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if open_modal and _bump_seq:
self._app.push_screen(IndexerScreen(self.collection or collection))
if open_modal and _bump_seq:
chain_total = getattr(self, "chain_total", 1) or 1
chain_pending = getattr(self, "chain_remaining", None) or []
chain_index = max(1, chain_total - len(chain_pending))
self._app.push_screen(
IndexerScreen(
self.collection or collection,
chain_total=chain_total,
chain_index=chain_index,
)
)
return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/indexer_service.py` around lines 127 - 128, When re-opening the
running modal the code path that pushes IndexerScreen uses only the collection
name and loses multi-collection progress; update the branch that runs when
open_modal and _bump_seq to pass the existing chain context (chain_total and
chain_index) into IndexerScreen (the same way the fresh-start flow does),
ensuring you read the current chain_total and chain_index variables and forward
them to IndexerScreen(self.collection or collection, chain_total=...,
chain_index=...).

Comment on lines +331 to +340
state = load_state(state_file_for("default"))
if not is_state_resumable(
state, known_collections=set(cfg.collections), now=_dt.datetime.now(tz=_dt.UTC)
):
return
assert state is not None # narrowed by is_state_resumable
try:
self._app.start_indexer(
collection="default", index_dir=default_index_dir(), open_modal=False
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the app’s configured index directory for auto-resume.

Lines 331-340 hard-code the default index root, but this service otherwise treats self._app._index_dir as the source of truth for the active session. On an app constructed with a non-default index directory, maybe_resume() can resume work in one directory while on_reindex_complete() reopens the searcher from another, so the resumed rebuild never becomes visible in the live session.

Please thread self._app._index_dir through both the saved-state lookup and the resumed start_indexer() call so resume uses the same index root as the rest of the app.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/indexer_service.py` around lines 331 - 340, The resume logic uses the
hard-coded default index root; change the saved-state lookup and the resumed
start to use the app’s configured index root (self._app._index_dir) instead of
default_index_dir() or state_file_for("default"). In practice, pass
self._app._index_dir into the state file lookup (replace
state_file_for("default") usage) and call self._app.start_indexer(...) with
index_dir=self._app._index_dir (replace index_dir=default_index_dir()), so both
is_state_resumable/load_state and start_indexer operate on the same index root.

Comment on lines +123 to +138
# Filter by preview_cache (widget tree ready), not chunk_cache:
# a file whose chunks are cached but whose mount got drained
# by a prior cursor move must be re-queued. Also skip if it's
# the active preview — that one's owned by the user-side path.
in_preview = self._app._preview_cache.get(g.parent_id, query_sig_for_filter) is not None
is_active = (
self._app._active_preview is not None
and self._app._active_preview.parent_doc_id == g.parent_id
and self._app._active_preview.query_signature == query_sig_for_filter
)
if in_preview or is_active:
already_cached.append(g.parent_id[:8])
continue
focus = g.hits[0].chunk_seq if g.hits else 0
targets.append((g.parent_id, focus))
if len(targets) >= n:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Skip flat previews that are already warmed.

This filter only consults self._app._preview_cache, so a flat result already present in self._app._flat_buffer_cache is still re-targeted on every anchor move. That forces build_rendered_document(...) to run again for files that are already prefetched, which burns CPU on the hot navigation path for no gain.

💡 Suggested fix
-            in_preview = self._app._preview_cache.get(g.parent_id, query_sig_for_filter) is not None
+            in_preview = self._app._preview_cache.get(g.parent_id, query_sig_for_filter) is not None
+            in_flat_cache = (
+                (g.parent_id, query_sig_for_filter) in self._app._flat_buffer_cache
+            )
             is_active = (
                 self._app._active_preview is not None
                 and self._app._active_preview.parent_doc_id == g.parent_id
                 and self._app._active_preview.query_signature == query_sig_for_filter
             )
-            if in_preview or is_active:
+            if in_preview or in_flat_cache or is_active:
                 already_cached.append(g.parent_id[:8])
                 continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/preview/prefetch.py` around lines 123 - 138, The prefetch filter only
checks self._app._preview_cache so files that already have flat renders in
self._app._flat_buffer_cache still get re-targeted; update the same conditional
that computes in_preview to also check the flat buffer (e.g., treat warmed_flat
= self._app._flat_buffer_cache.get(g.parent_id, query_sig_for_filter) is not
None) and consider a result warmed if either warmed_flat or in_preview is true
before appending to already_cached/continuing, so build_rendered_document isn't
re-run for already-prefetched flat results; adjust the logic around
in_preview/is_active/targets to use this combined check.

Comment on lines +467 to +492
finally:
_perf.mark(
"prefetch_loop_end",
parent_id=parent_id,
n_mounted=n_mounted,
mounted_indices_size=len(container.mounted_indices),
is_complete=container.is_complete,
)
self._app._diag_log(
f"prefetch_loop_end parent={parent_id[:8]} n_mounted={n_mounted} "
f"mounted_size={len(container.mounted_indices)} "
f"is_complete={container.is_complete}"
)
if container.mounted_indices:
evicted = self._app._preview_cache.put(container, protect=self._app._active_preview)
for old in evicted:
with contextlib.suppress(Exception):
old.remove()
else:
# Loop bailed on user-mount-in-flight (or every mount raised)
# before any chunk landed. Caching the empty container would
# block the next prefetch attempt for this (parent_id, sig)
# via the already-cached short-circuit; instead, drop it so a
# later trigger (cursor move, second query) can retry cleanly.
with contextlib.suppress(Exception):
container.remove()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not cache a prefetch container after its query has gone stale.

_mount_chunk_loop() returns as soon as the query signature changes, but this finally block still inserts any partially mounted container into _preview_cache. With the cache capped to a single file, that late stale insert can evict the current query’s warmed preview and repopulate hidden DOM that _run_query has just cleared.

💡 Suggested fix
         finally:
             _perf.mark(
                 "prefetch_loop_end",
                 parent_id=parent_id,
                 n_mounted=n_mounted,
                 mounted_indices_size=len(container.mounted_indices),
                 is_complete=container.is_complete,
             )
             self._app._diag_log(
                 f"prefetch_loop_end parent={parent_id[:8]} n_mounted={n_mounted} "
                 f"mounted_size={len(container.mounted_indices)} "
                 f"is_complete={container.is_complete}"
             )
-            if container.mounted_indices:
+            stale = query_sig != self._app._current_query_signature()
+            if stale:
+                with contextlib.suppress(Exception):
+                    container.remove()
+            elif container.mounted_indices:
                 evicted = self._app._preview_cache.put(container, protect=self._app._active_preview)
                 for old in evicted:
                     with contextlib.suppress(Exception):
                         old.remove()
             else:
                 # Loop bailed on user-mount-in-flight (or every mount raised)
                 # before any chunk landed. Caching the empty container would
                 # block the next prefetch attempt for this (parent_id, sig)
                 # via the already-cached short-circuit; instead, drop it so a
                 # later trigger (cursor move, second query) can retry cleanly.
                 with contextlib.suppress(Exception):
                     container.remove()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
finally:
_perf.mark(
"prefetch_loop_end",
parent_id=parent_id,
n_mounted=n_mounted,
mounted_indices_size=len(container.mounted_indices),
is_complete=container.is_complete,
)
self._app._diag_log(
f"prefetch_loop_end parent={parent_id[:8]} n_mounted={n_mounted} "
f"mounted_size={len(container.mounted_indices)} "
f"is_complete={container.is_complete}"
)
if container.mounted_indices:
evicted = self._app._preview_cache.put(container, protect=self._app._active_preview)
for old in evicted:
with contextlib.suppress(Exception):
old.remove()
else:
# Loop bailed on user-mount-in-flight (or every mount raised)
# before any chunk landed. Caching the empty container would
# block the next prefetch attempt for this (parent_id, sig)
# via the already-cached short-circuit; instead, drop it so a
# later trigger (cursor move, second query) can retry cleanly.
with contextlib.suppress(Exception):
container.remove()
finally:
_perf.mark(
"prefetch_loop_end",
parent_id=parent_id,
n_mounted=n_mounted,
mounted_indices_size=len(container.mounted_indices),
is_complete=container.is_complete,
)
self._app._diag_log(
f"prefetch_loop_end parent={parent_id[:8]} n_mounted={n_mounted} "
f"mounted_size={len(container.mounted_indices)} "
f"is_complete={container.is_complete}"
)
stale = query_sig != self._app._current_query_signature()
if stale:
with contextlib.suppress(Exception):
container.remove()
elif container.mounted_indices:
evicted = self._app._preview_cache.put(container, protect=self._app._active_preview)
for old in evicted:
with contextlib.suppress(Exception):
old.remove()
else:
# Loop bailed on user-mount-in-flight (or every mount raised)
# before any chunk landed. Caching the empty container would
# block the next prefetch attempt for this (parent_id, sig)
# via the already-cached short-circuit; instead, drop it so a
# later trigger (cursor move, second query) can retry cleanly.
with contextlib.suppress(Exception):
container.remove()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/preview/prefetch.py` around lines 467 - 492, The finally block may
insert a stale partially-mounted container into _preview_cache; before calling
self._app._preview_cache.put(container, ...), verify the container still belongs
to the active query by comparing its query signature to the current signature
(i.e., only cache when container.query_sig (or container.sig if that is the
existing attribute you use to store the query signature) equals the current
signature from the mounting context such as parent_id/current query signature),
and skip caching (and call container.remove()) when they differ; update
_mount_chunk_loop()/container creation to set/propagate the signature if it does
not already exist so the check can be performed.

Comment thread fnd/tui/scope_panel.py
Comment on lines +331 to +350
currently_full = name in self.collections or (
bool(source_ids) and all(sid in self.active_sources for sid in source_ids)
)
if currently_full:
if name in self.collections:
self.collections.remove(name)
if source_ids:
# A source shared with a still-active collection stays
# on — only drop ids no remaining collection claims.
still_claimed = {
sid
for other in self.collections
for sid in self.collection_source_ids(other)
}
keep = (set(self.active_sources) - set(source_ids)) | (
set(self.active_sources) & still_claimed
)
# Preserve the user's relative ordering of the kept
# sources (set difference loses it).
self.active_sources = [s for s in self.active_sources if s in keep]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Track shared-source selection with provenance, not just a flat id list.

Lines 331-350 prune active_sources after removing the collection, but the state at that point only knows that a source id is “on”, not why it is on. For a source shared by collections A and B, turning A off cannot distinguish “this id was inherited from A being fully on” from “this id is still explicitly selected through B”. The prune can therefore silently clear a row the user still has selected in B, which narrows subsequent searches unexpectedly.

This needs per-collection source provenance, ref-counting, or an equivalent ownership model before the toggle-off path can be made correct.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/scope_panel.py` around lines 331 - 350, The pruning logic in the
currently_full/remove-collection branch uses only self.active_sources and cannot
tell why an id is active (collection inheritance vs explicit user selection), so
replace the flat-id removal with provenance-aware logic: introduce a per-source
provenance structure (e.g. self.source_provenance: mapping from source_id -> set
of owners or a refcount plus a flag for explicit user selection) and update it
whenever collections are added/removed and when the user explicitly toggles
sources; then in the block around currently_full / collection_source_ids use
that provenance to remove only those source ids whose provenance no longer
contains any owner (and are not explicitly selected), updating
self.active_sources accordingly; update collection_source_ids, collection
add/remove paths, and any explicit-toggle handlers to maintain the provenance
mapping.

Comment on lines +156 to +162
try:
plan = QueryPlan.from_user_text(query)
except QueryError as e:
self._show_query_notice(e)
self.groups = []
self._app._refresh_results_tree()
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear preview state on invalid queries.

Lines 156-162 and 215-219 only empty groups and rebuild the tree. After a successful search, a malformed query or filter leaves the old preview mounted, the old preview/cache state intact, and the previous trace still hanging around, so the UI can show an empty results pane beside stale content. Route both error paths through the same cleanup as clear_results() and reset latest_trace before returning.

Possible fix
         try:
             plan = QueryPlan.from_user_text(query)
         except QueryError as e:
             self._show_query_notice(e)
-            self.groups = []
-            self._app._refresh_results_tree()
+            self.latest_trace = None
+            self.clear_results()
             return
@@
         try:
             self.groups = self._search_layered(
                 lexical=lexical,
                 filter_prefix=filter_prefix,
                 limit=50,
@@
             )
         except (QueryError, FilterError) as e:
             self._show_query_notice(e)
-            self.groups = []
-            self._app._refresh_results_tree()
+            self.latest_trace = None
+            self.clear_results()
             return

Also applies to: 215-219

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/search_controller.py` around lines 156 - 162, The error-handling
paths after QueryPlan.from_user_text failure currently only clear self.groups
and refresh the tree, leaving stale preview/cache and trace; update both error
blocks (the one around QueryPlan.from_user_text and the other at lines 215-219)
to call the controller's clear_results() method and set self.latest_trace = None
(or reset latest_trace) before returning, while still invoking
self._show_query_notice(e) so the UI fully clears previews/traces and rebuilds
clean state.

Comment on lines +416 to +422
match_coord = _find_first_match_coord_in_table(headers, rows, spec)
if match_coord is not None:
dt._fnd_match_coord = Coordinate(*match_coord) # type: ignore[attr-defined]
# Register self as parent's first_match_block — TH/TD
# widgets are bypassed so _record_first_match never fires.
if isinstance(md, FNDMarkdown) and md._first_match_block is None:
md._first_match_block = self

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Textual 8.0.0, do DataTablecoordinates used byget_cell_at(...)and_get_cell_region(...) address header cells, or only body rows?

💡 Result:

In Textual 8.0.0, the DataTable coordinates used by get_cell_at(...) and _get_cell_region(...) refer to body-row data cells, not header cells. get_cell_at(coordinate) converts the provided Coordinate(row_index, column_index) into a row_key and column_key via coordinate_to_cell_key(...), then returns get_cell(row_key, column_key) (which is backed by self._data[row_key][column_key]). This means header coordinates are not mapped to a “header cell” value lookup; they map to actual row/column cell data keys instead. [1] _get_cell_region(coordinate) similarly treats coordinate.row as an index into self.ordered_rows and uses it to look up row_key = self._row_locations.get_key(row_index) and then row = self.rows[row_key]. It does have a if self.show_header: y += self.header_height offset when computing the y-position, but that offset is only to shift where the data-row regions are drawn vertically when the header is shown—not to select header rows/cells as part of the coordinate system. [2] Bottom line: the coordinate system for these methods addresses the data grid (body rows) cells; header visuals affect rendering offsets, but header cells are not addressed via get_cell_at(...)/_get_cell_region(...) coordinates. [1][2]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant file(s)
ls -la fnd/tui/widgets || true
sed -n '350,650p' fnd/tui/widgets/markdown.py | nl -ba | sed -n '380,620p'

Repository: ben-dev-au/fnd

Length of output: 560


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show where match coord is defined/used
rg -n "_find_first_match_coord_in_table|_fnd_match_coord|first_match_block|match_coord" fnd/tui/widgets/markdown.py

# Print the relevant sections (avoid relying on nl)
sed -n '380,470p' fnd/tui/widgets/markdown.py
echo "----"
sed -n '520,620p' fnd/tui/widgets/markdown.py

Repository: ben-dev-au/fnd

Length of output: 10060


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "_fnd_match_coord" -S .
rg -n "_fnd_match_coord|first_match_block|match_coord" -S fnd/tui/widgets/markdown.py

Repository: ben-dev-au/fnd

Length of output: 1933


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '430,590p' fnd/tui/preview_scroll.py
echo "----"
sed -n '240,340p' tests/test_preview_scrolls_to_match.py
echo "----"
sed -n '1,220p' tests/test_preview_scroll_characterization.py
echo "----"
sed -n '150,260p' fnd/tui/_md_hybrid.py

Repository: ben-dev-au/fnd

Length of output: 25539


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show where _scroll_proxy_for / _scroll_pane_to_table_cell are used
rg -n "_scroll_proxy_for\\(|_scroll_pane_to_table_cell\\(|_fallback_match_target\\(" fnd/tui/preview_scroll.py

# Inspect surrounding scroll-to-match logic
sed -n '1,260p' fnd/tui/preview_scroll.py
echo "----"
sed -n '260,430p' fnd/tui/preview_scroll.py
echo "----"
sed -n '430,720p' fnd/tui/preview_scroll.py

Repository: ben-dev-au/fnd

Length of output: 30668


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "header|TH/TD|tbody|thead|Term0|Term1|Deterministic|tall_table|match in.*header|header-only" tests/test_preview_scroll*.py tests -S
rg -n "_find_first_match_coord_in_table" tests -S

Repository: ben-dev-au/fnd

Length of output: 13668


Don’t encode header-only matches as DataTable body-row (row 0) coordinates.

_find_first_match_coord_in_table() returns (0, col) for header hits, and compose() stores it in dt._fnd_match_coord. The scroll controller then uses that coordinate with DataTable._get_cell_region(coord) to compute the scroll target; DataTable row/column coordinates address body/data cells (not TH/header cells), so a header-only match will scroll to the first body row in that column instead of the highlighted header.

Keep md._first_match_block = self for header matches, but only set _fnd_match_coord when the match is found in an actual body cell (or otherwise leave _fnd_match_coord unset so the controller falls back to scrolling the table wrapper). Add a regression test for a query that matches only within table headers.

File: fnd/tui/widgets/markdown.py (416-422)
Also applies to: 571-580

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/widgets/markdown.py` around lines 416 - 422, The current compose path
stores header-only matches returned by _find_first_match_coord_in_table() (which
returns (0, col) for TH hits) into dt._fnd_match_coord, causing DataTable
scrolling to target a body row; change the logic so after calling
_find_first_match_coord_in_table(headers, rows, spec) you still set
md._first_match_block = self for header hits but only assign dt._fnd_match_coord
= Coordinate(*match_coord) when match_coord is not None and match_coord[0] > 0
(i.e., an actual body row); apply the same fix in the other compose location
(the block around lines 571-580) and add a regression test that runs a query
matching only a table header to assert the table wrapper (not a body cell) is
the scroll target.

Comment on lines +667 to +689
self._first_match_block: MarkdownBlock | None = None
# Set by ``_on_mount`` after ``super()._on_mount`` (which awaits
# ``Markdown.update``) returns. Lets the scroll path event-trigger
# on build completion instead of polling.
self.build_done: _asyncio.Event = _asyncio.Event()

@property
def first_match_block(self) -> MarkdownBlock | None:
"""The first highlighted block in document order, or ``None``
when the source has no matches. Set by the highlight-aware
block subclasses during ``build_from_token``."""
return self._first_match_block

def update(self, markdown): # type: ignore[no-untyped-def, override]
# Textual's dispatcher walks the MRO and invokes every class's
# _on_mount — overriding _on_mount and calling super() ran
# Markdown._on_mount twice; the second pass saw _initial_markdown
# already consumed and called update("") which removed all
# blocks. Hook into update() instead: AwaitComplete's future
# fires when parse+mount completes — set build_done from there.
aw = super().update(markdown)
aw._future.add_done_callback(lambda _: self.build_done.set()) # type: ignore[attr-defined]
return aw

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset per-render match state before each update().

update() currently reuses the previous render's build_done event and first_match_block. On a second FNDMarkdown.update(...), callers can observe the widget as "done" immediately and still see a stale match anchor until the new parse/mount completes.

Suggested fix
 class FNDMarkdown(Markdown):
@@
         self.render_mermaid: bool = render_mermaid
         self._first_match_block: MarkdownBlock | None = None
+        self._build_generation = 0
         # Set by ``_on_mount`` after ``super()._on_mount`` (which awaits
         # ``Markdown.update``) returns. Lets the scroll path event-trigger
         # on build completion instead of polling.
         self.build_done: _asyncio.Event = _asyncio.Event()
@@
     def update(self, markdown):  # type: ignore[no-untyped-def, override]
+        self._build_generation += 1
+        generation = self._build_generation
+        self._first_match_block = None
+        self.build_done.clear()
         # Textual's dispatcher walks the MRO and invokes every class's
         # _on_mount — overriding _on_mount and calling super() ran
         # Markdown._on_mount twice; the second pass saw _initial_markdown
         # already consumed and called update("") which removed all
         # blocks. Hook into update() instead: AwaitComplete's future
         # fires when parse+mount completes — set build_done from there.
         aw = super().update(markdown)
-        aw._future.add_done_callback(lambda _: self.build_done.set())  # type: ignore[attr-defined]
+        aw._future.add_done_callback(  # type: ignore[attr-defined]
+            lambda _: generation == self._build_generation and self.build_done.set()
+        )
         return aw
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fnd/tui/widgets/markdown.py` around lines 667 - 689, The update method must
reset per-render state so callers can't observe a stale build_done or
first_match_block; at the start of Markdown.update (the override in this class)
reinitialize self.build_done to a new _asyncio.Event() and set
self._first_match_block = None (so the first_match_block property is cleared)
before calling super().update(markdown), then keep the existing
aw._future.add_done_callback(...) behavior to set the new build_done when the
parse+mount completes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant