Add interactive pager for list commands with a row template#5015
Add interactive pager for list commands with a row template#5015simonfaltum merged 15 commits intomainfrom
Conversation
15b0327 to
8003087
Compare
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
ab60e08 to
cbd549c
Compare
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
| out io.Writer, | ||
| headerTemplate, tmpl string, | ||
| ) error { | ||
| return renderIteratorPagedTemplateCore(ctx, iter, os.Stdin, out, headerTemplate, tmpl, pagerPageSize) |
There was a problem hiding this comment.
The input (here os.Stdin) can come from the cmdIO struct (or as arg to the function).
There was a problem hiding this comment.
good point, threaded c.in through now in ea9e103
| // 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 |
There was a problem hiding this comment.
Compose with SupportsPrompt?
There was a problem hiding this comment.
ye done, SupportsPager = SupportsPrompt() && stdoutIsTTY
| ) | ||
| if _, err := p.Run(); err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| ) | ||
|
|
||
| // pagerPageSize is the number of items rendered between prompts. | ||
| const pagerPageSize = 50 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
nice catch, fixed. we now size from tea.WindowSizeMsg (height minus 2 for the prompt line), and 50 is just the pre-resize fallback
| const pagerPromptText = "[space] more [enter] all [q|esc] quit" | ||
|
|
||
| // pagerLoadingText is appended to the spinner while a fetch is in flight. | ||
| const pagerLoadingText = "loading…" |
There was a problem hiding this comment.
Intentional unicode for the triple dots?
There was a problem hiding this comment.
nope, swapped to loading...
| // 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 |
There was a problem hiding this comment.
Can we avoid storing context on the struct?
There was a problem hiding this comment.
agreed, dropped. newPagerModel binds a fetch func() tea.Msg closure with ctx captured, so the struct doesnt hold context anymore
|
|
||
| // fetchCmd runs off the update loop so a slow network fetch doesn't | ||
| // stall key handling. | ||
| func (m *pagerModel[T]) fetchCmd() tea.Cmd { |
There was a problem hiding this comment.
E.g. make this take a context.Context.
There was a problem hiding this comment.
done, the inner logic is now doFetch(ctx) and the tea.Cmd-shaped closure lives on the model (m.fetch)
| 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) |
There was a problem hiding this comment.
We have c.in here as well that we can pass down.
There was a problem hiding this comment.
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
|
|
||
| // 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 |
There was a problem hiding this comment.
Without header this should be 1.
There was a problem hiding this comment.
ye good point, set to 1
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
…d' into simonfaltum/list-simple-paginated
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> listdrained 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:
Between batches the prompt takes over:
SPACEfetches the next page.ENTERdrains the rest (still interruptible byq/esc/Ctrl+Cbetween pages, with the spinner staying up while fetching).q/esc/Ctrl+Cstop immediately. Piped output and--output jsonkeep the existing non-paged behavior.Rendering reuses the existing
Annotations["template"]andAnnotations["headerTemplate"]: colors, alignment, and row format come from the same code path as today's non-pagedjobs list. No newTableConfig, no newColumnDef, no changes to any override files.Files under
libs/cmdio/:capabilities.go:SupportsPager()(stdin + stdout + stderr all TTYs, not Git Bash).pager.go:pagerModel(atea.Model) that drives the paged render loop. Captures keys astea.KeyMsg, emits rendered rows withtea.Println(which prints above the TUI area), and inView()shows either a spinner (while fetching) or the prompt (between pages). Same braille frames + green color ascmdio.NewSpinner. Only one fetch runs at a time; SPACE during an in-flight fetch is dropped, ENTER flipsdrainAlland lets the pendingbatchMsgchain 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:RenderIteratorroutes to the template pager when the capability check passes and a row template is set.No
cmd/changes. No new public API beyondCapabilities.SupportsPager.Notes on the bubbletea approach:
tea.Model+tea.Printlnmeans we don't callterm.MakeRawourselves: tea enters and restores raw mode on its own, so the earliercrlfWriterworkaround for the clearedOPOSTflag is gone.*template.Templateinstances. Sharing one receiver causes the secondParseto overwrite the first, which madeapps listrender the header in place of every data row.done=truewith header lines, and the pager prints them before quitting.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 onpagerModel.Update(init, batch handling, drain chaining, error propagation, every key path, in-flight-fetch serialization, spinner visibility) plus end-to-end tests viatea.Programfor full drain,--limitintegration, header-once, empty iterator, header + rows, cross-batch column stability, and content parity with the non-paged path for single-page lists.make checkspasses.make lintpasses (0 issues).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/qquit (and interrupt a drain).--output json: output unchanged frommain.