Skip to content

Streaming Textual explorer as the default agentgrep surface#7

Open
tony wants to merge 17 commits into
masterfrom
mcp-2-tui-2
Open

Streaming Textual explorer as the default agentgrep surface#7
tony wants to merge 17 commits into
masterfrom
mcp-2-tui-2

Conversation

@tony
Copy link
Copy Markdown
Owner

@tony tony commented May 19, 2026

Summary

  • Default bare agentgrep to the interactive Textual explorer (new ui subcommand); agentgrep <terms> remains shorthand for agentgrep search <terms>.
  • Extract the streaming Textual app into a dedicated agentgrep.ui subpackage so the top-level package stays focused on the CLI / search engine and Textual stays lazily imported.
  • Add a top search bar that dispatches live, cancellable searches on Enter and tears down prior workers cleanly, paired with a sticky in-list filter that narrows results without re-running search.
  • Wire tig-style per-pane chrome: results and detail panes each get their own footer status line with match count, cursor position, compact path, and a scroll-percent indicator.
  • Tighten rendering on the hot paths — memoized casefolded haystacks, OptionList delta updates instead of full rebuilds, chunked streaming batch apply with cooperative event-loop yields, and memoized detail-pane body formatting + first-match lookup.

Changes by area

CLI entry

  • agentgrep ui [initial_query] is a first-class subcommand and the default when no positional command is supplied. agentgrep with no arguments and agentgrep --color never both land in the explorer; agentgrep bliss still resolves to agentgrep search bliss.

UI package split

  • src/agentgrep/ui/__init__.py: new subpackage re-exporting run_ui and build_streaming_ui_app. Textual is imported lazily inside the factory, so import agentgrep does not require Textual at import time.
  • src/agentgrep/ui/app.py: new home for the streaming Textual app — message subclasses, SpinnerWidget, FilterInput, AgentGrepApp, plus the scroll_percent helper. The factory keeps Textual imports gated behind importlib.import_module so the import error is deferred to UI construction.
  • src/agentgrep/__init__.py: keeps shared search/serialization plumbing, the new SearchRequestedPayload, the haystack/match-count helpers, and the SearchControl.reset() method the cancellable-search path uses.

Search & filter behaviour

  • Live, cancellable search: the top search bar posts SearchRequestedPayload on Enter; pressing Enter again replaces the active SearchControl, asking the prior worker to wrap up before the new one starts.
  • Sticky in-list filter: a separate filter input casefolds against the result haystack on every keystroke. Empty-up releases focus back to the search bar; empty-right releases focus rightward into the detail pane; a non-empty right keeps cursor-in-input semantics.

Performance

  • cached_haystack / clear_haystack_cache: per-record casefolded haystack cached across filter passes; cleared when the result set changes.
  • compute_filter_matches: filter applies adds/removes as deltas against the existing OptionList rather than clearing and rebuilding; falls back to a full rebuild when the change is majority-removal.
  • Streaming batch apply: yields control to the event loop between chunks so the UI stays responsive while large result batches stream in.
  • Detail pane: memoizes formatted body and first-match line per record; caches invalidate on reset_search_chrome.

Per-pane chrome

  • Results pane: status line is now the top of the column. The right side renders match count, cursor position, and a scroll_percent-derived indicator that mirrors tig's "100% when everything fits" convention.
  • Detail pane: footer status line shows the compact source path and the same scroll-percent indicator.
  • Result-row timestamps: rendered in tig-style local-timezone format, falling back to the raw value when parsing fails.

Test plan

  • uv run ruff format .
  • uv run ruff check . --fix --show-fixes
  • uv run ty check
  • uv run pytest
  • CLI dispatch — test_inject_default_subcommand_empty_returns_ui, test_inject_default_subcommand_color_only_returns_ui, test_parse_args_ui_subcommand_returns_ui_args, test_parse_args_ui_subcommand_with_initial_query, test_parse_args_empty_argv_returns_ui_args.
  • Live-search wiring — test_search_input_posts_search_requested_only_on_enter, test_search_input_dispatch_spawns_search_group_worker, test_search_input_enter_replaces_control_to_cancel_prior_search, test_empty_query_focuses_search_input_and_marks_search_done.
  • Focus traversal off the in-list filter — test_up_on_empty_filter_releases_focus_to_search, test_up_on_filter_with_cursor_at_start_releases_focus_to_search, test_right_on_empty_filter_releases_focus_to_detail, test_right_on_non_empty_filter_moves_cursor.
  • Delta filter & cached haystack — test_cached_haystack_memoizes_per_record, test_compute_filter_matches_uses_cached_haystack, test_set_records_narrowing_avoids_clear_options, test_set_records_widening_triggers_full_rebuild, test_set_records_majority_removal_falls_back_to_rebuild, test_apply_records_batch_yields_between_chunks.
  • Per-pane chrome — test_scroll_percent_returns_full_when_nothing_scrolls, test_scroll_percent_clamps_to_bounds, test_results_status_right_shows_match_count_cursor_and_percent, test_detail_statusline_shows_path_and_scroll_percent, test_results_scroll_changed_updates_status_right, test_format_timestamp_tig_renders_iso_with_offset_in_local_tz, test_format_timestamp_tig_renders_zulu_input, test_format_timestamp_tig_returns_empty_string_for_missing_input, test_format_timestamp_tig_falls_back_to_raw_on_parse_error.
  • Detail-pane memoization — test_show_detail_memoizes_body_formatting, test_show_detail_memoizes_first_match_line, test_reset_search_chrome_invalidates_detail_caches.

tony added 17 commits May 18, 2026 21:16
why: Users had no shortcut into the TUI — `agentgrep` alone printed
help, and the only way to launch the interactive explorer was
`agentgrep search <query> --ui`. Promoting the TUI to a first-class
subcommand removes a friction point and matches how the user
expects modern interactive tools to behave when run with no args.

what:
- Inject `ui` as the default subcommand when no positional argv is
  provided. `agentgrep <terms>` still falls back to `search <terms>`.
- Add `UIArgs` dataclass, `ui` subcommand parser, and
  `run_ui_command()` to wire `agentgrep ui [initial_query]` through
  the existing `run_ui()` factory.
- Guard `main()` against non-interactive bare invocations: pipelines,
  redirected output, and CI subprocesses without a TTY fall back to
  the help message instead of attempting to launch a TUI.
- Update `CLI_DESCRIPTION` to reflect the new default.
- Add five tests covering empty-argv injection, the `ui` subcommand
  parser, and the optional `initial_query` positional.
…y in-list filter

why: The user wants a top-level search bar at the top of the screen and
a sticky filter input pinned to the top of the result list. Promoting
the search affordance to the top of the layout makes the primary
action discoverable, and keeping the filter docked above the list
mirrors the toad / oterm convention of "sticky above scrollable
content."

what:
- Add a `Vertical` container binding to the lazy Textual import block
  so the results column can stack the filter above the OptionList in
  its own layout context.
- Rewrite `AgentGrepApp.compose()` so the widget tree is
  ``Header → SearchInput(#search) → chrome → Horizontal(#body)[
  Vertical(#results-column)[FilterInput(#filter), OptionList(#results)],
  DetailScroll(#detail-scroll)] → Footer``. Pre-fill the top search
  input with the initial CLI query so users land on a search they
  recognise.
- Extend the CSS with `#search { height: 3 }` and
  `#results-column { layout: vertical }` so the filter stays pinned
  while the OptionList scrolls.
- Wire `#search` into the tmux-style `ctrl+hjkl` directional pane
  bindings: from inside the body, `Ctrl-K` lands on the body's top
  row (`#filter`); from `#filter`, another `Ctrl-K` jumps out of the
  body to `#search`. `Ctrl-J` from `#search` lands on `#filter`.
- Explicitly focus `#filter` in `on_mount` so existing initial-focus
  contracts and tests stay green. The top-level search input is a
  visual placeholder in this commit; subsequent work makes it dispatch
  live searches and takes over initial focus.
…lable searches

why: The top search bar previously rendered as a placeholder Input but
did not dispatch a search. The user wants typing into the bar to fire
a backend search and the sticky in-list filter to narrow the loaded
results — two independent worker pipelines so neither blocks the
other.

what:
- Add ``SearchRequestedPayload`` pydantic model and a ``SearchRequested``
  Textual message so the top input has a typed channel to talk to the
  app, mirroring the existing ``FilterRequested`` plumbing.
- Add a ``SearchInput`` widget that subclasses Textual's ``Input`` and
  debounces value changes by 150 ms before posting a ``SearchRequested``.
  An initial-dispatch guard suppresses the auto-post when the input is
  constructed with an initial value so the caller controls whether to
  seed the first search.
- Compose now yields ``SearchInput(#search)`` at the top of the screen.
- ``AgentGrepApp.on_search_requested`` resets per-search chrome (records,
  detail, status, spinner, elapsed) and spawns a worker in the
  ``group="search"`` worker group with ``exclusive=True``. Textual's
  worker manager auto-cancels any prior in-flight search, which is the
  canonical pattern for "fire a fresh backend search on every debounced
  keystroke without piling up cancellations."
- Empty / whitespace-only search input parks the UI in an idle "Type to
  search" state instead of spawning a worker against zero terms.
- ``on_mount`` skips the initial backend search when the CLI query has
  no terms and focuses ``#search`` instead of ``#filter``. With non-empty
  initial terms (e.g. ``agentgrep search foo --ui``), behaviour is
  unchanged: focus lands on the filter and the worker auto-starts.
- Add ``SearchControl.reset()`` so a fresh search clears any prior
  ``answer_now`` request, and add ``unfreeze()`` on ``SpinnerWidget`` /
  ``ElapsedWidget`` so they resume animation when a new search starts.
- Update directional-pane focus tests that previously relied on
  ``#filter`` being the implicit initial focus — they now explicitly
  focus the filter before exercising their key bindings.
- Add three new functional tests: empty-query lands on ``#search``;
  typing into ``#search`` posts exactly one debounced ``SearchRequested``;
  and a non-empty search value spawns a worker in the ``search`` group.
…s filter passes

why: ``compute_filter_matches`` runs ``build_search_haystack(record).casefold()``
per record on every filter keystroke. With ten thousand loaded records
and a fast typist, the same string concatenations and case-folds repeat
tens of thousands of times per second on the worker thread, and the
filter pass itself becomes the wall-clock cost between keystrokes.
Memoizing the casefolded haystack by record identity erases the
redundant work entirely.

what:
- Add a module-level ``_HAYSTACK_CACHE: dict[int, str]`` keyed by
  ``id(record)``. ``SearchRecord`` is a frozen slotted dataclass so an
  instance's id is stable for the life of a search session, which is
  exactly the useful lifetime of the cache.
- Add ``cached_haystack(record)`` that returns
  ``build_search_haystack(record).casefold()`` once per record and
  caches the result. Add ``clear_haystack_cache()`` so callers can
  invalidate before a new search allocates a fresh record set.
- ``compute_filter_matches`` now scans ``cached_haystack(record)``
  instead of recomputing per pass.
- ``SearchResultsList.append_records`` eagerly warms the cache for every
  appended record so the cost lands during streaming (when the user is
  already watching the spinner) rather than on the next filter keystroke.
- ``AgentGrepApp._reset_search_chrome`` calls ``clear_haystack_cache``
  before each new search so stale entries cannot accumulate across
  searches in a long-lived TUI session.
- Add two tests: a memoization test that proves ``build_search_haystack``
  runs at most once per record, and an integration test that monkey-
  patches ``build_search_haystack`` to raise after the cache is warm and
  confirms a filter pass over a warm cache never invokes the slow path.
…ng the OptionList

why: ``set_records`` previously did ``clear_options()`` + ``add_options([...])``
on every filter completion. Even though Textual's ``OptionList`` virtualizes
*rendering*, the option-model rebuild is O(n) Python work on the main
thread — for ten thousand records that wipes out the worker-thread
debounce and the user sees the UI freeze for the duration of the
rebuild on every keystroke.

what:
- Rewrite ``SearchResultsList.set_records`` as a delta-apply. The
  common case — the user types another character to narrow further —
  only invokes ``remove_option_at_index`` for the now-unmatched records
  and never touches the kept options. Filter latency drops from O(n)
  to O(removed).
- Fall back to a full chunked rebuild when:
  (a) the list is currently empty, or
  (b) the new set introduces records not currently displayed (widening
      via backspace); a rebuild preserves the source-order semantics
      callers expect, and
  (c) more than half of the current options would be removed — at that
      ratio a single ``clear_options`` + ``add_options`` is cheaper
      than N ``remove_option_at_index`` calls (each shifts the
      internal options list).
- Extract the rebuild path into a ``_rebuild_options`` helper so both
  branches share warming :func:`cached_haystack` for the records they
  emit.
- Add three tests that prove the delta-apply path is taken in the
  narrowing case, the widening case rebuilds for order correctness,
  and the majority-removal case falls back to rebuild rather than N
  remove calls.
…t-match lookup

why: The detail pane re-runs ``json.loads`` + ``json.dumps(indent=2)`` and
``find_first_match_line`` on every cursor highlight change. On a record
with a 100 KB JSON body, holding ``j`` to scroll the result list does
the parse-format work tens of times per second on the main thread —
enough to lag the cursor visibly. Memoizing per ``(record, query)``
collapses that work to once per (record, query) pair for the duration
of a search session.

what:
- Add two LRU caches on ``AgentGrepApp`` keyed by
  ``(id(record), query.terms, case_sensitive, regex)``:
  ``_detail_body_cache`` for the ``(renderable, scroll_text)`` tuple
  returned by ``_build_detail_body``, and ``_first_match_cache`` for the
  match-line index used by ``_scroll_detail_to_first_match``.
- Both caches use ``collections.OrderedDict`` with ``move_to_end`` +
  ``popitem(last=False)`` eviction, capped at 1024 entries via
  ``_DETAIL_CACHE_MAX`` so a long browse session never grows them
  unbounded.
- Introduce ``_detail_cache_key`` so both wrappers compose the same
  key shape from the current record + query.
- ``_reset_search_chrome`` clears both caches before each new search,
  so a fresh search never inherits stale entries from a prior record
  set whose objects have been freed.
- Add three tests: re-rendering the same record + query never re-parses
  the JSON body; ``find_first_match_line`` is not called twice for a
  repeat view; and ``_reset_search_chrome`` empties both caches.
… event-loop yields

why: ``_apply_records_batch`` previously ran ``OptionList.add_options``
synchronously for an entire incoming batch — for a 5000-record arrival
that builds 5000 ``Option`` instances and 5000 styled ``Text`` rows on
the main thread without ever returning to the event loop. Even though
``call_from_thread`` already provides backpressure (the producer thread
blocks for the full duration of the apply), the lockup happens
*within* a single apply because the event loop can't paint frames or
respond to keystrokes until the synchronous block finishes.

what:
- Make ``AgentGrepApp._apply_records_batch`` async and slice ``matching``
  into ``_APPLY_CHUNK_SIZE`` (200) blocks. Each block is appended via
  ``SearchResultsList.append_records`` and the loop ``await asyncio.sleep(0)``
  s between blocks so the event loop can interleave keystroke / timer /
  render work between chunks.
- ``call_from_thread`` accepts async callables (it ``await``s them
  internally via ``invoke``), so the existing dispatch in ``make_emit``
  works unchanged — the worker thread now blocks on the full chunked
  apply, which preserves the natural backpressure while removing the
  intra-apply UI freeze.
- Add a functional test that monkey-patches ``asyncio.sleep`` with a
  counter and asserts that applying three chunks worth of records
  yields at least twice (between each pair of adjacent chunks).
- Add the previously-missing ``asyncio`` import to the test module so
  the new yield-counter test compiles.
…p.ui.app

why: ``build_streaming_ui_app`` had grown to a ~1400-line closure inside
the 5500-line package ``__init__``. The body owns every TUI concern
(messages, widgets, app class, LRU caches, key bindings, focus
routing) so splitting it into its own module makes review and
maintenance practical without inflating the public-API entry point.

This is a pure structural refactor — landed after the perf fixes so
behavioural correctness was already verified against the user's real
``~/.codex`` / ``~/.claude`` stores.

what:
- Create ``src/agentgrep/ui/__init__.py`` and
  ``src/agentgrep/ui/app.py``. The ``app.py`` module owns the closure
  body verbatim: lazy Textual imports, the
  ``FilterRequested`` / ``FilterCompleted`` / ``SearchRequested``
  messages, ``make_emit``, the spinner / elapsed / results / detail /
  filter / search widgets, and the ``AgentGrepApp`` class.
- Replace the ~1400-line definitions in ``agentgrep/__init__.py`` with
  ten-line thin wrappers for ``run_ui`` and ``build_streaming_ui_app``.
  Both wrappers import the real implementation from
  ``agentgrep.ui.app`` lazily so ``import agentgrep`` continues to
  load without Textual; only the first call into the TUI triggers
  the Textual import.
- Public API is unchanged. ``agentgrep.run_ui``,
  ``agentgrep.build_streaming_ui_app``, and every other symbol that
  the tests / MCP server / docs autodoc resolve continue to be
  importable from the top-level package.
…`up`

why: The top search bar was only reachable via ``Ctrl-K`` or Tab. With
the filter input as the natural starting focus after a query lands,
plain ``up`` was a no-op — which felt like the search bar was
unreachable from inside the body. Making ``up`` on an empty (or
cursor-at-start) filter release focus to ``#search`` mirrors the
symmetric ``down`` → results behaviour and makes the navigation chain
``detail → filter → search`` discoverable via plain arrow keys.

what:
- ``FilterInput._on_key``: when ``key == "up"`` and the cursor is at the
  start of the input (or the input is empty), suppress the default
  behaviour and call ``app.query_one("#search").focus()``. The lookup
  is wrapped in ``contextlib.suppress(Exception)`` so a missing
  ``#search`` widget never raises out of an arrow-key handler.
- Add the matching ``contextlib`` import at the top of ``ui/app.py``.
- Two functional tests cover the new path: empty filter +
  ``up`` → ``#search``, and non-empty filter with cursor at position 0
  + ``up`` → ``#search``.
…timezone

why: Result rows previously rendered timestamps as the raw upstream ISO
string truncated to twenty characters, so the user saw
``2026-05-18T01:34:03.`` with the seconds and timezone clipped. Tig
formats commit times as ``YYYY-MM-DD HH:MM ±HHMM`` localized to the
viewer's timezone, which is more scannable and answers "when did this
happen in my local clock" directly.

what:
- Add :func:`format_timestamp_tig` next to :func:`isoformat_from_mtime_ns`
  in ``agentgrep/__init__.py``. The helper accepts the ISO string the
  parsers already populate, handles both ``Z`` and ``±HH:MM`` offsets,
  localizes via ``datetime.astimezone()`` (no argument → system TZ),
  and formats via ``strftime("%Y-%m-%d %H:%M %z")``. Returns ``""`` for
  empty/missing input and a clipped raw string for unparseable input
  so the caller can pad consistently. Doctest covers the happy path.
- Swap ``_render_record`` in ``agentgrep/ui/app.py`` to call the new
  helper, widening the result-row timestamp column to 22 characters so
  the full tig form (``2026-05-18 01:34 -0500``) fits without trailing
  truncation.
- Import the new helper from ``agentgrep`` in ``ui/app.py``.
- Four functional tests cover the helper: ISO + offset, ``Z`` suffix,
  ``None``/empty input, and unparseable-string fallback.
… lines

why: The reactive chrome row (spinner, status text, match count, elapsed
time) used to sit on its own line between the search bar and the body.
Moving that content into a footer attached to the results column makes
the reactive state visually belong to the pane it describes and frees
the top of the screen for the search input alone — closer to the
"results widget owns its own status" model the user named in the
brief. The detail column gets a matching footer that will carry the
record path + scroll-% in a follow-up commit.

what:
- Drop the entire ``#chrome`` ``Horizontal`` block from ``compose()``.
  Replace with a ``#results-statusline`` ``Horizontal`` placed at the
  bottom of ``#results-column`` carrying ``#status-spinner``,
  ``#status-text`` (1fr), and ``#status-right`` (auto width).
- Wrap ``DetailScroll`` in a new ``#detail-column`` ``Vertical`` so a
  ``#detail-statusline`` ``Static`` can dock at its bottom. The detail
  pane is now first-class column-shaped instead of a bare scrollable.
- Remove the ``ElapsedWidget`` class entirely — nothing imports it
  externally and the live-ticking elapsed display didn't survive the
  redesign. Final elapsed time is folded into ``_apply_finished``'s
  status string (``Search complete: NNNN matches in X.Ys``); progress
  updates remain unchanged.
- ``on_mount`` queries the new ``#status-*`` and ``#detail-statusline``
  IDs and caches them on the same ``self._status_widget`` /
  ``self._matches_widget`` / ``self._spinner_widget`` slots so every
  existing writer path remains shape-compatible.
- ``_reset_search_chrome`` resets the detail status line alongside the
  results matches text on every fresh search.
- No public-API symbols change; no tests assert on chrome internals so
  the existing 236 tests pass unchanged.
… footers

why: The pane footers now have room for a tig-style scroll indicator
on the right and the path/identity of the current view on the left.
With the results list and the detail pane both backed by scrollable
Textual containers, the same reactive watchers can drive both
indicators — the user immediately sees how far through they are in
each pane.

what:
- Add module-level :func:`scroll_percent` to ``agentgrep/ui/app.py``
  that clamps the ``scroll_y / max_scroll_y`` ratio to ``[0, 100]``
  and returns ``100`` when there is no scrollable region (matching
  tig's "fully visible view shows 100%" convention).
- Add ``ResultsScrollChanged`` (cursor, total, percent) and
  ``DetailScrollChanged`` (percent) messages so widgets stay decoupled
  from the app's status surface and the existing
  ``FilterRequested`` / ``SearchRequested`` style is preserved.
- ``SearchResultsList`` now overrides ``watch_scroll_y`` and
  ``watch_highlighted`` to post ``ResultsScrollChanged`` whenever the
  cursor or viewport moves. ``DetailScroll`` does the same for its own
  scroll position via ``watch_scroll_y``.
- ``AgentGrepApp`` gains ``on_results_scroll_changed`` /
  ``on_detail_scroll_changed`` handlers that re-render the right side
  of the results status line and the detail footer. The right slot
  composes ``{N} matches  {cursor+1}/{visible}  {pct}%`` — each segment
  drops out when its inputs are unknown — so streaming updates and
  interactive moves both flow through one formatter.
- ``show_detail`` repaints the detail footer with the compact record
  path + scroll % whenever a record loads; the same ``_refresh_*``
  helpers run from the scroll-change handler so the path/% stay in
  sync as the user scrolls.
- Streaming batches now route their match-count update through
  ``_refresh_results_status_right`` instead of writing the matches
  widget directly, so the right slot is always shape-consistent.
- Five new tests cover the formatter: scroll-% clamping, the
  full ``{N} matches  ...  N%`` composition, the detail footer's
  path + ``%`` shape, and the live update path from a
  ``ResultsScrollChanged`` post.
…andler

why: The reactive ``watch_highlighted`` override on ``SearchResultsList``
fires after the OptionList parent posts ``OptionHighlighted``, so the
right side of the results footer does pick up cursor moves. But
piggy-backing on the existing ``on_option_list_option_highlighted``
handler gives the footer a second, deterministic update path — no
need to trust the watch-order between subclass and parent — and
matches the same handler we already use for the detail pane.

what:
- ``AgentGrepApp.on_option_list_option_highlighted`` now calls
  ``_refresh_results_status_right`` with the explicit cursor / visible
  / percent triple drawn from the event and the widget. When the event
  has no option index (cursor cleared) it falls back to the no-arg
  refresh.
- No semantic change to the watcher path; the handler refresh is
  additive and idempotent.
…e on empty `right`

why: With ``up``/``down`` arrows already escaping the filter input
(``up`` → search, ``down`` → results), ``right`` was the missing path
to reach the detail pane via plain arrows. Wiring an empty-filter
``right`` to focus ``#detail-scroll`` gives the user a full arrow-key
navigation perimeter without reaching for ``Ctrl-L``.

what:
- ``FilterInput._on_key``: when ``key == "right"`` and the input has no
  value, suppress the default behaviour and call
  ``app.query_one("#detail-scroll").focus()`` (wrapped in the same
  ``contextlib.suppress(Exception)`` used by the symmetric ``up`` path).
  Non-empty filters fall through so the cursor still walks the text
  character-by-character.
- Two functional tests: empty filter + ``right`` → ``#detail-scroll``,
  and non-empty filter + ``right`` keeps focus on the filter while
  advancing the cursor by one.
why: With the live chrome carried in the results column, the user
reads from top to bottom — search bar → status (spinner + match
count + scroll %) → filter → results. Putting the status row above
the filter sits the running search state next to the search input
that drives it, instead of asking the user to look at the foot of
the column for "is the search still going?". The detail column
keeps its status line at the bottom — record path + scroll % is
contextual to whatever's being read, so the natural place to
glance is the foot of the pane.

what:
- Reorder ``compose()`` for the results column: the
  ``#results-statusline`` ``Horizontal`` (spinner + status text +
  right slot) yields first, then ``#filter``, then ``#results``.
  Detail column structure is unchanged.
- Refresh the ``compose()`` docstring to explain the asymmetry
  between the two columns.
- No CSS change — ``#results-statusline { height: 1 }`` paints
  the same one-row strip regardless of position. All 245 tests
  still pass; cached ``self._status_widget`` / ``self._spinner_widget``
  / ``self._matches_widget`` references resolve the same widget IDs.
why: The top search input auto-fired a debounced backend search on
every keystroke, which both surprised users mid-type and exposed a
cancel race — `on_search_requested` set the cooperative cancel flag
on `self.control`, but `_reset_search_chrome` immediately called
`self.control.reset()`, often wiping the signal before the running
worker thread observed it. Switching the trigger to Enter makes the
search intent explicit and gives the cancel path a clean hook.

what:
- Strip the debounce timer + `_watch_value` auto-dispatch from
  `SearchInput`; replace with `on_input_submitted` that posts
  `SearchRequested` only when the user presses Enter.
- In `_reset_search_chrome`, replace `self.control.reset()` with a
  fresh `SearchControl()` instance so the outgoing worker keeps its
  signaled reference while the new worker starts un-signaled.
- Update idle status text from "Type to search" to
  "Press Enter to search".
- Rework the two existing search-input tests to assert the new
  Enter-gated behavior and add a regression test that proves each
  Enter both signals the prior `SearchControl` and installs a new
  one.
why: Filter typing felt laggy because each ``FilterCompleted`` did
two heavy main-thread re-renders of the detail pane back-to-back —
the explicit ``show_detail(filtered_records[0])`` plus another
``show_detail`` triggered by the ``OptionHighlighted`` event that
``set_records`` emits during its rebuild. Detail rendering (Rich
``Text`` header, JSON/Markdown body, scroll-to-match) is one of the
heavier units of main-thread work per keystroke and was running
twice for no visible benefit.

what:
- In ``on_filter_completed`` skip ``show_detail`` when the top
  filtered record is the same object already in the detail pane.
- In ``on_option_list_option_highlighted`` skip ``show_detail`` for
  highlights that target the already-displayed record (the
  re-render that ``set_records`` re-emits during its rebuild).
- Status-line refreshes still run on every highlight so cursor /
  scroll-percent chrome stays live.
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