Redesign Search TUI Table with Master-Detail Layout

Lint clean, tests pass. The search TUI results list now mirrors the web activity UI in the screenshot.
What changed
In cmd/entire/cli/search_tui.go, I replaced the dense columnar table (viewTable/viewRow) with a two-line list/card renderer (viewResultList + viewResultItem):
- Graph node + title line: a type-colored node glyph (
◇,◆when selected) followed by the bold title, with the relative age (5 days ago) right-aligned — matching the screenshot. - Metadata line: indented to align with the title, showing
type · repo · ⎇ branch · authorin dim text, with the type word color-coded (orange = checkpoint, purple = session, blue = commit) viaresultNodeStyle. - Thin separators between items, like the web list's row dividers.
- Selection shows as a
▸ ◆caret + the shared orange highlight on the title.
The accessible/static fallback (renderSearchStatic, computeColumns, typeLabel) is untouched — screen-reader output keeps its columnar form.
I also updated the two view tests (TestSearchModel_View, TestSearchModel_ViewMultiTypes) that asserted the old column headers / CP/CM/SS badges to check the new type words on the meta line.
As requested, I did not commit anything — the changes are in your working tree. You can preview it live with entire search auth (or any query). Note one cosmetic detail to confirm in a real terminal: the separator rule runs the full content width (same as the old header separator), so it may look one char wide at the right edge — let me know if you'd like it inset.
All green.
ResultTitle() now uses the commit title for checkpoint results — commit subject, falling back to commit message, and finally the prompt only for uncommitted checkpoints. This flows through to both the list view title and the static fallback table. The detail card still shows the full Prompt field separately, so nothing is lost.
Nothing committed — changes are in your working tree.
Lint clean, all tests green. Both changes are done (nothing committed):
-
10 per page —
resultsPerPageis now10, governing the TUI display page size, the--limitdefault, and the JSON pagination fallback. Updated the pagination/fetch-more tests and the JSON limit-fallback test to 10-based math. -
Default to checkpoints —
newSearchModelnow setsfilterType: typeFilterCheckpoints, so the results table opens on the Checkpoints tab instead of All. Users can still switch with0(All) /1–3. Updated the all-types pagination/view tests to set the All filter explicitly where they exercise cross-type behavior.
Lint clean, all tests pass. The "All" tab is now removed from the TUI:
- The type tabs are just Checkpoints (1) · Sessions (2) · Commits (3) — no
[0] All. - The
0key is no longer bound to a filter (it's a no-op now), and the footer hint reads1-3 type. - The TUI still defaults to the Checkpoints tab.
I kept typeFilterAll as an internal sentinel (with a comment explaining it's no longer user-selectable) so the existing pagination/fetch-more math that reasons about the grand total stays intact — only the user-facing tab and key were removed, per your "just remove it from the tui."
Nothing committed.
The root cause is in how browse mode composes its output. Right now everything — the SEARCH header, tabs, RESULTS, all 10 result rows, and the inline detail card — is concatenated into one scrolling viewport (browseVP), with the card appended at the very bottom (renderBrowseContent → viewDetailCard). The combined height (~8 lines of chrome + ~30 for 10 rows + ~19 for the bordered card) overflows the terminal, so the viewport window clips the card's bottom border. The card is also only reachable by scrolling past the whole list, and maxCardContentLines = 15 is a band-aid that still doesn't help because the card sits below a tall list.
Here are the realistic ways to fix it, roughly best → simplest:
1. Master–detail split (pinned detail pane) — recommended
Stop putting the card inside the scrolling content. Split the screen into two fixed regions computed from terminal height:
- Top: the result list in its own scroll viewport, height =
terminalHeight − detailHeight − chrome − footer. - Bottom: the detail card pinned above the footer, given a fixed height and truncated with
▼ enter for more.
The card is then always visible for the selected row and can never be clipped. This is the standard TUI pattern (k9s, lazygit). It's the most work but the correct fix, and it makes the existing full-screen detail (Enter) the natural "see everything" path.
2. Pin only the detail, keep list scrolling, auto-fit card
Lighter version of #1: keep one list viewport, but render the card outside it in View() (list + "\n" + card + "\n" + footer), sizing the card to the leftover height each frame. Same guarantee (never clipped) with less restructuring than a full two-viewport split.
3. Shrink the page so list + card always fit
Make resultsPerPage dynamic: rows = (height − reservedCardHeight − chrome) / linesPerRow. Simple, but the card height varies (files list, wrapped prompt), so the reservation is either wasteful or occasionally still clips — brittle.
4. Drop the inline card; rely on Enter for detail
Remove viewDetailCard entirely, show a one-line summary under the cursor (or nothing), and use the existing full-screen detail view on Enter. Smallest change, zero clipping — but you lose the at-a-glance detail you have now.
5. Toggle the detail pane
Hide the card by default; bind a key (e.g. tab/space) to reveal it. Keeps the list clean and avoids overflow until explicitly requested.
My recommendation is #1 (or #2 as a lighter first step) — a pinned detail region whose height is derived from the terminal, with overflow handled by the enter for more hint that already exists. That fixes the clipping permanently and keeps detail visible without scrolling.
Want me to implement #2 first (smaller diff, immediate fix) and we can graduate to the full split in #1 if you want the list to scroll independently?
Tests pass and lint is clean. Here's what I implemented for the master-detail split with a pinned detail pane:
Summary
The browse screen is now a fixed four-region layout budgeted from terminal height, so the detail card can never be clipped:
Key changes in search_tui.go:
viewBrowse()composes the regions with exact line math:listH = height − headerLines − paneH − 1. A finalclampToHeightis a safety net for terminals too short to fit the chrome.viewBrowseHeader()(replacesrenderBrowseContent) renders only the pinned top chrome and signals whether the list/detail regions should show (loading/error/empty states return early).refreshBrowseContent()now also sizes the list viewport and callsensureCursorVisible(), which scrolls the list so the selected row follows the cursor (visible in the dump — it auto-scrolled to row 7).detailPaneHeight()reserves ~40% of the screen for the detail (clamped 7–24 rows), keeps the list at ≥4 rows, and returns 0 on very short terminals (falling back to full-screen detail via Enter).viewDetailPane()(replacesviewDetailCard) renders the card to exactly its budgeted height, truncating overflow with▼ enter for more. Each line is hard-capped ANSI-aware (xansi.Truncate) to the box's true inner width (borderWidth−6in lipgloss v2) so no inner line wraps and inflates the card.
I verified rendered height never exceeds terminal height across color on/off × widths 40–200 × heights 10–80, and added TestSearchModel_BrowseNeverExceedsHeight as a regression guard.
Nothing committed — all changes are in your working tree.
good can you add a small space between the results table and the chekpoint detail. also reduce padding on top and bottom of checkpoint detail box
Done. Both tweaks are in, lint is clean, and tests pass:
- Gap between list and detail —
viewBrowsenow joins the list and detail with"\n\n"(one blank line,detailGap), and that row is reserved in the height budget in bothrefreshBrowseContentanddetailPaneHeight, so the layout still fits the terminal exactly. - Tighter detail box —
detailBorderpadding changed fromPadding(1, 2)toPadding(0, 2), removing the blank top/bottom rows inside the box. The pane's vertical chrome dropped 4→2 accordingly (andminDetailPaneHeight7→6).
The regression test (TestSearchModel_BrowseNeverExceedsHeight) still passes, confirming the detail card stays fully visible across sizes. Nothing committed — changes are in your working tree.
can we add something to show user to scroll the table at the bottom? basically if there is a cut off and not all rows are visible on the page
Done. The result list now shows a scroll affordance when rows are cut off.
What I added
-
viewListScrollHint()— returns a single centered, dim line for the reserved gap row between the list and the detail box:↓ more resultswhen there are rows below the visible window,↑↓ more resultswhen scrolled in the middle,↑ more resultsat the bottom,- blank when the whole list fits (so the gap stays as plain spacing).
It reads the list viewport's
YOffset,Height, andTotalLineCountto decide direction, so it updates live as you scroll/move the cursor. -
Layout — the gap/hint row is now always reserved (in both
refreshBrowseContent'slistHbudget andviewBrowse), so the hint has a home whether or not the detail pane is showing, and the total still fits the terminal exactly. -
Trailing-newline fix —
refreshBrowseContentnow trims the trailing newline from the list content, so the viewport's line count is accurate (otherwise a phantom blank line kept "scroll down" lit even at the bottom).
I added TestSearchModel_ListScrollHint covering the cut-off (top/bottom arrows) and fits-entirely (no hint) cases. Lint clean, all search tests pass. Nothing committed.
The footer already says ↑/↓, j/k scroll, so the new hint just signals that there's more to scroll to — let me know if you'd prefer different wording (e.g. ▾ 12 more with a count) or right-aligned instead of centered.
Done. The page X/Y · N results indicator now lives on a status row directly beneath the results table.
What changed
-
viewListStatusRow()(renamed/expanded fromviewListScrollHint) renders the single reserved row under the list with two parts:- left: the scroll affordance (
↓/↑↓/↑ more results) — only when the list viewport is cut off, - right:
page X/Y · N results(drops thepage X/Yclause when there's a single page), right-aligned and dim.
It's hard-capped to one line (drops the scroll hint, then truncates the count, if a narrow terminal can't fit both) so the height budget never breaks.
- left: the scroll affordance (
-
viewHelp()(footer) no longer repeats the count — it's just key hints now, since the count moved next to the table.
So the area under the table reads e.g.:
and the footer is purely / search · ↑/↓, j/k scroll · … · q quit.
I updated TestSearchModel_ListScrollHint to assert the page 1/3 · 25 results count renders in the status row. Lint clean, all tests pass. Nothing committed — it's all in your working tree.
Committed and pushed.
- Commit
ae7482524onevis/search-multi-type-supportwith the four changed files (search-only scope). - Pre-push checks:
mise run check(fmt + lint + full test:ci) passes — lint clean, all unit/integration/canary tests green. The one transient failure on the first run didn't reproduce across two subsequent clean runs and wasn't in the search code. - Pushed to
origin/evis/search-multi-type-support(5a1f93c2d..ae7482524).
The commit captures the whole session's work: the master-detail list redesign, commit-title-as-title, 10/page, checkpoints default + removed All tab, the pinned non-clipping detail pane, the gap + reduced box padding, the scroll affordance, and the page X/Y · N results status row.
/simplify → 4 cleanup agents in parallel → apply the fixes
You are improving the quality of the changed code, not hunting for bugs. Review
it for reuse, simplification, efficiency, and altitude issues, then fix what you
find. Do not look for correctness bugs — that is what /code-review is for.
Phase 0 — Gather the diff
Run git diff @{upstream}...HEAD (or git diff main...HEAD / git diff HEAD~1
if there's no upstream) to get the unified diff under review. If there are
uncommitted changes, or the range diff is empty, also run git diff HEAD and
include the working-tree changes in scope — the review often runs before the
commit. If a PR number, branch name, or file path was passed as an argument,
review that target instead. Treat this diff as the review scope.
Phase 1 — Review (4 cleanup agents in parallel)
Launch 4 independent review agents via the Agent tool, all in a
single message so they run concurrently. Pass each agent the diff and one of
the four angles below. Each returns its findings with file, line, a
one-line summary, and the concrete cost (what is duplicated, wasted, or
harder to maintain).
Reuse
Flag new code that re-implements something the codebase already has — Grep shared/utility modules and files adjacent to the change, and name the existing helper to call instead.
Simplification
Flag unnecessary complexity the diff adds: redundant or derivable state, copy-paste with slight variation, deep nesting, dead code left behind. Name the simpler form that does the same job.
Efficiency
Flag wasted work the diff introduces: redundant computation or repeated I/O, independent operations run sequentially, blocking work added to startup or hot paths. Also flag long-lived objects built from closures or captured environments — they keep the entire enclosing scope alive for the object's lifetime (a memory leak when that scope holds large values); prefer a class/struct that copies only the fields it needs. Name the cheaper alternative.
Altitude
Check that each change is implemented at the right depth, not as a fragile bandaid. Special cases layered on shared infrastructure are a sign the fix isn't deep enough — prefer generalizing the underlying mechanism over adding special cases.
Phase 2 — Apply the fixes
Wait for all four agents to complete, dedup findings that point at the same line or mechanism, and fix each remaining one directly. Skip any finding whose fix would change intended behavior, require changes well outside the reviewed diff, or that you judge to be a false positive — note the skip rather than arguing with it. Finish with a brief summary of what was fixed and what was skipped (or confirm the code was already clean).
Done. Lint is clean (the dupl linter no longer flags the renderers) and all search tests pass.
Summary
Fixed — extracted the duplicated detail renderers (cmd/entire/cli/search_tui.go)
renderCheckpointDetail, renderCommitDetail, and renderSessionDetail each redefined identical helper closures (formatLabel/writeField/writeWrappedField/writeSection) plus the labelWidth/valueWidth setup, and repeated the same "Match + score" and "author (username)" blocks. I introduced a detailWriter type that owns the shared layout (label column, value wrap width, section spacing) with field/wrappedField/section/matchField/authorField/String methods. The three renderers now read as plain field lists and differ only in what they emit. Net: ~140 lines of copy-paste collapsed into one ~75-line helper; behavior preserved (detail-content tests green). This was the top finding from both the reuse and simplification agents.
Skipped (with reasons):
- Remove
m.countsfield (simplification) — false positive. API counts are per-type totals across all pages; thecomputeTypeCountsfallback only counts loaded results. Removing it would make the tab counts undercount. Behavior change. - Remove
typeFilterAllsentinel (simplification/altitude) — a genuine leftover from removing the All tab, but unwinding it ripples into the fetch-more pagination semantics and the tests that exercise it. Out of scope for a cleanup pass / risks behavior change. xansi.Truncateon styled lines (altitude) — false positive;xansi.Truncateis purpose-built to be ANSI-aware (it preserves and closes SGR sequences).- lipgloss magic constants,
clampToHeight,linesPerResultstride (altitude) — documented, centralized in named constants, and guarded byTestSearchModel_BrowseNeverExceedsHeight. Acceptable pragmatic tradeoffs. - Per-frame recompute / layout caching (efficiency) — a Bubble Tea TUI re-renders on keypress, not continuously; the ~30 ops/frame are negligible, and adding cached layout fields would introduce staleness risk — counterproductive for a simplify pass.
The changes are in your working tree (uncommitted) — say the word if you'd like me to commit and push.
Committed and pushed — aadf0b976 on evis/search-multi-type-support (the detailWriter dedup cleanup, no behavior change; lint and tests green).
Committed and pushed (7ef3a5746). Here's the rundown of the PR review comments and what I did with each.
Fixed (real issues)
| Comment (bot) | Fix |
|---|---|
--all-repos ignores explicit --repo (Cursor + Copilot) | toQuery now scopes to an explicit owner/name filter even when --all-repos is set — the more specific filter wins instead of being silently dropped. Added TestSearch_ExplicitRepoWinsOverAllRepos. |
HasFilters ignores AllRepos (Copilot) | Added || c.AllRepos, so entire search --all-repos --json with an empty query produces JSON instead of opening the TUI. Added a case to TestConfig_HasFilters. |
UnmarshalJSON doesn't clear typed pointers (Copilot) | Reset Checkpoint/Commit/Session to nil before decoding, enforcing the "exactly one non-nil" invariant on reuse. |
| Stale "fetch the maximum" comment (Copilot) | Corrected — it fetches DefaultLimit (100, the web page size), not MaxLimit. |
Moot (already resolved by this session's TUI redesign)
- Footer count vs. paging and All-tab count wrong (Cursor + Copilot) — these were about the
Alltab and a footer that printedlen(filtered)whiletotalPagesused the API total. The All tab is gone, and the new status row derives bothpage X/YandN resultsfrom the same per-typefilteredResults()/totalPages(), so they're consistent.
Skipped (design, not a clear bug — flagging for you)
- Type-tab counts can exceed what's browsable (Cursor "Type tab counts mislead users"): tabs show the API per-type totals (e.g.
Checkpoints 47) while you can only page through the loaded subset (the firstDefaultLimit=100results, split across types). For >100-result searches the tab number can exceed the rows you can reach, since there's no per-type fetch-more. The counts-match-the-web behavior looks intentional, and an honest fix means wiring per-type incremental fetching — a real feature, not a cleanup. Want me to either (a) make tabs show the browsable/loaded count, or (b) add per-type fetch-more so you can page to the full count? Otherwise I'll leave it as-is.
I didn't post replies on the GitHub threads — let me know if you'd like me to comment on each thread (resolving the fixed ones and noting the moot/skipped ones), and I'll use gh to do that.
No — nothing this branch added is unused. Verified three ways:
- The
unusedlinter (staticcheck) reports nothing for the search files. - Every new symbol I added is referenced:
detailWriter+ all its methods (field/wrappedField/section/matchField/authorField/label/String), the layout constants (linesPerResult,minListHeight,minDetailPaneHeight,maxDetailPaneHeight,detailGap,gutterWidth), andviewBrowse/viewBrowseHeader/viewListStatusRow/ensureCursorVisible/detailPaneHeight/padToHeight/clampToHeight/resultNodeStyle. - The old table renderers (
viewTable,viewRow) were removed during the redesign, not left behind.computeColumns/columnLayout/renderSearchStatic/typeLabelare still live — they back the accessible/static fallback (search_cmd.go:188).
Two adjacent things worth naming, neither introduced by my work:
-
typeFilterAllis dead-in-production — it's the internal sentinel kept when I removed the "All" tab. No UI path setsfilterTypeto it anymore (the tab and0key are gone); it's only reached by the pagination tests, so its branches infilteredResults/totalPages/computeTypeCountsnever execute in the real app. The compiler/linter see it as "used" (test refs + reachable branches), so it isn't flagged. I deliberately left it during/simplifybecause removing it ripples into the fetch-more pagination semantics and several tests. -
MaxLimit(=200) is unused — but it predates this branch (added in PR #679, appears as context in the diff, not an addition), so out of scope here.
Want me to remove typeFilterAll and collapse the dead branches? That's a clean win but it touches the fetch-more pagination path and the tests that set the sentinel, so I'd treat it as its own small change rather than fold it in silently.
Done. Pulled the newest PR comment and fixed it.
Newest comment (cursor[bot], on my latest commit 7ef3a5746): "All-repos flag ignored for filters" — a real follow-up to my earlier Config.HasFilters fix. The non-interactive fast-fail guard in search_cmd.go builds its own local hasFilters (it runs before git/auth, so it can't call searchCfg.HasFilters()), and that local copy still omitted --all-repos. So entire search --all-repos --json with an empty query was rejected with "query required" instead of running a wildcard search across all repos.
Fix: added || allRepos to the local guard so it mirrors search.Config.HasFilters, with a comment explaining why it can't reuse the method directly.
Build, lint, and full test suite pass. Committed and pushed as 2f7fe5de4.
The other comments in the thread are the same ones from before — already fixed in earlier commits (HasFilters, repo precedence, UnmarshalJSON, comment) or moot after the TUI redesign (All-tab footer/count). The only open design item remains the "tab counts show API totals vs. browsable subset" one, which I flagged earlier as needing per-type fetch-more rather than a quick fix.