Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a7e6b00
refactor(tui): split the progress strip into a package
ben-dev-au Aug 15, 2026
9a8bf44
feat(tui): phase-weighted progress model with learned pacing
ben-dev-au Aug 15, 2026
a04d432
feat(tui): draw the progress line full width, and keep it on screen
ben-dev-au Aug 15, 2026
9062ffd
feat(tui): drive the progress line from the preview pipeline itself
ben-dev-au Aug 15, 2026
34eb30f
feat(tui): show a background index on the progress line
ben-dev-au Aug 15, 2026
3198943
feat(tui): run search off the event loop
ben-dev-au Aug 15, 2026
4764918
fix(tui): stop a finished preview holding the progress line open
ben-dev-au Aug 15, 2026
61f1977
fix(tui): bound how long the progress line can stay up
ben-dev-au Aug 15, 2026
8dff4fe
perf(tui): stop repainting the progress line to draw the same thing
ben-dev-au Aug 16, 2026
d1b857a
fix(tui): address the review findings on the progress line
ben-dev-au Aug 16, 2026
01c8f39
fix(tui): address the Copilot review — both findings were real
ben-dev-au Aug 16, 2026
5ebda3b
Merge main into feat/progress-line
ben-dev-au Aug 16, 2026
57ef094
fix(tui): make the fill mean something — curve, plans, and reachability
ben-dev-au Aug 16, 2026
2f5215e
feat(tui): count the flat decode's real work instead of estimating it
ben-dev-au Aug 16, 2026
d210a1e
feat(tui): let one line serve two classes of work
ben-dev-au Aug 16, 2026
131221b
feat(tui): give background work its own row, and stop losing phases t…
ben-dev-au Aug 17, 2026
3a899b4
Revert the status row: the label stays beside the bar
ben-dev-au Aug 17, 2026
df402de
test(tui): pin the signals the progress line reads from the preview
ben-dev-au Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ FNDApp
├── PrefetchEngine fnd/tui/preview/prefetch.py background warming
├── LazyMounter fnd/tui/preview/lazy_mount.py scroll-driven mounting
├── PreviewScrollController fnd/tui/preview_scroll.py scroll positioning
└── ProgressFacility fnd/tui/progress.py preview progress sessions
└── ProgressFacility fnd/tui/progress/ the progress line
```

Textual-specific surfaces stay on the app: `@on` message handlers and
Expand Down Expand Up @@ -165,10 +165,63 @@ to the user-side mount.
Mount-window tunables live in `fnd/tui/preview/tuning.py` and are read
at call time.

## The progress line

One row under the panes, blank at rest, driven by `fnd/tui/progress/`.
An operation opens a session against an `OperationPlan` — an ordered set
of phases, each with an expected duration. Phases with real units report
them; phases with nothing to count (a single `await build_done`, a layout
settle) ease on elapsed time. A phase's **weight is its share of the
plan's total expected duration**, so `calibration` — which records what
each phase actually cost and summarises the recent runs, the same shape
as `cost_estimate.py` — reshapes the bar without any hand-tuned numbers.

Sessions are **observed, not reported**. `PreviewProgressTracker` reads
the preview pipeline's own signals (`pipeline_busy()`, the mount window's
`mounted_indices`, `inflight_target`, `is_settling`); `IndexProgressTracker`
reads `IndexerService.state` rather than the event queue, which has a
single consumer in the modal. The mount path therefore has no progress
calls to keep in step, and no stale exit can strand or steal the line.

Adding a subsystem means adding a plan and a tracker satisfying
`ProgressTracker` — nothing else knows about it. Each tracker translates
its own units (rendered lines, mounted chunks, indexed files) into
`report(done, total)` at the boundary, and the phase weights turn the
rest into one 0..1 fraction; that normalisation is what lets operations
with no unit in common share a line.

A plan also declares its `OperationKind`. INTERACTIVE work answers
something the user just did and always owns the line; AMBIENT work — a
background reindex — is *suspended* while that happens and resumes
afterwards, so a run spanning hundreds of navigations is not retired by
the first one. Since only one can be on screen at a time, ambient is
also the only class that carries a label, and it paints in a dimmer
accent: a line that appears without the user touching anything reads
differently from one that answers a keypress. Its stall backstop is
correspondingly looser, because its terminator (`task.done()`) is a real
result rather than an inference.

One known rough edge, left alone deliberately: the ambient label shares
the bar's single row, and `─` is drawn at the middle of its cell while
text sits on a baseline near the bottom of one, so the label reads as
sitting lower than the rule beside it. That is font metrics, not layout —
no alignment rule reaches inside a cell. Every fix costs either a row of
preview height or a second place for status text, and neither is worth
it for a label that only appears during a background index.

Sessions are owned: closing one that has already been superseded does
nothing. Visibility is policy, not caller choice — a session paints on
the frame it opens, holds a minimum visible duration, always eases to a
full line before clearing, and hands its fill to a successor so a held
cursor key doesn't saw the bar back to zero. Fast work is shown, not
suppressed: a load the user can see complete is what makes the app feel
fast.

## Concurrency rules

| Owner | Task / primitive | Cancelled by |
|---|---|---|
| `SearchController` | search worker (`search`, exclusive, thread) | a newer query — but Textual only *marks* a thread worker cancelled, so the stale search still runs to completion and is discarded by the generation guard in `_commit` |
| `PreviewPresenter` | mount worker (`preview-load`, exclusive), debounce timer, in-flight coalescing latch | file switch / query change (`cancel_mount_task`, latch drop) |
| `LazyMounter` | scroll-driven mount task + debounce timer | file switch / query change (`cancel`) |
| `PrefetchEngine` | decode pool (`preview-prefetch`, exclusive), sink queue + drainer task | stale-query signature checks; user mount preempts |
Expand Down
5 changes: 5 additions & 0 deletions fnd/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ def throughput_log_path() -> Path:
return app_data_dir() / "indexer_throughput.jsonl"


def progress_calibration_path() -> Path:
"""Observed per-phase durations behind the progress line's pacing."""
return app_data_dir() / "progress_calibration.jsonl"


def failure_log_path() -> Path:
"""Per-(collection, file) extraction failure log."""
return app_data_dir() / "indexer_failures.toml"
Expand Down
21 changes: 19 additions & 2 deletions fnd/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
)
from fnd.tui.preview_scrollbar import MatchAwareScroll, ThinScrollBarRender
from fnd.tui.progress import FNDProgressBar, ProgressFacility, ProgressSession
from fnd.tui.progress.operations import IndexProgressTracker, PreviewProgressTracker
from fnd.tui.results_labels import (
_elide_middle_keep_suffix,
)
Expand Down Expand Up @@ -408,6 +409,13 @@ def __init__(
# fnd/tui/preview/lazy_mount.py.
self._lazy = LazyMounter(self)
self._progress = ProgressFacility(self)
# Watches the preview pipeline and drives the progress line from it, so
# the mount path never has to report its own progress. See
# fnd/tui/progress/operations.py.
self._nav_progress = PreviewProgressTracker(self)
# Mirrors a running index onto the same line. A background run
# (auto-resume on launch) otherwise surfaces nothing but a toast.
self._index_progress = IndexProgressTracker(self)
# Prefetch warming pipeline (sink queue + drainer task started in
# on_mount); see fnd/tui/preview/prefetch.py.
self._prefetch = PrefetchEngine(self)
Expand All @@ -418,6 +426,11 @@ def open_progress(self, phase: str = "", *, total: int = 1) -> ProgressSession:
"""Open a new ProgressSession. Use as a context manager."""
return self._progress.open(phase, total=total)

def on_unmount(self) -> None:
"""Stop the progress tick loop and persist what the phases actually
cost, so the next session's pacing starts calibrated."""
self._progress.shutdown()

# ── Layout ────────────────────────────────────────────────────

def compose(self) -> ComposeResult:
Expand Down Expand Up @@ -595,10 +608,14 @@ def on_mount(self) -> None:
with contextlib.suppress(Exception):
self.query_one(f"#{panel_id}").add_class("collapsed")
self._refresh_status()
# Focus the query bar first and let the search take it back: results
# no longer exist synchronously after ``run()``, so there is nothing to
# branch on here. ``_refresh_results_tree`` focuses the tree itself once
# groups land, and skips that when there are none — the same end state
# the old ``not self._search.groups`` check produced.
self.query_one("#query_bar", Input).focus()
if self._initial_query:
self._search.run(self._initial_query)
if not self._initial_query or not self._search.groups:
self.query_one("#query_bar", Input).focus()
# Auto-resume any interrupted reindex from a previous fnd session.
# Runs in background (no modal); user can click the footer
# indicator or invoke `action_reindex_default` to view progress.
Expand Down
28 changes: 24 additions & 4 deletions fnd/tui/indexer_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@
__all__ = ["IndexerService"]


def chain_position(service: object) -> int:
"""Which collection of an update-all chain is running, 1-based.

Shared by the IndexerScreen title and the progress line's label so the
two cannot drift. Clamped: a state where ``chain_remaining`` still holds
every collection would otherwise read as "(0 of 4)".
"""
total = getattr(service, "chain_total", 1) or 1
pending = len(getattr(service, "chain_remaining", None) or [])
return max(1, total - pending)


class IndexerService:
"""Owns the indexer task/cancel/event state and the reindex entry
points; one instance lives on the app for the session."""
Expand Down Expand Up @@ -132,8 +144,7 @@ def start(
# "(N of M)" title instead of dropping to a single-run one.
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))
chain_index = chain_position(self)
# Say so. Otherwise the user confirms Update all,
# gets a modal titled with a DIFFERENT collection,
# and reads the run they asked for as "did nothing".
Expand Down Expand Up @@ -253,10 +264,19 @@ async def _await_then_start() -> None:
run_seq=my_seq,
)
)
# Put the run on the app-level line. The modal, when it is open, sits
# on its own screen and hides this — which is right: it shows strictly
# more. What this covers is the background run (auto-resume on launch,
# or a modal the user dismissed with "Background"), which until now
# reported nothing at all after its opening toast.
# Suppressed for the same reason as the preview's: this runs after
# self.task is assigned, so a raise would leave an indexer running
# while start() reported failure and skipped the modal.
with contextlib.suppress(Exception):
self._app._index_progress.begin()
if open_modal:
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))
chain_index = chain_position(self)
self._app.push_screen(
IndexerScreen(
collection,
Expand Down
27 changes: 23 additions & 4 deletions fnd/tui/line_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

from __future__ import annotations

from collections.abc import Sequence
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, ClassVar

Expand Down Expand Up @@ -232,9 +232,18 @@ def structural_map(self) -> list[StructuralBlock]:
return self.fv.structural_map


def build_rendered_document(fv: FileView, *, wrap_width: int) -> RenderedDocument:
# Report every N lines. Frequent enough that a slow document moves the line
# several times a second, rare enough that the reporting is not itself a cost.
_PROGRESS_EVERY = 64


def build_rendered_document(
fv: FileView, *, wrap_width: int, on_progress: Callable[[int], None] | None = None
) -> RenderedDocument:
"""Pure: render fv.lines to strips at wrap_width. Safe off-thread."""
strips, v2l, l2vs = LineBufferPreview._render_lines(fv.lines, wrap_width=wrap_width)
strips, v2l, l2vs = LineBufferPreview._render_lines(
fv.lines, wrap_width=wrap_width, on_progress=on_progress
)
base_width = 1 if wrap_width > 0 else max(fv.widest_line, 1)
return RenderedDocument(
fv=fv,
Expand Down Expand Up @@ -678,9 +687,17 @@ def _render_lines(
lines: list[Text],
*,
wrap_width: int,
on_progress: Callable[[int], None] | None = None,
) -> tuple[list[Strip], list[int], list[int]]:
"""Render lines to Strips. ``wrap_width=0`` disables wrapping.
Returns (strips, visual_to_logical, logical_to_visual_start)."""
Returns (strips, visual_to_logical, logical_to_visual_start).

``on_progress`` is called with the number of lines walked so far, every
:data:`_PROGRESS_EVERY` lines. This is the only real unit of work the
flat path exposes — everything else about it is one opaque call — so
the progress line reads it rather than estimating a duration it cannot
predict. Off by default and free when unset.
"""
if wrap_width > 0:
console = Console(width=wrap_width, file=None, force_terminal=False)
opts = console.options.update(max_width=wrap_width, overflow="fold", no_wrap=False)
Expand All @@ -692,6 +709,8 @@ def _render_lines(
v2l: list[int] = []
l2vs: list[int] = [0] * len(lines)
for li, line in enumerate(lines):
if on_progress is not None and li % _PROGRESS_EVERY == 0:
on_progress(li)
l2vs[li] = len(strips)
current: list[Segment] = []
produced_any = False
Expand Down
73 changes: 73 additions & 0 deletions fnd/tui/preview/decode_progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Live unit count for the flat-preview decode.

The flat path (PDF, TXT) builds its whole document in one worker call, so
from the outside it is a single opaque step — and its duration varies by
more than an order of magnitude (measured on a real corpus: p25 226 ms,
median 1081 ms, p75 3135 ms). Nothing observable at dispatch predicts
that: file size scales the duration by roughly its fourth root, because
the preview only ever mounts a window regardless of how big the file is.

So the progress line cannot estimate this path. It has to be told. The
renderer walks a known number of lines, which is the real unit of work,
and reports as it goes.

Same shape as :mod:`fnd.tui.live_progress`, which does this for PDF page
extraction: the worker writes, the UI polls a snapshot. Deliberately not
a callback into the app — this runs on a worker thread, and marshalling
per line would cost more than the work being measured.
"""

from __future__ import annotations

import threading
from dataclasses import dataclass

_lock = threading.Lock()


@dataclass(slots=True)
class _State:
token: int = 0
done: int = 0
total: int = 0


_state = _State()


def begin(token: int, total: int) -> None:
"""Start counting ``total`` lines for the load identified by ``token``.

The token is the caller's own generation counter. A superseded decode is
still running when its successor starts, and without it the loser's
reports would land on the winner's count.
"""
with _lock:
_state.token = token
_state.done = 0
_state.total = max(0, total)


def advance(token: int, done: int) -> None:
"""Report ``done`` lines rendered so far. Ignored once superseded."""
with _lock:
if token == _state.token:
_state.done = done


def snapshot(token: int) -> tuple[int, int]:
"""``(done, total)`` for ``token``, or ``(0, 0)`` when it is not current."""
with _lock:
if token != _state.token:
return (0, 0)
return (_state.done, _state.total)


def reset() -> None:
with _lock:
_state.token = 0
_state.done = 0
_state.total = 0


__all__ = ["advance", "begin", "reset", "snapshot"]
Loading
Loading