diff --git a/docs/architecture.md b/docs/architecture.md index fc3e2e8..e7873a2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,6 +67,11 @@ - Built-in `markdown` (with `--markdown` alias via output alias map). - Global format selectors: `--json`, `--xml`, `--yaml`, `--yml`, `--output:`. - Unknown format returns explicit error text and non-zero exit code. +- Result flow: + - `IReplPagingContext` lets handlers page at the data source. + - `ReplPage` carries current-page data plus continuation metadata. + - Human/Spectre terminal output can use an integrated pager; redirected stdout remains pipe-friendly. + - MCP maps `_replCursor` and `_replPageSize` to structured paged tool results. - Numeric parsing: - Numeric culture is configurable via `ParsingOptions.NumericCulture` (`Invariant` default, `Current` optional). - Integer literals support C-like forms: hexadecimal (`0xFF`), binary (`0b1010` or `1010b`), and `_` separators (`1_000_000`). @@ -101,6 +106,7 @@ The toolkit provides two application entry points for different scenarios. ## Related docs - Command reference: `docs/commands.md` +- Result flow and paging: `docs/result-flow.md` - Parameter system: `docs/parameter-system.md` - Shell completion: `docs/shell-completion.md` diff --git a/docs/commands.md b/docs/commands.md index 4028a8c..cd172d2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -18,6 +18,10 @@ These flags are parsed before route execution: - `--no-interactive` - `--no-logo` - `--output:` +- `--result:page-size ` / `--result:page-size=` +- `--result:cursor ` / `--result:cursor=` +- `--result:all` +- `--result:pager=auto|off|more|scroll|external` - output aliases mapped by `OutputOptions.Aliases` (defaults include `--json`, `--xml`, `--yaml`, `--yml`, `--markdown`) - `--answer:[=value]` for non-interactive prompt answers - custom global options registered via `options.Parsing.AddGlobalOption(...)` @@ -29,6 +33,7 @@ Global parsing notes: - option value syntaxes accepted by command parsing: `--name value`, `--name=value`, `--name:value` - use `--` to stop option parsing and force remaining tokens to positional arguments - response files are supported with `@file.rsp` (enabled by default); nested `@` expansion is not supported +- result-flow options are reserved by the framework and do not bind to handler business parameters ## Declaring command options @@ -249,6 +254,7 @@ Handlers can return any type. The framework renders the return value through the | `string` | Rendered as plain text | | Object / anonymous type | Rendered as key-value pairs (human) or serialized (JSON/XML/YAML) | | `IEnumerable` | Rendered as a table (human) or collection (structured formats) | +| `ReplPage` | Rendered as the current page plus `PageInfo`; JSON uses `{ items, pageInfo }` | | `IReplResult` | Structured result with kind prefix (`Results.Ok`, `Error`, `NotFound`...) | | `ReplNavigationResult` | Renders payload and navigates scope (`Results.NavigateUp`, `NavigateTo`) | | `IExitResult` | Renders optional payload and sets process exit code (`Results.Exit`) | @@ -294,6 +300,30 @@ Tuple semantics: - null elements are silently skipped - nested tuples are not flattened — use a flat tuple instead +## Paging large results + +Handlers that may return large result sets can request `IReplPagingContext`: + +```csharp +app.Map("contacts", async (IReplPagingContext paging, ContactStore store, CancellationToken ct) => +{ + var rows = await store.QueryAsync(paging.Cursor, paging.SuggestedPageSize, ct); + return paging.Page(rows.Items, rows.NextCursor, rows.TotalCount); +}); +``` + +When human users should continue through later pages without rerunning the +command, return a page source: + +```csharp +app.Map("contacts", (ContactStore store) => + ReplPageSource.FromOffset( + (offset, take, ct) => store.QueryAsync(offset, take, ct), + totalCount: store.Count)); +``` + +Use this when the data source can page efficiently. See [Result Flow And Paging](result-flow.md) for CLI flags, pager behavior, MCP paging arguments, and output format details. + ## Interactive prompts Handlers can use `IReplInteractionChannel` for guided prompts, progress reporting, and user-facing feedback. Extension methods add enum prompts, numeric input, validated text, notices, warnings, problem summaries, and more. diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index fed67fe..be26844 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -95,10 +95,21 @@ Accessed via `ReplOptions.Output`. - `ColorizeStructuredInteractive` (`bool`, default: `true`) — Colorize JSON/XML in interactive mode. - `PreferredWidth` (`int?`, default: `null`) — Preferred render width. `null` uses automatic detection. - `FallbackWidth` (`int`, default: `120`) — Fallback width when the terminal is unavailable. +- `ResultFlow` (`ResultFlowOptions`) - Paging and large-result behavior. - `JsonSerializerOptions` (`JsonSerializerOptions`, default: Web defaults + indented) — JSON serializer options. Built-in transformers: `human`, `json`, `xml`, `yaml`, `markdown`. +### ResultFlowOptions + +Accessed via `ReplOptions.Output.ResultFlow`. + +- `DefaultPageSize` (`int`, default: `100`) - Page size used when no caller or terminal hint provides one. +- `MaxPageSize` (`int`, default: `1000`) - Maximum accepted page size. +- `ReservedVisibleRows` (`int`, default: `2`) - Rows reserved when computing terminal-visible data rows. +- `DefaultPagerMode` (`ReplPagerMode`, default: `Auto`) - Pager behavior for human formats. +- `ProgrammaticMaxInlineBytes` (`int`, default: `65536`) - Reserved for programmatic inline payload policy. + ### OutputOptions Methods - `AddTransformer(name, transformer)` — Register a custom output transformer. diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 89e5b4d..489b2cf 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -178,7 +178,7 @@ app.UseMcpServer(o => o.InteractivityMode = InteractivityMode.PrefillThenElicita | Method | Where it goes | Use? | |---|---|---| -| **Return value** | `CallToolResult.Content` (JSON) | **Yes.** Preferred for all data. | +| **Return value** | `CallToolResult.Content` and, for paged results, `StructuredContent` | **Yes.** Preferred for all data. | | **`IReplInteractionChannel`** | MCP primitives (progress, prompts, user-facing notices/problems) | **Yes.** Portable feedback that also works outside MCP. | | **`IMcpFeedback`** | MCP progress and logging/message notifications | **Yes.** MCP-specific feedback when you need direct control. | | **`ReplSessionIO.Output`** | Session output | Advanced cases only. | @@ -187,6 +187,30 @@ app.UseMcpServer(o => o.InteractivityMode = InteractivityMode.PrefillThenElicita > **Why this matters:** Console-style writes blur the boundary between result data, progress, logs, and protocol traffic. In MCP, this ranges from confusing agent behavior to protocol corruption. +### Paged tool results + +Every MCP tool schema includes two reserved Repl result-flow inputs: + +- `_replCursor`: opaque continuation cursor returned by a previous paged result. +- `_replPageSize`: requested page size. + +Handlers receive these values through `IReplPagingContext`, not as business parameters. A handler can return `ReplPage`: + +```csharp +app.Map("contacts", (IReplPagingContext paging, ContactStore store) => +{ + var page = store.Query(paging.Cursor, paging.SuggestedPageSize); + return paging.Page(page.Items, page.NextCursor, page.TotalCount); +}).ReadOnly(); +``` + +MCP responses for `ReplPage` include: + +- `StructuredContent`: `{ items, pageInfo }` +- `Content`: short text summary with the next `_replCursor` when more data exists + +This avoids dumping large JSON arrays into a single `TextContentBlock`. + `WriteProgressAsync` maps to MCP progress notifications. `WriteStatusAsync` maps to log messages (`level: info`). See [Progress](progress.md#mcp) for the centralized progress model across console, hosted sessions, Spectre, and MCP: ```csharp diff --git a/docs/output-system.md b/docs/output-system.md index fc7becb..c3adba0 100644 --- a/docs/output-system.md +++ b/docs/output-system.md @@ -2,6 +2,8 @@ The output system controls how command results are serialized and rendered to the user. It supports multiple built-in formats, custom transformers, ANSI detection, and banner rendering. +Large result flow and paging are documented separately in [Result Flow And Paging](result-flow.md). + ## Format Selection Precedence The active output format is resolved in this order: @@ -102,7 +104,14 @@ The output width used for wrapping and table layout is resolved as: In interactive mode, when ANSI is supported, JSON output is syntax-highlighted automatically. This applies only to the `json` format rendered to a terminal — redirected or non-ANSI output remains plain. +## Paging + +Human terminal formats (`human` and `spectre`) can use the integrated result pager when rendered output exceeds the visible row capacity or a result-flow page source has more data. The pager is never used for redirected stdout, protocol passthrough, MCP/programmatic execution, or machine formats. + +Paged handler results should return `ReplPage` through `IReplPagingContext`. JSON serializes these as `{ items, pageInfo }`; human and Spectre formats render the current page plus continuation metadata. + ## See Also - [Configuration Reference](configuration-reference.md) — `OutputOptions` properties. - [Execution Pipeline](execution-pipeline.md) — output formatting occurs at stage 11. +- [Result Flow And Paging](result-flow.md) - paging contracts, CLI flags, and MCP behavior. diff --git a/docs/result-flow.md b/docs/result-flow.md new file mode 100644 index 0000000..e26504c --- /dev/null +++ b/docs/result-flow.md @@ -0,0 +1,652 @@ +# Result Flow And Paging + +Result flow is the layer between handler execution and output formatting. It lets commands avoid returning unbounded result sets, gives handlers a page-size hint, and lets each output surface choose the safest delivery behavior. + +This is separate from output format selection. `--json`, `--human`, `--spectre`, and other formats still control serialization and rendering. Result flow controls how much data is returned and whether an interactive pager is used. + +## Goals + +- Avoid flooding terminal output with very large handler results. +- Preserve Unix pipe behavior: `| less`, `| more`, `| grep`, `| tail`, and file redirection must receive normal stdout data. +- Give handlers enough context to page at the source instead of loading everything. +- Return MCP results as small, structured pages instead of huge text blocks. +- Keep `Repl.Core` dependency-free and let richer packages such as `Repl.Spectre` adapt the same contracts. + +## Handler Paging Context + +Handlers can request `IReplPagingContext` as an injected parameter: + +```csharp +app.Map("contacts", async (IReplPagingContext paging, ContactStore store, CancellationToken ct) => +{ + var rows = await store.QueryAsync( + cursor: paging.Cursor, + take: paging.SuggestedPageSize, + ct); + + return paging.Page( + rows.Items, + nextCursor: rows.NextCursor, + totalCount: rows.TotalCount); +}); +``` + +The context exposes: + +| Member | Meaning | +|---|---| +| `VisibleRowCapacityHint` | Best-effort number of data rows the current surface can show. Null for redirected/programmatic surfaces. | +| `SuggestedPageSize` | Page size after applying caller options, terminal hints, and `ResultFlowOptions.MaxPageSize`. | +| `MaxPageSize` | Application maximum page size. | +| `Cursor` | Opaque continuation cursor supplied by the caller. | +| `AllRequested` | True when the caller passed `--result:all`. Handlers decide whether to honor it. | +| `Surface` | `Console`, `Interactive`, `Hosted`, `Redirected`, or `Programmatic`. | +| `Page(...)` | Creates a `ReplPage` result. | +| `CreateSource(...)` | Creates an `IReplPageSource` for renderer-driven future paging. | + +`VisibleRowCapacityHint` is a hint, not a contract. Handlers may use it to tune `take`, but should still enforce their own data-source limits. + +For interactive human output, prefer a page source when the data source can fetch +later pages. The integrated pager can then continue in the same command run +instead of asking the user to rerun with a cursor. + +For in-memory data: + +```csharp +app.Map("contacts", (ContactStore store) => + ReplPageSource.FromItems(store.List())); +``` + +For offset-based stores: + +```csharp +app.Map("contacts", (ContactStore store) => + ReplPageSource.FromOffset( + (offset, take, ct) => store.QueryAsync(offset, take, ct), + totalCount: store.Count)); +``` + +For replayable async streams: + +```csharp +app.Map("logs", (LogStore store) => + ReplPageSource.FromAsyncEnumerable(ct => store.StreamAsync(ct))); +``` + +Helpers also have state overloads so handlers can use static lambdas instead of +capturing local variables: + +```csharp +app.Map("contacts", (ContactStore store) => + ReplPageSource.FromOffset( + store, + static (state, offset, take, ct) => state.QueryAsync(offset, take, ct), + totalCount: store.Count)); +``` + +When a data source cannot apply a filter server-side, helpers can apply a +client-side filter before the final page is emitted: + +```csharp +app.Map("contacts", (ContactStore store, string search) => + ReplPageSource.FromOffset( + store, + static (state, offset, take, ct) => state.QueryAsync(offset, take, ct), + filter: (_, row) => row.Name.Contains(search, StringComparison.OrdinalIgnoreCase))); +``` + +Client-side filtering is a fallback, not the preferred path. Repl may fetch and +discard source rows while it fills one visible page, and the true filtered total +count is usually unknown unless the handler computes it separately. Prefer +pushing filters, search terms, tenant constraints, and sorting into the data +source whenever possible. + +For opaque cursors, API tokens, and keyset paging, use `CreateSource(...)`: + +```csharp +app.Map("contacts", (IReplPagingContext paging, ContactStore store) => + paging.CreateSource(async (request, ct) => + { + var rows = await store.QueryAsync( + cursor: request.Cursor, + take: request.PageSize, + ct); + + return request.Page( + rows.Items, + nextCursor: rows.NextCursor, + totalCount: rows.TotalCount); + })); +``` + +## Cursor Basics + +A cursor is an opaque bookmark owned by the handler. Repl does not interpret it. +The handler consumes `request.Cursor` or `paging.Cursor`, and emits the next +bookmark as `nextCursor`. + +The contract is: + +```csharp +var currentCursor = request.Cursor; // consume +var rows = await store.QueryAsync(currentCursor, request.PageSize, ct); +return request.Page(rows.Items, rows.NextCursor, rows.TotalCount); // emit +``` + +Rules of thumb: + +- `null` or empty cursor means "first page". +- `nextCursor: null` means "there is no next page". +- `PageInfo.HasMore` is derived from `nextCursor` by the helpers. +- Treat incoming cursors as untrusted input. Validate and bound them. +- Prefer opaque, versioned cursor formats over exposing database internals. +- Include filters/sort/snapshot information in the cursor when changing those values would make the bookmark unsafe. + +Use `ReplPage` when the command returns one explicit page and expects callers +to pass `--result:cursor` for the next page. Use `IReplPageSource` when human +users should continue interactively in the same run. + +## Pagination Mode Matrix + +| Mode | Cursor shape | What the handler/source needs | Best fit | Built-in helper | +|---|---|---|---|---| +| In-memory list | Offset string such as `25` | A bounded `IReadOnlyList` | Samples, small cached data, tests | `ReplPageSource.FromItems(items)` | +| Async enumerable | Offset string such as `25` | A replayable `IAsyncEnumerable` factory and cancellation-aware enumeration | Streams from files, SDK pagers exposed as async streams, tests | `ReplPageSource.FromAsyncEnumerable(ct => source.StreamAsync(ct))` | +| Offset/limit | Offset string such as `100` | Query by `offset` and `take`; ideally deterministic sort | SQL `Skip/Take`, search indexes, admin tables | `ReplPageSource.FromOffset((offset, take, ct) => ...)` | +| Page index | Page number or zero-based index | Query by page index and page size; agreement on one-based vs zero-based | APIs that already expose page numbers | Custom `CreateSource` | +| Range/window | Encoded range, for example `2026-01-01..2026-01-31` | Stable ordering and a next range boundary | Time-series, logs, reporting windows | Custom `CreateSource` | +| Keyset/seek | Last sort key, often encoded JSON | Deterministic sort and unique tie-breaker | Large mutable tables | Custom `CreateSource` | +| Opaque cursor | Signed/encrypted bookmark | Cursor encoder/decoder and validation | Multi-tenant data, private filters, versioned cursors | Custom `CreateSource` | +| External API token | Provider page token | API client that accepts a page token and returns the next token | REST/Graph/Cloud SDK paging | Custom `CreateSource` | +| External nextLink | Provider URL | Validation that the URL belongs to the expected API | APIs that return full continuation links | Custom `CreateSource` | +| Snapshot cursor | Snapshot id plus offset/key | Snapshot creation and cleanup policy | Consistent reports over changing data | Custom `CreateSource` | + +`FromOffset` and `FromAsyncEnumerable` fetch one extra matching item (`pageSize + 1`) +to detect whether another page exists without requiring a total count. When a +total is cheap and represents the final result set, pass it to `FromOffset` so +human output can show "Showing x of y". If the total is expensive, unknown, or +not meaningful for the current feed, leave it null. + +Offset-style helpers are intentionally simple. They re-read or re-skip from the +start for later pages. For deep paging, mutable datasets, or live infinite +streams, prefer keyset, range, or an external provider cursor. Live feeds that +never finish are a separate use case; do not expose them through `--result:all` +or an unbounded in-memory list. + +## Cursor Patterns + +### Offset Cursor + +Offset cursors are simple and work well for stable, append-only, or demo data. +They are not ideal for frequently changing result sets because inserts/deletes +can shift rows between requests. + +For a store that can query by offset and take: + +```csharp +app.Map("events", (EventStore store) => + ReplPageSource.FromOffset( + (offset, take, ct) => store.QueryAsync(offset, take, ct), + totalCount: store.Count)); +``` + +For the common in-memory version: + +```csharp +app.Map("events", (EventStore store) => + ReplPageSource.FromItems(store.AllEvents)); +``` + +For a replayable async stream: + +```csharp +app.Map("events", (EventStore store) => + ReplPageSource.FromAsyncEnumerable(ct => store.StreamAsync(ct))); +``` + +`FromAsyncEnumerable` passes the request cancellation token to the stream factory +and uses `WithCancellation(...)` while enumerating. It requires a replayable, +idempotent, and deterministic factory because later pages reopen the stream and +skip to the requested offset. For live streams or changing result sets that +cannot restart with the same ordering and contents, emit a keyset/range cursor +instead or use a future live/tail-oriented API. + +Do not pass a channel, database cursor, network cursor, or shared enumerator +instance to `FromAsyncEnumerable`. Those are single-use streams. Use +`ReplPageSource.Create(...)` and emit an opaque source-owned cursor instead. + +When you author the async iterator, accept cancellation with +`[EnumeratorCancellation]`: + +```csharp +using System.Runtime.CompilerServices; + +public async IAsyncEnumerable StreamAsync( + [EnumeratorCancellation] CancellationToken ct = default) +{ + await foreach (var row in sdk.ReadLogsAsync(ct).WithCancellation(ct)) + { + yield return row; + } +} +``` + +### Keyset Cursor + +Keyset cursors are better for databases and changing result sets. The cursor +contains the last row's sort key, not a row offset. + +```csharp +using System.Text.Json; + +record EventCursor(DateTimeOffset CreatedAt, long Id); + +app.Map("events", (IReplPagingContext paging, EventDb db) => + paging.CreateSource(async (request, ct) => + { + var cursor = DecodeCursor(request.Cursor); + var query = db.Events.AsQueryable(); + + if (cursor is not null) + { + query = query.Where(e => + e.CreatedAt > cursor.CreatedAt + || (e.CreatedAt == cursor.CreatedAt && e.Id > cursor.Id)); + } + + var rows = await query + .OrderBy(e => e.CreatedAt) + .ThenBy(e => e.Id) + .Take(request.PageSize) + .Select(e => new EventRow(e.Id, e.CreatedAt, e.Summary)) + .ToListAsync(ct); + + var nextCursor = rows.Count == request.PageSize + ? EncodeCursor(new EventCursor(rows[^1].CreatedAt, rows[^1].Id)) + : null; + + return request.Page(rows, nextCursor); + })); + +static string EncodeCursor(EventCursor cursor) => + Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(cursor)); + +static EventCursor? DecodeCursor(string? cursor) => + string.IsNullOrWhiteSpace(cursor) + ? null + : JsonSerializer.Deserialize(Convert.FromBase64String(cursor)); +``` + +For production, consider signing or encrypting cursor payloads when they contain +tenant ids, filters, or other sensitive data. + +### External API Page Token + +Many APIs already expose a page token. Pass Repl's cursor through to that API, +then emit the API's next token. + +```csharp +app.Map("incidents", (IReplPagingContext paging, IncidentApi api) => + paging.CreateSource(async (request, ct) => + { + var response = await api.SearchAsync( + pageSize: request.PageSize, + pageToken: request.Cursor, + ct); + + return request.Page( + response.Items, + nextCursor: response.NextPageToken, + totalCount: response.TotalCount); + })); +``` + +### External API Next Link + +Some APIs return a full `nextLink` URL instead of a token. The cursor can be that +link, as long as the handler validates that it belongs to the expected API. + +```csharp +app.Map("messages", (IReplPagingContext paging, MailApi api) => + paging.CreateSource(async (request, ct) => + { + var response = string.IsNullOrWhiteSpace(request.Cursor) + ? await api.ListMessagesAsync(request.PageSize, ct) + : await api.GetNextPageAsync(request.Cursor, ct); + + return request.Page(response.Items, response.NextLink); + })); +``` + +### Snapshot Cursor + +When users expect a consistent report, include a snapshot id or timestamp in the +cursor. The first request creates the snapshot, later requests continue inside it. + +```csharp +record AuditCursor(string SnapshotId, int Offset); + +app.Map("audit", (IReplPagingContext paging, AuditStore store) => + paging.CreateSource(async (request, ct) => + { + var cursor = DecodeAuditCursor(request.Cursor) + ?? new AuditCursor(await store.CreateSnapshotAsync(ct), Offset: 0); + + var page = await store.ReadSnapshotAsync( + cursor.SnapshotId, + cursor.Offset, + request.PageSize, + ct); + + var nextCursor = page.HasMore + ? EncodeAuditCursor(cursor with { Offset = cursor.Offset + page.Items.Count }) + : null; + + return request.Page(page.Items, nextCursor, page.TotalCount); + })); +``` + +## Result Page Shape + +`ReplPage` contains: + +- `Items`: the current page. +- `PageInfo.Cursor`: cursor used for the current page. +- `PageInfo.NextCursor`: cursor for the next page. +- `PageInfo.TotalCount`: optional total count. +- `PageInfo.PageSize`: effective page size. +- `PageInfo.HasMore`: true when `NextCursor` is present. + +JSON output uses a clean automation envelope: + +```json +{ + "$type": "page", + "items": [ + { "id": 1, "name": "Alice" } + ], + "pageInfo": { + "cursor": "start", + "nextCursor": "page-2", + "totalCount": 42, + "pageSize": 1, + "hasMore": true + } +} +``` + +The technical properties used by the renderer, such as `ItemType` and `UntypedItems`, are not serialized. + +## CLI Flags + +Result-flow flags are global and use the `--result:` prefix so they do not collide with command options such as `--limit` or `--cursor`. + +| Flag | Meaning | +|---|---| +| `--result:page-size ` or `--result:page-size=` | Requested page size. Clamped to `ResultFlowOptions.MaxPageSize`. | +| `--result:cursor ` or `--result:cursor=` | Opaque continuation cursor. | +| `--result:all` | Signals that the caller wants all rows. Bounded helpers such as `FromItems` can honor it; unbounded helpers such as `FromOffset` and `FromAsyncEnumerable` reject it by default. | +| `--result:pager=auto\|off\|more\|scroll\|external` | Pager preference for human formats. | + +`auto` uses a `less`-style alternate-screen viewport when ANSI rendering and key +input are available, then falls back to the simple `more` behavior in limited +terminals. `external` is accepted as a forward-compatible mode and currently +falls back to the integrated pager. + +## CLI And Pipe Behavior + +The integrated pager only applies to human terminal formats: + +- `human` +- `spectre` + +It does not apply to machine formats: + +- `json` +- `xml` +- `yaml` +- `markdown` + +It also does not apply when stdout is redirected, when input cannot read keys, in MCP/programmatic execution, or during protocol passthrough. + +This preserves standard shell behavior: + +```bash +myapp contacts --human | less +myapp contacts --json | jq '.items[]' +myapp contacts --human | grep Alice +myapp contacts --human | tail -20 +``` + +In those cases Repl writes the normal output stream and lets the receiving Unix tool do the paging/filtering. + +## Integrated Pager + +The integrated pager activates automatically when: + +- the selected format is `human` or `spectre`; +- output is an interactive terminal or hosted session with key input; +- the rendered payload has more lines than the visible row capacity, or an + `IReplPageSource` reports another data page; +- pager mode is not `off`. + +Supported keys: + +| Key | Behavior | +|---|---| +| `Space` / `PageDown` / any unhandled key | Continue to the next screen, fetching the next data page when needed. | +| `Enter` / `DownArrow` | Next line. | +| `UpArrow` | Re-display one previous line window. | +| `PageUp` | Re-display previous page window. | +| `q` / `Esc` | Quit paging. | + +The integrated pager has two render paths: + +- `more` fallback: writes page by page in the normal terminal buffer and never + uses cursor movement. +- `scroll` viewport: enters the terminal alternate screen, keeps an internal + line buffer, redraws a viewport explicitly, and leaves the original scrollback + untouched when the user exits. + +The scroll viewport is inspired by `less`: it does not depend on terminal +scrollback. It renders from an internal buffer and fetches additional +`IReplPageSource` payloads as the user pages past the buffered end. + +## Testing Result Flow + +Test the cursor contract first. A page source can be exercised without a console: + +```csharp +[TestMethod] +public async Task Contacts_ArePagedByCursor() +{ + var source = ReplPageSource.FromItems([ + new ContactRow(1, "Alice"), + new ContactRow(2, "Bob"), + new ContactRow(3, "Carla"), + ]); + + var first = await source.FetchAsync(new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Programmatic)); + + var second = await source.FetchAsync(new ReplPageRequest( + PageSize: 2, + Cursor: first.PageInfo.NextCursor, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Programmatic)); + + first.Items.Select(c => c.Name).Should().Equal("Alice", "Bob"); + first.PageInfo.NextCursor.Should().Be("2"); + second.Items.Select(c => c.Name).Should().Equal("Carla"); + second.PageInfo.HasMore.Should().BeFalse(); +} +``` + +For CLI JSON, assert the automation envelope: + +```csharp +var output = CaptureConsole(() => + app.Run([ + "contacts", + "--json", + "--result:page-size=2", + "--no-logo", + ])); + +var page = JsonSerializer.Deserialize>(output.Text); +page!.Items.Should().HaveCount(2); +page.PageInfo.NextCursor.Should().NotBeNull(); + +public sealed record PageEnvelope( + IReadOnlyList Items, + ReplPageInfo PageInfo); +``` + +For MCP, call the generated tool twice. MCP uses `_replPageSize` and +`_replCursor`, and returns `pageInfo` in structured content: + +```csharp +var first = await mcpClient.CallToolAsync( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + ["_replPageSize"] = 2, + }); + +var firstRoot = first.StructuredContent!.Value; +var nextCursor = firstRoot + .GetProperty("pageInfo") + .GetProperty("nextCursor") + .GetString(); + +var second = await mcpClient.CallToolAsync( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + ["_replPageSize"] = 2, + ["_replCursor"] = nextCursor, + }); + +second.StructuredContent!.Value + .GetProperty("pageInfo") + .GetProperty("cursor") + .GetString() + .Should().Be(nextCursor); +``` + +For Spectre CLI output, use the same command surface with the Spectre renderer +enabled. Assert content and, when ANSI is enabled, styling: + +```csharp +var app = ReplApp.Create(services => services.AddSpectreConsole()) + .UseSpectreConsole(); + +app.Map("contacts", () => ReplPageSource.FromItems(rows)); + +var output = CaptureConsole(() => + app.Run([ + "contacts", + "--spectre", + "--result:page-size=2", + "--result:pager=off", + "--no-logo", + ])); + +output.Text.Should().Contain("Alice"); +output.Text.Should().Contain("Next data page:"); +``` + +For a Spectre TUI command, use Spectre prompts for selection workflows rather +than the result-flow pager. `SelectionPrompt` and `MultiSelectionPrompt` +support `.PageSize(...)` and `.MoreChoicesText(...)`, which is useful for +choosing an item from a page: + +```csharp +var selected = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select a contact") + .PageSize(10) + .MoreChoicesText("[grey](Use arrows to see more contacts)[/]") + .UseConverter(c => $"[bold]{c.Name}[/] [grey]{c.Email}[/]") + .AddChoices(page.Items)); +``` + +Use Spectre `Live(...)` for dashboards or dynamic refreshes. It is not a +replacement for a `less`-style pager, but it is a good fit for a TUI screen that +owns its render area. + +## MCP Behavior + +MCP tools expose two reserved input properties on every tool schema: + +| Property | Meaning | +|---|---| +| `_replCursor` | Continuation cursor from a previous paged result. | +| `_replPageSize` | Requested page size for the tool call. | + +These properties are consumed by the Repl MCP adapter and mapped to `IReplPagingContext`. They are not forwarded as command business options. +MCP cursors are expected to be compact opaque values, for example base64url or +another whitespace-free token. Repl rejects cursors that contain whitespace, +start with `-`, or exceed 512 characters before they can be converted to CLI +tokens. MCP page-size values must be numeric and at most 20 characters before +normal result-flow clamping is applied. + +When a handler returns `ReplPage`, MCP returns: + +- `StructuredContent`: the full `{ "$type": "page", items, pageInfo }` envelope. +- `Content`: a short text summary such as `Returned 1 item(s). Total: 2. Continue with _replCursor; cursor available in structured content.` + +This keeps agents from receiving a giant JSON string in `TextContentBlock` while still preserving structured data for clients that support it. + +## Spectre Behavior + +`Repl.Spectre` renders `ReplPage` with the same lightweight Spectre table style used for collections, followed by continuation metadata when the output is not being driven by the interactive pager. The core paging contract remains framework-neutral; handlers do not need Spectre-specific code. + +The integrated pager still owns the final rendered text. Spectre live/full-screen surfaces should continue to capture or redirect regular Repl feedback as documented in [interaction.md](interaction.md#spectre-and-screen-ownership). + +## Configuration + +Configure through `ReplOptions.Output.ResultFlow`: + +```csharp +app.Options(options => +{ + options.Output.ResultFlow.DefaultPageSize = 100; + options.Output.ResultFlow.MaxPageSize = 1000; + options.Output.ResultFlow.ReservedVisibleRows = 2; + options.Output.ResultFlow.DefaultPagerMode = ReplPagerMode.Auto; + options.Output.ResultFlow.ProgrammaticMaxInlineBytes = 64 * 1024; +}); +``` + +| Option | Default | Meaning | +|---|---:|---| +| `DefaultPageSize` | `100` | Used when no caller or terminal hint provides a better size. | +| `MaxPageSize` | `1000` | Maximum accepted page size. | +| `ReservedVisibleRows` | `2` | Rows reserved for prompts/status when computing visible data rows. | +| `DefaultPagerMode` | `Auto` | Default pager behavior for human formats. | +| `ProgrammaticMaxInlineBytes` | `65536` | Reserved for programmatic inline-size policy. | + +## Implementation Notes + +- Existing handlers that return `IEnumerable` keep their current behavior. +- Handlers that can page efficiently should request `IReplPagingContext` and return `ReplPage`. +- Handlers that want human users to continue without rerunning the command should return `IReplPageSource`. +- Non-interactive and machine outputs fetch the first source page and preserve the continuation cursor in the rendered page metadata. +- `--result:all` is advisory. Handlers should reject or cap it when the data source cannot safely return everything; built-in unbounded page-source helpers reject it by default. +- The pager operates after formatting for line navigation, and can fetch additional data pages when the handler returns `IReplPageSource`. + +## See Also + +- [Core Basics sample](../samples/01-core-basics/README.md#result-flow-paging) +- [Spectre sample](../samples/07-spectre/README.md#activity--paged-long-data-source) +- [MCP Server sample](../samples/08-mcp-server/README.md#demo-workflow) +- [Output System](output-system.md) +- [Command Reference](commands.md) +- [MCP Reference](mcp-reference.md) +- [Interaction](interaction.md) diff --git a/samples/01-core-basics/ActivityFeed.cs b/samples/01-core-basics/ActivityFeed.cs new file mode 100644 index 0000000..c0619e4 --- /dev/null +++ b/samples/01-core-basics/ActivityFeed.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Repl; + +sealed record ActivityEvent( + [property: Display(Name = "#", Order = 0)] int Id, + [property: Display(Name = "At", Order = 1)] string At, + [property: Display(Name = "Area", Order = 2)] string Area, + [property: Display(Name = "Event", Order = 3)] string Event, + [property: Display(Name = "Summary", Order = 4)] string Summary); + +internal sealed class ActivityFeed +{ + private readonly List _items = CreateItems(); + + public IReplPageSource Query(IReplPagingContext paging) + { + ArgumentNullException.ThrowIfNull(paging); + + return ReplPageSource.FromOffset( + (offset, take, _) => + ValueTask.FromResult>( + _items.Skip(offset).Take(take).ToList()), + _items.Count); + } + + private static List CreateItems() + { + string[] areas = ["identity", "billing", "catalog", "search", "import", "reporting"]; + string[] events = ["validated", "queued", "indexed", "exported", "reconciled", "notified"]; + var start = new DateTimeOffset(2026, 1, 12, 8, 0, 0, TimeSpan.Zero); + + return Enumerable.Range(1, 250) + .Select(i => + { + var area = areas[(i - 1) % areas.Length]; + var eventName = events[(i - 1) % events.Length]; + + return new ActivityEvent( + i, + start.AddMinutes(i * 7d).ToString("yyyy-MM-dd HH:mm'Z'", CultureInfo.InvariantCulture), + area, + eventName, + $"{area} batch {((i - 1) / 5) + 1} {eventName} successfully"); + }) + .ToList(); + } +} diff --git a/samples/01-core-basics/Program.cs b/samples/01-core-basics/Program.cs index 991ba9f..d600b14 100644 --- a/samples/01-core-basics/Program.cs +++ b/samples/01-core-basics/Program.cs @@ -6,19 +6,21 @@ // - minimal CoreReplApp (no DI package) // - simple contact commands + metadata attributes var store = new ContactStore(); -var commands = new ContactCommands(store); +var activityFeed = new ActivityFeed(); +var commands = new ContactCommands(store, activityFeed); var app = CoreReplApp.Create() .WithDescription("Core basics sample: minimal contacts REPL without DI dependencies.") .WithBanner(""" - Try: list, add Alice alice@test.com, show 1, count - Also: error (exception handling), debug reset + Try: list, add Alice alice@test.com, show 1, count, activity + Also: activity --result:page-size=12, error (exception handling), debug reset """); app.Map("list", commands.List); app.Map("add {name} {email:email}", commands.Add); app.Map("show {id:int}", commands.Show); app.Map("count", commands.Count); +app.Map("activity", commands.Activity); app.Map("report period", commands.ReportPeriod); app.Map("error", ErrorCommand); app.Map("debug reset", commands.Reset); @@ -28,7 +30,7 @@ static object ErrorCommand() => throw new ApplicationException("this is an error."); -file sealed class ContactCommands(ContactStore store) +file sealed class ContactCommands(ContactStore store, ActivityFeed activityFeed) { [Description("List all contacts.")] public List List(SampleOutputOptions output) @@ -57,6 +59,10 @@ public object Show( [Description("Return the number of contacts.")] public object Count() => Results.Success("Contact count.", store.Count()); + [Description("Return a paged activity log generated from a long data source.")] + public IReplPageSource Activity(IReplPagingContext paging) => + activityFeed.Query(paging); + [Description("Render a date-only reporting period from a temporal range literal.")] public string ReportPeriod(ReplDateRange period) => $"Reporting from {period.From:yyyy-MM-dd} to {period.To:yyyy-MM-dd} ({store.Count()} contacts in memory)."; diff --git a/samples/01-core-basics/README.md b/samples/01-core-basics/README.md index 17928ec..ad6649c 100644 --- a/samples/01-core-basics/README.md +++ b/samples/01-core-basics/README.md @@ -35,6 +35,7 @@ Commands: add {name} {email} show {id} count + activity ``` **Same commands, interactive** @@ -87,7 +88,8 @@ myapp ├── list ├── add {name} {email} ├── show {id:int} -└── count +├── count +└── activity ``` - There is **no** `help` node in the graph. @@ -120,6 +122,7 @@ app.Map("list", commands.List); app.Map("add {name} {email:email}", commands.Add); app.Map("show {id:int}", commands.Show); app.Map("count", commands.Count); +app.Map("activity", commands.Activity); app.Map("report period", commands.ReportPeriod); app.Map("error", ErrorCommand); app.Map("debug reset", commands.Reset); @@ -139,6 +142,7 @@ return app.Run(args); - `[Browsable(false)]` hides a command from discovery. - **Return values are semantic**: - `IEnumerable` → table + - `IReplPageSource` → paged table with interactive continuation - `Contact` → structured output (or JSON with `--json`) - `string` → plain text. @@ -155,6 +159,7 @@ Commands: add {name} {email} show {id} count + activity ``` ```text @@ -198,6 +203,23 @@ Expected behavior: - `report period` accepts `start..end` and `start@duration`. - `ReplDateRange` accepts whole-day durations only. +## Result-flow paging + +The `activity` command returns a synthetic long data source through +`IReplPagingContext` and `IReplPageSource`. The handler fetches only the +requested page, and human output can continue to the next page in the same run. +The sample uses `ReplPageSource.FromOffset(...)` so it does not have to parse or +emit offset cursors manually. + +```text +myapp activity --result:page-size=5 +myapp activity --result:page-size=5 --result:cursor=5 +myapp activity --json --result:page-size=2 +``` + +Human output renders a compact table with an integrated pager. JSON output +returns an `{ items, pageInfo }` envelope for automation. + Validation example: ```text diff --git a/samples/07-spectre/ActivityFeed.cs b/samples/07-spectre/ActivityFeed.cs new file mode 100644 index 0000000..a03d844 --- /dev/null +++ b/samples/07-spectre/ActivityFeed.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Repl; + +internal sealed record ActivityEvent( + [property: Display(Name = "#", Order = 0)] int Id, + [property: Display(Name = "At", Order = 1)] string At, + [property: Display(Name = "Team", Order = 2)] string Team, + [property: Display(Name = "Status", Order = 3)] string Status, + [property: Display(Name = "Work Item", Order = 4)] string WorkItem); + +internal sealed class ActivityFeed +{ + private readonly List _items = CreateItems(); + + public IReplPageSource Query(IReplPagingContext paging) + { + ArgumentNullException.ThrowIfNull(paging); + + return ReplPageSource.FromOffset( + (offset, take, _) => + ValueTask.FromResult>( + _items.Skip(offset).Take(take).ToList()), + _items.Count); + } + + private static List CreateItems() + { + string[] teams = ["platform", "growth", "support", "data", "security"]; + string[] statuses = ["triaged", "running", "blocked", "reviewed", "done"]; + var start = new DateTimeOffset(2026, 2, 9, 9, 30, 0, TimeSpan.Zero); + + return Enumerable.Range(1, 320) + .Select(i => + { + var team = teams[(i - 1) % teams.Length]; + var status = statuses[(i - 1) % statuses.Length]; + + return new ActivityEvent( + i, + start.AddMinutes(i * 11d).ToString("yyyy-MM-dd HH:mm'Z'", CultureInfo.InvariantCulture), + team, + status, + $"{team}-{i:0000} {status}"); + }) + .ToList(); + } +} diff --git a/samples/07-spectre/Program.cs b/samples/07-spectre/Program.cs index 11ece85..aa6be4a 100644 --- a/samples/07-spectre/Program.cs +++ b/samples/07-spectre/Program.cs @@ -6,14 +6,15 @@ var app = ReplApp.Create(services => { services.AddSingleton(); + services.AddSingleton(); services.AddSpectreConsole(); }) .WithDescription("Spectre.Console integration: rich renderables, interactive prompts, data visualization.") .WithBanner((IAnsiConsole console) => { console.Write(new FigletText("Spectre").Color(Color.Blue)); - console.MarkupLine(" [grey]Commands:[/] tour, list, detail, chart, tree, json, path, calendar,"); - console.MarkupLine(" figlet, status, progress, add, configure, login"); + console.MarkupLine(" [grey]Commands:[/] tour, list, activity, detail, chart, tree, json, path,"); + console.MarkupLine(" calendar, figlet, status, progress, add, configure, login"); }) .UseDefaultInteractive() .UseCliProfile() @@ -133,6 +134,13 @@ [Description("List all contacts (auto-rendered table)")] (IContactStore store) => store.All()); +// ────────────────────────────────────────────────────────────── +// activity — Paged long data source +// ────────────────────────────────────────────────────────────── +app.Map("activity", + [Description("List a paged activity feed generated from a long data source")] + (ActivityFeed feed, IReplPagingContext paging) => feed.Query(paging)); + // ────────────────────────────────────────────────────────────── // detail — Panel + Grid // ────────────────────────────────────────────────────────────── diff --git a/samples/07-spectre/README.md b/samples/07-spectre/README.md index 7b01448..09dcc5d 100644 --- a/samples/07-spectre/README.md +++ b/samples/07-spectre/README.md @@ -3,7 +3,7 @@ **Rich Spectre.Console integration: renderables, visualizations, and interactive prompts** This sample showcases the `Repl.Spectre` package with **21 Spectre.Console features** -across **14 commands**. It demonstrates both direct `IAnsiConsole` usage for custom +across **15 commands**. It demonstrates both direct `IAnsiConsole` usage for custom renderables and the transparent prompt upgrade where `IReplInteractionChannel` calls are automatically rendered as Spectre prompts. @@ -39,6 +39,17 @@ A multi-step flow chaining 10 Spectre features sequentially: Returns a collection; the `"spectre"` output transformer renders it as a bordered table automatically. Zero rendering code in the handler. +### `activity` — Paged long data source + +Returns a synthetic activity feed through `IReplPagingContext` and +`IReplPageSource`. The Spectre output transformer renders the requested page, +and the integrated pager can fetch more data in the same run. + +```bash +dotnet run --project samples/07-spectre/SpectreOpsSample.csproj -- activity --result:page-size=8 +dotnet run --project samples/07-spectre/SpectreOpsSample.csproj -- activity --result:page-size=8 --result:cursor=8 +``` + ### `detail {name}` — Panel + Grid Uses `IAnsiConsole` to render a `Panel` containing a `Grid` of contact details. @@ -101,7 +112,7 @@ Uses `AskSecretAsync` which renders as a Spectre `TextPrompt` with masked input. |---------|-------|---------| | FigletText | `FigletText` | `tour`, `figlet`, banner | | Table | `Table` | `tour` | -| Table (auto) | via output transformer | `list` | +| Table (auto) | via output transformer | `list`, `activity` | | Tree | `Tree` | `tour`, `tree` | | Panel | `Panel` | `tour`, `detail`, `json`, `calendar`, `chart` | | Rule | `Rule` | `tour` | diff --git a/samples/08-mcp-server/DirectoryContactFeed.cs b/samples/08-mcp-server/DirectoryContactFeed.cs new file mode 100644 index 0000000..b30cf67 --- /dev/null +++ b/samples/08-mcp-server/DirectoryContactFeed.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Repl; + +internal sealed record DirectoryContact( + [property: Display(Name = "#", Order = 0)] int Id, + [property: Display(Name = "Name", Order = 1)] string Name, + [property: Display(Name = "Email", Order = 2)] string Email, + [property: Display(Name = "Department", Order = 3)] string Department, + [property: Display(Name = "Region", Order = 4)] string Region); + +internal sealed class DirectoryContactFeed +{ + private readonly List _items = CreateItems(); + + public ReplPage Query(IReplPagingContext paging) + { + ArgumentNullException.ThrowIfNull(paging); + + var offset = paging.AllRequested ? 0 : ParseOffset(paging.Cursor); + var items = paging.AllRequested + ? _items + : _items.Skip(offset).Take(paging.SuggestedPageSize).ToList(); + + var nextOffset = offset + items.Count; + var nextCursor = !paging.AllRequested && nextOffset < _items.Count + ? nextOffset.ToString(CultureInfo.InvariantCulture) + : null; + + return paging.Page(items, nextCursor, _items.Count); + } + + private static int ParseOffset(string? cursor) => + int.TryParse(cursor, NumberStyles.None, CultureInfo.InvariantCulture, out var offset) && offset > 0 + ? offset + : 0; + + private static List CreateItems() + { + string[] firstNames = ["Alice", "Bob", "Charlie", "Diana", "Eve", "Frank", "Grace", "Heidi"]; + string[] lastNames = ["Martin", "Tremblay", "Singh", "Nguyen", "Roy", "Garcia", "Smith", "Brown"]; + string[] departments = ["Engineering", "Sales", "Support", "Marketing", "Finance", "Operations"]; + string[] regions = ["NA", "EMEA", "APAC", "LATAM"]; + + return Enumerable.Range(1, 500) + .Select(i => + { + var firstName = firstNames[(i - 1) % firstNames.Length]; + var lastName = lastNames[((i - 1) / firstNames.Length) % lastNames.Length]; + + return new DirectoryContact( + i, + $"{firstName} {lastName} {i:000}", + $"{firstName.ToLowerInvariant()}.{lastName.ToLowerInvariant()}{i:000}@example.com", + departments[(i - 1) % departments.Length], + regions[(i - 1) % regions.Length]); + }) + .ToList(); + } +} diff --git a/samples/08-mcp-server/Program.cs b/samples/08-mcp-server/Program.cs index 5ed64a0..77b84e7 100644 --- a/samples/08-mcp-server/Program.cs +++ b/samples/08-mcp-server/Program.cs @@ -16,6 +16,7 @@ var app = ReplApp.Create(services => { services.AddSingleton(); + services.AddSingleton(); }).UseDefaultInteractive(); app.UseMcpServer(o => @@ -30,6 +31,14 @@ .ReadOnly() .AsResource(); +app.Map("contacts paged", (DirectoryContactFeed contacts, IReplPagingContext paging) => contacts.Query(paging)) + .WithDescription("List the large contact directory as a paged result") + .WithDetails(""" + Demonstrates result-flow paging on both CLI and MCP surfaces. + In MCP mode, continue with the reserved _replCursor input returned by pageInfo.nextCursor. + """) + .ReadOnly(); + app.Map("contacts dashboard", (ContactStore contacts) => { var items = string.Join( diff --git a/samples/08-mcp-server/README.md b/samples/08-mcp-server/README.md index e827adf..f2cefd2 100644 --- a/samples/08-mcp-server/README.md +++ b/samples/08-mcp-server/README.md @@ -5,6 +5,7 @@ Expose a Repl command graph as an MCP server for AI agents, including a minimal ## What this sample shows - `app.UseMcpServer()` — one line to enable MCP stdio server +- `contacts paged` — paged structured output for large result sets - `IReplInteractionChannel` in MCP mode — portable notices, warnings, problems, and progress updates - `feedback demo` / `feedback fail` — deterministic progress sequences that are easy to inspect in MCP Inspector - `.ReadOnly()` / `.Destructive()` / `.OpenWorld()` — behavioral annotations @@ -44,6 +45,8 @@ In the current Repl.Mcp version, MCP Apps are experimental and the UI handler re In the interactive REPL, try: +- `contacts paged --result:page-size=5` to inspect the first page of a synthetic long directory +- `contacts paged --result:page-size=5 --result:cursor=5` to continue from the next cursor - `feedback demo` to emit a successful sequence with normal, indeterminate, and warning progress states - `feedback fail` to emit warning and error progress, then finish with a problem result - `import contacts.csv` to see the realistic workflow that uses sampling and elicitation when the connected client supports them @@ -51,11 +54,13 @@ In the interactive REPL, try: In MCP Inspector: 1. Start the sample in MCP mode. -2. Call `feedback_demo`. -3. Watch the tool emit `notifications/progress` during the run. -4. Call `feedback_fail`. -5. Watch the warning/error feedback arrive before the final tool error result. -6. Call `import` with any file name to see the longer workflow: +2. Call `contacts_paged` with `_replPageSize` set to `5`. +3. Call `contacts_paged` again with `_replPageSize` set to `5` and `_replCursor` set to the returned `pageInfo.nextCursor`. +4. Call `feedback_demo`. +5. Watch the tool emit `notifications/progress` during the run. +6. Call `feedback_fail`. +7. Watch the warning/error feedback arrive before the final tool error result. +8. Call `import` with any file name to see the longer workflow: the tool reports progress while reading, column-mapping, duplicate review, and commit. The deterministic `feedback_*` tools make it easy to verify the host's notification rendering without depending on a real CSV file. diff --git a/samples/README.md b/samples/README.md index 02bf051..0363665 100644 --- a/samples/README.md +++ b/samples/README.md @@ -7,7 +7,7 @@ If you’re new, start with **01**, then follow the sequence. ## Index (recommended order) 1. [01 — Core Basics](01-core-basics/) - `Repl.Core` only: routing, parsing/binding, typed params + constraints, reusable options groups, temporal ranges, help/discovery, CLI + REPL from the same command graph. + `Repl.Core` only: routing, parsing/binding, typed params + constraints, reusable options groups, temporal ranges, result-flow paging, help/discovery, CLI + REPL from the same command graph. 2. [02 — Scoped Contacts](02-scoped-contacts/) Dynamic scopes + REPL navigation (`..`) + DI-backed handlers. 3. [03 — Modular Ops](03-modular-ops/) @@ -19,9 +19,9 @@ If you’re new, start with **01**, then follow the sequence. 6. [06 — Testing](06-testing/) `Repl.Testing` harness: multi-step + multi-session, typed results, interaction/timeline events, metadata snapshots. 7. [07 — Spectre](07-spectre/) - `Repl.Spectre` integration: FigletText, Table, Panel, Tree, BarChart, BreakdownChart, Calendar, JsonText, TextPath, Grid, Columns, Rule, Status, Progress, and all Spectre-powered prompts. + `Repl.Spectre` integration: FigletText, Table, paged result tables, Panel, Tree, BarChart, BreakdownChart, Calendar, JsonText, TextPath, Grid, Columns, Rule, Status, Progress, and all Spectre-powered prompts. 8. [08 — MCP Server](08-mcp-server/) - MCP server mode: tools, resources, prompts, behavioral annotations, automation visibility, and a minimal MCP Apps UI. + MCP server mode: tools, paged structured results, resources, prompts, behavioral annotations, automation visibility, and a minimal MCP Apps UI. ## Run diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 4717f7d..eb927e4 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -516,7 +516,12 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma { if (enterInteractive.Payload is not null) { - _ = await RenderOutputAsync(enterInteractive.Payload, globalOptions.OutputFormat, cancellationToken, scopeTokens is not null) + _ = await RenderOutputAsync( + enterInteractive.Payload, + globalOptions.OutputFormat, + cancellationToken, + scopeTokens is not null, + globalOptions.ResultFlow) .ConfigureAwait(false); } @@ -525,7 +530,12 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma var normalizedResult = ApplyNavigationResult(result, scopeTokens); ExecutionObserver?.OnResult(normalizedResult); - var rendered = await RenderOutputAsync(normalizedResult, globalOptions.OutputFormat, cancellationToken, scopeTokens is not null) + var rendered = await RenderOutputAsync( + normalizedResult, + globalOptions.OutputFormat, + cancellationToken, + scopeTokens is not null, + globalOptions.ResultFlow) .ConfigureAwait(false); return (rendered ? ComputeExitCode(normalizedResult) : 1, false); } @@ -617,7 +627,12 @@ private static async ValueTask TryClearProgressAsync(IServiceProvider servicePro ExecutionObserver?.OnResult(normalized); - var rendered = await RenderOutputAsync(normalized, globalOptions.OutputFormat, cancellationToken, isInteractive) + var rendered = await RenderOutputAsync( + normalized, + globalOptions.OutputFormat, + cancellationToken, + isInteractive, + globalOptions.ResultFlow) .ConfigureAwait(false); if (!rendered) @@ -664,7 +679,8 @@ internal async ValueTask RenderOutputAsync( object? result, string? requestedFormat, CancellationToken cancellationToken, - bool isInteractive = false) + bool isInteractive = false, + ResultFlowInvocationOptions? resultFlow = null) { if (result is IExitResult exitResult) { @@ -686,16 +702,232 @@ internal async ValueTask RenderOutputAsync( return false; } + if (result is IReplPageSource pageSource) + { + return await RenderPageSourceAsync( + pageSource, + transformer, + isInteractive, + resultFlow, + cancellationToken) + .ConfigureAwait(false); + } + var payload = await transformer.TransformAsync(result, cancellationToken).ConfigureAwait(false); payload = TryColorizeStructuredPayload(payload, format, isInteractive); if (!string.IsNullOrEmpty(payload)) { - await ReplSessionIO.Output.WriteLineAsync(payload).ConfigureAwait(false); + await WritePayloadAsync(payload, transformer, resultFlow, cancellationToken).ConfigureAwait(false); } return true; } + private async ValueTask RenderPageSourceAsync( + IReplPageSource source, + IOutputTransformer transformer, + bool isInteractive, + ResultFlowInvocationOptions? resultFlow, + CancellationToken cancellationToken) + { + var request = CreatePageSourceRequest(resultFlow); + var page = await FetchPageSourceAsync(source, request, cancellationToken).ConfigureAwait(false); + var payload = await transformer.TransformAsync(page, cancellationToken).ConfigureAwait(false); + payload = TryColorizeStructuredPayload(payload, transformer.Name, isInteractive); + + if (!TryCreatePager( + payload, + transformer, + resultFlow, + page.PageInfo.HasMore, + out var keyReader, + out var visibleRows, + out var pagerMode, + out var ansiEnabled)) + { + if (!string.IsNullOrEmpty(payload)) + { + await ReplSessionIO.Output.WriteLineAsync(payload).ConfigureAwait(false); + } + + return true; + } + + var nextCursor = page.PageInfo.NextCursor; + var pagerPayload = await transformer.TransformAsync(CreatePagerDisplayPage(page), cancellationToken) + .ConfigureAwait(false); + pagerPayload = TryColorizeStructuredPayload(pagerPayload, transformer.Name, isInteractive); + await ResultFlowPager.WriteAsync( + pagerPayload, + ReplSessionIO.Output, + keyReader, + visibleRows, + pagerMode, + ansiEnabled, + page.PageInfo.HasMore, + FetchNextPayloadAsync, + cancellationToken) + .ConfigureAwait(false); + return true; + + async ValueTask FetchNextPayloadAsync(CancellationToken token) + { + if (string.IsNullOrWhiteSpace(nextCursor)) + { + return null; + } + + var nextRequest = request with { Cursor = nextCursor }; + var nextPage = await FetchPageSourceAsync(source, nextRequest, token).ConfigureAwait(false); + nextCursor = nextPage.PageInfo.NextCursor; + var nextPayload = await transformer.TransformAsync(CreatePagerDisplayPage(nextPage), token) + .ConfigureAwait(false); + nextPayload = TryColorizeStructuredPayload(nextPayload, transformer.Name, isInteractive); + return new ResultFlowPagerPage(nextPayload, nextPage.PageInfo.HasMore); + } + } + + private async ValueTask WritePayloadAsync( + string payload, + IOutputTransformer transformer, + ResultFlowInvocationOptions? resultFlow, + CancellationToken cancellationToken) + { + if (TryCreatePager( + payload, + transformer, + resultFlow, + out var keyReader, + out var visibleRows, + out var pagerMode, + out var ansiEnabled)) + { + await ResultFlowPager.WriteAsync( + payload, + ReplSessionIO.Output, + keyReader, + visibleRows, + pagerMode, + ansiEnabled, + cancellationToken) + .ConfigureAwait(false); + return; + } + + await ReplSessionIO.Output.WriteLineAsync(payload).ConfigureAwait(false); + } + + private bool TryCreatePager( + string payload, + IOutputTransformer transformer, + ResultFlowInvocationOptions? resultFlow, + [NotNullWhen(true)] out IReplKeyReader? keyReader, + out int visibleRows, + out ReplPagerMode pagerMode, + out bool ansiEnabled) + => TryCreatePager( + payload, + transformer, + resultFlow, + hasMorePayload: false, + out keyReader, + out visibleRows, + out pagerMode, + out ansiEnabled); + + private bool TryCreatePager( + string payload, + IOutputTransformer transformer, + ResultFlowInvocationOptions? resultFlow, + bool hasMorePayload, + [NotNullWhen(true)] out IReplKeyReader? keyReader, + out int visibleRows, + out ReplPagerMode pagerMode, + out bool ansiEnabled) + { + keyReader = null; + visibleRows = 0; + ansiEnabled = false; + + pagerMode = resultFlow?.PagerMode ?? _options.Output.ResultFlow.DefaultPagerMode; + if (pagerMode == ReplPagerMode.Off + || ReplSessionIO.IsProgrammatic + || ReplSessionIO.IsProtocolPassthrough + || !transformer.SupportsInteractivePaging) + { + return false; + } + + if (!TryResolvePagerVisibleRows(out visibleRows) + || (!hasMorePayload && ResultFlowPager.CountLines(payload) <= visibleRows) + || !TryResolvePagerKeyReader(out keyReader)) + { + return false; + } + + ansiEnabled = _options.Output.IsAnsiEnabled(); + return true; + } + + private bool TryResolvePagerVisibleRows(out int visibleRows) + { + var height = ReplSessionIO.WindowSize?.Height ?? TryGetConsoleWindowHeight(); + var reservedRows = Math.Max(0, _options.Output.ResultFlow.ReservedVisibleRows); + visibleRows = height is > 0 + ? Math.Max(1, height.Value - reservedRows) + : Math.Max(1, _options.Output.ResultFlow.DefaultPageSize); + return visibleRows > 0; + } + + private static bool TryResolvePagerKeyReader([NotNullWhen(true)] out IReplKeyReader? keyReader) + { + if (ReplSessionIO.KeyReader is { } sessionKeyReader) + { + keyReader = sessionKeyReader; + return true; + } + + if (!Console.IsInputRedirected && !Console.IsOutputRedirected && !ReplSessionIO.IsSessionActive) + { + keyReader = new ConsoleKeyReader(); + return true; + } + + keyReader = null; + return false; + } + + private ReplPageRequest CreatePageSourceRequest(ResultFlowInvocationOptions? resultFlow) + { + var surface = ResolveResultSurface(); + return new ReplPagingContext( + _options.Output.ResultFlow, + resultFlow ?? new ResultFlowInvocationOptions(), + surface, + ResolveVisibleRowCapacityHint(surface)) + .CreateRequest(); + } + + private static IReplPage CreatePagerDisplayPage(IReplPage page) + { + if (!page.PageInfo.HasMore) + { + return page; + } + + var pageInfo = page.PageInfo with + { + NextCursor = null, + }; + return new ReplPageDisplaySnapshot(page, pageInfo); + } + + private static ValueTask FetchPageSourceAsync( + IReplPageSource source, + ReplPageRequest request, + CancellationToken cancellationToken) => + source.FetchPageAsync(request, cancellationToken); + private string TryColorizeStructuredPayload(string payload, string format, bool isInteractive) { if (string.IsNullOrEmpty(payload) @@ -831,6 +1063,7 @@ private InvocationBindingContext CreateInvocationBindingContext( CancellationToken cancellationToken) { var contextValues = BuildContextHierarchyValues(match.Route.Template, matchedPathTokens, contexts); + contextValues.Add(CreatePagingContext(globalOptions)); var mergedNamedOptions = MergeNamedOptions( parsedOptions.NamedOptions, globalOptions.CustomGlobalNamedOptions); @@ -848,6 +1081,81 @@ private InvocationBindingContext CreateInvocationBindingContext( cancellationToken); } + private ReplPagingContext CreatePagingContext(GlobalInvocationOptions globalOptions) + { + var surface = ResolveResultSurface(); + var visibleRows = ResolveVisibleRowCapacityHint(surface); + return new ReplPagingContext( + _options.Output.ResultFlow, + globalOptions.ResultFlow, + surface, + visibleRows); + } + + private ReplResultSurface ResolveResultSurface() + { + if (ReplSessionIO.IsProgrammatic) + { + return ReplResultSurface.Programmatic; + } + + if (_runtimeState.Value?.IsInteractiveSession == true) + { + return ReplResultSurface.Interactive; + } + + if (ReplSessionIO.IsHostedSession) + { + return ReplResultSurface.Hosted; + } + + return Console.IsOutputRedirected + ? ReplResultSurface.Redirected + : ReplResultSurface.Console; + } + + private int? ResolveVisibleRowCapacityHint(ReplResultSurface surface) + { + if (surface is ReplResultSurface.Redirected or ReplResultSurface.Programmatic) + { + return null; + } + + var height = ReplSessionIO.WindowSize?.Height ?? TryGetConsoleWindowHeight(); + if (height is not > 0) + { + return null; + } + + var reservedRows = Math.Max(0, _options.Output.ResultFlow.ReservedVisibleRows); + return Math.Max(1, height.Value - reservedRows); + } + + private static int? TryGetConsoleWindowHeight() + { + try + { + var height = Console.WindowHeight; + return height > 0 ? height : null; + } + catch (IOException) + { + return null; + } + catch (PlatformNotSupportedException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + catch (System.Security.SecurityException) + { + return null; + } + } + private static bool TryFindGlobalCommandOptionCollision( GlobalInvocationOptions globalOptions, HashSet knownOptionNames, diff --git a/src/Repl.Core/Help/HelpRenderCommand.cs b/src/Repl.Core/Help/HelpRenderCommand.cs index 6ce268e..0ddd76e 100644 --- a/src/Repl.Core/Help/HelpRenderCommand.cs +++ b/src/Repl.Core/Help/HelpRenderCommand.cs @@ -7,4 +7,5 @@ internal sealed record HelpRenderCommand( IReadOnlyList Aliases, IReadOnlyList Arguments, IReadOnlyList Options, + IReadOnlyList ResultFlow, IReadOnlyList Answers); diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index 24eea17..83539d8 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -7,6 +7,14 @@ namespace Repl; internal static partial class HelpTextBuilder { + private static readonly HelpRenderEntry[] ResultFlowRows = + [ + new("--result:page-size ", "Request a page size for paged handlers."), + new("--result:cursor ", "Continue from a cursor returned by a previous page."), + new("--result:all", "Request all rows when the handler supports it."), + new("--result:pager=auto|off|more|scroll|external", "Control the integrated pager for human output."), + ]; + private static string BuildCommandHelp(RouteDefinition[] routes, bool useAnsi, AnsiPalette palette) { if (routes.Length == 1) @@ -47,10 +55,11 @@ private static string BuildSingleCommandHelp(RouteDefinition route, bool useAnsi : $"{Environment.NewLine}Aliases: {string.Join(", ", route.Command.Aliases)}"; var argumentSection = BuildArgumentSection(route, useAnsi, palette); var optionSection = BuildOptionSection(route, useAnsi, palette); + var resultFlowSection = BuildResultFlowSection(route, useAnsi, palette); var answerSection = BuildAnswerSection(route, useAnsi, palette); if (!useAnsi) { - return $"Usage: {displayTemplate}{Environment.NewLine}Description: {description}{aliases}{argumentSection}{optionSection}{answerSection}"; + return $"Usage: {displayTemplate}{Environment.NewLine}Description: {description}{aliases}{argumentSection}{optionSection}{resultFlowSection}{answerSection}"; } var usage = $"{AnsiText.Apply("Usage:", palette.SectionStyle)} {AnsiText.Apply(displayTemplate, palette.CommandStyle)}"; @@ -58,7 +67,7 @@ private static string BuildSingleCommandHelp(RouteDefinition route, bool useAnsi var aliasText = route.Command.Aliases.Count == 0 ? string.Empty : $"{Environment.NewLine}{AnsiText.Apply("Aliases:", palette.SectionStyle)} {AnsiText.Apply(string.Join(", ", route.Command.Aliases), palette.CommandStyle)}"; - return $"{usage}{Environment.NewLine}{desc}{aliasText}{argumentSection}{optionSection}{answerSection}"; + return $"{usage}{Environment.NewLine}{desc}{aliasText}{argumentSection}{optionSection}{resultFlowSection}{answerSection}"; } private static string BuildArgumentSection(RouteDefinition route, bool useAnsi, AnsiPalette palette) @@ -109,6 +118,29 @@ private static string BuildAnswerSection(RouteDefinition route, bool useAnsi, An return builder.ToString(); } + private static string BuildResultFlowSection(RouteDefinition route, bool useAnsi, AnsiPalette palette) + { + if (!UsesResultFlow(route)) + { + return string.Empty; + } + + var builder = new StringBuilder(); + builder.AppendLine(); + builder.Append(useAnsi + ? AnsiText.Apply("Result Flow:", palette.SectionStyle) + : "Result Flow:"); + foreach (var row in ResultFlowRows) + { + builder.AppendLine(); + builder.Append(useAnsi + ? $" {AnsiText.Apply(row.Name, palette.CommandStyle)} {AnsiText.Apply(row.Description, palette.DescriptionStyle)}" + : $" {row.Name} {row.Description}"); + } + + return builder.ToString(); + } + private static string BuildOptionSection(RouteDefinition route, bool useAnsi, AnsiPalette palette) { var optionRows = BuildOptionRows(route); @@ -257,6 +289,31 @@ private static HelpRenderEntry[] BuildOptionRows(RouteDefinition route) .ToArray(); } + private static bool UsesResultFlow(RouteDefinition route) => + route.Command.Handler.Method.GetParameters() + .Any(static parameter => parameter.ParameterType == typeof(IReplPagingContext)) + || IsPagedReturnType(route.Command.Handler.Method.ReturnType); + + private static bool IsPagedReturnType(Type returnType) + { + var effectiveType = UnwrapAsyncReturnType(returnType); + return typeof(IReplPage).IsAssignableFrom(effectiveType) + || typeof(IReplPageSource).IsAssignableFrom(effectiveType); + } + + private static Type UnwrapAsyncReturnType(Type returnType) + { + if (!returnType.IsGenericType) + { + return returnType; + } + + var definition = returnType.GetGenericTypeDefinition(); + return definition == typeof(Task<>) || definition == typeof(ValueTask<>) + ? returnType.GetGenericArguments()[0] + : returnType; + } + private static bool IsDefaultForType(object value, Type type) { if (type == typeof(bool)) diff --git a/src/Repl.Core/Help/HelpTextBuilder.cs b/src/Repl.Core/Help/HelpTextBuilder.cs index 32e46e5..a215c5d 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.cs @@ -268,6 +268,7 @@ private static HelpRenderCommand CreateRenderCommand(RouteDefinition route) Aliases: route.Command.Aliases.ToArray(), Arguments: BuildArgumentRows(route), Options: BuildOptionRows(route), + ResultFlow: UsesResultFlow(route) ? ResultFlowRows : [], Answers: BuildAnswerRows(route)); } @@ -310,6 +311,7 @@ private static HelpRenderCommand[] BuildScopeCommandEntries( Aliases: aliases, Arguments: [], Options: [], + ResultFlow: [], Answers: []); }) .Where(command => command is not null) diff --git a/src/Repl.Core/IOutputTransformer.cs b/src/Repl.Core/IOutputTransformer.cs index ea764d6..ae91438 100644 --- a/src/Repl.Core/IOutputTransformer.cs +++ b/src/Repl.Core/IOutputTransformer.cs @@ -10,6 +10,11 @@ public interface IOutputTransformer /// string Name { get; } + /// + /// Gets a value indicating whether this transformer can be displayed by the interactive result pager. + /// + bool SupportsInteractivePaging => false; + /// /// Transforms a value to the target representation. /// @@ -17,4 +22,4 @@ public interface IOutputTransformer /// Cancellation token. /// Transformed payload as text. ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/src/Repl.Core/Output/HumanOutputTransformer.cs b/src/Repl.Core/Output/HumanOutputTransformer.cs index ec68760..9d2ce92 100644 --- a/src/Repl.Core/Output/HumanOutputTransformer.cs +++ b/src/Repl.Core/Output/HumanOutputTransformer.cs @@ -23,6 +23,8 @@ public HumanOutputTransformer(Func resolveRenderSettings) public string Name => "human"; + public bool SupportsInteractivePaging => true; + public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -33,6 +35,11 @@ public ValueTask TransformAsync(object? value, CancellationToken cancell return ValueTask.FromResult(string.Empty); } + if (value is IReplPage page) + { + return ValueTask.FromResult(RenderPage(page, settings)); + } + if (value is IReplResult replResult) { return ValueTask.FromResult(RenderReplResult(replResult, settings)); @@ -80,6 +87,37 @@ public ValueTask TransformAsync(object? value, CancellationToken cancell Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); } + private static string RenderPage(IReplPage page, HumanRenderSettings settings) + { + var body = page.UntypedItems.Count == 0 + ? "No results." + : RenderCollection(page.UntypedItems, depth: 0, settings); + var footer = RenderPageFooter(page); + return string.IsNullOrWhiteSpace(footer) + ? body + : string.Concat(body, Environment.NewLine, footer); + } + + private static string RenderPageFooter(IReplPage page) + { + var info = page.PageInfo; + var count = page.UntypedItems.Count; + if (info.TotalCount is { } total) + { + var prefix = $"Showing {count.ToString(CultureInfo.InvariantCulture)} of {total.ToString(CultureInfo.InvariantCulture)}."; + return info.HasMore + ? $"{prefix} Next data page: rerun with --result:cursor {info.NextCursor}." + : prefix; + } + + if (!info.HasMore) + { + return string.Empty; + } + + return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Next data page: rerun with --result:cursor {info.NextCursor}."; + } + private static bool TryRenderObject(object value, HumanRenderSettings settings, out string text) { var members = GetDisplayMembers(value.GetType()); @@ -363,6 +401,11 @@ private static string RenderReplResult(IReplResult result, HumanRenderSettings s return message; } + if (result.Details is IReplPage page) + { + return $"{message}{Environment.NewLine}{RenderPage(page, settings)}"; + } + if (TryRenderDictionary(result.Details, settings, out var dictionaryText)) { return $"{message}{Environment.NewLine}{dictionaryText}"; diff --git a/src/Repl.Core/Output/JsonOutputTransformer.cs b/src/Repl.Core/Output/JsonOutputTransformer.cs index df42605..e1ea52b 100644 --- a/src/Repl.Core/Output/JsonOutputTransformer.cs +++ b/src/Repl.Core/Output/JsonOutputTransformer.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; namespace Repl; @@ -9,9 +10,23 @@ internal sealed class JsonOutputTransformer(JsonSerializerOptions serializerOpti public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + if (value is IReplPage page) + { +#pragma warning disable IL2026 // JSON object serialization is an explicit extensibility behavior in v1. + return ValueTask.FromResult(JsonSerializer.Serialize( + new ReplPageJsonResult("page", page.UntypedItems, page.PageInfo), + serializerOptions)); +#pragma warning restore IL2026 + } + #pragma warning disable IL2026 // JSON object serialization is an explicit extensibility behavior in v1. var payload = JsonSerializer.Serialize(value, serializerOptions); #pragma warning restore IL2026 return ValueTask.FromResult(payload); } + + private sealed record ReplPageJsonResult( + [property: JsonPropertyName("$type")] string Type, + IReadOnlyList Items, + ReplPageInfo PageInfo); } diff --git a/src/Repl.Core/Output/MarkdownOutputTransformer.cs b/src/Repl.Core/Output/MarkdownOutputTransformer.cs index 9b02371..571b5b6 100644 --- a/src/Repl.Core/Output/MarkdownOutputTransformer.cs +++ b/src/Repl.Core/Output/MarkdownOutputTransformer.cs @@ -26,11 +26,21 @@ public ValueTask TransformAsync(object? value, CancellationToken cancell return ValueTask.FromResult(RenderDocumentation(documentation)); } + if (value is HelpRenderDocument help) + { + return ValueTask.FromResult(RenderHelp(help)); + } + if (value is string text) { return ValueTask.FromResult(text); } + if (value is IReplPage page) + { + return ValueTask.FromResult(RenderPage(page)); + } + if (value is IReplResult result) { return ValueTask.FromResult(RenderReplResult(result)); @@ -68,6 +78,8 @@ private static string RenderReplResult(IReplResult result) var details = result.Details is string detailsText ? detailsText + : result.Details is IReplPage page + ? RenderPage(page) : result.Details is System.Collections.IEnumerable enumerable && result.Details is not string ? RenderEnumerable(enumerable) : RenderObject(result.Details); @@ -80,9 +92,40 @@ private static string RenderReplResult(IReplResult result) return string.Concat(message, Environment.NewLine, Environment.NewLine, details); } + private static string RenderPage(IReplPage page) + { + var body = page.UntypedItems.Count == 0 + ? "No results." + : RenderEnumerable(page.UntypedItems); + var footer = RenderPageFooter(page); + return string.IsNullOrWhiteSpace(footer) + ? body + : string.Concat(body, Environment.NewLine, Environment.NewLine, footer); + } + + private static string RenderPageFooter(IReplPage page) + { + var info = page.PageInfo; + var count = page.UntypedItems.Count; + if (info.TotalCount is { } total) + { + var prefix = $"Showing {count} of {total}."; + return info.HasMore + ? $"{prefix} Continue with `--result:cursor {info.NextCursor}`." + : prefix; + } + + if (!info.HasMore) + { + return string.Empty; + } + + return $"Showing {count} result(s). Continue with `--result:cursor {info.NextCursor}`."; + } + private static string RenderEnumerable(System.Collections.IEnumerable enumerable) { - var items = enumerable.Cast().ToArray(); + var items = ToObjectArray(enumerable); if (items.Length == 0) { return "No results."; @@ -109,6 +152,8 @@ private static string RenderEnumerable(System.Collections.IEnumerable enumerable items.Select(item => $"- {item?.ToString() ?? string.Empty}")); } + var emptyRow = new string[members.Length]; + Array.Fill(emptyRow, string.Empty); var rows = new List(items.Length + 1) { members.Select(member => EscapeCell(member.Label)).ToArray(), @@ -118,7 +163,7 @@ private static string RenderEnumerable(System.Collections.IEnumerable enumerable { if (item is null) { - rows.Add(members.Select(_ => string.Empty).ToArray()); + rows.Add(emptyRow); continue; } @@ -183,7 +228,7 @@ private static string RenderScalar(object? value, DisplayMember member) if (value is System.Collections.IEnumerable enumerable) { - var count = enumerable.Cast().Count(); + var count = CountEnumerable(enumerable); return count.ToString(System.Globalization.CultureInfo.InvariantCulture); } @@ -212,6 +257,120 @@ private static bool IsSimpleValue(Type type) => private static string EscapeCell(string value) => value.Replace("|", "\\|", StringComparison.Ordinal); + private static object?[] ToObjectArray(System.Collections.IEnumerable enumerable) + { + if (enumerable is object?[] array) + { + return array; + } + + if (enumerable is IReplPage page) + { + return page.UntypedItems as object?[] ?? [.. page.UntypedItems]; + } + + return enumerable.Cast().ToArray(); + } + + private static int CountEnumerable(System.Collections.IEnumerable enumerable) + { + if (enumerable is System.Collections.ICollection collection) + { + return collection.Count; + } + + if (enumerable is IReadOnlyCollection readonlyCollection) + { + return readonlyCollection.Count; + } + + return enumerable.Cast().Count(); + } + + private static string RenderHelp(HelpRenderDocument help) + { + var builder = new StringBuilder(); + if (help.IsCommandHelp) + { + if (help.Commands.Count == 1) + { + RenderCommandHelp(builder, help.Commands[0]); + } + else + { + AppendEntrySection(builder, "Commands", help.Commands.Select(CommandEntry).ToArray()); + } + + return builder.ToString().TrimEnd(); + } + + builder.AppendLine($"# Help: {EscapeMarkdown(help.Scope)}"); + AppendCommandSection(builder, help.Commands); + AppendEntrySection(builder, "Scopes", help.Scopes); + AppendEntrySection(builder, "Global Options", help.GlobalOptions); + AppendEntrySection(builder, "Global Commands", help.GlobalCommands); + return builder.ToString().TrimEnd(); + } + + private static void RenderCommandHelp(StringBuilder builder, HelpRenderCommand command) + { + builder.AppendLine($"# `{EscapeMarkdown(command.Usage)}`"); + builder.AppendLine(); + builder.AppendLine($"- **Usage**: `{EscapeMarkdown(command.Usage)}`"); + builder.AppendLine($"- **Description**: {EscapeMarkdown(command.Description)}"); + if (command.Aliases.Count > 0) + { + builder.AppendLine($"- **Aliases**: {EscapeMarkdown(string.Join(", ", command.Aliases))}"); + } + + AppendEntrySection(builder, "Arguments", command.Arguments); + AppendEntrySection(builder, "Options", command.Options); + AppendEntrySection(builder, "Result Flow", command.ResultFlow); + AppendEntrySection(builder, "Answers", command.Answers); + } + + private static void AppendCommandSection(StringBuilder builder, IReadOnlyList commands) + { + if (commands.Count == 0) + { + return; + } + + AppendEntrySection(builder, "Commands", commands.Select(CommandEntry).ToArray()); + } + + private static HelpRenderEntry CommandEntry(HelpRenderCommand command) => + new(command.Name, command.Description); + + private static void AppendEntrySection( + StringBuilder builder, + string title, + IReadOnlyList entries) + { + if (entries.Count == 0) + { + return; + } + + builder.AppendLine(); + builder.AppendLine($"## {title}"); + builder.AppendLine(); + builder.AppendLine("| Name | Description |"); + builder.AppendLine("| --- | --- |"); + foreach (var entry in entries) + { + builder + .Append("| `") + .Append(EscapeCell(entry.Name)) + .Append("` | ") + .Append(EscapeCell(entry.Description)) + .AppendLine(" |"); + } + } + + private static string EscapeMarkdown(string value) => + value.Replace("|", "\\|", StringComparison.Ordinal); + [UnconditionalSuppressMessage( "Trimming", "IL2070", diff --git a/src/Repl.Core/OutputOptions.cs b/src/Repl.Core/OutputOptions.cs index b315960..3f473b6 100644 --- a/src/Repl.Core/OutputOptions.cs +++ b/src/Repl.Core/OutputOptions.cs @@ -30,6 +30,8 @@ public OutputOptions() _transformers["xml"] = new XmlOutputTransformer(JsonSerializerOptions); _transformers["yaml"] = new YamlOutputTransformer(JsonSerializerOptions); _transformers["markdown"] = new MarkdownOutputTransformer(); + _helpOutputFactories["markdown"] = static (routes, contexts, scopeTokens, parsingOptions, ambientOptions) => + HelpTextBuilder.BuildRenderModel(routes, contexts, scopeTokens, parsingOptions, ambientOptions); _aliases["json"] = "json"; _aliases["xml"] = "xml"; @@ -90,6 +92,11 @@ public OutputOptions() /// public int FallbackWidth { get; set; } = 120; + /// + /// Gets result-flow options for paging and large result sets. + /// + public ResultFlowOptions ResultFlow { get; } = new(); + /// /// Gets JSON serializer options used by the JSON transformer. /// diff --git a/src/Repl.Core/Parsing/GlobalInvocationOptions.cs b/src/Repl.Core/Parsing/GlobalInvocationOptions.cs index 718079c..92c563e 100644 --- a/src/Repl.Core/Parsing/GlobalInvocationOptions.cs +++ b/src/Repl.Core/Parsing/GlobalInvocationOptions.cs @@ -13,6 +13,8 @@ internal sealed record GlobalInvocationOptions( public string? OutputFormat { get; init; } + public ResultFlowInvocationOptions ResultFlow { get; init; } = new(); + public IReadOnlyDictionary PromptAnswers { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/Repl.Core/Parsing/GlobalOptionParser.cs b/src/Repl.Core/Parsing/GlobalOptionParser.cs index efa118e..3bbb96a 100644 --- a/src/Repl.Core/Parsing/GlobalOptionParser.cs +++ b/src/Repl.Core/Parsing/GlobalOptionParser.cs @@ -68,6 +68,19 @@ public static GlobalInvocationOptions Parse( continue; } + if (TryParseResultFlowOption( + args, + ref index, + argument, + optionComparison, + options.ResultFlow, + outputOptions.ResultFlow.MaxPageSize, + out var resultFlow)) + { + options = options with { ResultFlow = resultFlow }; + continue; + } + if (TryParsePromptAnswer(argument, promptAnswers)) { continue; @@ -143,6 +156,101 @@ private static bool TryParsePromptAnswer( return true; } + private static bool TryParseResultFlowOption( + IReadOnlyList args, + ref int index, + string argument, + StringComparison comparison, + ResultFlowInvocationOptions current, + int maxPageSize, + out ResultFlowInvocationOptions resultFlow) + { + const string prefix = "--result:"; + resultFlow = current; + if (!argument.StartsWith(prefix, comparison)) + { + return false; + } + + var token = argument[prefix.Length..]; + if (TrySplitToken(token, '=', out var name, out var inlineValue) + || TrySplitToken(token, ':', out name, out inlineValue)) + { + return ApplyResultFlowOption(name, inlineValue, current, maxPageSize, out resultFlow); + } + + if (string.Equals(token, "all", comparison)) + { + resultFlow = current with { AllRequested = true }; + return true; + } + + if (RequiresResultFlowValue(token, comparison) + && index + 1 < args.Count + && !args[index + 1].StartsWith('-')) + { + index++; + return ApplyResultFlowOption(token, args[index], current, maxPageSize, out resultFlow); + } + + return ApplyResultFlowOption(token, "true", current, maxPageSize, out resultFlow); + } + + private static bool ApplyResultFlowOption( + string name, + string value, + ResultFlowInvocationOptions current, + int maxPageSize, + out ResultFlowInvocationOptions resultFlow) + { + resultFlow = current; + if (string.Equals(name, "page-size", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse( + value, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out var pageSize)) + { + resultFlow = current with { PageSize = ClampPageSize(pageSize, maxPageSize) }; + } + + return true; + } + + if (string.Equals(name, "cursor", StringComparison.OrdinalIgnoreCase)) + { + resultFlow = current with { Cursor = value }; + return true; + } + + if (string.Equals(name, "pager", StringComparison.OrdinalIgnoreCase)) + { + if (Enum.TryParse(value, ignoreCase: true, out var mode)) + { + resultFlow = current with { PagerMode = mode }; + } + + return true; + } + + if (string.Equals(name, "all", StringComparison.OrdinalIgnoreCase)) + { + resultFlow = current with { AllRequested = !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) }; + return true; + } + + return false; + } + + private static bool RequiresResultFlowValue(string token, StringComparison comparison) => + string.Equals(token, "page-size", comparison) + || string.Equals(token, "cursor", comparison) + || string.Equals(token, "pager", comparison); + + private static int ClampPageSize(int pageSize, int maxPageSize) => + Math.Clamp(pageSize, 1, Math.Max(1, maxPageSize)); + private static Dictionary BuildCustomTokenMap( IReadOnlyDictionary definitions, StringComparer comparer) diff --git a/src/Repl.Core/Parsing/ImplicitServiceParameterRegistry.cs b/src/Repl.Core/Parsing/ImplicitServiceParameterRegistry.cs index 368704b..5683454 100644 --- a/src/Repl.Core/Parsing/ImplicitServiceParameterRegistry.cs +++ b/src/Repl.Core/Parsing/ImplicitServiceParameterRegistry.cs @@ -60,6 +60,7 @@ private static bool IsFrameworkInjectedParameter(Type parameterType) => || parameterType == typeof(IReplInteractionChannel) || parameterType == typeof(IReplIoContext) || parameterType == typeof(IReplKeyReader) + || parameterType == typeof(IReplPagingContext) || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal) || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpSampling", StringComparison.Ordinal) || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpElicitation", StringComparison.Ordinal) diff --git a/src/Repl.Core/ResultFlow/IReplPage.cs b/src/Repl.Core/ResultFlow/IReplPage.cs new file mode 100644 index 0000000..df120a9 --- /dev/null +++ b/src/Repl.Core/ResultFlow/IReplPage.cs @@ -0,0 +1,22 @@ +namespace Repl; + +/// +/// Represents a typed page using an untyped view for the output pipeline. +/// +public interface IReplPage +{ + /// + /// Gets the runtime item type declared by the page. + /// + Type ItemType { get; } + + /// + /// Gets page metadata. + /// + ReplPageInfo PageInfo { get; } + + /// + /// Gets the current page items as an untyped list. + /// + IReadOnlyList UntypedItems { get; } +} diff --git a/src/Repl.Core/ResultFlow/IReplPageSource.cs b/src/Repl.Core/ResultFlow/IReplPageSource.cs new file mode 100644 index 0000000..dd518d4 --- /dev/null +++ b/src/Repl.Core/ResultFlow/IReplPageSource.cs @@ -0,0 +1,41 @@ +namespace Repl; + +/// +/// Fetches result-flow pages on demand. +/// +public interface IReplPageSource +{ + /// + /// Fetches a page for the supplied request. + /// + /// Page request. + /// Cancellation token. + /// The fetched page. + ValueTask FetchPageAsync( + ReplPageRequest request, + CancellationToken cancellationToken = default); +} + +/// +/// Fetches pages of a result set on demand. +/// +/// Item type. +public interface IReplPageSource : IReplPageSource +{ + /// + /// Fetches a page for the supplied request. + /// + /// Page request. + /// Cancellation token. + /// The fetched page. + ValueTask> FetchAsync( + ReplPageRequest request, + CancellationToken cancellationToken = default); + + async ValueTask IReplPageSource.FetchPageAsync( + ReplPageRequest request, + CancellationToken cancellationToken) + { + return await FetchAsync(request, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Repl.Core/ResultFlow/IReplPagingContext.cs b/src/Repl.Core/ResultFlow/IReplPagingContext.cs new file mode 100644 index 0000000..9da6955 --- /dev/null +++ b/src/Repl.Core/ResultFlow/IReplPagingContext.cs @@ -0,0 +1,64 @@ +namespace Repl; + +/// +/// Provides paging intent and output-capacity hints to command handlers. +/// +/// +/// Handlers can use this context to avoid loading or returning unbounded result sets. +/// The visible-row hint is best-effort: terminal, hosted, and MCP surfaces can expose +/// different capacities, and redirected output usually has no visible screen. +/// +public interface IReplPagingContext +{ + /// + /// Gets a best-effort hint for the number of data rows the current output surface can show. + /// + int? VisibleRowCapacityHint { get; } + + /// + /// Gets the page size suggested for the current invocation. + /// + int SuggestedPageSize { get; } + + /// + /// Gets the maximum page size allowed by the current application configuration. + /// + int MaxPageSize { get; } + + /// + /// Gets the opaque cursor supplied by the caller, when continuing a paged result. + /// + string? Cursor { get; } + + /// + /// Gets a value indicating whether the caller explicitly requested all available rows. + /// + bool AllRequested { get; } + + /// + /// Gets the kind of output surface driving this invocation. + /// + ReplResultSurface Surface { get; } + + /// + /// Creates a paged result from an already fetched page. + /// + /// Item type. + /// Items in the current page. + /// Cursor for the next page, when one exists. + /// Total item count, when known without expensive enumeration. + /// A result page consumable by Repl renderers. + ReplPage Page( + IReadOnlyList items, + string? nextCursor = null, + long? totalCount = null); + + /// + /// Creates a lazy page source that can fetch additional pages on demand. + /// + /// Item type. + /// Page fetch delegate. + /// A page source consumable by interactive renderers. + IReplPageSource CreateSource( + Func>> fetch); +} diff --git a/src/Repl.Core/ResultFlow/ReplPage.cs b/src/Repl.Core/ResultFlow/ReplPage.cs new file mode 100644 index 0000000..35d3000 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPage.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; + +namespace Repl; + +/// +/// Represents one page of a larger result set. +/// +/// Item type. +public sealed record ReplPage : IReplPage +{ + private IReadOnlyList? _untypedItems; + + /// + /// Initializes a new instance of the record. + /// + /// Items in the page. + /// Page metadata. + public ReplPage(IReadOnlyList items, ReplPageInfo pageInfo) + { + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(pageInfo); + + Items = items; + PageInfo = pageInfo; + } + + /// + /// Gets the typed items in the page. + /// + public IReadOnlyList Items { get; init; } + + /// + [JsonIgnore] + public Type ItemType => typeof(T); + + /// + public ReplPageInfo PageInfo { get; init; } + + /// + [JsonIgnore] + public IReadOnlyList UntypedItems => _untypedItems ??= Items switch + { + object?[] array => array, + IReadOnlyList list => list, + _ => Items.Cast().ToArray(), + }; +} diff --git a/src/Repl.Core/ResultFlow/ReplPageDisplaySnapshot.cs b/src/Repl.Core/ResultFlow/ReplPageDisplaySnapshot.cs new file mode 100644 index 0000000..4c26be4 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPageDisplaySnapshot.cs @@ -0,0 +1,18 @@ +namespace Repl; + +internal sealed class ReplPageDisplaySnapshot : IReplPage +{ + private readonly IReplPage _page; + + public ReplPageDisplaySnapshot(IReplPage page, ReplPageInfo pageInfo) + { + _page = page ?? throw new ArgumentNullException(nameof(page)); + PageInfo = pageInfo ?? throw new ArgumentNullException(nameof(pageInfo)); + } + + public Type ItemType => _page.ItemType; + + public ReplPageInfo PageInfo { get; } + + public IReadOnlyList UntypedItems => _page.UntypedItems; +} diff --git a/src/Repl.Core/ResultFlow/ReplPageInfo.cs b/src/Repl.Core/ResultFlow/ReplPageInfo.cs new file mode 100644 index 0000000..ac7e423 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPageInfo.cs @@ -0,0 +1,20 @@ +namespace Repl; + +/// +/// Metadata describing one page of a result set. +/// +/// Cursor used to fetch the current page. +/// Cursor that fetches the next page, when available. +/// Total result count, when known. +/// Requested or effective page size. +public sealed record ReplPageInfo( + string? Cursor, + string? NextCursor, + long? TotalCount, + int PageSize) +{ + /// + /// Gets a value indicating whether another page is available. + /// + public bool HasMore => !string.IsNullOrWhiteSpace(NextCursor); +} diff --git a/src/Repl.Core/ResultFlow/ReplPageRequest.cs b/src/Repl.Core/ResultFlow/ReplPageRequest.cs new file mode 100644 index 0000000..527c310 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPageRequest.cs @@ -0,0 +1,16 @@ +namespace Repl; + +/// +/// Request sent to a page source. +/// +/// Requested page size. +/// Opaque cursor for continuation. +/// Best-effort visible row capacity for the output surface. +/// Whether the caller requested all available rows. +/// Output surface requesting the page. +public sealed record ReplPageRequest( + int PageSize, + string? Cursor, + int? VisibleRowCapacityHint, + bool AllRequested, + ReplResultSurface Surface); diff --git a/src/Repl.Core/ResultFlow/ReplPageRequestExtensions.cs b/src/Repl.Core/ResultFlow/ReplPageRequestExtensions.cs new file mode 100644 index 0000000..3532efa --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPageRequestExtensions.cs @@ -0,0 +1,34 @@ +namespace Repl; + +/// +/// Convenience helpers for creating result-flow pages from page-source requests. +/// +public static class ReplPageRequestExtensions +{ + /// + /// Creates a typed result page for the supplied request. + /// + /// Item type. + /// The page-source request being handled. + /// Items in the current page. + /// Cursor for the next page, when one exists. + /// Total item count, when known without expensive enumeration. + /// A result page consumable by Repl renderers. + public static ReplPage Page( + this ReplPageRequest request, + IReadOnlyList items, + string? nextCursor = null, + long? totalCount = null) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(items); + + return new ReplPage( + items, + new ReplPageInfo( + Cursor: request.Cursor, + NextCursor: nextCursor, + TotalCount: totalCount, + PageSize: request.PageSize)); + } +} diff --git a/src/Repl.Core/ResultFlow/ReplPageSource.cs b/src/Repl.Core/ResultFlow/ReplPageSource.cs new file mode 100644 index 0000000..a0dc580 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPageSource.cs @@ -0,0 +1,399 @@ +using System.Globalization; + +namespace Repl; + +/// +/// Convenience factories for result-flow page sources. +/// +public static class ReplPageSource +{ + private const int DefaultMaxSourceItemsToScan = 10000; + + /// + /// Creates a page source from a fetch delegate. + /// + /// Item type. + /// Delegate that fetches one page for each request. + /// A page source consumable by Repl renderers. + public static IReplPageSource Create( + Func>> fetch) + { + ArgumentNullException.ThrowIfNull(fetch); + return new DelegateReplPageSource(fetch); + } + + /// + /// Creates a page source from a fetch delegate and explicit state. + /// + /// Item type. + /// State type. + /// State passed to the fetch delegate. + /// Delegate that fetches one page for each request. + /// A page source consumable by Repl renderers. + public static IReplPageSource Create( + TState state, + Func>> fetch) + { + ArgumentNullException.ThrowIfNull(fetch); + return Create((request, cancellationToken) => fetch(state, request, cancellationToken)); + } + + /// + /// Creates an offset-cursor page source over an in-memory list. + /// + /// Item type. + /// Items to expose as pages. + /// Optional client-side filter applied before final paging. + /// A page source consumable by Repl renderers. + public static IReplPageSource FromItems( + IReadOnlyList items, + Func? filter = null) + { + ArgumentNullException.ThrowIfNull(items); + + return Create((request, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(CreateItemsPage(items, request, filter)); + }); + } + + /// + /// Creates an offset-cursor page source over an in-memory list and explicit state. + /// + /// Item type. + /// State type. + /// Items to expose as pages. + /// State passed to the filter delegate. + /// Optional client-side filter applied before final paging. + /// A page source consumable by Repl renderers. + public static IReplPageSource FromItems( + IReadOnlyList items, + TState state, + Func? filter = null) + { + ArgumentNullException.ThrowIfNull(items); + return FromItems(items, filter is null ? null : item => filter(state, item)); + } + + /// + /// Creates an offset-cursor page source over a store that can fetch by offset and take. + /// + /// Item type. + /// Delegate called with offset, take, and cancellation token. + /// Total item count, when known without expensive enumeration. + /// Optional client-side filter applied after source fetches and before final paging. + /// Maximum source rows to scan while filling one filtered page. + /// A page source consumable by Repl renderers. + public static IReplPageSource FromOffset( + Func>> fetch, + long? totalCount = null, + Func? filter = null, + int? maxSourceItemsToScan = null) + { + ArgumentNullException.ThrowIfNull(fetch); + return Create((request, cancellationToken) => + CreateOffsetPageAsync(fetch, request, totalCount, filter, maxSourceItemsToScan, cancellationToken)); + } + + /// + /// Creates an offset-cursor page source over a store that can fetch by offset and take, with explicit state. + /// + /// Item type. + /// State type. + /// State passed to the fetch and filter delegates. + /// Delegate called with state, offset, take, and cancellation token. + /// Total item count, when known without expensive enumeration. + /// Optional client-side filter applied after source fetches and before final paging. + /// Maximum source rows to scan while filling one filtered page. + /// A page source consumable by Repl renderers. + public static IReplPageSource FromOffset( + TState state, + Func>> fetch, + long? totalCount = null, + Func? filter = null, + int? maxSourceItemsToScan = null) + { + ArgumentNullException.ThrowIfNull(fetch); + return FromOffset( + (offset, take, cancellationToken) => fetch(state, offset, take, cancellationToken), + totalCount, + filter is null ? null : item => filter(state, item), + maxSourceItemsToScan); + } + + /// + /// Creates an offset-cursor page source over an async stream factory. + /// + /// Item type. + /// Factory that creates the async stream for each page request. + /// Optional client-side filter applied before final paging. + /// Maximum source rows to scan while filling one filtered page. + /// A page source consumable by Repl renderers. + /// + /// The factory must be replayable, idempotent, and deterministic for the same + /// underlying result set: each page request reopens the stream and advances to the + /// requested offset. Do not use this helper for single-use streams, live re-queries, + /// mutable files, channels, network cursors, or shared enumerator instances. For + /// those sources, use + /// with an opaque cursor owned by the source. + /// + /// Performance note: fetching page N re-streams from the beginning and skips + /// (N-1) × pageSize items; cost is O(offset) per page. For large or expensive + /// sources prefer + /// with a source-native cursor so each page starts directly at the right position. + /// + /// + public static IReplPageSource FromAsyncEnumerable( + Func> createItems, + Func? filter = null, + int? maxSourceItemsToScan = null) + { + ArgumentNullException.ThrowIfNull(createItems); + return Create((request, cancellationToken) => + CreateAsyncEnumerablePageAsync(createItems, request, filter, maxSourceItemsToScan, cancellationToken)); + } + + /// + /// Creates an offset-cursor page source over an async stream factory, with explicit state. + /// + /// Item type. + /// State type. + /// State passed to the stream factory and filter delegate. + /// Factory that creates the async stream for each page request. + /// Optional client-side filter applied before final paging. + /// Maximum source rows to scan while filling one filtered page. + /// A page source consumable by Repl renderers. + /// + /// The factory must be replayable, idempotent, and deterministic for the same + /// underlying result set: each page request reopens the stream and advances to the + /// requested offset. Do not use this helper for single-use streams, live re-queries, + /// mutable files, channels, network cursors, or shared enumerator instances. For + /// those sources, use + /// with an opaque cursor owned by the source. + /// + /// Performance note: fetching page N re-streams from the beginning and skips + /// (N-1) × pageSize items; cost is O(offset) per page. For large or expensive + /// sources prefer the stateful overload with a + /// source-native cursor so each page starts directly at the right position. + /// + /// + public static IReplPageSource FromAsyncEnumerable( + TState state, + Func> createItems, + Func? filter = null, + int? maxSourceItemsToScan = null) + { + ArgumentNullException.ThrowIfNull(createItems); + return FromAsyncEnumerable( + cancellationToken => createItems(state, cancellationToken), + filter is null ? null : item => filter(state, item), + maxSourceItemsToScan); + } + + private static ReplPage CreateItemsPage( + IReadOnlyList items, + ReplPageRequest request, + Func? filter) + { + var offset = request.AllRequested ? 0 : ParseOffset(request.Cursor); + var filteredItems = filter is null + ? items + : items.Where(filter).ToArray(); + var pageItems = request.AllRequested + ? filteredItems + : filteredItems.Skip(offset).Take(request.PageSize).ToArray(); + var nextOffset = offset + pageItems.Count; + var nextCursor = !request.AllRequested && nextOffset < filteredItems.Count + ? nextOffset.ToString(CultureInfo.InvariantCulture) + : null; + + return request.Page(pageItems, nextCursor, filteredItems.Count); + } + + private static async ValueTask> CreateOffsetPageAsync( + Func>> fetch, + ReplPageRequest request, + long? totalCount, + Func? filter, + int? maxSourceItemsToScan, + CancellationToken cancellationToken) + { + ThrowIfAllRequestedForUnboundedSource(request); + var offset = request.AllRequested ? 0 : ParseOffset(request.Cursor); + var take = GetProbeSize(request.PageSize); + if (filter is not null) + { + return await CreateFilteredOffsetPageAsync( + fetch, + request, + offset, + take, + totalCount, + filter, + ResolveMaxSourceItemsToScan(maxSourceItemsToScan), + cancellationToken) + .ConfigureAwait(false); + } + + var items = await fetch(offset, take, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The offset page source returned null."); + return CreateOffsetProbePage(request, offset, items, totalCount); + } + + private static async ValueTask> CreateAsyncEnumerablePageAsync( + Func> createItems, + ReplPageRequest request, + Func? filter, + int? maxSourceItemsToScan, + CancellationToken cancellationToken) + { + ThrowIfAllRequestedForUnboundedSource(request); + var offset = request.AllRequested ? 0 : ParseOffset(request.Cursor); + var pageItems = new List(request.PageSize + 1); + var scanned = 0; + var index = 0; + int? nextOffsetAfterVisible = null; + var maxScan = ResolveMaxSourceItemsToScan(maxSourceItemsToScan); + await foreach (var item in CreateStreamAsync(createItems, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + ThrowIfScanLimitExceeded(scanned++, maxScan); + if (index++ < offset) + { + continue; + } + + if (filter is not null && !filter(item)) + { + continue; + } + + if (pageItems.Count == request.PageSize) + { + return request.Page( + pageItems, + nextOffsetAfterVisible?.ToString(CultureInfo.InvariantCulture)); + } + + pageItems.Add(item); + nextOffsetAfterVisible = index; + } + + return request.Page(pageItems); + } + + private static ReplPage CreateOffsetProbePage( + ReplPageRequest request, + int offset, + IReadOnlyList items, + long? totalCount) + { + var hasMore = items.Count > request.PageSize; + var visibleItems = hasMore + ? items.Take(request.PageSize).ToArray() + : items; + var nextCursor = hasMore + ? (offset + visibleItems.Count).ToString(CultureInfo.InvariantCulture) + : null; + return request.Page(visibleItems, nextCursor, totalCount); + } + + private static async ValueTask> CreateFilteredOffsetPageAsync( + Func>> fetch, + ReplPageRequest request, + int offset, + int take, + long? totalCount, + Func filter, + int maxSourceItemsToScan, + CancellationToken cancellationToken) + { + var pageItems = new List(request.PageSize); + var currentOffset = offset; + var scanned = 0; + int? nextOffset = null; + + while (true) + { + var items = await fetch(currentOffset, take, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The offset page source returned null."); + if (items.Count == 0) + { + return request.Page(pageItems, totalCount: totalCount); + } + + for (var index = 0; index < items.Count; index++) + { + ThrowIfScanLimitExceeded(scanned++, maxSourceItemsToScan); + var item = items[index]; + if (!filter(item)) + { + continue; + } + + var sourceOffsetAfterItem = currentOffset + index + 1; + if (pageItems.Count == request.PageSize) + { + var cursor = nextOffset ?? sourceOffsetAfterItem; + return request.Page(pageItems, cursor.ToString(CultureInfo.InvariantCulture), totalCount); + } + + pageItems.Add(item); + nextOffset = sourceOffsetAfterItem; + } + + if (items.Count < take) + { + return request.Page(pageItems, totalCount: totalCount); + } + + currentOffset += items.Count; + } + } + + private static int GetProbeSize(int pageSize) => + pageSize == int.MaxValue ? pageSize : pageSize + 1; + + private static IAsyncEnumerable CreateStreamAsync( + Func> createItems, + CancellationToken cancellationToken) => + createItems(cancellationToken) + ?? throw new InvalidOperationException("The async enumerable page source returned null."); + + private static int ParseOffset(string? cursor) => + int.TryParse(cursor, NumberStyles.None, CultureInfo.InvariantCulture, out var offset) && offset >= 0 + ? offset + : 0; + + private static int ResolveMaxSourceItemsToScan(int? value) => + value is > 0 ? value.Value : DefaultMaxSourceItemsToScan; + + private static void ThrowIfAllRequestedForUnboundedSource(ReplPageRequest request) + { + if (request.AllRequested) + { + throw new InvalidOperationException( + "--result:all is not supported by this page source because it could read an unbounded result set."); + } + } + + private static void ThrowIfScanLimitExceeded(int scanned, int maxSourceItemsToScan) + { + if (scanned >= maxSourceItemsToScan) + { + throw new InvalidOperationException( + "The client-side filter scan limit was reached before a complete page could be produced."); + } + } + + private sealed class DelegateReplPageSource( + Func>> fetch) : IReplPageSource + { + public ValueTask> FetchAsync( + ReplPageRequest request, + CancellationToken cancellationToken = default) => + fetch(request, cancellationToken); + } +} diff --git a/src/Repl.Core/ResultFlow/ReplPagerMode.cs b/src/Repl.Core/ResultFlow/ReplPagerMode.cs new file mode 100644 index 0000000..045fc2c --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPagerMode.cs @@ -0,0 +1,32 @@ +namespace Repl; + +/// +/// Controls how human-readable large results are paged. +/// +public enum ReplPagerMode +{ + /// + /// Let Repl choose the best pager for the active output surface. + /// + Auto, + + /// + /// Disable Repl-owned paging. + /// + Off, + + /// + /// Use a simple more-style pager. + /// + More, + + /// + /// Use an interactive scrolling pager. + /// + Scroll, + + /// + /// Use an external pager process when available. + /// + External, +} diff --git a/src/Repl.Core/ResultFlow/ReplPagingContext.cs b/src/Repl.Core/ResultFlow/ReplPagingContext.cs new file mode 100644 index 0000000..290b8e8 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPagingContext.cs @@ -0,0 +1,64 @@ +namespace Repl; + +internal sealed class ReplPagingContext : IReplPagingContext +{ + public ReplPagingContext( + ResultFlowOptions options, + ResultFlowInvocationOptions invocation, + ReplResultSurface surface, + int? visibleRowCapacityHint) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(invocation); + + MaxPageSize = Math.Max(1, options.MaxPageSize); + VisibleRowCapacityHint = visibleRowCapacityHint; + Cursor = invocation.Cursor; + AllRequested = invocation.AllRequested; + Surface = surface; + SuggestedPageSize = ClampPageSize( + invocation.PageSize + ?? visibleRowCapacityHint + ?? options.DefaultPageSize, + MaxPageSize); + } + + public int? VisibleRowCapacityHint { get; } + + public int SuggestedPageSize { get; } + + public int MaxPageSize { get; } + + public string? Cursor { get; } + + public bool AllRequested { get; } + + public ReplResultSurface Surface { get; } + + public ReplPage Page( + IReadOnlyList items, + string? nextCursor = null, + long? totalCount = null) + { + ArgumentNullException.ThrowIfNull(items); + var pageInfo = new ReplPageInfo( + Cursor, + nextCursor, + totalCount, + SuggestedPageSize); + return new ReplPage(items, pageInfo); + } + + public IReplPageSource CreateSource( + Func>> fetch) + { + ArgumentNullException.ThrowIfNull(fetch); + return ReplPageSource.Create(fetch); + } + + internal ReplPageRequest CreateRequest() => + new(SuggestedPageSize, Cursor, VisibleRowCapacityHint, AllRequested, Surface); + + private static int ClampPageSize(int value, int maxPageSize) => + Math.Clamp(value, 1, maxPageSize); +} diff --git a/src/Repl.Core/ResultFlow/ReplResultSurface.cs b/src/Repl.Core/ResultFlow/ReplResultSurface.cs new file mode 100644 index 0000000..71af194 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplResultSurface.cs @@ -0,0 +1,32 @@ +namespace Repl; + +/// +/// Describes the output surface used for a command result. +/// +public enum ReplResultSurface +{ + /// + /// A local console or terminal. + /// + Console, + + /// + /// An interactive REPL session. + /// + Interactive, + + /// + /// Standard output is redirected to a pipe or file. + /// + Redirected, + + /// + /// A hosted terminal session is active. + /// + Hosted, + + /// + /// A programmatic client, such as MCP, is driving execution. + /// + Programmatic, +} diff --git a/src/Repl.Core/ResultFlow/ResultFlowInvocationOptions.cs b/src/Repl.Core/ResultFlow/ResultFlowInvocationOptions.cs new file mode 100644 index 0000000..609c808 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ResultFlowInvocationOptions.cs @@ -0,0 +1,7 @@ +namespace Repl; + +internal sealed record ResultFlowInvocationOptions( + int? PageSize = null, + string? Cursor = null, + bool AllRequested = false, + ReplPagerMode? PagerMode = null); diff --git a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs new file mode 100644 index 0000000..bbfdb3a --- /dev/null +++ b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs @@ -0,0 +1,32 @@ +namespace Repl; + +/// +/// Configures Repl result-flow behavior for paging and large result sets. +/// +public sealed class ResultFlowOptions +{ + /// + /// Gets or sets the default page size when no terminal-specific hint is available. + /// + public int DefaultPageSize { get; set; } = 100; + + /// + /// Gets or sets the maximum page size a caller can request. + /// + public int MaxPageSize { get; set; } = 1000; + + /// + /// Gets or sets the number of non-data rows reserved in interactive pagers. + /// + public int ReservedVisibleRows { get; set; } = 2; + + /// + /// Gets or sets the default pager mode for human output. + /// + public ReplPagerMode DefaultPagerMode { get; set; } = ReplPagerMode.Auto; + + /// + /// Gets or sets the maximum inline payload size for programmatic clients. + /// + public int ProgrammaticMaxInlineBytes { get; set; } = 64 * 1024; +} diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs new file mode 100644 index 0000000..0e278e9 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -0,0 +1,484 @@ +namespace Repl; + +internal static class ResultFlowPager +{ + private const string MorePrompt = "--More-- Space/PageDown: continue, Enter/Down: line, Up/PageUp: back, q/Esc: stop"; + private const string EnterAlternateScreen = "\u001b[?1049h"; + private const string LeaveAlternateScreen = "\u001b[?1049l"; + private const string HideCursor = "\u001b[?25l"; + private const string ShowCursor = "\u001b[?25h"; + private const string CursorHome = "\u001b[H"; + private const string ClearToEndOfScreen = "\u001b[J"; + + public static int CountLines(string payload) => SplitLines(payload).Length; + + public static async ValueTask WriteAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + CancellationToken cancellationToken = default) + { + await WriteAsync( + payload, + output, + keyReader, + visibleRows, + hasMorePayload: false, + fetchNextPayload: null, + cancellationToken) + .ConfigureAwait(false); + } + + public static async ValueTask WriteAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + ReplPagerMode pagerMode, + bool ansiEnabled, + CancellationToken cancellationToken = default) + { + await WriteAsync( + payload, + output, + keyReader, + visibleRows, + pagerMode, + ansiEnabled, + hasMorePayload: false, + fetchNextPayload: null, + cancellationToken) + .ConfigureAwait(false); + } + + public static async ValueTask WriteAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + bool hasMorePayload, + Func>? fetchNextPayload, + CancellationToken cancellationToken = default) + { + await WriteAsync( + payload, + output, + keyReader, + visibleRows, + ReplPagerMode.More, + ansiEnabled: false, + hasMorePayload, + fetchNextPayload, + cancellationToken) + .ConfigureAwait(false); + } + + public static async ValueTask WriteAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + ReplPagerMode pagerMode, + bool ansiEnabled, + bool hasMorePayload, + Func>? fetchNextPayload, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(keyReader); + + if (ShouldUseScrollPager(pagerMode, ansiEnabled)) + { + await WriteScrollAsync( + payload, + output, + keyReader, + visibleRows, + ansiEnabled, + hasMorePayload, + fetchNextPayload, + cancellationToken) + .ConfigureAwait(false); + return; + } + + await WriteMoreAsync( + payload, + output, + keyReader, + visibleRows, + hasMorePayload, + fetchNextPayload, + cancellationToken) + .ConfigureAwait(false); + } + + private static async ValueTask WriteMoreAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + bool hasMorePayload, + Func>? fetchNextPayload, + CancellationToken cancellationToken) + { + var state = new PagerState(SplitLines(payload), Math.Max(1, visibleRows), hasMorePayload); + if (state.Lines.Length == 0 && !state.HasMorePayload) + { + return; + } + + while (true) + { + if (state.Lines.Length == 0 && state.HasMorePayload && fetchNextPayload is not null) + { + var payloadPage = await fetchNextPayload(cancellationToken).ConfigureAwait(false); + if (payloadPage is null) + { + return; + } + + state.Reset(SplitLines(payloadPage.Payload), payloadPage.HasMore); + continue; + } + + if (await WriteCurrentPayloadAsync(state, output, keyReader, cancellationToken).ConfigureAwait(false)) + { + return; + } + + if (!state.HasMorePayload || fetchNextPayload is null) + { + break; + } + + var boundaryKey = await ReadPromptAsync(output, keyReader, cancellationToken).ConfigureAwait(false); + if (ApplyBoundaryKey(state, boundaryKey)) + { + return; + } + + if (state.Index < state.Lines.Length) + { + continue; + } + + var nextPayload = await fetchNextPayload(cancellationToken).ConfigureAwait(false); + if (nextPayload is null) + { + break; + } + + state.Reset(SplitLines(nextPayload.Payload), nextPayload.HasMore); + } + } + + private static async ValueTask WriteScrollAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + bool ansiEnabled, + bool hasMorePayload, + Func>? fetchNextPayload, + CancellationToken cancellationToken) + { + if (!ansiEnabled) + { + throw new InvalidOperationException("The scroll result pager requires ANSI support."); + } + + var state = new ScrollPagerState(SplitLines(payload), Math.Max(2, visibleRows), hasMorePayload); + if (state.Buffer.Count == 0 && !state.HasMorePayload) + { + return; + } + + await output.WriteAsync(EnterAlternateScreen).ConfigureAwait(false); + await output.WriteAsync(HideCursor).ConfigureAwait(false); + try + { + await EnsureScrollBufferAsync(state, fetchNextPayload, cancellationToken).ConfigureAwait(false); + while (true) + { + await RenderScrollAsync(state, output, cancellationToken).ConfigureAwait(false); + var key = await keyReader.ReadKeyAsync(cancellationToken).ConfigureAwait(false); + if (ApplyScrollKey(state, key)) + { + return; + } + + if (state.HasReachedBottom + && state.Buffer.Count > state.ViewportHeight + && state.HasMorePayload + && fetchNextPayload is not null) + { + var before = state.Buffer.Count; + await FetchIntoScrollBufferAsync(state, fetchNextPayload, cancellationToken).ConfigureAwait(false); + if (state.Buffer.Count > before) + { + state.TopLine = Math.Min(state.TopLine + state.ViewportHeight, state.MaxTopLine); + } + } + } + } + finally + { + await output.WriteAsync(ShowCursor).ConfigureAwait(false); + await output.WriteAsync(LeaveAlternateScreen).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + + private static async ValueTask WriteCurrentPayloadAsync( + PagerState state, + TextWriter output, + IReplKeyReader keyReader, + CancellationToken cancellationToken) + { + while (state.Index < state.Lines.Length) + { + cancellationToken.ThrowIfCancellationRequested(); + var windowStart = state.Index; + var take = Math.Min(state.NextWindow, state.Lines.Length - state.Index); + for (var i = 0; i < take; i++) + { + await output.WriteLineAsync(state.Lines[state.Index + i]).ConfigureAwait(false); + } + + state.Index += take; + if (state.Index >= state.Lines.Length) + { + break; + } + + var key = await ReadPromptAsync(output, keyReader, cancellationToken).ConfigureAwait(false); + if (ApplyWindowKey(state, key, windowStart)) + { + return true; + } + } + + return false; + } + + private static bool ApplyWindowKey(PagerState state, ConsoleKeyInfo key, int windowStart) + { + switch (key.Key) + { + case ConsoleKey.Q: + case ConsoleKey.Escape: + return true; + case ConsoleKey.Enter: + case ConsoleKey.DownArrow: + state.NextWindow = 1; + return false; + case ConsoleKey.UpArrow: + state.Index = Math.Max(0, windowStart - 1); + state.NextWindow = 1; + return false; + case ConsoleKey.PageUp: + state.Index = Math.Max(0, windowStart - state.PageSize); + state.NextWindow = state.PageSize; + return false; + default: + state.NextWindow = state.PageSize; + return false; + } + } + + private static bool ApplyBoundaryKey(PagerState state, ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.Q: + case ConsoleKey.Escape: + return true; + case ConsoleKey.Enter: + case ConsoleKey.DownArrow: + state.NextWindow = 1; + return false; + case ConsoleKey.UpArrow: + state.Index = Math.Max(0, state.Lines.Length - state.PageSize); + state.NextWindow = state.PageSize; + return false; + case ConsoleKey.PageUp: + state.Index = Math.Max(0, state.Lines.Length - state.PageSize); + state.NextWindow = state.PageSize; + return false; + default: + state.NextWindow = state.PageSize; + return false; + } + } + + private static async ValueTask ReadPromptAsync( + TextWriter output, + IReplKeyReader keyReader, + CancellationToken cancellationToken) + { + await output.WriteAsync(MorePrompt).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + var key = await keyReader.ReadKeyAsync(cancellationToken).ConfigureAwait(false); + await output.WriteLineAsync().ConfigureAwait(false); + return key; + } + + private static async ValueTask EnsureScrollBufferAsync( + ScrollPagerState state, + Func>? fetchNextPayload, + CancellationToken cancellationToken) + { + while (state.Buffer.Count == 0 && state.HasMorePayload && fetchNextPayload is not null) + { + await FetchIntoScrollBufferAsync(state, fetchNextPayload, cancellationToken).ConfigureAwait(false); + } + } + + private static async ValueTask FetchIntoScrollBufferAsync( + ScrollPagerState state, + Func> fetchNextPayload, + CancellationToken cancellationToken) + { + var nextPayload = await fetchNextPayload(cancellationToken).ConfigureAwait(false); + if (nextPayload is null) + { + state.HasMorePayload = false; + return; + } + + state.Append(SplitLines(nextPayload.Payload), nextPayload.HasMore); + } + + private static async ValueTask RenderScrollAsync( + ScrollPagerState state, + TextWriter output, + CancellationToken cancellationToken) + { + await output.WriteAsync(CursorHome).ConfigureAwait(false); + await output.WriteAsync(ClearToEndOfScreen).ConfigureAwait(false); + var take = Math.Min(state.ViewportHeight, Math.Max(0, state.Buffer.Count - state.TopLine)); + for (var i = 0; i < take; i++) + { + await output.WriteLineAsync(state.Buffer[state.TopLine + i]).ConfigureAwait(false); + } + + for (var i = take; i < state.ViewportHeight; i++) + { + await output.WriteLineAsync().ConfigureAwait(false); + } + + var lastLine = state.Buffer.Count == 0 + ? 0 + : Math.Min(state.Buffer.Count, state.TopLine + state.ViewportHeight); + var status = state.Buffer.Count == 0 + ? "-- result-flow: loading --" + : $"-- result-flow {state.TopLine + 1}-{lastLine}/{state.Buffer.Count}{(state.HasMorePayload ? "+" : string.Empty)} Space: next Up/Down: scroll q: quit --"; + await output.WriteAsync(status).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private static bool ApplyScrollKey(ScrollPagerState state, ConsoleKeyInfo key) + { + switch (key.Key) + { + case ConsoleKey.Q: + case ConsoleKey.Escape: + return true; + case ConsoleKey.Spacebar: + case ConsoleKey.PageDown: + case ConsoleKey.F: + state.TopLine = Math.Min(state.TopLine + state.ViewportHeight, state.MaxTopLine); + return false; + case ConsoleKey.Enter: + case ConsoleKey.DownArrow: + case ConsoleKey.J: + state.TopLine = Math.Min(state.TopLine + 1, state.MaxTopLine); + return false; + case ConsoleKey.UpArrow: + case ConsoleKey.K: + state.TopLine = Math.Max(0, state.TopLine - 1); + return false; + case ConsoleKey.PageUp: + case ConsoleKey.B: + state.TopLine = Math.Max(0, state.TopLine - state.ViewportHeight); + return false; + case ConsoleKey.Home: + case ConsoleKey.G when key.Modifiers.HasFlag(ConsoleModifiers.Shift): + state.TopLine = 0; + return false; + default: + return false; + } + } + + private static bool ShouldUseScrollPager(ReplPagerMode pagerMode, bool ansiEnabled) => + ansiEnabled && pagerMode is ReplPagerMode.Auto or ReplPagerMode.Scroll; + + private static string[] SplitLines(string payload) => + string.IsNullOrEmpty(payload) + ? [] + : SplitNonEmptyPayloadLines(payload); + + private static string[] SplitNonEmptyPayloadLines(string payload) + { + var lines = new List(); + foreach (var line in payload.AsSpan().EnumerateLines()) + { + lines.Add(line.ToString()); + } + + // EnumerateLines adds a trailing empty entry when the payload ends with a newline; + // strip it to stay consistent with how the pager counts visible lines. + if (lines.Count > 0 && lines[^1].Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + return [.. lines]; + } + + private sealed class PagerState(string[] lines, int pageSize, bool hasMorePayload) + { + private string[] _lines = lines; + + public string[] Lines => _lines; + + public int PageSize { get; } = pageSize; + + public int NextWindow { get; set; } = pageSize; + + public int Index { get; set; } + + public bool HasMorePayload { get; private set; } = hasMorePayload; + + public void Reset(string[] lines, bool hasMorePayload) + { + _lines = lines; + Index = 0; + HasMorePayload = hasMorePayload; + } + } + + private sealed class ScrollPagerState(string[] lines, int visibleRows, bool hasMorePayload) + { + public List Buffer { get; } = [.. lines]; + + public int ViewportHeight { get; } = Math.Max(1, visibleRows - 1); + + public int TopLine { get; set; } + + public bool HasMorePayload { get; set; } = hasMorePayload; + + public int MaxTopLine => Math.Max(0, Buffer.Count - ViewportHeight); + + public bool HasReachedBottom => TopLine >= MaxTopLine; + + public void Append(string[] lines, bool hasMorePayload) + { + Buffer.AddRange(lines); + HasMorePayload = hasMorePayload; + } + } +} diff --git a/src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs b/src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs new file mode 100644 index 0000000..fc3257d --- /dev/null +++ b/src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs @@ -0,0 +1,3 @@ +namespace Repl; + +internal sealed record ResultFlowPagerPage(string Payload, bool HasMore); diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index c521d47..68f7c61 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -7,6 +7,8 @@ namespace Repl.IntegrationTests; [DoNotParallelize] public sealed class Given_HelpDiscovery { + private static readonly string[] SingleResult = ["one"]; + [TestMethod] [Description("Regression guard: verifies requesting root help so that hidden commands are excluded.")] public void When_RequestingRootHelp_Then_HiddenCommandsAreExcluded() @@ -417,6 +419,85 @@ public void When_RequestingCommandHelpWithDeclaredOptions_Then_OptionsSectionInc output.Text.Should().Contain("--verbose, --no-verbose"); } + [TestMethod] + [Description("Regression guard: verifies command help explains result-flow paging controls for paged handlers.")] + public void When_RequestingCommandHelpForPagedHandler_Then_ResultFlowOptionsAreShown() + { + var sut = ReplApp.Create(); + sut.Map("activity", (IReplPagingContext paging) => + paging.Page(["one"], nextCursor: "next", totalCount: 2)); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["activity", "--help", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Result Flow:"); + output.Text.Should().Contain("--result:page-size "); + output.Text.Should().Contain("--result:cursor "); + output.Text.Should().Contain("--result:all"); + output.Text.Should().Contain("--result:pager=auto|off|more|scroll|external"); + } + + [TestMethod] + [Description("Regression guard: verifies Spectre command help explains result-flow paging controls for paged handlers.")] + public void When_RequestingCommandHelpForPagedHandlerInSpectre_Then_ResultFlowOptionsAreShown() + { + var sut = ReplApp.Create(services => services.AddSpectreConsole()) + .UseSpectreConsole(); + sut.Map("activity", (IReplPagingContext paging) => + paging.Page(["one"], nextCursor: "next", totalCount: 2)); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["activity", "--help", "--spectre", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Result Flow"); + output.Text.Should().Contain("--result:page-size "); + } + + [TestMethod] + [Description("Regression guard: verifies markdown command help explains result-flow paging controls for paged handlers.")] + public void When_RequestingCommandHelpForPagedHandlerInMarkdown_Then_ResultFlowOptionsAreShown() + { + var sut = ReplApp.Create(); + sut.Map("activity", (IReplPagingContext paging) => + paging.Page(["one"], nextCursor: "next", totalCount: 2)); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["activity", "--help", "--markdown", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("# `activity`"); + output.Text.Should().Contain("## Result Flow"); + output.Text.Should().Contain("`--result:page-size `"); + output.Text.Should().NotContain("| Field | Value |"); + } + + [TestMethod] + [Description("Regression guard: verifies command help explains result-flow paging controls for page-source handlers.")] + public void When_RequestingCommandHelpForPageSourceHandler_Then_ResultFlowOptionsAreShown() + { + var sut = ReplApp.Create(); + sut.Map("activity", () => new StaticPageSource()); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["activity", "--help", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Result Flow:"); + output.Text.Should().Contain("--result:page-size "); + } + + [TestMethod] + [Description("Regression guard: verifies result-flow controls stay hidden for handlers that do not support paging.")] + public void When_RequestingCommandHelpForNonPagedHandler_Then_ResultFlowOptionsAreHidden() + { + var sut = ReplApp.Create(); + sut.Map("list", () => SingleResult); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["list", "--help", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().NotContain("Result Flow:"); + output.Text.Should().NotContain("--result:page-size "); + } + [TestMethod] [Description("Regression guard: verifies injected global-options accessor parameters are omitted from command help.")] public void When_RequestingCommandHelpWithGlobalOptionsAccessor_Then_AccessorIsNotListedAsCommandOption() @@ -439,6 +520,20 @@ private enum HelpMode Slow, } + private sealed class StaticPageSource : IReplPageSource + { + public ValueTask> FetchAsync( + ReplPageRequest request, + CancellationToken cancellationToken = default) => + ValueTask.FromResult(new ReplPage( + [], + new ReplPageInfo( + Cursor: request.Cursor, + NextCursor: null, + TotalCount: 0, + PageSize: request.PageSize))); + } + [TestMethod] [Description("Regression guard: verifies Spectre help uses a dedicated renderer so command help keeps the expected sections.")] public void When_RequestingCommandHelpInSpectre_Then_DedicatedHelpSectionsAreRendered() diff --git a/src/Repl.IntegrationTests/Given_OutputFormatting.cs b/src/Repl.IntegrationTests/Given_OutputFormatting.cs index cdfd10d..02cf469 100644 --- a/src/Repl.IntegrationTests/Given_OutputFormatting.cs +++ b/src/Repl.IntegrationTests/Given_OutputFormatting.cs @@ -1,11 +1,12 @@ using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; using Repl.Spectre; namespace Repl.IntegrationTests; [TestClass] [DoNotParallelize] -public sealed class Given_OutputFormatting +public sealed partial class Given_OutputFormatting { [TestMethod] [Description("Regression guard: verifies rendering human string result so that output contains raw text.")] @@ -196,6 +197,125 @@ public void When_RenderingObjectCollectionInMarkdown_Then_TableMarkdownIsProduce output.Text.Should().NotContain("System.Collections.Generic.List"); } + [TestMethod] + [Description("Regression guard: verifies paged results render their current page and continuation hint in human output.")] + public void When_RenderingPagedResultInHuman_Then_ItemsAndContinuationAreRendered() + { + var sut = ReplApp.Create(); + sut.Map("contact list", (IReplPagingContext paging) => + paging.Page( + new[] + { + new ContactRow("Alice Martin", "alice@example.com"), + new ContactRow("Bob Tremblay", "bob@example.com"), + }, + nextCursor: "page-2", + totalCount: 3)); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["contact", "list", "--result:page-size=2", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Alice Martin"); + output.Text.Should().Contain("Bob Tremblay"); + output.Text.Should().Contain("Showing 2 of 3."); + output.Text.Should().Contain("Next data page:"); + output.Text.Should().Contain("--result:cursor page-2"); + } + + [TestMethod] + [Description("Regression guard: verifies human page sources continue interactively instead of asking users to rerun with a cursor.")] + public void When_RenderingPageSourceInHumanPager_Then_SpaceFetchesNextPageWithoutCursorRerun() + { + var sut = ReplApp.Create(); + var fetchedCursors = new List(); + var contacts = new[] + { + new ContactRow("Alice Martin", "alice@example.com"), + new ContactRow("Bob Tremblay", "bob@example.com"), + }; + + sut.Map("contact list", (IReplPagingContext paging) => + paging.CreateSource((request, _) => + { + fetchedCursors.Add(request.Cursor); + var offset = string.Equals(request.Cursor, "1", StringComparison.Ordinal) ? 1 : 0; + var items = contacts.Skip(offset).Take(request.PageSize).ToArray(); + var nextOffset = offset + items.Length; + var nextCursor = nextOffset < contacts.Length ? "1" : null; + return ValueTask.FromResult(new ReplPage( + items, + new ReplPageInfo( + request.Cursor, + nextCursor, + contacts.Length, + request.PageSize))); + })); + + using var output = new StringWriter(); + using var session = ReplSessionIO.SetSession(output, TextReader.Null); + ReplSessionIO.KeyReader = new QueueKeyReader([Key(ConsoleKey.Spacebar, ' ')]); + ReplSessionIO.WindowSize = (100, 20); + + var exitCode = sut.Run(["contact", "list", "--result:page-size=1", "--no-logo"]); + + exitCode.Should().Be(0); + fetchedCursors.Should().Equal(null, "1"); + var text = output.ToString(); + text.Should().Contain("Alice Martin"); + text.Should().Contain("Bob Tremblay"); + text.Should().NotContain("rerun with --result:cursor"); + } + + [TestMethod] + [Description("Regression guard: verifies paged results serialize to a clean JSON envelope for automation.")] + public void When_RenderingPagedResultInJson_Then_ItemsAndPageInfoAreSerialized() + { + var sut = ReplApp.Create(); + sut.Map("contact list", (IReplPagingContext paging) => + paging.Page( + new[] + { + new Contact(1, "Alice"), + }, + nextCursor: "page-2", + totalCount: 2)); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["contact", "list", "--json", "--result:page-size=1", "--result:cursor=start", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("\"items\""); + output.Text.Should().Contain("\"pageInfo\""); + output.Text.Should().Contain("\"nextCursor\": \"page-2\""); + output.Text.Should().Contain("\"cursor\": \"start\""); + output.Text.Should().Contain("\"totalCount\": 2"); + output.Text.Should().NotContain("itemType"); + output.Text.Should().NotContain("untypedItems"); + } + + [TestMethod] + [Description("Regression guard: verifies paged results render their current page in markdown output.")] + public void When_RenderingPagedResultInMarkdown_Then_ItemsAndContinuationAreRendered() + { + var sut = ReplApp.Create(); + sut.Map("contact list", (IReplPagingContext paging) => + paging.Page( + new[] + { + new ContactMarkdownRow(1, "Alice Martin", "alice@example.com"), + }, + nextCursor: "page-2")); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["contact", "list", "--markdown", "--result:page-size=1", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("| Id | Name | Email |"); + output.Text.Should().Contain("| 1 | Alice Martin | alice@example.com |"); + output.Text.Should().Contain("`--result:cursor page-2`"); + } + [TestMethod] [Description("Regression guard: verifies requesting unknown output format so that user gets a clear error and non-zero exit code.")] public void When_RenderingWithUnknownFormat_Then_ClearErrorIsShownAndExitCodeIsNonZero() @@ -457,13 +577,38 @@ public void When_SpectreOutputAndPreferredRenderWidthIsConfigured_Then_TableRows var output = ConsoleCaptureHelper.Capture(() => sut.Run(["contact", "list", "--no-logo"])); var lines = output.Text - .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(StripAnsi); output.ExitCode.Should().Be(0); lines.Should().OnlyContain(line => line.Length <= width); output.Text.Should().Contain("ong@example.com"); } + [TestMethod] + [Description("Regression guard: verifies Spectre renders paged result pages with continuation metadata.")] + public void When_SpectreOutputAndPagedResult_Then_ItemsAndContinuationAreRendered() + { + var sut = ReplApp.Create(services => services.AddSpectreConsole()) + .UseSpectreConsole(); + sut.Map("contact list", (IReplPagingContext paging) => + paging.Page( + new[] + { + new ContactRow("Alice Martin", "alice@example.com"), + }, + nextCursor: "page-2", + totalCount: 2)); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["contact", "list", "--result:page-size=1", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Alice Martin"); + output.Text.Should().Contain("Showing 1 of 2."); + output.Text.Should().Contain("--result:cursor page-2"); + } + [TestMethod] [Description("Regression guard: verifies --human remains available even when Spectre is the default output format.")] public void When_UsingHumanAliasWithSpectreDefault_Then_ClassicHumanTransformerIsUsed() @@ -519,6 +664,29 @@ public AnsiPalette Create(ThemeMode themeMode) => DescriptionStyle: "\u001b[33m"); } + private sealed class QueueKeyReader(IEnumerable keys) : IReplKeyReader + { + private readonly Queue _keys = new(keys); + + public bool KeyAvailable => _keys.Count > 0; + + public ValueTask ReadKeyAsync(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return _keys.TryDequeue(out var key) + ? ValueTask.FromResult(key) + : throw new InvalidOperationException("No key available in QueueKeyReader."); + } + } + + private static ConsoleKeyInfo Key(ConsoleKey key, char keyChar) => + new(keyChar, key, shift: false, alt: false, control: false); + + private static string StripAnsi(string value) => + BuildAnsiEscapeRegex().Replace(value, string.Empty); + + [GeneratedRegex(@"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", RegexOptions.None, matchTimeoutMilliseconds: 50)] + private static partial Regex BuildAnsiEscapeRegex(); } diff --git a/src/Repl.Mcp/McpResultFlowArgumentNames.cs b/src/Repl.Mcp/McpResultFlowArgumentNames.cs new file mode 100644 index 0000000..fbb82f9 --- /dev/null +++ b/src/Repl.Mcp/McpResultFlowArgumentNames.cs @@ -0,0 +1,7 @@ +namespace Repl.Mcp; + +internal static class McpResultFlowArgumentNames +{ + public const string Cursor = "_replCursor"; + public const string PageSize = "_replPageSize"; +} diff --git a/src/Repl.Mcp/McpSchemaGenerator.cs b/src/Repl.Mcp/McpSchemaGenerator.cs index 275d167..970b77c 100644 --- a/src/Repl.Mcp/McpSchemaGenerator.cs +++ b/src/Repl.Mcp/McpSchemaGenerator.cs @@ -57,6 +57,7 @@ public static JsonElement BuildInputSchema(ReplDocCommand command) } AddAnswerProperties(command, properties); + AddResultFlowProperties(properties); var schema = new JsonObject { @@ -72,6 +73,21 @@ public static JsonElement BuildInputSchema(ReplDocCommand command) return JsonSerializer.SerializeToElement(schema, McpJsonContext.Default.JsonObject); } + private static void AddResultFlowProperties(JsonObject properties) + { + properties[McpResultFlowArgumentNames.Cursor] = new JsonObject + { + ["type"] = "string", + ["description"] = "Opaque Repl continuation cursor returned by a previous paged tool result.", + }; + properties[McpResultFlowArgumentNames.PageSize] = new JsonObject + { + ["type"] = "integer", + ["description"] = "Requested Repl page size for large tool results.", + ["minimum"] = 1, + }; + } + private static void AddAnswerProperties(ReplDocCommand command, JsonObject properties) { if (command.Answers is not { Count: > 0 }) diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 061db90..6413399 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -145,20 +145,89 @@ private async Task ExecuteThroughPipelineAsync( output = exitCode == 0 ? "OK" : $"Command failed with exit code {exitCode}."; } + return BuildToolResult(output, exitCode); + } + } + + private static CallToolResult BuildToolResult(string output, int exitCode) + { + if (exitCode == 0 && TryCreatePagedStructuredResult(output, out var structuredContent, out var summary)) + { return new CallToolResult { - Content = [new TextContentBlock { Text = output }], - IsError = exitCode != 0, + Content = [new TextContentBlock { Text = summary }], + StructuredContent = structuredContent, + IsError = false, }; } + + return new CallToolResult + { + Content = [new TextContentBlock { Text = output }], + IsError = exitCode != 0, + }; } - private static (List Tokens, Dictionary Prefills) PrepareExecution( + private static bool TryCreatePagedStructuredResult( + string output, + out JsonElement structuredContent, + out string summary) + { + structuredContent = default; + summary = string.Empty; + try + { + using var document = JsonDocument.Parse(output); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("$type", out var type) + || type.ValueKind != JsonValueKind.String + || !string.Equals(type.GetString(), "page", StringComparison.Ordinal) + || !root.TryGetProperty("items", out var items) + || items.ValueKind != JsonValueKind.Array + || !root.TryGetProperty("pageInfo", out var pageInfo) + || pageInfo.ValueKind != JsonValueKind.Object) + { + return false; + } + + structuredContent = root.Clone(); + summary = BuildPagedSummary(items.GetArrayLength(), pageInfo); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static string BuildPagedSummary(int count, JsonElement pageInfo) + { + var summary = $"Returned {count.ToString(System.Globalization.CultureInfo.InvariantCulture)} item(s)."; + if (pageInfo.TryGetProperty("totalCount", out var totalCount) + && totalCount.ValueKind == JsonValueKind.Number + && totalCount.TryGetInt64(out var total)) + { + summary += $" Total: {total.ToString(System.Globalization.CultureInfo.InvariantCulture)}."; + } + + if (pageInfo.TryGetProperty("nextCursor", out var nextCursor) + && nextCursor.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(nextCursor.GetString())) + { + summary += $" Continue with {McpResultFlowArgumentNames.Cursor}; cursor available in structured content."; + } + + return summary; + } + + internal static (List Tokens, Dictionary Prefills) PrepareExecution( string routePath, IDictionary arguments) { var stringArgs = new Dictionary(StringComparer.OrdinalIgnoreCase); var prefills = new Dictionary(StringComparer.OrdinalIgnoreCase); + var resultFlowTokens = new List(); foreach (var (key, value) in arguments) { @@ -170,13 +239,58 @@ private static (List Tokens, Dictionary Prefills) Prepar { prefills[key["answer.".Length..]] = strValue; } + else if (string.Equals(key, McpResultFlowArgumentNames.Cursor, StringComparison.Ordinal)) + { + ValidateResultCursor(strValue); + resultFlowTokens.Add("--result:cursor"); + resultFlowTokens.Add(strValue); + } + else if (string.Equals(key, McpResultFlowArgumentNames.PageSize, StringComparison.Ordinal)) + { + ValidateResultPageSize(strValue); + resultFlowTokens.Add("--result:page-size"); + resultFlowTokens.Add(strValue); + } else { stringArgs[key] = strValue; } } - return (ReconstructTokens(routePath, stringArgs), prefills); + var tokens = ReconstructTokens(routePath, stringArgs); + tokens.InsertRange(0, resultFlowTokens); + return (tokens, prefills); + } + + private static void ValidateResultCursor(string cursor) + { + if (cursor.Length > 512) + { + throw new InvalidOperationException("The MCP result cursor cannot exceed 512 characters."); + } + + if (cursor.Length > 0 && cursor[0] == '-') + { + throw new InvalidOperationException("The MCP result cursor cannot start like a CLI option."); + } + + if (cursor.Any(char.IsWhiteSpace)) + { + throw new InvalidOperationException("The MCP result cursor cannot contain whitespace."); + } + } + + private static void ValidateResultPageSize(string pageSize) + { + if (pageSize.Length > 20) + { + throw new InvalidOperationException("The MCP result page size cannot exceed 20 characters."); + } + + if (pageSize.Length == 0 || pageSize.Any(static c => c < '0' || c > '9')) + { + throw new InvalidOperationException("The MCP result page size must be numeric."); + } } /// diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index 9497e02..07166cc 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -51,6 +51,10 @@ public async Task When_ToolsList_Then_SchemaIsCorrect() .Should().Be("string"); schema.GetProperty("properties").GetProperty("id").GetProperty("format").GetString() .Should().Be("uuid"); + schema.GetProperty("properties").TryGetProperty("_replCursor", out _) + .Should().BeTrue("MCP tools should expose Repl continuation cursors for paged data"); + schema.GetProperty("properties").TryGetProperty("_replPageSize", out _) + .Should().BeTrue("MCP tools should expose Repl page sizing for large data"); schema.GetProperty("required")[0].GetString() .Should().Be("id"); } @@ -74,6 +78,107 @@ public async Task When_ToolsCall_Then_ReturnsCommandOutput() textBlock!.Text.Should().Contain("Hello, Alice!"); } + [TestMethod] + [Description("tools/call returns paged results as structured content with a continuation summary.")] + public async Task When_ToolsCallReturnsPagedResult_Then_StructuredContentContainsPageInfo() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("contacts", (IReplPagingContext paging) => + paging.Page( + new[] + { + new ContactDto(1, "Alice"), + }, + nextCursor: "page-2", + totalCount: 2)) + .ReadOnly(); + }); + + var result = await fixture.Client.CallToolAsync( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + ["_replPageSize"] = 1, + ["_replCursor"] = "start", + }); + + result.IsError.Should().NotBeTrue(); + result.StructuredContent.Should().NotBeNull(); + var root = result.StructuredContent!.Value; + root.GetProperty("items").GetArrayLength().Should().Be(1); + root.GetProperty("pageInfo").GetProperty("cursor").GetString().Should().Be("start"); + root.GetProperty("pageInfo").GetProperty("nextCursor").GetString().Should().Be("page-2"); + root.GetProperty("pageInfo").GetProperty("totalCount").GetInt64().Should().Be(2); + var text = result.Content.OfType().FirstOrDefault()?.Text + ?? throw new AssertFailedException("Expected a text content block."); + text.Should().Contain("Returned 1 item(s)."); + text.Should().Contain("cursor available"); + text.Should().NotContain("page-2"); + } + + [TestMethod] + [Description("tools/call does not treat arbitrary JSON objects with items and pageInfo properties as paged results.")] + public async Task When_ToolsCallReturnsPageShapedObject_Then_ResultIsPlainText() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map( + "shape", + () => new + { + Items = PageShapedItems, + PageInfo = new { NextCursor = "raw-cursor" }, + }) + .ReadOnly(); + }); + + var result = await fixture.Client.CallToolAsync( + "shape", + new Dictionary(StringComparer.Ordinal)); + + result.StructuredContent.Should().BeNull(); + result.Content.OfType().Single().Text.Should().Contain("not-a-page"); + } + + [TestMethod] + [Description("tools/call returns page-source results as structured pages and consumes MCP cursor arguments.")] + public async Task When_ToolsCallReturnsPageSource_Then_CursorFetchesNextPage() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Map("contacts", () => ReplPageSource.FromItems( + [ + new ContactDto(1, "Alice"), + new ContactDto(2, "Bob"), + ])) + .ReadOnly(); + }); + + var first = await fixture.Client.CallToolAsync( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + ["_replPageSize"] = 1, + }); + var firstRoot = first.StructuredContent!.Value; + var nextCursor = firstRoot.GetProperty("pageInfo").GetProperty("nextCursor").GetString(); + + var second = await fixture.Client.CallToolAsync( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + ["_replPageSize"] = 1, + ["_replCursor"] = nextCursor, + }); + + second.IsError.Should().NotBeTrue(); + var secondRoot = second.StructuredContent!.Value; + secondRoot.GetProperty("items")[0].GetProperty("name").GetString().Should().Be("Bob"); + secondRoot.GetProperty("pageInfo").GetProperty("cursor").GetString().Should().Be(nextCursor); + secondRoot.GetProperty("pageInfo").GetProperty("hasMore").GetBoolean().Should().BeFalse(); + } + [TestMethod] [Description("Context commands are flattened into underscore-separated tool names.")] public async Task When_ContextCommands_Then_FlattenedToolNames() @@ -304,6 +409,10 @@ private sealed class MarkerService private sealed class AnotherService; + private sealed record ContactDto(int Id, string Name); + + private static readonly string[] PageShapedItems = ["not-a-page"]; + // ── Prompts ──────────────────────────────────────────────────────── [TestMethod] diff --git a/src/Repl.McpTests/Given_McpToolAdapter.cs b/src/Repl.McpTests/Given_McpToolAdapter.cs index a449361..ee87f6e 100644 --- a/src/Repl.McpTests/Given_McpToolAdapter.cs +++ b/src/Repl.McpTests/Given_McpToolAdapter.cs @@ -1,4 +1,5 @@ using Repl.Mcp; +using System.Text.Json; namespace Repl.McpTests; @@ -87,4 +88,107 @@ public void When_MixedArguments_Then_ReconstructedCorrectly() tokens.Should().BeEquivalentTo(["contact", "42", "delete", "--verbose", "true"]); } + + [TestMethod] + [Description("PrepareExecution accepts compact opaque result cursors and emits them as result-flow tokens.")] + public void When_ResultCursorIsValid_Then_ResultFlowTokenIsEmitted() + { + var (tokens, _) = McpToolAdapter.PrepareExecution( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement("abc_DEF-123"), + }); + + tokens.Should().ContainInOrder("--result:cursor", "abc_DEF-123", "contacts"); + } + + [TestMethod] + [Description("PrepareExecution rejects result cursors that could be confused with CLI token boundaries.")] + public void When_ResultCursorContainsWhitespace_Then_Rejected() + { + var action = () => McpToolAdapter.PrepareExecution( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement("abc def"), + }); + + action.Should().Throw() + .WithMessage("*cursor*whitespace*"); + } + + [TestMethod] + [Description("PrepareExecution rejects result cursors that start like CLI options.")] + public void When_ResultCursorStartsWithDash_Then_Rejected() + { + var action = () => McpToolAdapter.PrepareExecution( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement("--result:all"), + }); + + action.Should().Throw() + .WithMessage("*cursor*option*"); + } + + [TestMethod] + [Description("PrepareExecution rejects overly large result cursors.")] + public void When_ResultCursorIsTooLong_Then_Rejected() + { + var action = () => McpToolAdapter.PrepareExecution( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement(new string('a', 513)), + }); + + action.Should().Throw() + .WithMessage("*cursor*512*"); + } + + [TestMethod] + [Description("PrepareExecution accepts compact numeric result page sizes and emits them as result-flow tokens.")] + public void When_ResultPageSizeIsValid_Then_ResultFlowTokenIsEmitted() + { + var (tokens, _) = McpToolAdapter.PrepareExecution( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + [McpResultFlowArgumentNames.PageSize] = JsonSerializer.SerializeToElement(25), + }); + + tokens.Should().ContainInOrder("--result:page-size", "25", "contacts"); + } + + [TestMethod] + [Description("PrepareExecution rejects result page sizes that are not numeric.")] + public void When_ResultPageSizeIsNotNumeric_Then_Rejected() + { + var action = () => McpToolAdapter.PrepareExecution( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + [McpResultFlowArgumentNames.PageSize] = JsonSerializer.SerializeToElement("abc"), + }); + + action.Should().Throw() + .WithMessage("*page size*numeric*"); + } + + [TestMethod] + [Description("PrepareExecution rejects overly large result page size tokens.")] + public void When_ResultPageSizeTokenIsTooLong_Then_Rejected() + { + var action = () => McpToolAdapter.PrepareExecution( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + [McpResultFlowArgumentNames.PageSize] = JsonSerializer.SerializeToElement(new string('1', 21)), + }); + + action.Should().Throw() + .WithMessage("*page size*20*"); + } } diff --git a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs index 1e3b451..0670fb8 100644 --- a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs +++ b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs @@ -29,6 +29,9 @@ public SpectreHumanOutputTransformer(Func resolveRenderSett /// public string Name => "spectre"; + /// + public bool SupportsInteractivePaging => true; + /// public ValueTask TransformAsync(object? value, CancellationToken cancellationToken = default) { @@ -42,6 +45,7 @@ public ValueTask TransformAsync(object? value, CancellationToken cancell return ValueTask.FromResult(value switch { HelpRenderDocument help => RenderHelp(help), + IReplPage page => RenderPage(page), IReplResult replResult => RenderReplResult(replResult), string text => text, System.Collections.IEnumerable enumerable => RenderEnumerable(enumerable), @@ -119,6 +123,13 @@ private string RenderSingleCommandHelp(HelpRenderCommand command) sections.Add(BuildEntryTable(command.Options)); } + if (command.ResultFlow.Count > 0) + { + AppendSpacer(sections); + sections.Add(new Markup("[bold]Result Flow[/]")); + sections.Add(BuildEntryTable(command.ResultFlow)); + } + if (command.Answers.Count > 0) { AppendSpacer(sections); @@ -157,7 +168,9 @@ private string RenderReplResult(IReplResult result) return RenderToString(new Markup(statusMarkup)); } - var details = RenderValueRenderable(result.Details, nested: false); + var details = result.Details is IReplPage page + ? new Text(RenderPage(page)) + : RenderValueRenderable(result.Details, nested: false); return RenderToString(new Rows(new IRenderable[] { new Markup(statusMarkup), @@ -198,6 +211,37 @@ private string RenderEnumerable(System.Collections.IEnumerable enumerable) return RenderToString(BuildObjectTable(items, members)); } + private string RenderPage(IReplPage page) + { + var body = page.UntypedItems.Count == 0 + ? "No results." + : RenderEnumerable(page.UntypedItems); + var footer = RenderPageFooter(page); + return string.IsNullOrWhiteSpace(footer) + ? body + : string.Concat(body, Environment.NewLine, footer); + } + + private static string RenderPageFooter(IReplPage page) + { + var info = page.PageInfo; + var count = page.UntypedItems.Count; + if (info.TotalCount is { } total) + { + var prefix = $"Showing {count.ToString(CultureInfo.InvariantCulture)} of {total.ToString(CultureInfo.InvariantCulture)}."; + return info.HasMore + ? $"{prefix} Next data page: rerun with --result:cursor {info.NextCursor}." + : prefix; + } + + if (!info.HasMore) + { + return string.Empty; + } + + return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Next data page: rerun with --result:cursor {info.NextCursor}."; + } + private bool TryRenderObject(object value, out string text) { var members = GetDisplayMembers(value.GetType()); @@ -251,7 +295,7 @@ private static Table BuildObjectTable(object?[] items, IReadOnlyList commands) { var table = new Table() diff --git a/src/Repl.Tests/Given_GlobalOptionParser.cs b/src/Repl.Tests/Given_GlobalOptionParser.cs index b2d3e94..342212c 100644 --- a/src/Repl.Tests/Given_GlobalOptionParser.cs +++ b/src/Repl.Tests/Given_GlobalOptionParser.cs @@ -105,4 +105,36 @@ public void When_BoolGlobalOptionWithInlineValue_Then_ValueIsUsed() parsed.RemainingTokens.Should().Equal("deploy"); parsed.CustomGlobalNamedOptions["verbose"].Should().ContainSingle().Which.Should().Be("false"); } + + [TestMethod] + [Description("Result-flow global options are consumed before command parsing and stored separately from custom global options.")] + public void When_ResultFlowOptionsArePresent_Then_ParserConsumesThemIntoResultFlow() + { + var parsed = GlobalOptionParser.Parse( + ["users", "list", "--result:page-size=25", "--result:cursor", "abc", "--result:all", "--result:pager=off"], + new OutputOptions(), + new ParsingOptions()); + + parsed.RemainingTokens.Should().Equal("users", "list"); + parsed.CustomGlobalNamedOptions.Should().BeEmpty(); + parsed.ResultFlow.PageSize.Should().Be(25); + parsed.ResultFlow.Cursor.Should().Be("abc"); + parsed.ResultFlow.AllRequested.Should().BeTrue(); + parsed.ResultFlow.PagerMode.Should().Be(ReplPagerMode.Off); + } + + [TestMethod] + [Description("Result-flow page size is clamped during global option parsing before it reaches handlers or page sources.")] + public void When_ResultFlowPageSizeExceedsMaximum_Then_ParserClampsIt() + { + var outputOptions = new OutputOptions(); + outputOptions.ResultFlow.MaxPageSize = 50; + + var parsed = GlobalOptionParser.Parse( + ["users", "list", "--result:page-size=2147483647"], + outputOptions, + new ParsingOptions()); + + parsed.ResultFlow.PageSize.Should().Be(50); + } } diff --git a/src/Repl.Tests/Given_HandlerBinding.cs b/src/Repl.Tests/Given_HandlerBinding.cs index e0fd811..776b857 100644 --- a/src/Repl.Tests/Given_HandlerBinding.cs +++ b/src/Repl.Tests/Given_HandlerBinding.cs @@ -160,6 +160,37 @@ public void When_HandlerReturnsValueTaskOfResult_Then_ExitCodeReflectsResolvedRe exitCode.Should().Be(1); } + [TestMethod] + [Description("Result-flow paging context is injected so handlers can page data at the source.")] + public void When_HandlerRequestsPagingContext_Then_ResultFlowOptionsAreAvailable() + { + var sut = ReplApp.Create(); + IReplPagingContext? captured = null; + ReplPage? page = null; + + sut.Map("users list", (IReplPagingContext paging) => + { + captured = paging; + page = paging.Page(["Alice", "Bob"], nextCursor: "next", totalCount: 3); + return "ok"; + }); + + var exitCode = sut.Run( + ["users", "list", "--result:page-size=2", "--result:cursor=start", "--no-logo"]); + + exitCode.Should().Be(0); + captured.Should().NotBeNull(); + captured!.SuggestedPageSize.Should().Be(2); + captured.Cursor.Should().Be("start"); + captured.MaxPageSize.Should().BeGreaterThanOrEqualTo(2); + page.Should().NotBeNull(); + page!.Items.Should().Equal("Alice", "Bob"); + page.PageInfo.Cursor.Should().Be("start"); + page.PageInfo.NextCursor.Should().Be("next"); + page.PageInfo.TotalCount.Should().Be(3); + page.PageInfo.HasMore.Should().BeTrue(); + } + private interface ITestCounter { int Value { get; } @@ -175,4 +206,3 @@ private sealed class TestCounter(int value) : ITestCounter - diff --git a/src/Repl.Tests/Given_ReplPageSource.cs b/src/Repl.Tests/Given_ReplPageSource.cs new file mode 100644 index 0000000..701d46b --- /dev/null +++ b/src/Repl.Tests/Given_ReplPageSource.cs @@ -0,0 +1,446 @@ +namespace Repl.Tests; + +using System.Runtime.CompilerServices; + +[TestClass] +public sealed class Given_ReplPageSource +{ + [TestMethod] + [Description("ReplPageSource.FromItems uses offset cursors so in-memory result sets can be paged without custom interface implementations.")] + public async Task When_FromItemsFetchesPages_Then_EmitsOffsetCursor() + { + var source = ReplPageSource.FromItems(["one", "two", "three"]); + + var first = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + var second = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: first.PageInfo.NextCursor, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + first.Items.Should().Equal("one", "two"); + first.PageInfo.NextCursor.Should().Be("2"); + first.PageInfo.HasMore.Should().BeTrue(); + second.Items.Should().Equal("three"); + second.PageInfo.NextCursor.Should().BeNull(); + second.PageInfo.HasMore.Should().BeFalse(); + } + + [TestMethod] + [Description("ReplPageSource.FromOffset fetches one extra row so offset-based stores do not need to compute totals.")] + public async Task When_FromOffsetFetchesPages_Then_EmitsOffsetCursor() + { + var all = new[] { "one", "two", "three" }; + var source = ReplPageSource.FromOffset((offset, take, _) => + ValueTask.FromResult>(all.Skip(offset).Take(take).ToArray()), all.Length); + + var first = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + var second = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: first.PageInfo.NextCursor, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + first.Items.Should().Equal("one", "two"); + first.PageInfo.NextCursor.Should().Be("2"); + first.PageInfo.TotalCount.Should().Be(3); + second.Items.Should().Equal("three"); + second.PageInfo.HasMore.Should().BeFalse(); + } + + [TestMethod] + [Description("ReplPageSource.FromOffset supports state arguments so handlers can use static lambdas without closure allocations.")] + public async Task When_FromOffsetUsesState_Then_StaticFetchCanReadState() + { + var state = new PageStore(["one", "two", "three"]); + var source = ReplPageSource.FromOffset( + state, + static (store, offset, take, _) => + ValueTask.FromResult>(store.Items.Skip(offset).Take(take).ToArray())); + + var page = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + page.Items.Should().Equal("one", "two"); + page.PageInfo.NextCursor.Should().Be("2"); + page.PageInfo.TotalCount.Should().BeNull(); + } + + [TestMethod] + [Description("ReplPageSource.FromOffset can apply a client-side filter after source paging and before the final page is emitted.")] + public async Task When_FromOffsetUsesClientSideFilter_Then_PageContainsFilteredItems() + { + var state = new PageStore(["one", "two", "three", "four", "five", "six"]); + var source = ReplPageSource.FromOffset( + state, + static (store, offset, take, _) => + ValueTask.FromResult>(store.Items.Skip(offset).Take(take).ToArray()), + filter: static (_, item) => item.Length == 3); + + var first = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + var second = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: first.PageInfo.NextCursor, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + first.Items.Should().Equal("one", "two"); + first.PageInfo.NextCursor.Should().Be("2"); + first.PageInfo.TotalCount.Should().BeNull(); + second.Items.Should().Equal("six"); + second.PageInfo.HasMore.Should().BeFalse(); + } + + [TestMethod] + [Description("ReplPageSource.FromOffset fails clearly when All is requested because unbounded source paging can exhaust memory.")] + public async Task When_FromOffsetReceivesAllRequest_Then_FailsClearly() + { + var source = ReplPageSource.FromOffset( + static (_, take, _) => ValueTask.FromResult>(Enumerable.Range(0, take).ToArray())); + + var action = async () => await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: true, + Surface: ReplResultSurface.Console)).ConfigureAwait(false); + + await action.Should().ThrowAsync() + .WithMessage("*--result:all*not supported*") + .ConfigureAwait(false); + } + + [TestMethod] + [Description("ReplPageSource.FromOffset treats an explicit zero cursor as the first offset.")] + public async Task When_FromOffsetReceivesZeroCursor_Then_StartsAtFirstItem() + { + var source = ReplPageSource.FromOffset( + static (offset, take, _) => ValueTask.FromResult>( + Enumerable.Range(offset, take).ToArray())); + + var page = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: "0", + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + page.Items.Should().Equal(0, 1); + } + + [TestMethod] + [Description("ReplPageSource.FromAsyncEnumerable pages async streams with offset cursors.")] + public async Task When_FromAsyncEnumerableFetchesPages_Then_EmitsOffsetCursor() + { + var source = ReplPageSource.FromAsyncEnumerable(_ => ReadItemsAsync(["one", "two", "three"])); + + var first = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + var second = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: first.PageInfo.NextCursor, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + first.Items.Should().Equal("one", "two"); + first.PageInfo.NextCursor.Should().Be("2"); + first.PageInfo.HasMore.Should().BeTrue(); + second.Items.Should().Equal("three"); + second.PageInfo.NextCursor.Should().BeNull(); + second.PageInfo.HasMore.Should().BeFalse(); + } + + [TestMethod] + [Description("ReplPageSource.FromAsyncEnumerable requires deterministic replay so page two returns the raw offset continuation.")] + public async Task When_FromAsyncEnumerableFactoryIsDeterministic_Then_SecondPageUsesRawOffset() + { + var source = ReplPageSource.FromAsyncEnumerable(_ => ReadItemsAsync(["one", "two", "three", "four"])); + + var first = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + var second = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: first.PageInfo.NextCursor, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + second.Items.Should().Equal("three", "four"); + second.PageInfo.Cursor.Should().Be("2"); + } + + [TestMethod] + [Description("ReplPageSource.FromAsyncEnumerable requires a replayable factory and fails clearly when the factory returns a single-use stream.")] + public async Task When_FromAsyncEnumerableFactoryIsNotReplayable_Then_SecondPageFailsClearly() + { + var state = new SingleUseAsyncEnumerable(["one", "two", "three"]); + var source = ReplPageSource.FromAsyncEnumerable(_ => state); + + var first = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + var action = async () => await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: first.PageInfo.NextCursor, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)).ConfigureAwait(false); + + await action.Should().ThrowAsync() + .WithMessage("*replayable*") + .ConfigureAwait(false); + } + + [TestMethod] + [Description("ReplPageSource.FromOffset enforces the client-side filter scan limit per source item.")] + public async Task When_FilteredOffsetSourceExceedsScanLimit_Then_FailsBeforeFetchingAnotherBatch() + { + var fetches = 0; + var source = ReplPageSource.FromOffset( + (_, take, _) => + { + fetches++; + return ValueTask.FromResult>(Enumerable.Range(0, take).ToArray()); + }, + filter: static _ => false, + maxSourceItemsToScan: 2); + + var action = async () => await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)).ConfigureAwait(false); + + await action.Should().ThrowAsync() + .WithMessage("*scan limit*") + .ConfigureAwait(false); + fetches.Should().Be(1); + } + + [TestMethod] + [Description("ReplPageSource.FromAsyncEnumerable passes cancellation to the async stream.")] + public async Task When_FromAsyncEnumerableIsCancelled_Then_SourceObservesCancellation() + { + using var cts = new CancellationTokenSource(); + var observed = false; + var source = ReplPageSource.FromAsyncEnumerable(ct => ReadUntilCancelledAsync(() => observed = true, ct)); + await cts.CancelAsync().ConfigureAwait(false); + + var action = async () => await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console), + cts.Token).ConfigureAwait(false); + + await action.Should().ThrowAsync().ConfigureAwait(false); + observed.Should().BeTrue(); + } + + [TestMethod] + [Description("ReplPageSource.FromAsyncEnumerable supports state arguments and client-side filtering over replayable streams.")] + public async Task When_FromAsyncEnumerableUsesStateAndFilter_Then_StaticFactoryCanReadState() + { + var state = new PageStore(["one", "two", "three", "four"]); + var source = ReplPageSource.FromAsyncEnumerable( + state, + static (store, _) => ReadItemsAsync(store.Items), + filter: static (_, item) => item.Contains('o', StringComparison.Ordinal)); + + var page = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + page.Items.Should().Equal("one", "two"); + page.PageInfo.NextCursor.Should().Be("2"); + + var second = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: page.PageInfo.NextCursor, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + second.Items.Should().Equal("four"); + second.PageInfo.HasMore.Should().BeFalse(); + } + + [TestMethod] + [Description("ReplPageSource.FromItems can filter bounded in-memory data before applying the final page window.")] + public async Task When_FromItemsUsesFilter_Then_PagesFilteredItems() + { + var source = ReplPageSource.FromItems( + ["one", "two", "three", "four"], + static item => item.Contains('o', StringComparison.Ordinal)); + + var page = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: null, + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + page.Items.Should().Equal("one", "two"); + page.PageInfo.NextCursor.Should().Be("2"); + page.PageInfo.TotalCount.Should().Be(3); + } + + [TestMethod] + [Description("ReplPageRequest.Page copies request metadata and marks HasMore from the emitted cursor.")] + public void When_RequestCreatesPage_Then_PageInfoUsesRequestAndNextCursor() + { + var request = new ReplPageRequest( + PageSize: 5, + Cursor: "start", + VisibleRowCapacityHint: 10, + AllRequested: false, + Surface: ReplResultSurface.Console); + + var page = request.Page(["one"], nextCursor: "next", totalCount: 2); + + page.Items.Should().Equal("one"); + page.PageInfo.Cursor.Should().Be("start"); + page.PageInfo.NextCursor.Should().Be("next"); + page.PageInfo.TotalCount.Should().Be(2); + page.PageInfo.PageSize.Should().Be(5); + page.PageInfo.HasMore.Should().BeTrue(); + } + + [TestMethod] + [Description("ReplPageInfo derives HasMore from NextCursor so manual construction cannot create divergent page metadata.")] + public void When_PageInfoHasNoNextCursor_Then_HasMoreIsFalse() + { + var pageInfo = new ReplPageInfo( + Cursor: "current", + NextCursor: null, + TotalCount: null, + PageSize: 10); + + pageInfo.HasMore.Should().BeFalse(); + } + + [TestMethod] + [Description("ReplPage reuses object arrays for UntypedItems instead of allocating another array.")] + public void When_ReplPageItemsAreObjectArray_Then_UntypedItemsReusesArray() + { + object?[] items = ["one", 2]; + var page = new ReplPage( + items, + new ReplPageInfo( + Cursor: null, + NextCursor: null, + TotalCount: items.Length, + PageSize: items.Length)); + + page.UntypedItems.Should().BeSameAs(items); + } + + private static async IAsyncEnumerable ReadItemsAsync(IEnumerable items) + { + foreach (var item in items) + { + await Task.Yield(); + yield return item; + } + } + + private static async IAsyncEnumerable ReadUntilCancelledAsync( + Action observeCancellation, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + while (true) + { + if (cancellationToken.IsCancellationRequested) + { + observeCancellation(); + } + + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return 1; + } + } + + private sealed record PageStore(IReadOnlyList Items); + + private sealed class SingleUseAsyncEnumerable(IReadOnlyList items) : IAsyncEnumerable + { + private bool _used; + + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + if (_used) + { + throw new InvalidOperationException("The stream is not replayable."); + } + + _used = true; + foreach (var item in items) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + } + } + } +} diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs new file mode 100644 index 0000000..ad450d7 --- /dev/null +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -0,0 +1,417 @@ +using Repl.Tests.TerminalSupport; + +namespace Repl.Tests; + +[TestClass] +public sealed class Given_ResultFlowPager +{ + [TestMethod] + [Description("Result-flow pager advances by page on Space and stops on Q.")] + public async Task When_PagingWithSpaceAndQuit_Then_WritesOnlyRequestedPages() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree\nfour\nfive", + writer, + keys, + visibleRows: 2, + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain("one"); + output.Should().Contain("two"); + output.Should().Contain("three"); + output.Should().Contain("four"); + output.Should().NotContain("five"); + output.Should().Contain("--More--"); + output.Should().Contain("Space/PageDown: continue"); + output.Should().Contain("Enter/Down: line"); + output.Should().Contain("Up/PageUp: back"); + output.Should().Contain("q/Esc: stop"); + } + + [TestMethod] + [Description("Result-flow pager advances by one line on Enter.")] + public async Task When_PagingWithEnter_Then_AdvancesSingleLine() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Enter, '\r'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree\nfour", + writer, + keys, + visibleRows: 2, + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain("one"); + output.Should().Contain("two"); + output.Should().Contain("three"); + output.Should().NotContain("four"); + } + + [TestMethod] + [Description("Result-flow pager UpArrow moves back one line instead of jumping to the header.")] + public async Task When_PagingBackWithUpArrow_Then_DoesNotRepeatHeader() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + MakeKey(ConsoleKey.UpArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "# At Area Event Summary\nr1\nr2\nr3\nr4\nr5", + writer, + keys, + visibleRows: 2, + CancellationToken.None); + + var output = writer.ToString(); + output.Split("# At Area Event Summary", StringSplitOptions.None) + .Should().HaveCount(2); + output.Should().Contain("r1"); + output.Should().Contain("r2"); + output.Should().Contain("r3"); + } + + [TestMethod] + [Description("Result-flow pager fetches the next data page in the same interactive run.")] + public async Task When_CurrentPayloadEndsAndMoreDataExists_Then_SpaceFetchesNextPayload() + { + using var writer = new StringWriter(); + var fetches = 0; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo", + writer, + keys, + visibleRows: 2, + hasMorePayload: true, + fetchNextPayload: _ => + { + fetches++; + return ValueTask.FromResult( + new ResultFlowPagerPage("three\nfour", HasMore: false)); + }, + CancellationToken.None); + + fetches.Should().Be(1); + var output = writer.ToString(); + output.Should().Contain("one"); + output.Should().Contain("two"); + output.Should().Contain("three"); + output.Should().Contain("four"); + } + + [TestMethod] + [Description("Result-flow pager stops at a data-page boundary without fetching more data when the user quits.")] + public async Task When_CurrentPayloadEndsAndUserQuits_Then_DoesNotFetchNextPayload() + { + using var writer = new StringWriter(); + var fetches = 0; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo", + writer, + keys, + visibleRows: 2, + hasMorePayload: true, + fetchNextPayload: _ => + { + fetches++; + return ValueTask.FromResult( + new ResultFlowPagerPage("three\nfour", HasMore: false)); + }, + CancellationToken.None); + + fetches.Should().Be(0); + var output = writer.ToString(); + output.Should().Contain("one"); + output.Should().Contain("two"); + output.Should().NotContain("three"); + output.Should().NotContain("four"); + } + + [TestMethod] + [Description("Result-flow pager fetches the next data page instead of showing an empty --More-- prompt when a payload has no content.")] + public async Task When_CurrentPayloadIsEmptyAndMoreDataExists_Then_FetchesNextPayload() + { + using var writer = new StringWriter(); + var fetches = 0; + var keys = new FakeKeyReader([]); + + await ResultFlowPager.WriteAsync( + string.Empty, + writer, + keys, + visibleRows: 2, + hasMorePayload: true, + fetchNextPayload: _ => + { + fetches++; + return ValueTask.FromResult( + new ResultFlowPagerPage("one\ntwo", HasMore: false)); + }, + CancellationToken.None); + + fetches.Should().Be(1); + var output = writer.ToString(); + output.Should().Contain("one"); + output.Should().Contain("two"); + output.Should().NotContain("--More--"); + } + + [TestMethod] + [Description("Result-flow pager replays the previous full window when the user presses UpArrow at a data-page boundary.")] + public async Task When_AtPayloadBoundaryAndUserPressesUpArrow_Then_ReplaysPreviousWindow() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + MakeKey(ConsoleKey.UpArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree\nfour", + writer, + keys, + visibleRows: 2, + hasMorePayload: true, + fetchNextPayload: _ => throw new InvalidOperationException("Should not fetch while replaying the previous window."), + CancellationToken.None); + + var output = writer.ToString(); + output.Split("three", StringSplitOptions.None).Should().HaveCount(3); + output.Split("four", StringSplitOptions.None).Should().HaveCount(3); + } + + [TestMethod] + [Description("Result-flow scroll pager owns an alternate-screen viewport instead of relying on terminal scrollback.")] + public async Task When_ScrollPagerRunsWithAnsi_Then_UsesAlternateScreenViewport() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree", + writer, + keys, + visibleRows: 3, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain("\u001b[?1049h"); + output.Should().Contain("\u001b[?1049l"); + output.Should().Contain("\u001b[H\u001b[J"); + output.Should().Contain("one"); + output.Should().Contain("three"); + output.Should().Contain("q: quit"); + output.Should().NotContain("--More--"); + } + + [TestMethod] + [Description("Result-flow scroll pager fetches additional payloads into the same viewport when the user pages past the buffered end.")] + public async Task When_ScrollPagerReachesBufferedEnd_Then_FetchesNextPayload() + { + using var writer = new StringWriter(); + var fetches = 0; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree", + writer, + keys, + visibleRows: 3, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => + { + fetches++; + return ValueTask.FromResult( + new ResultFlowPagerPage("three\nfour", HasMore: false)); + }, + CancellationToken.None); + + fetches.Should().Be(1); + var output = writer.ToString(); + output.Should().Contain("one"); + output.Should().Contain("four"); + output.Should().Contain("\u001b[?1049h"); + } + + [TestMethod] + [Description("Result-flow scroll pager advances to the new buffered end when a fetch returns fewer lines than one viewport.")] + public async Task When_ScrollPagerFetchesShortPayload_Then_ViewportAdvances() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree\nfour", + writer, + keys, + visibleRows: 4, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => ValueTask.FromResult( + new ResultFlowPagerPage("five", HasMore: false)), + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain("3-5/5"); + output.Should().Contain("five"); + } + + [TestMethod] + [Description("Result-flow scroll pager does not fetch another payload when the current payload is exactly visible and the user presses Space once.")] + public async Task When_ScrollPagerContentExactlyFitsViewport_Then_SpaceDoesNotFetchImmediately() + { + using var writer = new StringWriter(); + var fetches = 0; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree", + writer, + keys, + visibleRows: 4, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => + { + fetches++; + return ValueTask.FromResult( + new ResultFlowPagerPage("four", HasMore: false)); + }, + CancellationToken.None); + + fetches.Should().Be(0); + writer.ToString().Should().NotContain("four"); + } + + [TestMethod] + [Description("Result-flow pager does not add a phantom empty line when a payload ends with a newline.")] + public async Task When_PayloadEndsWithNewline_Then_LineCountExcludesTrailingEmptyLine() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\n", + writer, + keys, + visibleRows: 3, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + CancellationToken.None); + + writer.ToString().Should().Contain("1-2/2"); + } + + [TestMethod] + [Description("Result-flow scroll pager treats unrecognized keys as no-ops and does not advance the viewport or trigger a fetch.")] + public async Task When_ScrollPagerUnknownKeyPressed_Then_ViewportDoesNotAdvanceAndNoFetch() + { + using var writer = new StringWriter(); + var fetches = 0; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.F1, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree\nfour", + writer, + keys, + visibleRows: 3, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => + { + fetches++; + return ValueTask.FromResult( + new ResultFlowPagerPage("five", HasMore: false)); + }, + CancellationToken.None); + + fetches.Should().Be(0); + writer.ToString().Should().NotContain("five"); + } + + [TestMethod] + [Description("Result-flow scroll pager advances the viewport only on Space/PageDown, not on Enter or other keys.")] + public async Task When_ScrollPagerEnterKeyPressed_Then_ViewportAdvancesByOneLine() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Enter, '\r'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree\nfour", + writer, + keys, + visibleRows: 3, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + CancellationToken.None); + + // Enter maps to DownArrow (one line); status bar should show 2-3/4, not 3-4/4 + writer.ToString().Should().Contain("2-3/4"); + } + + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => + new(keyChar, key, shift: false, alt: false, control: false); +}