Skip to content

Result-Flow Paging

When a command can return more rows than fit on a screen, Repl handles paging for you. The handler chooses how data flows; Repl handles the terminal UX, the JSON envelope, and the MCP surface.

I have…ReturnInteractive experience
A small bounded listReplPageSource.FromItems(items)Pager scrolls in-place; --result:all honored
An offset/limit backing storeReplPageSource.FromOffset(...)Pager fetches more pages on demand
A replayable async streamReplPageSource.FromAsyncEnumerable(...)Pager fetches more pages on demand
A custom cursor I ownInject IReplPagingContext, return paging.Page(...) or paging.CreateSource(...)Caller passes --result:cursor to continue

Two layers: source paging vs output paging

Section titled “Two layers: source paging vs output paging”

Source paging controls how many data rows the handler fetches from the backing store. Output paging controls how many rendered lines the terminal displays at once.

One data row can produce many output lines (long strings, nested collections, narrow-column wrapping). Returning a 100,000-row list and relying on the terminal pager still allocates and formats the entire list before the first screen appears. Keep source pages small. The pager handles the rest.

The inverse is also true: a source page of 20 compact objects may fit on one screen with room to spare. The output pager is just a view on whatever the source provided.


The simplest form: you already have all the data, and you want Repl to page through it.

app.Map("events", (EventStore store) =>
ReplPageSource.FromItems(store.GetAll()));

FromItems is bounded — it knows the exact count up front. It honors --result:all safely. The integrated pager fetches slices on demand as the user scrolls.


Use FromOffset when your store supports Skip/Take, SQL OFFSET/LIMIT, or any similar contract:

app.Map("contacts", (ContactStore store) =>
ReplPageSource.FromOffset<Contact>(
(offset, take, ct) => store.QueryAsync(offset, take, ct),
totalCount: store.CountAsync()));

Pass totalCount when it’s cheap — Repl uses it to show “showing x of y”. Omit it if the count is expensive; FromOffset detects HasMore by fetching pageSize + 1 rows instead.

FromOffset rejects --result:all by default because the source may be unbounded.

Static lambda with state (avoids closure allocation):

app.Map("contacts", (ContactStore store, string? search) =>
ReplPageSource.FromOffset<Contact, ContactStore>(
store,
static (state, offset, take, ct) => state.QueryAsync(offset, take, ct)));

When the backing store cannot filter server-side, add a filter predicate:

ReplPageSource.FromOffset<ContactRow, ContactStore>(
store,
static (state, offset, take, ct) => state.QueryAsync(offset, take, ct),
filter: (_, row) => row.Name.Contains(search, StringComparison.OrdinalIgnoreCase))

Use FromAsyncEnumerable when your source is naturally an async iterator (file scans, SDK pagers exposed as IAsyncEnumerable<T>):

app.Map("logs", (LogStore store) =>
ReplPageSource.FromAsyncEnumerable<LogRow>(
ct => store.StreamAsync(ct)));

FromAsyncEnumerable also rejects --result:all by default.


When you need keyset, page-token, nextLink, or snapshot cursors, inject IReplPagingContext and own the cursor format:

app.Map("contacts", async (IReplPagingContext paging, ContactStore store, CancellationToken ct) =>
{
var result = await store.SearchAsync(paging.Cursor, paging.SuggestedPageSize, ct);
return paging.Page(
result.Items,
nextCursor: result.NextCursor,
totalCount: result.TotalCount);
});

paging.Cursor is null on the first call and carries the opaque continuation value on subsequent calls. The caller passes it back via --result:cursor.

For sources where Repl should drive continuation in the same command run, return a source instead:

return paging.CreateSource<Contact>(
async (req, ct) =>
{
var (items, next) = await store.FetchPageAsync(req.Cursor, req.PageSize, ct);
return new ReplPage<Contact>(items, nextCursor: next);
});

See Cursor Patterns for a table of keyset, external token, nextLink, and snapshot strategies with code examples.


app.Options(o =>
{
o.Output.ResultFlow.DefaultPageSize = 50; // default: 100
o.Output.ResultFlow.MaxPageSize = 500; // clamps --result:page-size
o.Output.ResultFlow.MaxBufferedLines = 5_000; // default: 10_000 — line buffer cap for interactive pager
o.Output.ResultFlow.DefaultPagerMode = ReplPagerMode.Auto;
});

MaxBufferedLines bounds the in-memory line buffer of the interactive pager. When the cap is reached, the pager stops fetching and reports a diagnostic instead of growing memory without bound.

Handlers should honor it only when the source is bounded. Built-in helpers:

HelperHonors --result:all
FromItems✅ Yes — source is bounded
FromOffset❌ No — may be unbounded
FromAsyncEnumerable❌ No — may be unbounded
Create / paging.CreateSourceYour choice — implement the check in your fetcher

Repl validates incoming cursors before use. A cursor is rejected if it is empty, contains whitespace or control characters, starts with -, or exceeds 512 characters. Build cursor values that satisfy these constraints — prefer base64 encoding for structured data.

IReplResultFlowDiagnostics is a dependency-free interface for fetch events (start, success, failure). When AddReplLogging() is present, diagnostics are bridged automatically: Debug on start/success, Error on failure.


The integrated pager activates automatically on a TTY. The user scrolls with arrow keys, Page Up/Down, Space, or Home/End. Repl fetches the next source page transparently as the user reaches the buffered end.

Control the pager mode with --result:pager:

ModeBehavior
auto (default)full on ANSI terminals, more as fallback
morePauses at page boundaries; no cursor movement
inlineScrolls in-place within the terminal viewport (ANSI required)
fullFull alternate-screen viewport, less-style navigation (ANSI required)
offNo pager; all output scrolls to stdout

These options are reserved by the result-flow system and appear in --help only for commands that support paging:

--result:page-size <n> Override the page size (clamped to MaxPageSize)
--result:cursor <value> Resume from an opaque continuation cursor
--result:all Request all pages (advisory — honored only by bounded sources)
--result:pager=auto|off|more|inline|full
Control the interactive pager mode

For any handler that injects IReplPagingContext or returns ReplPage<T> / IReplPageSource<T>, Repl automatically adds these reserved inputs to the MCP tool schema:

"_replCursor": { "type": "string", "description": "Continuation cursor from a previous call" }
"_replPageSize": { "type": "integer", "description": "Number of items to return per page" }

These inputs are validated before use. _replPageSize must be numeric and compact. _replCursor must not contain whitespace, control characters, or a leading dash.


  • Treat incoming cursors as untrusted input. Validate length, encoding, and ownership. Sign or encrypt cursors that carry tenant IDs or filter state.
  • Live / infinite feeds are out of scope. Streams that never finish (message queues, live logs, real-time events) belong to a future API. Do not expose them through --result:all or FromAsyncEnumerable.
  • VisibleRowCapacityHint is a hint, not a contract. Use it to tune take, but enforce your own data-source size limits on top.
  • For interactive human output, prefer a page source over a single page. IReplPageSource<T> lets the integrated pager continue in the same command run without asking the user to rerun with --result:cursor.