Skip to content

Add interactive pager for list commands with a row template#5015

Merged
simonfaltum merged 15 commits intomainfrom
simonfaltum/list-simple-paginated
Apr 24, 2026
Merged

Add interactive pager for list commands with a row template#5015
simonfaltum merged 15 commits intomainfrom
simonfaltum/list-simple-paginated

Conversation

@simonfaltum
Copy link
Copy Markdown
Member

@simonfaltum simonfaltum commented Apr 17, 2026

Why

List commands with a row template (jobs list, clusters list, apps list, pipelines list, workspace list, etc.) drain the full iterator and render every row at once. In workspaces with hundreds of resources, the output scrolls past before you can read it. An interactive terminal should get a chance to step through the output.

This PR is an alternative to #4729 (the Bubble Tea TUI). It only solves pagination, nothing else. Smaller diff, no new public API, no override file changes. Interactive I/O is consolidated on bubbletea (already a direct dep via the spinner), so no new dependency is added.

Changes

Before: databricks <resource> list drained the full iterator through the existing template + tabwriter pipeline before showing anything.

Now: when stdin, stdout, and stderr are all TTYs and the command has a row template, the CLI streams 50 rows at a time. While a batch is being fetched, the view shows a loading spinner:

⣾ loading…

Between batches the prompt takes over:

[space] more  [enter] all  [q|esc] quit

SPACE fetches the next page. ENTER drains the rest (still interruptible by q/esc/Ctrl+C between pages, with the spinner staying up while fetching). q/esc/Ctrl+C stop immediately. Piped output and --output json keep the existing non-paged behavior.

Rendering reuses the existing Annotations["template"] and Annotations["headerTemplate"]: colors, alignment, and row format come from the same code path as today's non-paged jobs list. No new TableConfig, no new ColumnDef, no changes to any override files.

Files under libs/cmdio/:

  • capabilities.go: SupportsPager() (stdin + stdout + stderr all TTYs, not Git Bash).
  • pager.go: pagerModel (a tea.Model) that drives the paged render loop. Captures keys as tea.KeyMsg, emits rendered rows with tea.Println (which prints above the TUI area), and in View() shows either a spinner (while fetching) or the prompt (between pages). Same braille frames + green color as cmdio.NewSpinner. Only one fetch runs at a time; SPACE during an in-flight fetch is dropped, ENTER flips drainAll and lets the pending batchMsg chain the next fetch, so the iterator is never read from two goroutines.
  • paged_template.go: the template pager. Executes the header + row templates into an intermediate buffer per batch, splits by tab, computes visual column widths (stripping ANSI SGR so colors don't inflate), locks those widths from the first page, and pads every subsequent page to the same widths. Single-page output matches tabwriter's alignment; columns stay aligned across pages for longer lists.
  • render.go: RenderIterator routes to the template pager when the capability check passes and a row template is set.

No cmd/ changes. No new public API beyond Capabilities.SupportsPager.

Notes on the bubbletea approach:

  • Using tea.Model + tea.Println means we don't call term.MakeRaw ourselves: tea enters and restores raw mode on its own, so the earlier crlfWriter workaround for the cleared OPOST flag is gone.
  • The header and row templates parse into independent *template.Template instances. Sharing one receiver causes the second Parse to overwrite the first, which made apps list render the header in place of every data row.
  • Empty iterators still flush their header: the first fetch returns done=true with header lines, and the pager prints them before quitting.
  • Tabwriter computes column widths per-flush and resets them. The pager does the padding itself with widths locked from the first batch, so a short final batch does not compress visually against wider pages above it.

History: this consolidates #5016 (shared pager infrastructure) and drops an earlier JSON-output pager. JSON output is mostly consumed by scripts, so paging it adds complexity without a clear win.

Test plan

  • go test ./libs/cmdio/... passes. Coverage: state-machine unit tests on pagerModel.Update (init, batch handling, drain chaining, error propagation, every key path, in-flight-fetch serialization, spinner visibility) plus end-to-end tests via tea.Program for full drain, --limit integration, header-once, empty iterator, header + rows, cross-batch column stability, and content parity with the non-paged path for single-page lists.
  • make checks passes.
  • make lint passes (0 issues).
  • Manual smoke in a TTY: apps list, jobs list, clusters list, workspace list /. First page renders after a brief spinner, SPACE fetches next (spinner reappears), ENTER drains (spinner stays up), Ctrl+C/esc/q quit (and interrupt a drain).
  • Manual smoke with piped stdout and --output json: output unchanged from main.

@simonfaltum simonfaltum changed the base branch from main to simonfaltum/list-json-pager April 17, 2026 19:26
@simonfaltum simonfaltum force-pushed the simonfaltum/list-simple-paginated branch from 15b0327 to 8003087 Compare April 17, 2026 19:55
@simonfaltum simonfaltum changed the title Add simple text pager for interactive list commands (alternative to #4729) Add interactive pager for list commands with a row template Apr 21, 2026
@simonfaltum simonfaltum changed the base branch from simonfaltum/list-json-pager to main April 21, 2026 09:50
When stdin, stdout, and stderr are all TTYs and the command has a row
template (jobs list, clusters list, apps list, pipelines list, etc.), the
CLI streams 50 rows at a time and prompts on stderr:

  [space] more  [enter] all  [q|esc] quit

SPACE fetches the next page. ENTER drains the rest (interruptible by
q/esc/Ctrl+C between pages). q/esc/Ctrl+C quit immediately. Piped
output and --output json keep the existing non-paged behavior.

Rendering reuses the existing template + headerTemplate annotations
(same colors, same alignment as today). Column widths are locked from
the first page so they stay stable across batches.

Co-authored-by: Isaac
@simonfaltum simonfaltum force-pushed the simonfaltum/list-simple-paginated branch from ab60e08 to cbd549c Compare April 21, 2026 09:59
@simonfaltum simonfaltum requested a review from pietern April 21, 2026 10:03
Cuts ~100 lines without behavior changes:
- Trim over-long doc blocks on SupportsPager, startRawStdinKeyReader,
  and renderIteratorPagedTemplateCore.
- Drop comments that restate the code.
- Extract the flushPage closure into a templatePager struct.
- Collapse q/Q/esc/Ctrl+C exit tests into a table-driven test.
- Drop the brittle hard-coded-offset column test; single-page
  equivalence and the header-once test already cover the behavior.

Co-authored-by: Isaac
Two principles from docs/go-code-structure:
- Table-driven tests: collapse four pagination behavior tests into one.
- Test the pure logic directly: add unit tests for visualWidth,
  computeWidths, and padRow instead of exercising them only via the
  integration path. Failures now point directly at the broken helper.

Co-authored-by: Isaac
…le-paginated

# Conflicts:
#	NEXT_CHANGELOG.md
Consolidates the interactive I/O on bubbletea (already a direct
dependency via the spinner) so the paged list renderer no longer
needs its own raw-mode stdin handling. Removes
golang.org/x/term as a direct dep.

Architecture:
- pagerModel implements tea.Model. It owns the iterator and
  fetches one batch per tea.Cmd; batches are emitted with
  tea.Println (which prints above the TUI area), and the prompt
  lives in View() while waiting for a key.
- Only one fetch runs at a time (fetching flag). SPACE during an
  in-flight fetch is dropped; ENTER flips drainAll and lets the
  pending batchMsg chain the next fetch. This keeps the iterator
  off two goroutines.

Gone: startRawStdinKeyReader, pagerNextKey, pagerShouldQuit,
crlfWriter, the OPOST staircase workaround, and the x/term
NOTICE entry.

Kept: templatePager, computeWidths, padRow, visualWidth, and the
cross-page column-width locking. flush() became flushLines() and
returns []string instead of writing to an io.Writer, so the tea
path can fold rendered rows into tea.Println.

Co-authored-by: Isaac
Keep the non-obvious WHY comments (separate template.Template
instances; WithoutSignalHandler for Ctrl+C) and drop the ones
that just restate what the identifiers already say.

Co-authored-by: Isaac
Two cleanups from codex review:
- SupportsPager's doc said "stderr for the prompt", which was true
  under the old x/term path. With bubbletea, View and Println share
  one stream, and we route both to stdout so paged and non-paged
  renders land in the same place. Update the comment to match.
- firstBatch flipped to true on every batch, not just the first;
  rename to hasPrinted to match View()'s actual gate.

Co-authored-by: Isaac
Replaces the empty view during a fetch with the same braille
spinner the rest of the CLI uses, plus a "loading…" label.
Appears on initial load, after SPACE between pages, and while
draining via ENTER. Cleared as soon as the batchMsg lands and
the next prompt (or more content) is ready.

Co-authored-by: Isaac
Comment thread libs/cmdio/paged_template.go Outdated
out io.Writer,
headerTemplate, tmpl string,
) error {
return renderIteratorPagedTemplateCore(ctx, iter, os.Stdin, out, headerTemplate, tmpl, pagerPageSize)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input (here os.Stdin) can come from the cmdIO struct (or as arg to the function).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, threaded c.in through now in ea9e103

Comment thread libs/cmdio/capabilities.go Outdated
// spinners that fire mid-drain don't interleave into a redirected log.
// Git Bash is excluded because its raw-mode stdin is unreliable.
func (c Capabilities) SupportsPager() bool {
return c.stdinIsTTY && c.stdoutIsTTY && c.stderrIsTTY && !c.isGitBash
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compose with SupportsPrompt?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ye done, SupportsPager = SupportsPrompt() && stdoutIsTTY

)
if _, err := p.Run(); err != nil {
return err
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spinner does acquire/release because it returns the control flow to the caller and cleanup needs to happen when the CLI exits (restore terminal control characters).

This is not needed here because it is blocking.

Could be helpful context in a comment for future refactors.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a comment near p.Run explaining why we dont need the dance here (blocking, tea restores the terminal on its own). thanks for the context

Comment thread libs/cmdio/pager.go Outdated
)

// pagerPageSize is the number of items rendered between prompts.
const pagerPageSize = 50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we pull terminal height from the tea context?

I now get the impression I see the full first page when in fact there are more rows outside view.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch, fixed. we now size from tea.WindowSizeMsg (height minus 2 for the prompt line), and 50 is just the pre-resize fallback

Comment thread libs/cmdio/pager.go Outdated
const pagerPromptText = "[space] more [enter] all [q|esc] quit"

// pagerLoadingText is appended to the spinner while a fetch is in flight.
const pagerLoadingText = "loading…"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional unicode for the triple dots?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, swapped to loading...

Comment thread libs/cmdio/pager.go Outdated
// fetchCmd produces a batchMsg, Update prints it via tea.Println, and
// View shows the prompt between pages.
type pagerModel[T any] struct {
ctx context.Context
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid storing context on the struct?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, dropped. newPagerModel binds a fetch func() tea.Msg closure with ctx captured, so the struct doesnt hold context anymore

Comment thread libs/cmdio/pager.go Outdated

// fetchCmd runs off the update loop so a slow network fetch doesn't
// stall key handling.
func (m *pagerModel[T]) fetchCmd() tea.Cmd {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E.g. make this take a context.Context.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, the inner logic is now doFetch(ctx) and the tea.Cmd-shaped closure lives on the model (m.fetch)

Comment thread libs/cmdio/render.go Outdated
func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error {
c := fromContext(ctx)
if c.capabilities.SupportsPager() && c.outputFormat == flags.OutputText && c.template != "" {
return renderIteratorPagedTemplate(ctx, i, c.out, c.headerTemplate, c.template)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have c.in here as well that we can pass down.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, done in the same commit 🙂

- Thread c.in through renderIteratorPagedTemplate instead of
  hardcoding os.Stdin, so the cmdIO override is honored.
- SupportsPager = SupportsPrompt() && stdoutIsTTY, so the pager
  picks up the Git Bash and stdin checks from one place.
- Drop ctx from pagerModel. newPagerModel binds the fetch
  closure with the caller's context so tea.Cmd doesn't need a
  context parameter and the struct stays ctx-free.
- Size pages from tea.WindowSizeMsg (height - overhead, floored
  at 5). 50 is kept only as the pre-resize fallback.
- "loading…" -> "loading..." for portability.
- Note near p.Run why the pager doesn't need the acquire/release
  dance the spinner does.

Co-authored-by: Isaac
@simonfaltum simonfaltum requested a review from pietern April 23, 2026 10:54
Comment thread libs/cmdio/pager.go Outdated

// pagerViewOverhead is the number of lines we keep below the printed
// rows so the prompt (or spinner) doesn't force the top row off-screen.
const pagerViewOverhead = 2
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without header this should be 1.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ye good point, set to 1

@simonfaltum simonfaltum added this pull request to the merge queue Apr 23, 2026
@simonfaltum simonfaltum removed this pull request from the merge queue due to a manual request Apr 23, 2026
pagerViewOverhead was 2 on the assumption of header + prompt,
but the header is part of the already-printed rows, not the
reserved region below. Only the prompt (or spinner) line
sits under the current page.

Co-authored-by: Isaac
@simonfaltum simonfaltum disabled auto-merge April 24, 2026 07:26
@simonfaltum simonfaltum merged commit faac692 into main Apr 24, 2026
22 of 23 checks passed
@simonfaltum simonfaltum deleted the simonfaltum/list-simple-paginated branch April 24, 2026 07:36
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.

2 participants