Redesign Search TUI Table with Master-Detail Layout

Claude Code·Opus 4.8[1m]·alishakawaguchi·3mo ago·2hr 49min·4 Checkpoints·9 file changes·+669/-369·607K tokens

can we make search tui table look like this ui [Image #1] don't commit code

3mo ago·7m

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 · author in dim text, with the type word color-coded (orange = checkpoint, purple = session, blue = commit) via resultNodeStyle.
  • 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.

instead of prompt as title use the commit title

3mo ago·1m

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.

only show 10 per page

3mo ago·6m

Lint clean, all tests green. Both changes are done (nothing committed):

  1. 10 per page — resultsPerPage is now 10, governing the TUI display page size, the --limit default, and the JSON pagination fallback. Updated the pagination/fetch-more tests and the JSON limit-fallback test to 10-based math.

  2. Default to checkpoints — newSearchModel now sets filterType: typeFilterCheckpoints, so the results table opens on the Checkpoints tab instead of All. Users can still switch with 0 (All) / 1–3. Updated the all-types pagination/view tests to set the All filter explicitly where they exercise cross-type behavior.

get rid of the all filter. should just be checkpoints, sessions and commits

3mo ago·2m

Now filteredResults — always filter by type:

just remove it from the tui

3mo ago·2m

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 0 key is no longer bound to a filter (it's a no-op now), and the footer hint reads 1-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.

[Image #2] the checkpoint detail is always cut off. what are some ways to fix it

3mo ago·1m

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?

Master–detail split (pinned detail pane)

3mo ago·20m

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 final clampToHeight is a safety net for terminals too short to fit the chrome.
  • viewBrowseHeader() (replaces renderBrowseContent) 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 calls ensureCursorVisible(), 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() (replaces viewDetailCard) 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−6 in 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

3mo ago·2m

Done. Both tweaks are in, lint is clean, and tests pass:

  • Gap between list and detail — viewBrowse now joins the list and detail with "\n\n" (one blank line, detailGap), and that row is reserved in the height budget in both refreshBrowseContent and detailPaneHeight, so the layout still fits the terminal exactly.
  • Tighter detail box — detailBorder padding changed from Padding(1, 2) to Padding(0, 2), removing the blank top/bottom rows inside the box. The pane's vertical chrome dropped 4→2 accordingly (and minDetailPaneHeight 7→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

3mo ago·4m

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 results when there are rows below the visible window,
    • ↑↓ more results when scrolled in the middle,
    • ↑ more results at the bottom,
    • blank when the whole list fits (so the gap stays as plain spacing).

    It reads the list viewport's YOffset, Height, and TotalLineCount to decide direction, so it updates live as you scroll/move the cursor.

  • Layout — the gap/hint row is now always reserved (in both refreshBrowseContent's listH budget and viewBrowse), 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 — refreshBrowseContent now 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.

can we add the page count / results to bottom of results table "page 1/4 · 40 results"

3mo ago·4m

Done. The page X/Y · N results indicator now lives on a status row directly beneath the results table.

What changed

  • viewListStatusRow() (renamed/expanded from viewListScrollHint) 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 the page X/Y clause 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.

  • 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.

commit and push

3mo ago·7m

Committed and pushed.

  • Commit ae7482524 on evis/search-multi-type-support with 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).

3mo ago·5m

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.counts field (simplification) — false positive. API counts are per-type totals across all pages; the computeTypeCounts fallback only counts loaded results. Removing it would make the tab counts undercount. Behavior change.
  • Remove typeFilterAll sentinel (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.Truncate on styled lines (altitude) — false positive; xansi.Truncate is purpose-built to be ANSI-aware (it preserves and closes SGR sequences).
  • lipgloss magic constants, clampToHeight, linesPerResult stride (altitude) — documented, centralized in named constants, and guarded by TestSearchModel_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.

commit

3mo ago·43.5s

Committed and pushed — aadf0b976 on evis/search-multi-type-support (the detailWriter dedup cleanup, no behavior change; lint and tests green).

can you look at pr comments and fix any real issues

3mo ago·5m

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 All tab and a footer that printed len(filtered) while totalPages used the API total. The All tab is gone, and the new status row derives both page X/Y and N results from the same per-type filteredResults()/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 first DefaultLimit=100 results, 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.

any unused code added?

3mo ago·2m

No — nothing this branch added is unused. Verified three ways:

  • The unused linter (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), and viewBrowse/viewBrowseHeader/viewListStatusRow/ensureCursorVisible/detailPaneHeight/padToHeight/clampToHeight/resultNodeStyle.
  • The old table renderers (viewTable, viewRow) were removed during the redesign, not left behind. computeColumns/columnLayout/renderSearchStatic/typeLabel are still live — they back the accessible/static fallback (search_cmd.go:188).

Two adjacent things worth naming, neither introduced by my work:

  1. typeFilterAll is dead-in-production — it's the internal sentinel kept when I removed the "All" tab. No UI path sets filterType to it anymore (the tab and 0 key are gone); it's only reached by the pagination tests, so its branches in filteredResults/totalPages/computeTypeCounts never 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 /simplify because removing it ripples into the fetch-more pagination semantics and several tests.

  2. 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.

pull newest pr comment and fix

3mo ago·2m

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.