Streaming Textual explorer as the default agentgrep surface#7
Open
tony wants to merge 17 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
agentgrepto the interactive Textual explorer (newuisubcommand);agentgrep <terms>remains shorthand foragentgrep search <terms>.agentgrep.uisubpackage so the top-level package stays focused on the CLI / search engine and Textual stays lazily imported.Changes by area
CLI entry
agentgrep ui [initial_query]is a first-class subcommand and the default when no positional command is supplied.agentgrepwith no arguments andagentgrep --color neverboth land in the explorer;agentgrep blissstill resolves toagentgrep search bliss.UI package split
src/agentgrep/ui/__init__.py: new subpackage re-exportingrun_uiandbuild_streaming_ui_app. Textual is imported lazily inside the factory, soimport agentgrepdoes not require Textual at import time.src/agentgrep/ui/app.py: new home for the streaming Textual app — message subclasses,SpinnerWidget,FilterInput,AgentGrepApp, plus thescroll_percenthelper. The factory keeps Textual imports gated behindimportlib.import_moduleso the import error is deferred to UI construction.src/agentgrep/__init__.py: keeps shared search/serialization plumbing, the newSearchRequestedPayload, the haystack/match-count helpers, and theSearchControl.reset()method the cancellable-search path uses.Search & filter behaviour
SearchRequestedPayloadon Enter; pressing Enter again replaces the activeSearchControl, asking the prior worker to wrap up before the new one starts.upreleases focus back to the search bar; empty-rightreleases focus rightward into the detail pane; a non-emptyrightkeeps 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 existingOptionListrather than clearing and rebuilding; falls back to a full rebuild when the change is majority-removal.reset_search_chrome.Per-pane chrome
scroll_percent-derived indicator that mirrors tig's "100% when everything fits" convention.Test plan
uv run ruff format .uv run ruff check . --fix --show-fixesuv run ty checkuv run pytesttest_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.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.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.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.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.test_show_detail_memoizes_body_formatting,test_show_detail_memoizes_first_match_line,test_reset_search_chrome_invalidates_detail_caches.