From a43bad4019e38f272c46d853dabaa44b485e10fd Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 12:37:07 -0400 Subject: [PATCH 01/21] Add result flow paging contracts --- src/Repl.Core/CoreReplApp.Execution.cs | 64 +++++++++++++ src/Repl.Core/OutputOptions.cs | 5 + .../Parsing/GlobalInvocationOptions.cs | 2 + src/Repl.Core/Parsing/GlobalOptionParser.cs | 96 +++++++++++++++++++ .../ImplicitServiceParameterRegistry.cs | 1 + src/Repl.Core/ResultFlow/IReplPage.cs | 22 +++++ src/Repl.Core/ResultFlow/IReplPageSource.cs | 18 ++++ .../ResultFlow/IReplPagingContext.cs | 64 +++++++++++++ src/Repl.Core/ResultFlow/ReplPage.cs | 35 +++++++ src/Repl.Core/ResultFlow/ReplPageInfo.cs | 16 ++++ src/Repl.Core/ResultFlow/ReplPageRequest.cs | 16 ++++ src/Repl.Core/ResultFlow/ReplPagerMode.cs | 32 +++++++ src/Repl.Core/ResultFlow/ReplPagingContext.cs | 74 ++++++++++++++ src/Repl.Core/ResultFlow/ReplResultSurface.cs | 32 +++++++ .../ResultFlow/ResultFlowInvocationOptions.cs | 7 ++ src/Repl.Core/ResultFlow/ResultFlowOptions.cs | 32 +++++++ src/Repl.Tests/Given_GlobalOptionParser.cs | 17 ++++ src/Repl.Tests/Given_HandlerBinding.cs | 32 ++++++- 18 files changed, 564 insertions(+), 1 deletion(-) create mode 100644 src/Repl.Core/ResultFlow/IReplPage.cs create mode 100644 src/Repl.Core/ResultFlow/IReplPageSource.cs create mode 100644 src/Repl.Core/ResultFlow/IReplPagingContext.cs create mode 100644 src/Repl.Core/ResultFlow/ReplPage.cs create mode 100644 src/Repl.Core/ResultFlow/ReplPageInfo.cs create mode 100644 src/Repl.Core/ResultFlow/ReplPageRequest.cs create mode 100644 src/Repl.Core/ResultFlow/ReplPagerMode.cs create mode 100644 src/Repl.Core/ResultFlow/ReplPagingContext.cs create mode 100644 src/Repl.Core/ResultFlow/ReplResultSurface.cs create mode 100644 src/Repl.Core/ResultFlow/ResultFlowInvocationOptions.cs create mode 100644 src/Repl.Core/ResultFlow/ResultFlowOptions.cs diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 4717f7d..c0bfb28 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -831,6 +831,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 +849,69 @@ 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 + { + return null; + } + } + private static bool TryFindGlobalCommandOptionCollision( GlobalInvocationOptions globalOptions, HashSet knownOptionNames, diff --git a/src/Repl.Core/OutputOptions.cs b/src/Repl.Core/OutputOptions.cs index b315960..650efc2 100644 --- a/src/Repl.Core/OutputOptions.cs +++ b/src/Repl.Core/OutputOptions.cs @@ -90,6 +90,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..f70fac2 100644 --- a/src/Repl.Core/Parsing/GlobalOptionParser.cs +++ b/src/Repl.Core/Parsing/GlobalOptionParser.cs @@ -68,6 +68,12 @@ public static GlobalInvocationOptions Parse( continue; } + if (TryParseResultFlowOption(args, ref index, argument, optionComparison, options.ResultFlow, out var resultFlow)) + { + options = options with { ResultFlow = resultFlow }; + continue; + } + if (TryParsePromptAnswer(argument, promptAnswers)) { continue; @@ -143,6 +149,96 @@ private static bool TryParsePromptAnswer( return true; } + private static bool TryParseResultFlowOption( + IReadOnlyList args, + ref int index, + string argument, + StringComparison comparison, + ResultFlowInvocationOptions current, + 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, 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, out resultFlow); + } + + return ApplyResultFlowOption(token, "true", current, out resultFlow); + } + + private static bool ApplyResultFlowOption( + string name, + string value, + ResultFlowInvocationOptions current, + 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 = pageSize }; + } + + 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 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..d6f82c9 --- /dev/null +++ b/src/Repl.Core/ResultFlow/IReplPageSource.cs @@ -0,0 +1,18 @@ +namespace Repl; + +/// +/// Fetches pages of a result set on demand. +/// +/// Item type. +public interface IReplPageSource +{ + /// + /// Fetches a page for the supplied request. + /// + /// Page request. + /// Cancellation token. + /// The fetched page. + ValueTask> FetchAsync( + ReplPageRequest request, + CancellationToken cancellationToken = default); +} 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..9b30bdb --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPage.cs @@ -0,0 +1,35 @@ +namespace Repl; + +/// +/// Represents one page of a larger result set. +/// +/// Item type. +public sealed class ReplPage : IReplPage +{ + private object?[]? _untypedItems; + + /// + /// Initializes a new instance of the class. + /// + /// Items in the page. + /// Page metadata. + public ReplPage(IReadOnlyList items, ReplPageInfo pageInfo) + { + Items = items ?? throw new ArgumentNullException(nameof(items)); + PageInfo = pageInfo ?? throw new ArgumentNullException(nameof(pageInfo)); + } + + /// + /// Gets the typed items in the page. + /// + public IReadOnlyList Items { get; } + + /// + public Type ItemType => typeof(T); + + /// + public ReplPageInfo PageInfo { get; } + + /// + public IReadOnlyList UntypedItems => _untypedItems ??= Items.Cast().ToArray(); +} diff --git a/src/Repl.Core/ResultFlow/ReplPageInfo.cs b/src/Repl.Core/ResultFlow/ReplPageInfo.cs new file mode 100644 index 0000000..024b7cc --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPageInfo.cs @@ -0,0 +1,16 @@ +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. +/// Whether another page is available. +public sealed record ReplPageInfo( + string? Cursor, + string? NextCursor, + long? TotalCount, + int PageSize, + bool HasMore); 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/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..c3c8cc0 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPagingContext.cs @@ -0,0 +1,74 @@ +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, + HasMore: !string.IsNullOrWhiteSpace(nextCursor)); + return new ReplPage(items, pageInfo); + } + + public IReplPageSource CreateSource( + Func>> fetch) + { + ArgumentNullException.ThrowIfNull(fetch); + return new DelegateReplPageSource(fetch); + } + + internal ReplPageRequest CreateRequest() => + new(SuggestedPageSize, Cursor, VisibleRowCapacityHint, AllRequested, Surface); + + private static int ClampPageSize(int value, int maxPageSize) => + Math.Clamp(value, 1, maxPageSize); + + 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/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.Tests/Given_GlobalOptionParser.cs b/src/Repl.Tests/Given_GlobalOptionParser.cs index b2d3e94..39b3859 100644 --- a/src/Repl.Tests/Given_GlobalOptionParser.cs +++ b/src/Repl.Tests/Given_GlobalOptionParser.cs @@ -105,4 +105,21 @@ 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); + } } 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 - From 96fde43d71d5420330f879a286c6a22d9820891a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 12:40:21 -0400 Subject: [PATCH 02/21] Render paged results in core formats --- .../Output/HumanOutputTransformer.cs | 41 ++++++++++ .../Output/MarkdownOutputTransformer.cs | 38 ++++++++++ src/Repl.Core/ResultFlow/ReplPage.cs | 4 + .../Given_OutputFormatting.cs | 74 +++++++++++++++++++ 4 files changed, 157 insertions(+) diff --git a/src/Repl.Core/Output/HumanOutputTransformer.cs b/src/Repl.Core/Output/HumanOutputTransformer.cs index ec68760..8938b57 100644 --- a/src/Repl.Core/Output/HumanOutputTransformer.cs +++ b/src/Repl.Core/Output/HumanOutputTransformer.cs @@ -33,6 +33,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 +85,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} Continue with --result:cursor {info.NextCursor}." + : prefix; + } + + if (!info.HasMore) + { + return string.Empty; + } + + return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Continue with --result:cursor {info.NextCursor}."; + } + private static bool TryRenderObject(object value, HumanRenderSettings settings, out string text) { var members = GetDisplayMembers(value.GetType()); @@ -363,6 +399,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/MarkdownOutputTransformer.cs b/src/Repl.Core/Output/MarkdownOutputTransformer.cs index 9b02371..829a46c 100644 --- a/src/Repl.Core/Output/MarkdownOutputTransformer.cs +++ b/src/Repl.Core/Output/MarkdownOutputTransformer.cs @@ -31,6 +31,11 @@ public ValueTask TransformAsync(object? value, CancellationToken cancell 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 +73,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,6 +87,37 @@ 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(); diff --git a/src/Repl.Core/ResultFlow/ReplPage.cs b/src/Repl.Core/ResultFlow/ReplPage.cs index 9b30bdb..9277efe 100644 --- a/src/Repl.Core/ResultFlow/ReplPage.cs +++ b/src/Repl.Core/ResultFlow/ReplPage.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Repl; /// @@ -25,11 +27,13 @@ public ReplPage(IReadOnlyList items, ReplPageInfo pageInfo) public IReadOnlyList Items { get; } /// + [JsonIgnore] public Type ItemType => typeof(T); /// public ReplPageInfo PageInfo { get; } /// + [JsonIgnore] public IReadOnlyList UntypedItems => _untypedItems ??= Items.Cast().ToArray(); } diff --git a/src/Repl.IntegrationTests/Given_OutputFormatting.cs b/src/Repl.IntegrationTests/Given_OutputFormatting.cs index cdfd10d..462e4bd 100644 --- a/src/Repl.IntegrationTests/Given_OutputFormatting.cs +++ b/src/Repl.IntegrationTests/Given_OutputFormatting.cs @@ -196,6 +196,80 @@ 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("--result:cursor page-2"); + } + + [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() From fc096001e7b51ece1dcfa62b47e8e31067e48479 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 12:43:56 -0400 Subject: [PATCH 03/21] Add MCP and Spectre paged result support --- .../Given_OutputFormatting.cs | 24 ++++++ src/Repl.Mcp/McpResultFlowArgumentNames.cs | 7 ++ src/Repl.Mcp/McpSchemaGenerator.cs | 16 ++++ src/Repl.Mcp/McpToolAdapter.cs | 84 ++++++++++++++++++- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 44 ++++++++++ .../SpectreHumanOutputTransformer.cs | 36 +++++++- 6 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 src/Repl.Mcp/McpResultFlowArgumentNames.cs diff --git a/src/Repl.IntegrationTests/Given_OutputFormatting.cs b/src/Repl.IntegrationTests/Given_OutputFormatting.cs index 462e4bd..bd46bec 100644 --- a/src/Repl.IntegrationTests/Given_OutputFormatting.cs +++ b/src/Repl.IntegrationTests/Given_OutputFormatting.cs @@ -538,6 +538,30 @@ public void When_SpectreOutputAndPreferredRenderWidthIsConfigured_Then_TableRows 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() 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..43e2051 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -145,12 +145,77 @@ 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 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("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}={nextCursor.GetString()}."; + } + + return summary; } private static (List Tokens, Dictionary Prefills) PrepareExecution( @@ -159,6 +224,7 @@ private static (List Tokens, Dictionary Prefills) Prepar { var stringArgs = new Dictionary(StringComparer.OrdinalIgnoreCase); var prefills = new Dictionary(StringComparer.OrdinalIgnoreCase); + var resultFlowTokens = new List(); foreach (var (key, value) in arguments) { @@ -170,13 +236,25 @@ private static (List Tokens, Dictionary Prefills) Prepar { prefills[key["answer.".Length..]] = strValue; } + else if (string.Equals(key, McpResultFlowArgumentNames.Cursor, StringComparison.Ordinal)) + { + resultFlowTokens.Add("--result:cursor"); + resultFlowTokens.Add(strValue); + } + else if (string.Equals(key, McpResultFlowArgumentNames.PageSize, StringComparison.Ordinal)) + { + 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); } /// diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index 9497e02..f0288a3 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,44 @@ 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; + text.Should().NotBeNull(); + text!.Should().Contain("Returned 1 item(s)."); + text.Should().Contain("_replCursor=page-2"); + } + [TestMethod] [Description("Context commands are flattened into underscore-separated tool names.")] public async Task When_ContextCommands_Then_FlattenedToolNames() @@ -304,6 +346,8 @@ private sealed class MarkerService private sealed class AnotherService; + private sealed record ContactDto(int Id, string Name); + // ── Prompts ──────────────────────────────────────────────────────── [TestMethod] diff --git a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs index 1e3b451..fb78135 100644 --- a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs +++ b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs @@ -42,6 +42,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), @@ -157,7 +158,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 +201,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} Continue with --result:cursor {info.NextCursor}." + : prefix; + } + + if (!info.HasMore) + { + return string.Empty; + } + + return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Continue with --result:cursor {info.NextCursor}."; + } + private bool TryRenderObject(object value, out string text) { var members = GetDisplayMembers(value.GetType()); From 9fdc80341a5f7c86d98f3beac005fce8858236bb Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 12:47:34 -0400 Subject: [PATCH 04/21] Add interactive result pager --- src/Repl.Core/CoreReplApp.Execution.cs | 108 +++++++++++++++++++- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 74 ++++++++++++++ src/Repl.Tests/Given_ResultFlowPager.cs | 62 +++++++++++ 3 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 src/Repl.Core/ResultFlow/ResultFlowPager.cs create mode 100644 src/Repl.Tests/Given_ResultFlowPager.cs diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index c0bfb28..bcfd1b8 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) { @@ -690,12 +706,94 @@ internal async ValueTask RenderOutputAsync( payload = TryColorizeStructuredPayload(payload, format, isInteractive); if (!string.IsNullOrEmpty(payload)) { - await ReplSessionIO.Output.WriteLineAsync(payload).ConfigureAwait(false); + await WritePayloadAsync(payload, format, resultFlow, cancellationToken).ConfigureAwait(false); + } + + return true; + } + + private async ValueTask WritePayloadAsync( + string payload, + string format, + ResultFlowInvocationOptions? resultFlow, + CancellationToken cancellationToken) + { + if (TryCreatePager(payload, format, resultFlow, out var keyReader, out var visibleRows)) + { + await ResultFlowPager.WriteAsync( + payload, + ReplSessionIO.Output, + keyReader, + visibleRows, + cancellationToken) + .ConfigureAwait(false); + return; + } + + await ReplSessionIO.Output.WriteLineAsync(payload).ConfigureAwait(false); + } + + private bool TryCreatePager( + string payload, + string format, + ResultFlowInvocationOptions? resultFlow, + [NotNullWhen(true)] out IReplKeyReader? keyReader, + out int visibleRows) + { + keyReader = null; + visibleRows = 0; + + var pagerMode = resultFlow?.PagerMode ?? _options.Output.ResultFlow.DefaultPagerMode; + if (pagerMode == ReplPagerMode.Off + || ReplSessionIO.IsProgrammatic + || ReplSessionIO.IsProtocolPassthrough + || !IsPagedHumanFormat(format)) + { + return false; + } + + if (!TryResolvePagerVisibleRows(out visibleRows) + || ResultFlowPager.CountLines(payload) <= visibleRows + || !TryResolvePagerKeyReader(out keyReader)) + { + return false; } 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 static bool IsPagedHumanFormat(string format) => + string.Equals(format, "human", StringComparison.OrdinalIgnoreCase) + || string.Equals(format, "spectre", StringComparison.OrdinalIgnoreCase); + private string TryColorizeStructuredPayload(string payload, string format, bool isInteractive) { if (string.IsNullOrEmpty(payload) diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs new file mode 100644 index 0000000..8fc641a --- /dev/null +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -0,0 +1,74 @@ +namespace Repl; + +internal static class ResultFlowPager +{ + private const string MorePrompt = "--More--"; + + 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) + { + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(keyReader); + + var lines = SplitLines(payload); + if (lines.Length == 0) + { + return; + } + + var pageSize = Math.Max(1, visibleRows); + var nextWindow = pageSize; + var index = 0; + while (index < lines.Length) + { + cancellationToken.ThrowIfCancellationRequested(); + var take = Math.Min(nextWindow, lines.Length - index); + for (var i = 0; i < take; i++) + { + await output.WriteLineAsync(lines[index + i]).ConfigureAwait(false); + } + + index += take; + if (index >= lines.Length) + { + break; + } + + 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); + + switch (key.Key) + { + case ConsoleKey.Q: + case ConsoleKey.Escape: + return; + case ConsoleKey.Enter: + case ConsoleKey.DownArrow: + nextWindow = 1; + break; + case ConsoleKey.UpArrow: + case ConsoleKey.PageUp: + index = Math.Max(0, index - pageSize - take); + nextWindow = key.Key == ConsoleKey.UpArrow ? 1 : pageSize; + break; + default: + nextWindow = pageSize; + break; + } + } + } + + private static string[] SplitLines(string payload) => + payload + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n'); +} diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs new file mode 100644 index 0000000..614e720 --- /dev/null +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -0,0 +1,62 @@ +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() + { + 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--"); + } + + [TestMethod] + [Description("Result-flow pager advances by one line on Enter.")] + public async Task When_PagingWithEnter_Then_AdvancesSingleLine() + { + 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"); + } + + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => + new(keyChar, key, shift: false, alt: false, control: false); +} From 90af4e8f8db5aa0757e19631701ea97220ce36bf Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 12:51:13 -0400 Subject: [PATCH 05/21] Document result flow paging --- docs/architecture.md | 6 + docs/commands.md | 20 ++++ docs/configuration-reference.md | 11 ++ docs/mcp-reference.md | 26 ++++- docs/output-system.md | 9 ++ docs/result-flow.md | 200 ++++++++++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 docs/result-flow.md 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..fcf98fe 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,20 @@ 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); +}); +``` + +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..de7d325 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. 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..05eb87d --- /dev/null +++ b/docs/result-flow.md @@ -0,0 +1,200 @@ +# 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. + +## 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 +{ + "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. Handler decides whether this is allowed. | +| `--result:pager=auto|off|more|scroll|external` | Pager preference for human formats. | + +Current pager behavior is implemented by the integrated pager. `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; +- pager mode is not `off`. + +Supported keys: + +| Key | Behavior | +|---|---| +| `Space` / `PageDown` / any unhandled key | Next page. | +| `Enter` / `DownArrow` | Next line. | +| `UpArrow` | Re-display one previous line window. | +| `PageUp` | Re-display previous page window. | +| `q` / `Esc` | Quit paging. | + +The v1 pager is intentionally conservative. It is closer to `more` than a full-screen `less`: it does not own an alternate screen, does not search, and does not launch external processes. + +## 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. + +When a handler returns `ReplPage`, MCP returns: + +- `StructuredContent`: the full `{ items, pageInfo }` envelope. +- `Content`: a short text summary such as `Returned 1 item(s). Total: 2. Continue with _replCursor=page-2.` + +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. 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`. +- `IReplPageSource` is available for renderer-driven paging providers; current renderers use explicit `ReplPage` results. +- `--result:all` is advisory. Handlers should reject or cap it when the data source cannot safely return everything. +- The pager operates after formatting. It does not fetch additional data pages by itself in v1. + +## See Also + +- [Output System](output-system.md) +- [Command Reference](commands.md) +- [MCP Reference](mcp-reference.md) +- [Interaction](interaction.md) From c05fb64a9484d8ce60073a08cbdeb0eaab01e1aa Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 13:12:19 -0400 Subject: [PATCH 06/21] Add result flow paging demos --- docs/result-flow.md | 3 + samples/01-core-basics/ActivityFeed.cs | 59 ++++++++++++++++++ samples/01-core-basics/Program.cs | 14 +++-- samples/01-core-basics/README.md | 22 ++++++- samples/07-spectre/ActivityFeed.cs | 59 ++++++++++++++++++ samples/07-spectre/Program.cs | 12 +++- samples/07-spectre/README.md | 15 ++++- samples/08-mcp-server/DirectoryContactFeed.cs | 60 +++++++++++++++++++ samples/08-mcp-server/Program.cs | 9 +++ samples/08-mcp-server/README.md | 15 +++-- samples/README.md | 6 +- 11 files changed, 257 insertions(+), 17 deletions(-) create mode 100644 samples/01-core-basics/ActivityFeed.cs create mode 100644 samples/07-spectre/ActivityFeed.cs create mode 100644 samples/08-mcp-server/DirectoryContactFeed.cs diff --git a/docs/result-flow.md b/docs/result-flow.md index 05eb87d..37e08a0 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -194,6 +194,9 @@ app.Options(options => ## 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) diff --git a/samples/01-core-basics/ActivityFeed.cs b/samples/01-core-basics/ActivityFeed.cs new file mode 100644 index 0000000..907f0d6 --- /dev/null +++ b/samples/01-core-basics/ActivityFeed.cs @@ -0,0 +1,59 @@ +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 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[] 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 * 7).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..6e822bc 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 ReplPage 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..3225f05 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 + - `ReplPage` → paged table with continuation metadata - `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,21 @@ 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 `ReplPage`. The handler only returns the requested +page, plus a continuation cursor when more rows exist. + +```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 and a continuation hint. 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..53ee14c --- /dev/null +++ b/samples/07-spectre/ActivityFeed.cs @@ -0,0 +1,59 @@ +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 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[] 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 * 11).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..c9f49f8 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 `ReplPage`. +The Spectre output transformer renders only the requested page and appends the +continuation cursor. + +```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 From 017e05c5acbf78ac3ab126ba5415f8dacd688c7e Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 22:10:15 -0400 Subject: [PATCH 07/21] Add safer page source helpers --- docs/result-flow.md | 446 ++++++++++++++++++++- src/Repl.Core/ResultFlow/ReplPageSource.cs | 372 +++++++++++++++++ src/Repl.Tests/Given_ReplPageSource.cs | 304 ++++++++++++++ 3 files changed, 1115 insertions(+), 7 deletions(-) create mode 100644 src/Repl.Core/ResultFlow/ReplPageSource.cs create mode 100644 src/Repl.Tests/Given_ReplPageSource.cs diff --git a/docs/result-flow.md b/docs/result-flow.md index 37e08a0..adc91f8 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -46,6 +46,301 @@ The context exposes: `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 +factory because later pages reopen the stream and skip to the requested offset. +For live streams that cannot restart, emit a keyset/range cursor instead or use +a future live/tail-oriented API. + +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: @@ -84,7 +379,7 @@ Result-flow flags are global and use the `--result:` prefix so they do not colli |---|---| | `--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. Handler decides whether this is allowed. | +| `--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. | Current pager behavior is implemented by the integrated pager. `external` is accepted as a forward-compatible mode and currently falls back to the integrated pager. @@ -122,14 +417,15 @@ 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; +- 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 | Next page. | +| `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. | @@ -137,6 +433,141 @@ Supported keys: The v1 pager is intentionally conservative. It is closer to `more` than a full-screen `less`: it does not own an alternate screen, does not search, and does not launch external processes. +`less` feels different because it owns an interactive viewport over the already +rendered stream. Repl's integrated pager currently writes through the normal +scrollback buffer so the output remains copyable and pipe-friendly. A future +full-screen pager can be layered on top of the same `IReplPageSource` contract, +but it should remain opt-in because alternate-screen behavior is surprising in +automation, logs, and hosted transports. + +## 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: @@ -157,7 +588,7 @@ This keeps agents from receiving a giant JSON string in `TextContentBlock` while ## Spectre Behavior -`Repl.Spectre` renders `ReplPage` with the same lightweight Spectre table style used for collections, followed by continuation metadata. The core paging contract remains framework-neutral; handlers do not need Spectre-specific code. +`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). @@ -188,9 +619,10 @@ app.Options(options => - Existing handlers that return `IEnumerable` keep their current behavior. - Handlers that can page efficiently should request `IReplPagingContext` and return `ReplPage`. -- `IReplPageSource` is available for renderer-driven paging providers; current renderers use explicit `ReplPage` results. -- `--result:all` is advisory. Handlers should reject or cap it when the data source cannot safely return everything. -- The pager operates after formatting. It does not fetch additional data pages by itself in v1. +- 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 diff --git a/src/Repl.Core/ResultFlow/ReplPageSource.cs b/src/Repl.Core/ResultFlow/ReplPageSource.cs new file mode 100644 index 0000000..296e9ec --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPageSource.cs @@ -0,0 +1,372 @@ +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. + 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. + 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) + { + ThrowIfScanLimitExceeded(scanned, maxSourceItemsToScan); + 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.Tests/Given_ReplPageSource.cs b/src/Repl.Tests/Given_ReplPageSource.cs new file mode 100644 index 0000000..14bfbb0 --- /dev/null +++ b/src/Repl.Tests/Given_ReplPageSource.cs @@ -0,0 +1,304 @@ +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 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"); + } + + [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(); + } + + 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); +} From 95d5ce2e4491d99be5504ec0ec86d5a54a061bb2 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 22:13:43 -0400 Subject: [PATCH 08/21] Harden MCP paged result handling --- docs/result-flow.md | 5 +- src/Repl.Core/Output/JsonOutputTransformer.cs | 15 +++++ src/Repl.Core/Parsing/GlobalOptionParser.cs | 22 ++++-- src/Repl.Mcp/McpToolAdapter.cs | 5 +- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 67 ++++++++++++++++++- src/Repl.Tests/Given_GlobalOptionParser.cs | 15 +++++ 6 files changed, 120 insertions(+), 9 deletions(-) diff --git a/docs/result-flow.md b/docs/result-flow.md index adc91f8..32f0521 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -356,6 +356,7 @@ JSON output uses a clean automation envelope: ```json { + "$type": "page", "items": [ { "id": 1, "name": "Alice" } ], @@ -581,8 +582,8 @@ These properties are consumed by the Repl MCP adapter and mapped to `IReplPaging When a handler returns `ReplPage`, MCP returns: -- `StructuredContent`: the full `{ items, pageInfo }` envelope. -- `Content`: a short text summary such as `Returned 1 item(s). Total: 2. Continue with _replCursor=page-2.` +- `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. 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/Parsing/GlobalOptionParser.cs b/src/Repl.Core/Parsing/GlobalOptionParser.cs index f70fac2..3bbb96a 100644 --- a/src/Repl.Core/Parsing/GlobalOptionParser.cs +++ b/src/Repl.Core/Parsing/GlobalOptionParser.cs @@ -68,7 +68,14 @@ public static GlobalInvocationOptions Parse( continue; } - if (TryParseResultFlowOption(args, ref index, argument, optionComparison, options.ResultFlow, out var resultFlow)) + if (TryParseResultFlowOption( + args, + ref index, + argument, + optionComparison, + options.ResultFlow, + outputOptions.ResultFlow.MaxPageSize, + out var resultFlow)) { options = options with { ResultFlow = resultFlow }; continue; @@ -155,6 +162,7 @@ private static bool TryParseResultFlowOption( string argument, StringComparison comparison, ResultFlowInvocationOptions current, + int maxPageSize, out ResultFlowInvocationOptions resultFlow) { const string prefix = "--result:"; @@ -168,7 +176,7 @@ private static bool TryParseResultFlowOption( if (TrySplitToken(token, '=', out var name, out var inlineValue) || TrySplitToken(token, ':', out name, out inlineValue)) { - return ApplyResultFlowOption(name, inlineValue, current, out resultFlow); + return ApplyResultFlowOption(name, inlineValue, current, maxPageSize, out resultFlow); } if (string.Equals(token, "all", comparison)) @@ -182,16 +190,17 @@ private static bool TryParseResultFlowOption( && !args[index + 1].StartsWith('-')) { index++; - return ApplyResultFlowOption(token, args[index], current, out resultFlow); + return ApplyResultFlowOption(token, args[index], current, maxPageSize, out resultFlow); } - return ApplyResultFlowOption(token, "true", current, 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; @@ -203,7 +212,7 @@ private static bool ApplyResultFlowOption( System.Globalization.CultureInfo.InvariantCulture, out var pageSize)) { - resultFlow = current with { PageSize = pageSize }; + resultFlow = current with { PageSize = ClampPageSize(pageSize, maxPageSize) }; } return true; @@ -239,6 +248,9 @@ private static bool RequiresResultFlowValue(string token, StringComparison compa || 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.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 43e2051..d80ef9a 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -180,6 +180,9 @@ private static bool TryCreatePagedStructuredResult( 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) @@ -212,7 +215,7 @@ private static string BuildPagedSummary(int count, JsonElement pageInfo) && nextCursor.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(nextCursor.GetString())) { - summary += $" Continue with {McpResultFlowArgumentNames.Cursor}={nextCursor.GetString()}."; + summary += $" Continue with {McpResultFlowArgumentNames.Cursor}; cursor available in structured content."; } return summary; diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index f0288a3..a24a273 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -113,7 +113,70 @@ public async Task When_ToolsCallReturnsPagedResult_Then_StructuredContentContain var text = result.Content.OfType().FirstOrDefault()?.Text; text.Should().NotBeNull(); text!.Should().Contain("Returned 1 item(s)."); - text.Should().Contain("_replCursor=page-2"); + 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] @@ -348,6 +411,8 @@ 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.Tests/Given_GlobalOptionParser.cs b/src/Repl.Tests/Given_GlobalOptionParser.cs index 39b3859..342212c 100644 --- a/src/Repl.Tests/Given_GlobalOptionParser.cs +++ b/src/Repl.Tests/Given_GlobalOptionParser.cs @@ -122,4 +122,19 @@ public void When_ResultFlowOptionsArePresent_Then_ParserConsumesThemIntoResultFl 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); + } } From 0c533d325ac9a266a845134553c835c5f1a85700 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 22:15:57 -0400 Subject: [PATCH 09/21] Fix result pager boundary navigation --- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 209 ++++++++++++++++---- src/Repl.Tests/Given_ResultFlowPager.cs | 152 ++++++++++++++ 2 files changed, 325 insertions(+), 36 deletions(-) diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 8fc641a..b611133 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -2,7 +2,7 @@ namespace Repl; internal static class ResultFlowPager { - private const string MorePrompt = "--More--"; + private const string MorePrompt = "--More-- Space/PageDown: continue, Enter/Down: line, Up/PageUp: back, q/Esc: stop"; public static int CountLines(string payload) => SplitLines(payload).Length; @@ -12,63 +12,200 @@ public static async ValueTask WriteAsync( 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, + bool hasMorePayload, + Func>? fetchNextPayload, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(output); ArgumentNullException.ThrowIfNull(keyReader); - var lines = SplitLines(payload); - if (lines.Length == 0) + var state = new PagerState(SplitLines(payload), Math.Max(1, visibleRows), hasMorePayload); + if (state.Lines.Length == 0 && !state.HasMorePayload) { return; } - var pageSize = Math.Max(1, visibleRows); - var nextWindow = pageSize; - var index = 0; - while (index < lines.Length) + 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 WriteCurrentPayloadAsync( + PagerState state, + TextWriter output, + IReplKeyReader keyReader, + CancellationToken cancellationToken) + { + while (state.Index < state.Lines.Length) { cancellationToken.ThrowIfCancellationRequested(); - var take = Math.Min(nextWindow, lines.Length - index); + 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(lines[index + i]).ConfigureAwait(false); + await output.WriteLineAsync(state.Lines[state.Index + i]).ConfigureAwait(false); } - index += take; - if (index >= lines.Length) + state.Index += take; + if (state.Index >= state.Lines.Length) { break; } - 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); - - switch (key.Key) + var key = await ReadPromptAsync(output, keyReader, cancellationToken).ConfigureAwait(false); + if (ApplyWindowKey(state, key, windowStart)) { - case ConsoleKey.Q: - case ConsoleKey.Escape: - return; - case ConsoleKey.Enter: - case ConsoleKey.DownArrow: - nextWindow = 1; - break; - case ConsoleKey.UpArrow: - case ConsoleKey.PageUp: - index = Math.Max(0, index - pageSize - take); - nextWindow = key.Key == ConsoleKey.UpArrow ? 1 : pageSize; - break; - default: - nextWindow = pageSize; - break; + 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 string[] SplitLines(string payload) => - payload - .Replace("\r\n", "\n", StringComparison.Ordinal) - .Replace('\r', '\n') - .Split('\n'); + string.IsNullOrEmpty(payload) + ? [] + : payload + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n'); + + private sealed class PagerState(string[] lines, int pageSize, bool hasMorePayload) + { + public string[] Lines { get; private set; } = 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; + } + } } diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index 614e720..89a3e9b 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -30,6 +30,10 @@ await ResultFlowPager.WriteAsync( 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] @@ -57,6 +61,154 @@ await ResultFlowPager.WriteAsync( 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() + { + 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() + { + 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() + { + 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() + { + 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() + { + 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); + } + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => new(keyChar, key, shift: false, alt: false, control: false); } From f330ca241b1bed7da85d48464e85bc9d89a3551b Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 22:16:28 -0400 Subject: [PATCH 10/21] Wire page sources through result flow --- docs/commands.md | 10 ++ docs/output-system.md | 2 +- samples/01-core-basics/ActivityFeed.cs | 23 +--- samples/01-core-basics/Program.cs | 2 +- samples/01-core-basics/README.md | 12 +- samples/07-spectre/ActivityFeed.cs | 23 +--- samples/07-spectre/README.md | 6 +- src/Repl.Core/CoreReplApp.Execution.cs | 122 +++++++++++++++++- src/Repl.Core/Help/HelpRenderCommand.cs | 1 + .../Help/HelpTextBuilder.Rendering.cs | 61 ++++++++- src/Repl.Core/Help/HelpTextBuilder.cs | 2 + .../Output/HumanOutputTransformer.cs | 4 +- .../Output/MarkdownOutputTransformer.cs | 89 +++++++++++++ src/Repl.Core/OutputOptions.cs | 2 + src/Repl.Core/ResultFlow/IReplPageSource.cs | 25 +++- .../ResultFlow/ReplPageDisplaySnapshot.cs | 18 +++ .../ResultFlow/ReplPageRequestExtensions.cs | 35 +++++ src/Repl.Core/ResultFlow/ReplPagingContext.cs | 11 +- .../ResultFlow/ResultFlowPagerPage.cs | 3 + .../Given_HelpDiscovery.cs | 96 ++++++++++++++ .../Given_OutputFormatting.cs | 75 ++++++++++- .../SpectreHumanOutputTransformer.cs | 25 +++- 22 files changed, 582 insertions(+), 65 deletions(-) create mode 100644 src/Repl.Core/ResultFlow/ReplPageDisplaySnapshot.cs create mode 100644 src/Repl.Core/ResultFlow/ReplPageRequestExtensions.cs create mode 100644 src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs diff --git a/docs/commands.md b/docs/commands.md index fcf98fe..cd172d2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -312,6 +312,16 @@ app.Map("contacts", async (IReplPagingContext paging, ContactStore store, Cancel }); ``` +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 diff --git a/docs/output-system.md b/docs/output-system.md index de7d325..c3adba0 100644 --- a/docs/output-system.md +++ b/docs/output-system.md @@ -106,7 +106,7 @@ In interactive mode, when ANSI is supported, JSON output is syntax-highlighted a ## Paging -Human terminal formats (`human` and `spectre`) can use the integrated result pager when rendered output exceeds the visible row capacity. The pager is never used for redirected stdout, protocol passthrough, MCP/programmatic execution, or machine formats. +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. diff --git a/samples/01-core-basics/ActivityFeed.cs b/samples/01-core-basics/ActivityFeed.cs index 907f0d6..376fde9 100644 --- a/samples/01-core-basics/ActivityFeed.cs +++ b/samples/01-core-basics/ActivityFeed.cs @@ -13,28 +13,17 @@ internal sealed class ActivityFeed { private readonly List _items = CreateItems(); - public ReplPage Query(IReplPagingContext paging) + public IReplPageSource 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); + return ReplPageSource.FromOffset( + (offset, take, _) => + ValueTask.FromResult>( + _items.Skip(offset).Take(take).ToList()), + _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[] areas = ["identity", "billing", "catalog", "search", "import", "reporting"]; diff --git a/samples/01-core-basics/Program.cs b/samples/01-core-basics/Program.cs index 6e822bc..d600b14 100644 --- a/samples/01-core-basics/Program.cs +++ b/samples/01-core-basics/Program.cs @@ -60,7 +60,7 @@ public object Show( public object Count() => Results.Success("Contact count.", store.Count()); [Description("Return a paged activity log generated from a long data source.")] - public ReplPage Activity(IReplPagingContext paging) => + public IReplPageSource Activity(IReplPagingContext paging) => activityFeed.Query(paging); [Description("Render a date-only reporting period from a temporal range literal.")] diff --git a/samples/01-core-basics/README.md b/samples/01-core-basics/README.md index 3225f05..ad6649c 100644 --- a/samples/01-core-basics/README.md +++ b/samples/01-core-basics/README.md @@ -142,7 +142,7 @@ return app.Run(args); - `[Browsable(false)]` hides a command from discovery. - **Return values are semantic**: - `IEnumerable` → table - - `ReplPage` → paged table with continuation metadata + - `IReplPageSource` → paged table with interactive continuation - `Contact` → structured output (or JSON with `--json`) - `string` → plain text. @@ -206,8 +206,10 @@ Expected behavior: ## Result-flow paging The `activity` command returns a synthetic long data source through -`IReplPagingContext` and `ReplPage`. The handler only returns the requested -page, plus a continuation cursor when more rows exist. +`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 @@ -215,8 +217,8 @@ myapp activity --result:page-size=5 --result:cursor=5 myapp activity --json --result:page-size=2 ``` -Human output renders a compact table and a continuation hint. JSON output returns -an `{ items, pageInfo }` envelope for automation. +Human output renders a compact table with an integrated pager. JSON output +returns an `{ items, pageInfo }` envelope for automation. Validation example: diff --git a/samples/07-spectre/ActivityFeed.cs b/samples/07-spectre/ActivityFeed.cs index 53ee14c..3ee689c 100644 --- a/samples/07-spectre/ActivityFeed.cs +++ b/samples/07-spectre/ActivityFeed.cs @@ -13,28 +13,17 @@ internal sealed class ActivityFeed { private readonly List _items = CreateItems(); - public ReplPage Query(IReplPagingContext paging) + public IReplPageSource 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); + return ReplPageSource.FromOffset( + (offset, take, _) => + ValueTask.FromResult>( + _items.Skip(offset).Take(take).ToList()), + _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[] teams = ["platform", "growth", "support", "data", "security"]; diff --git a/samples/07-spectre/README.md b/samples/07-spectre/README.md index c9f49f8..09dcc5d 100644 --- a/samples/07-spectre/README.md +++ b/samples/07-spectre/README.md @@ -41,9 +41,9 @@ table automatically. Zero rendering code in the handler. ### `activity` — Paged long data source -Returns a synthetic activity feed through `IReplPagingContext` and `ReplPage`. -The Spectre output transformer renders only the requested page and appends the -continuation cursor. +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 diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index bcfd1b8..19c7dcc 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -702,6 +702,18 @@ internal async ValueTask RenderOutputAsync( return false; } + if (result is IReplPageSource pageSource) + { + return await RenderPageSourceAsync( + pageSource, + transformer, + format, + isInteractive, + resultFlow, + cancellationToken) + .ConfigureAwait(false); + } + var payload = await transformer.TransformAsync(result, cancellationToken).ConfigureAwait(false); payload = TryColorizeStructuredPayload(payload, format, isInteractive); if (!string.IsNullOrEmpty(payload)) @@ -712,6 +724,67 @@ internal async ValueTask RenderOutputAsync( return true; } + private async ValueTask RenderPageSourceAsync( + IReplPageSource source, + IOutputTransformer transformer, + string format, + 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, format, isInteractive); + + if (!TryCreatePager( + payload, + format, + resultFlow, + page.PageInfo.HasMore, + out var keyReader, + out var visibleRows)) + { + if (!string.IsNullOrEmpty(payload)) + { + await WritePayloadAsync(payload, format, resultFlow, cancellationToken).ConfigureAwait(false); + } + + return true; + } + + var nextCursor = page.PageInfo.NextCursor; + var pagerPayload = await transformer.TransformAsync(CreatePagerDisplayPage(page), cancellationToken) + .ConfigureAwait(false); + pagerPayload = TryColorizeStructuredPayload(pagerPayload, format, isInteractive); + await ResultFlowPager.WriteAsync( + pagerPayload, + ReplSessionIO.Output, + keyReader, + visibleRows, + 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, format, isInteractive); + return new ResultFlowPagerPage(nextPayload, nextPage.PageInfo.HasMore); + } + } + private async ValueTask WritePayloadAsync( string payload, string format, @@ -739,6 +812,21 @@ private bool TryCreatePager( ResultFlowInvocationOptions? resultFlow, [NotNullWhen(true)] out IReplKeyReader? keyReader, out int visibleRows) + => TryCreatePager( + payload, + format, + resultFlow, + hasMorePayload: false, + out keyReader, + out visibleRows); + + private bool TryCreatePager( + string payload, + string format, + ResultFlowInvocationOptions? resultFlow, + bool hasMorePayload, + [NotNullWhen(true)] out IReplKeyReader? keyReader, + out int visibleRows) { keyReader = null; visibleRows = 0; @@ -753,7 +841,7 @@ private bool TryCreatePager( } if (!TryResolvePagerVisibleRows(out visibleRows) - || ResultFlowPager.CountLines(payload) <= visibleRows + || (!hasMorePayload && ResultFlowPager.CountLines(payload) <= visibleRows) || !TryResolvePagerKeyReader(out keyReader)) { return false; @@ -794,6 +882,38 @@ private static bool IsPagedHumanFormat(string format) => string.Equals(format, "human", StringComparison.OrdinalIgnoreCase) || string.Equals(format, "spectre", StringComparison.OrdinalIgnoreCase); + 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, + HasMore = false, + }; + 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) 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/Output/HumanOutputTransformer.cs b/src/Repl.Core/Output/HumanOutputTransformer.cs index 8938b57..a01f46f 100644 --- a/src/Repl.Core/Output/HumanOutputTransformer.cs +++ b/src/Repl.Core/Output/HumanOutputTransformer.cs @@ -104,7 +104,7 @@ private static string RenderPageFooter(IReplPage page) { var prefix = $"Showing {count.ToString(CultureInfo.InvariantCulture)} of {total.ToString(CultureInfo.InvariantCulture)}."; return info.HasMore - ? $"{prefix} Continue with --result:cursor {info.NextCursor}." + ? $"{prefix} Next data page: rerun with --result:cursor {info.NextCursor}." : prefix; } @@ -113,7 +113,7 @@ private static string RenderPageFooter(IReplPage page) return string.Empty; } - return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Continue with --result:cursor {info.NextCursor}."; + 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) diff --git a/src/Repl.Core/Output/MarkdownOutputTransformer.cs b/src/Repl.Core/Output/MarkdownOutputTransformer.cs index 829a46c..a848eb6 100644 --- a/src/Repl.Core/Output/MarkdownOutputTransformer.cs +++ b/src/Repl.Core/Output/MarkdownOutputTransformer.cs @@ -26,6 +26,11 @@ 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); @@ -250,6 +255,90 @@ private static bool IsSimpleValue(Type type) => private static string EscapeCell(string value) => value.Replace("|", "\\|", StringComparison.Ordinal); + 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 650efc2..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"; diff --git a/src/Repl.Core/ResultFlow/IReplPageSource.cs b/src/Repl.Core/ResultFlow/IReplPageSource.cs index d6f82c9..dd518d4 100644 --- a/src/Repl.Core/ResultFlow/IReplPageSource.cs +++ b/src/Repl.Core/ResultFlow/IReplPageSource.cs @@ -1,10 +1,26 @@ 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 +public interface IReplPageSource : IReplPageSource { /// /// Fetches a page for the supplied request. @@ -15,4 +31,11 @@ public interface IReplPageSource 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/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/ReplPageRequestExtensions.cs b/src/Repl.Core/ResultFlow/ReplPageRequestExtensions.cs new file mode 100644 index 0000000..e285a1c --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPageRequestExtensions.cs @@ -0,0 +1,35 @@ +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, + HasMore: !string.IsNullOrWhiteSpace(nextCursor))); + } +} diff --git a/src/Repl.Core/ResultFlow/ReplPagingContext.cs b/src/Repl.Core/ResultFlow/ReplPagingContext.cs index c3c8cc0..c3ea778 100644 --- a/src/Repl.Core/ResultFlow/ReplPagingContext.cs +++ b/src/Repl.Core/ResultFlow/ReplPagingContext.cs @@ -54,7 +54,7 @@ public IReplPageSource CreateSource( Func>> fetch) { ArgumentNullException.ThrowIfNull(fetch); - return new DelegateReplPageSource(fetch); + return ReplPageSource.Create(fetch); } internal ReplPageRequest CreateRequest() => @@ -62,13 +62,4 @@ internal ReplPageRequest CreateRequest() => private static int ClampPageSize(int value, int maxPageSize) => Math.Clamp(value, 1, maxPageSize); - - 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/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..f5d06a4 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,21 @@ 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, + HasMore: false))); + } + [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 bd46bec..4d05757 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.")] @@ -218,9 +219,55 @@ public void When_RenderingPagedResultInHuman_Then_ItemsAndContinuationAreRendere 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, + nextCursor is not null))); + })); + + 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() @@ -531,7 +578,8 @@ 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); @@ -617,6 +665,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.Spectre/SpectreHumanOutputTransformer.cs b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs index fb78135..a19ffde 100644 --- a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs +++ b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs @@ -120,6 +120,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); @@ -220,7 +227,7 @@ private static string RenderPageFooter(IReplPage page) { var prefix = $"Showing {count.ToString(CultureInfo.InvariantCulture)} of {total.ToString(CultureInfo.InvariantCulture)}."; return info.HasMore - ? $"{prefix} Continue with --result:cursor {info.NextCursor}." + ? $"{prefix} Next data page: rerun with --result:cursor {info.NextCursor}." : prefix; } @@ -229,7 +236,7 @@ private static string RenderPageFooter(IReplPage page) return string.Empty; } - return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Continue with --result:cursor {info.NextCursor}."; + 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) @@ -285,7 +292,7 @@ private static Table BuildObjectTable(object?[] items, IReadOnlyList commands) { var table = new Table() From 2ef32da711fea3c52107fef4d0970297779b6cf0 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 22:21:26 -0400 Subject: [PATCH 11/21] Add scroll viewport result pager --- docs/result-flow.md | 22 +- src/Repl.Core/CoreReplApp.Execution.cs | 33 ++- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 239 ++++++++++++++++++++ src/Repl.Tests/Given_ResultFlowPager.cs | 65 ++++++ 4 files changed, 345 insertions(+), 14 deletions(-) diff --git a/docs/result-flow.md b/docs/result-flow.md index 32f0521..4c98526 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -383,7 +383,10 @@ Result-flow flags are global and use the `--result:` prefix so they do not colli | `--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. | -Current pager behavior is implemented by the integrated pager. `external` is accepted as a forward-compatible mode and currently falls back to the integrated pager. +`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 @@ -432,14 +435,17 @@ Supported keys: | `PageUp` | Re-display previous page window. | | `q` / `Esc` | Quit paging. | -The v1 pager is intentionally conservative. It is closer to `more` than a full-screen `less`: it does not own an alternate screen, does not search, and does not launch external processes. +The integrated pager has two render paths: -`less` feels different because it owns an interactive viewport over the already -rendered stream. Repl's integrated pager currently writes through the normal -scrollback buffer so the output remains copyable and pipe-friendly. A future -full-screen pager can be layered on top of the same `IReplPageSource` contract, -but it should remain opt-in because alternate-screen behavior is surprising in -automation, logs, and hosted transports. +- `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 diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 19c7dcc..f8e4974 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -743,7 +743,9 @@ private async ValueTask RenderPageSourceAsync( resultFlow, page.PageInfo.HasMore, out var keyReader, - out var visibleRows)) + out var visibleRows, + out var pagerMode, + out var ansiEnabled)) { if (!string.IsNullOrEmpty(payload)) { @@ -762,6 +764,8 @@ await ResultFlowPager.WriteAsync( ReplSessionIO.Output, keyReader, visibleRows, + pagerMode, + ansiEnabled, page.PageInfo.HasMore, FetchNextPayloadAsync, cancellationToken) @@ -791,13 +795,22 @@ private async ValueTask WritePayloadAsync( ResultFlowInvocationOptions? resultFlow, CancellationToken cancellationToken) { - if (TryCreatePager(payload, format, resultFlow, out var keyReader, out var visibleRows)) + if (TryCreatePager( + payload, + format, + 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; @@ -811,14 +824,18 @@ private bool TryCreatePager( string format, ResultFlowInvocationOptions? resultFlow, [NotNullWhen(true)] out IReplKeyReader? keyReader, - out int visibleRows) + out int visibleRows, + out ReplPagerMode pagerMode, + out bool ansiEnabled) => TryCreatePager( payload, format, resultFlow, hasMorePayload: false, out keyReader, - out visibleRows); + out visibleRows, + out pagerMode, + out ansiEnabled); private bool TryCreatePager( string payload, @@ -826,12 +843,15 @@ private bool TryCreatePager( ResultFlowInvocationOptions? resultFlow, bool hasMorePayload, [NotNullWhen(true)] out IReplKeyReader? keyReader, - out int visibleRows) + out int visibleRows, + out ReplPagerMode pagerMode, + out bool ansiEnabled) { keyReader = null; visibleRows = 0; + ansiEnabled = false; - var pagerMode = resultFlow?.PagerMode ?? _options.Output.ResultFlow.DefaultPagerMode; + pagerMode = resultFlow?.PagerMode ?? _options.Output.ResultFlow.DefaultPagerMode; if (pagerMode == ReplPagerMode.Off || ReplSessionIO.IsProgrammatic || ReplSessionIO.IsProtocolPassthrough @@ -847,6 +867,7 @@ private bool TryCreatePager( return false; } + ansiEnabled = _options.Output.IsAnsiEnabled(); return true; } diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index b611133..a8fcaed 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -3,6 +3,12 @@ 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; @@ -29,6 +35,52 @@ public static async ValueTask WriteAsync( 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) @@ -36,6 +88,40 @@ public static async ValueTask WriteAsync( ArgumentNullException.ThrowIfNull(output); ArgumentNullException.ThrowIfNull(keyReader); + if (ShouldUseScrollPager(pagerMode, ansiEnabled)) + { + await WriteScrollAsync( + payload, + output, + keyReader, + visibleRows, + 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) { @@ -87,6 +173,54 @@ public static async ValueTask WriteAsync( } } + private static async ValueTask WriteScrollAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + bool hasMorePayload, + Func>? fetchNextPayload, + CancellationToken cancellationToken) + { + 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.TopLine >= state.MaxTopLine && 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, @@ -181,6 +315,92 @@ private static async ValueTask ReadPromptAsync( 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.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: + state.TopLine = Math.Min(state.TopLine + state.ViewportHeight, state.MaxTopLine); + 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) ? [] @@ -208,4 +428,23 @@ public void Reset(string[] lines, bool hasMorePayload) 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 void Append(string[] lines, bool hasMorePayload) + { + Buffer.AddRange(lines); + HasMorePayload = hasMorePayload; + } + } } diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index 89a3e9b..e245770 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -209,6 +209,71 @@ await ResultFlowPager.WriteAsync( 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() + { + 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() + { + var writer = new StringWriter(); + var fetches = 0; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo", + 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"); + } + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => new(keyChar, key, shift: false, alt: false, control: false); } From 2b6497aca8cc55b8c570bccac79497456f2476ba Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 22:26:00 -0400 Subject: [PATCH 12/21] Tighten result flow contracts --- src/Repl.Core/CoreReplApp.Execution.cs | 41 ++++++++----------- src/Repl.Core/IOutputTransformer.cs | 7 +++- .../Output/HumanOutputTransformer.cs | 2 + src/Repl.Core/ResultFlow/ReplPage.cs | 15 ++++--- src/Repl.Core/ResultFlow/ReplPageInfo.cs | 10 +++-- .../ResultFlow/ReplPageRequestExtensions.cs | 3 +- src/Repl.Core/ResultFlow/ReplPagingContext.cs | 3 +- .../Given_HelpDiscovery.cs | 3 +- .../Given_OutputFormatting.cs | 3 +- .../SpectreHumanOutputTransformer.cs | 3 ++ src/Repl.Tests/Given_ReplPageSource.cs | 13 ++++++ 11 files changed, 61 insertions(+), 42 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index f8e4974..eb7c194 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -705,12 +705,11 @@ internal async ValueTask RenderOutputAsync( if (result is IReplPageSource pageSource) { return await RenderPageSourceAsync( - pageSource, - transformer, - format, - isInteractive, - resultFlow, - cancellationToken) + pageSource, + transformer, + isInteractive, + resultFlow, + cancellationToken) .ConfigureAwait(false); } @@ -718,7 +717,7 @@ internal async ValueTask RenderOutputAsync( payload = TryColorizeStructuredPayload(payload, format, isInteractive); if (!string.IsNullOrEmpty(payload)) { - await WritePayloadAsync(payload, format, resultFlow, cancellationToken).ConfigureAwait(false); + await WritePayloadAsync(payload, transformer, resultFlow, cancellationToken).ConfigureAwait(false); } return true; @@ -727,7 +726,6 @@ internal async ValueTask RenderOutputAsync( private async ValueTask RenderPageSourceAsync( IReplPageSource source, IOutputTransformer transformer, - string format, bool isInteractive, ResultFlowInvocationOptions? resultFlow, CancellationToken cancellationToken) @@ -735,11 +733,11 @@ private async ValueTask RenderPageSourceAsync( 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, format, isInteractive); + payload = TryColorizeStructuredPayload(payload, transformer.Name, isInteractive); if (!TryCreatePager( payload, - format, + transformer, resultFlow, page.PageInfo.HasMore, out var keyReader, @@ -749,7 +747,7 @@ private async ValueTask RenderPageSourceAsync( { if (!string.IsNullOrEmpty(payload)) { - await WritePayloadAsync(payload, format, resultFlow, cancellationToken).ConfigureAwait(false); + await ReplSessionIO.Output.WriteLineAsync(payload).ConfigureAwait(false); } return true; @@ -758,7 +756,7 @@ private async ValueTask RenderPageSourceAsync( var nextCursor = page.PageInfo.NextCursor; var pagerPayload = await transformer.TransformAsync(CreatePagerDisplayPage(page), cancellationToken) .ConfigureAwait(false); - pagerPayload = TryColorizeStructuredPayload(pagerPayload, format, isInteractive); + pagerPayload = TryColorizeStructuredPayload(pagerPayload, transformer.Name, isInteractive); await ResultFlowPager.WriteAsync( pagerPayload, ReplSessionIO.Output, @@ -784,20 +782,20 @@ await ResultFlowPager.WriteAsync( nextCursor = nextPage.PageInfo.NextCursor; var nextPayload = await transformer.TransformAsync(CreatePagerDisplayPage(nextPage), token) .ConfigureAwait(false); - nextPayload = TryColorizeStructuredPayload(nextPayload, format, isInteractive); + nextPayload = TryColorizeStructuredPayload(nextPayload, transformer.Name, isInteractive); return new ResultFlowPagerPage(nextPayload, nextPage.PageInfo.HasMore); } } private async ValueTask WritePayloadAsync( string payload, - string format, + IOutputTransformer transformer, ResultFlowInvocationOptions? resultFlow, CancellationToken cancellationToken) { if (TryCreatePager( payload, - format, + transformer, resultFlow, out var keyReader, out var visibleRows, @@ -821,7 +819,7 @@ await ResultFlowPager.WriteAsync( private bool TryCreatePager( string payload, - string format, + IOutputTransformer transformer, ResultFlowInvocationOptions? resultFlow, [NotNullWhen(true)] out IReplKeyReader? keyReader, out int visibleRows, @@ -829,7 +827,7 @@ private bool TryCreatePager( out bool ansiEnabled) => TryCreatePager( payload, - format, + transformer, resultFlow, hasMorePayload: false, out keyReader, @@ -839,7 +837,7 @@ private bool TryCreatePager( private bool TryCreatePager( string payload, - string format, + IOutputTransformer transformer, ResultFlowInvocationOptions? resultFlow, bool hasMorePayload, [NotNullWhen(true)] out IReplKeyReader? keyReader, @@ -855,7 +853,7 @@ private bool TryCreatePager( if (pagerMode == ReplPagerMode.Off || ReplSessionIO.IsProgrammatic || ReplSessionIO.IsProtocolPassthrough - || !IsPagedHumanFormat(format)) + || !transformer.SupportsInteractivePaging) { return false; } @@ -899,10 +897,6 @@ private static bool TryResolvePagerKeyReader([NotNullWhen(true)] out IReplKeyRea return false; } - private static bool IsPagedHumanFormat(string format) => - string.Equals(format, "human", StringComparison.OrdinalIgnoreCase) - || string.Equals(format, "spectre", StringComparison.OrdinalIgnoreCase); - private ReplPageRequest CreatePageSourceRequest(ResultFlowInvocationOptions? resultFlow) { var surface = ResolveResultSurface(); @@ -924,7 +918,6 @@ private static IReplPage CreatePagerDisplayPage(IReplPage page) var pageInfo = page.PageInfo with { NextCursor = null, - HasMore = false, }; return new ReplPageDisplaySnapshot(page, pageInfo); } 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 a01f46f..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(); diff --git a/src/Repl.Core/ResultFlow/ReplPage.cs b/src/Repl.Core/ResultFlow/ReplPage.cs index 9277efe..b82307c 100644 --- a/src/Repl.Core/ResultFlow/ReplPage.cs +++ b/src/Repl.Core/ResultFlow/ReplPage.cs @@ -6,32 +6,35 @@ namespace Repl; /// Represents one page of a larger result set. /// /// Item type. -public sealed class ReplPage : IReplPage +public sealed record ReplPage : IReplPage { private object?[]? _untypedItems; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the record. /// /// Items in the page. /// Page metadata. public ReplPage(IReadOnlyList items, ReplPageInfo pageInfo) { - Items = items ?? throw new ArgumentNullException(nameof(items)); - PageInfo = pageInfo ?? throw new ArgumentNullException(nameof(pageInfo)); + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(pageInfo); + + Items = items; + PageInfo = pageInfo; } /// /// Gets the typed items in the page. /// - public IReadOnlyList Items { get; } + public IReadOnlyList Items { get; init; } /// [JsonIgnore] public Type ItemType => typeof(T); /// - public ReplPageInfo PageInfo { get; } + public ReplPageInfo PageInfo { get; init; } /// [JsonIgnore] diff --git a/src/Repl.Core/ResultFlow/ReplPageInfo.cs b/src/Repl.Core/ResultFlow/ReplPageInfo.cs index 024b7cc..ac7e423 100644 --- a/src/Repl.Core/ResultFlow/ReplPageInfo.cs +++ b/src/Repl.Core/ResultFlow/ReplPageInfo.cs @@ -7,10 +7,14 @@ namespace Repl; /// Cursor that fetches the next page, when available. /// Total result count, when known. /// Requested or effective page size. -/// Whether another page is available. public sealed record ReplPageInfo( string? Cursor, string? NextCursor, long? TotalCount, - int PageSize, - bool HasMore); + int PageSize) +{ + /// + /// Gets a value indicating whether another page is available. + /// + public bool HasMore => !string.IsNullOrWhiteSpace(NextCursor); +} diff --git a/src/Repl.Core/ResultFlow/ReplPageRequestExtensions.cs b/src/Repl.Core/ResultFlow/ReplPageRequestExtensions.cs index e285a1c..3532efa 100644 --- a/src/Repl.Core/ResultFlow/ReplPageRequestExtensions.cs +++ b/src/Repl.Core/ResultFlow/ReplPageRequestExtensions.cs @@ -29,7 +29,6 @@ public static ReplPage Page( Cursor: request.Cursor, NextCursor: nextCursor, TotalCount: totalCount, - PageSize: request.PageSize, - HasMore: !string.IsNullOrWhiteSpace(nextCursor))); + PageSize: request.PageSize)); } } diff --git a/src/Repl.Core/ResultFlow/ReplPagingContext.cs b/src/Repl.Core/ResultFlow/ReplPagingContext.cs index c3ea778..290b8e8 100644 --- a/src/Repl.Core/ResultFlow/ReplPagingContext.cs +++ b/src/Repl.Core/ResultFlow/ReplPagingContext.cs @@ -45,8 +45,7 @@ public ReplPage Page( Cursor, nextCursor, totalCount, - SuggestedPageSize, - HasMore: !string.IsNullOrWhiteSpace(nextCursor)); + SuggestedPageSize); return new ReplPage(items, pageInfo); } diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index f5d06a4..68f7c61 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -531,8 +531,7 @@ public ValueTask> FetchAsync( Cursor: request.Cursor, NextCursor: null, TotalCount: 0, - PageSize: request.PageSize, - HasMore: false))); + PageSize: request.PageSize))); } [TestMethod] diff --git a/src/Repl.IntegrationTests/Given_OutputFormatting.cs b/src/Repl.IntegrationTests/Given_OutputFormatting.cs index 4d05757..02cf469 100644 --- a/src/Repl.IntegrationTests/Given_OutputFormatting.cs +++ b/src/Repl.IntegrationTests/Given_OutputFormatting.cs @@ -249,8 +249,7 @@ public void When_RenderingPageSourceInHumanPager_Then_SpaceFetchesNextPageWithou request.Cursor, nextCursor, contacts.Length, - request.PageSize, - nextCursor is not null))); + request.PageSize))); })); using var output = new StringWriter(); diff --git a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs index a19ffde..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) { diff --git a/src/Repl.Tests/Given_ReplPageSource.cs b/src/Repl.Tests/Given_ReplPageSource.cs index 14bfbb0..2bc27e2 100644 --- a/src/Repl.Tests/Given_ReplPageSource.cs +++ b/src/Repl.Tests/Given_ReplPageSource.cs @@ -274,6 +274,19 @@ public void When_RequestCreatesPage_Then_PageInfoUsesRequestAndNextCursor() 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(); + } + private static async IAsyncEnumerable ReadItemsAsync(IEnumerable items) { foreach (var item in items) From 3927f2a9740bf47f79c3d2343238188675b1267c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 22:34:46 -0400 Subject: [PATCH 13/21] Document replayable async page sources --- docs/result-flow.md | 4 ++ src/Repl.Core/ResultFlow/ReplPageSource.cs | 15 ++++- src/Repl.Tests/Given_ReplPageSource.cs | 77 ++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/docs/result-flow.md b/docs/result-flow.md index 4c98526..4fc0693 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -210,6 +210,10 @@ factory because later pages reopen the stream and skip to the requested offset. For live streams that cannot restart, 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]`: diff --git a/src/Repl.Core/ResultFlow/ReplPageSource.cs b/src/Repl.Core/ResultFlow/ReplPageSource.cs index 296e9ec..1e11603 100644 --- a/src/Repl.Core/ResultFlow/ReplPageSource.cs +++ b/src/Repl.Core/ResultFlow/ReplPageSource.cs @@ -130,6 +130,13 @@ public static IReplPageSource FromOffset( /// 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 and idempotent 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 such as channels, network cursors, or shared + /// enumerator instances. For those sources, use + /// with an opaque cursor owned by the source. + /// public static IReplPageSource FromAsyncEnumerable( Func> createItems, Func? filter = null, @@ -150,6 +157,13 @@ public static IReplPageSource FromAsyncEnumerable( /// 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 and idempotent 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 such as channels, network cursors, or shared + /// enumerator instances. For those sources, use + /// with an opaque cursor owned by the source. + /// public static IReplPageSource FromAsyncEnumerable( TState state, Func> createItems, @@ -289,7 +303,6 @@ private static async ValueTask> CreateFilteredOffsetPageAsync( while (true) { - ThrowIfScanLimitExceeded(scanned, maxSourceItemsToScan); var items = await fetch(currentOffset, take, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The offset page source returned null."); if (items.Count == 0) diff --git a/src/Repl.Tests/Given_ReplPageSource.cs b/src/Repl.Tests/Given_ReplPageSource.cs index 2bc27e2..955c045 100644 --- a/src/Repl.Tests/Given_ReplPageSource.cs +++ b/src/Repl.Tests/Given_ReplPageSource.cs @@ -188,6 +188,62 @@ public async Task When_FromAsyncEnumerableFetchesPages_Then_EmitsOffsetCursor() second.PageInfo.HasMore.Should().BeFalse(); } + [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() @@ -314,4 +370,25 @@ private static async IAsyncEnumerable ReadUntilCancelledAsync( } 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; + } + } + } } From 955b252d402d56d98ec59acac94b2e249db099f2 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 22:36:15 -0400 Subject: [PATCH 14/21] Validate MCP result cursors --- docs/result-flow.md | 4 ++ src/Repl.Mcp/McpToolAdapter.cs | 21 +++++++- src/Repl.McpTests/Given_McpToolAdapter.cs | 60 +++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/docs/result-flow.md b/docs/result-flow.md index 4fc0693..95f1f21 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -589,6 +589,10 @@ MCP tools expose two reserved input properties on every tool schema: | `_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. When a handler returns `ReplPage`, MCP returns: diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index d80ef9a..25a946c 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -221,7 +221,7 @@ private static string BuildPagedSummary(int count, JsonElement pageInfo) return summary; } - private static (List Tokens, Dictionary Prefills) PrepareExecution( + internal static (List Tokens, Dictionary Prefills) PrepareExecution( string routePath, IDictionary arguments) { @@ -241,6 +241,7 @@ private static (List Tokens, Dictionary Prefills) Prepar } else if (string.Equals(key, McpResultFlowArgumentNames.Cursor, StringComparison.Ordinal)) { + ValidateResultCursor(strValue); resultFlowTokens.Add("--result:cursor"); resultFlowTokens.Add(strValue); } @@ -260,6 +261,24 @@ private static (List Tokens, Dictionary Prefills) Prepar 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."); + } + } + /// /// Reconstructs CLI tokens from a route template and MCP arguments. /// diff --git a/src/Repl.McpTests/Given_McpToolAdapter.cs b/src/Repl.McpTests/Given_McpToolAdapter.cs index a449361..529a7d7 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,63 @@ 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*"); + } } From 2d85147b8cda4d6b9871d6b7f8f7d9368cd61237 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 22:37:26 -0400 Subject: [PATCH 15/21] Harden scroll pager rendering --- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 37 ++++++++++++++++++--- src/Repl.Tests/Given_ResultFlowPager.cs | 28 ++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index a8fcaed..1359255 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -95,6 +95,7 @@ await WriteScrollAsync( output, keyReader, visibleRows, + ansiEnabled, hasMorePayload, fetchNextPayload, cancellationToken) @@ -178,10 +179,16 @@ private static async ValueTask WriteScrollAsync( 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) { @@ -404,10 +411,32 @@ private static bool ShouldUseScrollPager(ReplPagerMode pagerMode, bool ansiEnabl private static string[] SplitLines(string payload) => string.IsNullOrEmpty(payload) ? [] - : payload - .Replace("\r\n", "\n", StringComparison.Ordinal) - .Replace('\r', '\n') - .Split('\n'); + : SplitNonEmptyPayloadLines(payload); + + private static string[] SplitNonEmptyPayloadLines(string payload) + { + var lines = new List(); + var start = 0; + for (var index = 0; index < payload.Length; index++) + { + var current = payload[index]; + if (current is not '\r' and not '\n') + { + continue; + } + + lines.Add(payload[start..index]); + if (current == '\r' && index + 1 < payload.Length && payload[index + 1] == '\n') + { + index++; + } + + start = index + 1; + } + + lines.Add(payload[start..]); + return [.. lines]; + } private sealed class PagerState(string[] lines, int pageSize, bool hasMorePayload) { diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index e245770..cdec9ca 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -274,6 +274,34 @@ await ResultFlowPager.WriteAsync( 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() + { + var writer = new StringWriter(); + 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: _ => ValueTask.FromResult( + new ResultFlowPagerPage("four", HasMore: false)), + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain("2-4/4"); + output.Should().Contain("four"); + } + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => new(keyChar, key, shift: false, alt: false, control: false); } From 8df04a42018423f0aceb47c64d9fe66d98682965 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 22:39:21 -0400 Subject: [PATCH 16/21] Avoid repeated markdown page allocations --- .../Output/MarkdownOutputTransformer.cs | 34 +++++++++++++++++-- src/Repl.Core/ResultFlow/ReplPage.cs | 9 +++-- src/Repl.Tests/Given_ReplPageSource.cs | 16 +++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/Repl.Core/Output/MarkdownOutputTransformer.cs b/src/Repl.Core/Output/MarkdownOutputTransformer.cs index a848eb6..a846f1b 100644 --- a/src/Repl.Core/Output/MarkdownOutputTransformer.cs +++ b/src/Repl.Core/Output/MarkdownOutputTransformer.cs @@ -125,7 +125,7 @@ private static string RenderPageFooter(IReplPage page) private static string RenderEnumerable(System.Collections.IEnumerable enumerable) { - var items = enumerable.Cast().ToArray(); + var items = ToObjectArray(enumerable); if (items.Length == 0) { return "No results."; @@ -226,7 +226,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); } @@ -255,6 +255,36 @@ 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(); diff --git a/src/Repl.Core/ResultFlow/ReplPage.cs b/src/Repl.Core/ResultFlow/ReplPage.cs index b82307c..35d3000 100644 --- a/src/Repl.Core/ResultFlow/ReplPage.cs +++ b/src/Repl.Core/ResultFlow/ReplPage.cs @@ -8,7 +8,7 @@ namespace Repl; /// Item type. public sealed record ReplPage : IReplPage { - private object?[]? _untypedItems; + private IReadOnlyList? _untypedItems; /// /// Initializes a new instance of the record. @@ -38,5 +38,10 @@ public ReplPage(IReadOnlyList items, ReplPageInfo pageInfo) /// [JsonIgnore] - public IReadOnlyList UntypedItems => _untypedItems ??= Items.Cast().ToArray(); + public IReadOnlyList UntypedItems => _untypedItems ??= Items switch + { + object?[] array => array, + IReadOnlyList list => list, + _ => Items.Cast().ToArray(), + }; } diff --git a/src/Repl.Tests/Given_ReplPageSource.cs b/src/Repl.Tests/Given_ReplPageSource.cs index 955c045..a8bbade 100644 --- a/src/Repl.Tests/Given_ReplPageSource.cs +++ b/src/Repl.Tests/Given_ReplPageSource.cs @@ -343,6 +343,22 @@ public void When_PageInfoHasNoNextCursor_Then_HasMoreIsFalse() 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) From d1e1253e25e5e070928b219fe12947b4df64d490 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 22:49:15 -0400 Subject: [PATCH 17/21] Address paging edge cases --- docs/result-flow.md | 12 ++-- src/Repl.Core/ResultFlow/ReplPageSource.cs | 18 +++--- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 13 ++++- src/Repl.Mcp/McpToolAdapter.cs | 14 +++++ src/Repl.McpTests/Given_McpToolAdapter.cs | 44 ++++++++++++++ src/Repl.Tests/Given_ReplPageSource.cs | 36 ++++++++++++ src/Repl.Tests/Given_ResultFlowPager.cs | 64 +++++++++++++++++++-- 7 files changed, 181 insertions(+), 20 deletions(-) diff --git a/docs/result-flow.md b/docs/result-flow.md index 95f1f21..efeb074 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -205,10 +205,11 @@ app.Map("events", (EventStore store) => ``` `FromAsyncEnumerable` passes the request cancellation token to the stream factory -and uses `WithCancellation(...)` while enumerating. It requires a replayable -factory because later pages reopen the stream and skip to the requested offset. -For live streams that cannot restart, emit a keyset/range cursor instead or use -a future live/tail-oriented API. +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 @@ -592,7 +593,8 @@ These properties are consumed by the Repl MCP adapter and mapped to `IReplPaging 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. +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: diff --git a/src/Repl.Core/ResultFlow/ReplPageSource.cs b/src/Repl.Core/ResultFlow/ReplPageSource.cs index 1e11603..fc1f160 100644 --- a/src/Repl.Core/ResultFlow/ReplPageSource.cs +++ b/src/Repl.Core/ResultFlow/ReplPageSource.cs @@ -131,10 +131,11 @@ public static IReplPageSource FromOffset( /// Maximum source rows to scan while filling one filtered page. /// A page source consumable by Repl renderers. /// - /// The factory must be replayable and idempotent 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 such as channels, network cursors, or shared - /// enumerator instances. For those sources, use + /// 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. /// public static IReplPageSource FromAsyncEnumerable( @@ -158,10 +159,11 @@ public static IReplPageSource FromAsyncEnumerable( /// Maximum source rows to scan while filling one filtered page. /// A page source consumable by Repl renderers. /// - /// The factory must be replayable and idempotent 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 such as channels, network cursors, or shared - /// enumerator instances. For those sources, use + /// 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. /// public static IReplPageSource FromAsyncEnumerable( diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 1359255..1febf38 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -209,7 +209,10 @@ private static async ValueTask WriteScrollAsync( return; } - if (state.TopLine >= state.MaxTopLine && state.HasMorePayload && fetchNextPayload is not null) + 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); @@ -434,7 +437,11 @@ private static string[] SplitNonEmptyPayloadLines(string payload) start = index + 1; } - lines.Add(payload[start..]); + if (start < payload.Length) + { + lines.Add(payload[start..]); + } + return [.. lines]; } @@ -470,6 +477,8 @@ private sealed class ScrollPagerState(string[] lines, int visibleRows, bool hasM public int MaxTopLine => Math.Max(0, Buffer.Count - ViewportHeight); + public bool HasReachedBottom => TopLine >= MaxTopLine; + public void Append(string[] lines, bool hasMorePayload) { Buffer.AddRange(lines); diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 25a946c..6413399 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -247,6 +247,7 @@ internal static (List Tokens, Dictionary Prefills) Prepa } else if (string.Equals(key, McpResultFlowArgumentNames.PageSize, StringComparison.Ordinal)) { + ValidateResultPageSize(strValue); resultFlowTokens.Add("--result:page-size"); resultFlowTokens.Add(strValue); } @@ -279,6 +280,19 @@ private static void ValidateResultCursor(string cursor) } } + 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."); + } + } + /// /// Reconstructs CLI tokens from a route template and MCP arguments. /// diff --git a/src/Repl.McpTests/Given_McpToolAdapter.cs b/src/Repl.McpTests/Given_McpToolAdapter.cs index 529a7d7..ee87f6e 100644 --- a/src/Repl.McpTests/Given_McpToolAdapter.cs +++ b/src/Repl.McpTests/Given_McpToolAdapter.cs @@ -147,4 +147,48 @@ public void When_ResultCursorIsTooLong_Then_Rejected() 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.Tests/Given_ReplPageSource.cs b/src/Repl.Tests/Given_ReplPageSource.cs index a8bbade..701d46b 100644 --- a/src/Repl.Tests/Given_ReplPageSource.cs +++ b/src/Repl.Tests/Given_ReplPageSource.cs @@ -188,6 +188,31 @@ public async Task When_FromAsyncEnumerableFetchesPages_Then_EmitsOffsetCursor() 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() @@ -286,6 +311,17 @@ public async Task When_FromAsyncEnumerableUsesStateAndFilter_Then_StaticFactoryC 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] diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index cdec9ca..bccc205 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -252,7 +252,7 @@ public async Task When_ScrollPagerReachesBufferedEnd_Then_FetchesNextPayload() ]); await ResultFlowPager.WriteAsync( - "one\ntwo", + "one\ntwo\nthree", writer, keys, visibleRows: 3, @@ -286,7 +286,7 @@ public async Task When_ScrollPagerFetchesShortPayload_Then_ViewportAdvances() ]); await ResultFlowPager.WriteAsync( - "one\ntwo\nthree", + "one\ntwo\nthree\nfour", writer, keys, visibleRows: 4, @@ -294,12 +294,66 @@ await ResultFlowPager.WriteAsync( ansiEnabled: true, hasMorePayload: true, fetchNextPayload: _ => ValueTask.FromResult( - new ResultFlowPagerPage("four", HasMore: false)), + new ResultFlowPagerPage("five", HasMore: false)), CancellationToken.None); var output = writer.ToString(); - output.Should().Contain("2-4/4"); - output.Should().Contain("four"); + 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() + { + 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() + { + 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"); } private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => From 2c770b867c3b4c002f959df3d95959e72983b357 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 23:00:35 -0400 Subject: [PATCH 18/21] fix(result-flow): tighten scroll pager key handling and doc perf - Map Space/PageDown/F explicitly to page-forward; Enter maps to line-forward (same as DownArrow); unknown keys are no-ops instead of silently advancing the viewport - Add regression tests: unrecognized key does not advance or fetch, Enter advances by one line only - Document O(offset/page) cost on FromAsyncEnumerable overloads and point callers toward the Create overload for large/expensive sources --- src/Repl.Core/ResultFlow/ReplPageSource.cs | 12 +++++ src/Repl.Core/ResultFlow/ResultFlowPager.cs | 7 ++- src/Repl.Tests/Given_ResultFlowPager.cs | 56 +++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/Repl.Core/ResultFlow/ReplPageSource.cs b/src/Repl.Core/ResultFlow/ReplPageSource.cs index fc1f160..a0dc580 100644 --- a/src/Repl.Core/ResultFlow/ReplPageSource.cs +++ b/src/Repl.Core/ResultFlow/ReplPageSource.cs @@ -137,6 +137,12 @@ public static IReplPageSource FromOffset( /// 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, @@ -165,6 +171,12 @@ public static IReplPageSource FromAsyncEnumerable( /// 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, diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 1febf38..d046484 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -386,6 +386,12 @@ private static bool ApplyScrollKey(ScrollPagerState state, ConsoleKeyInfo 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); @@ -403,7 +409,6 @@ private static bool ApplyScrollKey(ScrollPagerState state, ConsoleKeyInfo key) state.TopLine = 0; return false; default: - state.TopLine = Math.Min(state.TopLine + state.ViewportHeight, state.MaxTopLine); return false; } } diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index bccc205..da34189 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -356,6 +356,62 @@ await ResultFlowPager.WriteAsync( 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() + { + 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() + { + 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); } From cf89dea3f1a0bac057d3d55462e6d42daa84946f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 4 May 2026 23:03:40 -0400 Subject: [PATCH 19/21] refactor(result-flow): style polish - Replace char-by-char line splitter with EnumerateLines + trailing- empty trim; simpler, handles all newline conventions natively - Extract PagerState.Lines backing field so Reset does not expose a setter on the property - Pre-allocate emptyRow in MarkdownOutputTransformer table renderer to avoid one allocation per null item --- .../Output/MarkdownOutputTransformer.cs | 4 ++- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 29 +++++++------------ 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/Repl.Core/Output/MarkdownOutputTransformer.cs b/src/Repl.Core/Output/MarkdownOutputTransformer.cs index a846f1b..571b5b6 100644 --- a/src/Repl.Core/Output/MarkdownOutputTransformer.cs +++ b/src/Repl.Core/Output/MarkdownOutputTransformer.cs @@ -152,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(), @@ -161,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; } diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index d046484..0e278e9 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -424,27 +424,16 @@ private static string[] SplitLines(string payload) => private static string[] SplitNonEmptyPayloadLines(string payload) { var lines = new List(); - var start = 0; - for (var index = 0; index < payload.Length; index++) + foreach (var line in payload.AsSpan().EnumerateLines()) { - var current = payload[index]; - if (current is not '\r' and not '\n') - { - continue; - } - - lines.Add(payload[start..index]); - if (current == '\r' && index + 1 < payload.Length && payload[index + 1] == '\n') - { - index++; - } - - start = index + 1; + lines.Add(line.ToString()); } - if (start < payload.Length) + // 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.Add(payload[start..]); + lines.RemoveAt(lines.Count - 1); } return [.. lines]; @@ -452,7 +441,9 @@ private static string[] SplitNonEmptyPayloadLines(string payload) private sealed class PagerState(string[] lines, int pageSize, bool hasMorePayload) { - public string[] Lines { get; private set; } = lines; + private string[] _lines = lines; + + public string[] Lines => _lines; public int PageSize { get; } = pageSize; @@ -464,7 +455,7 @@ private sealed class PagerState(string[] lines, int pageSize, bool hasMorePayloa public void Reset(string[] lines, bool hasMorePayload) { - Lines = lines; + _lines = lines; Index = 0; HasMorePayload = hasMorePayload; } From 05605aa5a224199de2a7ab024fc520b1b44661ec Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 5 May 2026 09:07:28 -0400 Subject: [PATCH 20/21] Address review comments --- samples/01-core-basics/ActivityFeed.cs | 2 +- samples/07-spectre/ActivityFeed.cs | 2 +- src/Repl.Tests/Given_ResultFlowPager.cs | 28 ++++++++++++------------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/samples/01-core-basics/ActivityFeed.cs b/samples/01-core-basics/ActivityFeed.cs index 376fde9..c0619e4 100644 --- a/samples/01-core-basics/ActivityFeed.cs +++ b/samples/01-core-basics/ActivityFeed.cs @@ -38,7 +38,7 @@ private static List CreateItems() return new ActivityEvent( i, - start.AddMinutes(i * 7).ToString("yyyy-MM-dd HH:mm'Z'", CultureInfo.InvariantCulture), + start.AddMinutes(i * 7d).ToString("yyyy-MM-dd HH:mm'Z'", CultureInfo.InvariantCulture), area, eventName, $"{area} batch {((i - 1) / 5) + 1} {eventName} successfully"); diff --git a/samples/07-spectre/ActivityFeed.cs b/samples/07-spectre/ActivityFeed.cs index 3ee689c..a03d844 100644 --- a/samples/07-spectre/ActivityFeed.cs +++ b/samples/07-spectre/ActivityFeed.cs @@ -38,7 +38,7 @@ private static List CreateItems() return new ActivityEvent( i, - start.AddMinutes(i * 11).ToString("yyyy-MM-dd HH:mm'Z'", CultureInfo.InvariantCulture), + start.AddMinutes(i * 11d).ToString("yyyy-MM-dd HH:mm'Z'", CultureInfo.InvariantCulture), team, status, $"{team}-{i:0000} {status}"); diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index da34189..ad450d7 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -9,7 +9,7 @@ public sealed class Given_ResultFlowPager [Description("Result-flow pager advances by page on Space and stops on Q.")] public async Task When_PagingWithSpaceAndQuit_Then_WritesOnlyRequestedPages() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var keys = new FakeKeyReader( [ MakeKey(ConsoleKey.Spacebar, ' '), @@ -40,7 +40,7 @@ await ResultFlowPager.WriteAsync( [Description("Result-flow pager advances by one line on Enter.")] public async Task When_PagingWithEnter_Then_AdvancesSingleLine() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var keys = new FakeKeyReader( [ MakeKey(ConsoleKey.Enter, '\r'), @@ -65,7 +65,7 @@ await ResultFlowPager.WriteAsync( [Description("Result-flow pager UpArrow moves back one line instead of jumping to the header.")] public async Task When_PagingBackWithUpArrow_Then_DoesNotRepeatHeader() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var keys = new FakeKeyReader( [ MakeKey(ConsoleKey.Spacebar, ' '), @@ -92,7 +92,7 @@ await ResultFlowPager.WriteAsync( [Description("Result-flow pager fetches the next data page in the same interactive run.")] public async Task When_CurrentPayloadEndsAndMoreDataExists_Then_SpaceFetchesNextPayload() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var fetches = 0; var keys = new FakeKeyReader( [ @@ -125,7 +125,7 @@ await ResultFlowPager.WriteAsync( [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() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var fetches = 0; var keys = new FakeKeyReader( [ @@ -158,7 +158,7 @@ await ResultFlowPager.WriteAsync( [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() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var fetches = 0; var keys = new FakeKeyReader([]); @@ -187,7 +187,7 @@ await ResultFlowPager.WriteAsync( [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() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var keys = new FakeKeyReader( [ MakeKey(ConsoleKey.Spacebar, ' '), @@ -213,7 +213,7 @@ await ResultFlowPager.WriteAsync( [Description("Result-flow scroll pager owns an alternate-screen viewport instead of relying on terminal scrollback.")] public async Task When_ScrollPagerRunsWithAnsi_Then_UsesAlternateScreenViewport() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var keys = new FakeKeyReader( [ MakeKey(ConsoleKey.DownArrow, '\0'), @@ -243,7 +243,7 @@ await ResultFlowPager.WriteAsync( [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() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var fetches = 0; var keys = new FakeKeyReader( [ @@ -278,7 +278,7 @@ await ResultFlowPager.WriteAsync( [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() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var keys = new FakeKeyReader( [ MakeKey(ConsoleKey.Spacebar, ' '), @@ -306,7 +306,7 @@ await ResultFlowPager.WriteAsync( [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() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var fetches = 0; var keys = new FakeKeyReader( [ @@ -338,7 +338,7 @@ await ResultFlowPager.WriteAsync( [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() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var keys = new FakeKeyReader( [ MakeKey(ConsoleKey.Q, 'q'), @@ -360,7 +360,7 @@ await ResultFlowPager.WriteAsync( [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() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var fetches = 0; var keys = new FakeKeyReader( [ @@ -392,7 +392,7 @@ await ResultFlowPager.WriteAsync( [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() { - var writer = new StringWriter(); + using var writer = new StringWriter(); var keys = new FakeKeyReader( [ MakeKey(ConsoleKey.Enter, '\r'), From dc0b815d63f301b1780eec46b6db22e8f4b83207 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 5 May 2026 22:16:38 -0400 Subject: [PATCH 21/21] Fix review and documentation lint issues --- docs/result-flow.md | 2 +- src/Repl.Core/CoreReplApp.Execution.cs | 14 +++++++++++++- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 6 +++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/result-flow.md b/docs/result-flow.md index efeb074..e26504c 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -386,7 +386,7 @@ Result-flow flags are global and use the `--result:` prefix so they do not colli | `--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. | +| `--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 diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index eb7c194..eb927e4 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -1138,7 +1138,19 @@ private ReplResultSurface ResolveResultSurface() var height = Console.WindowHeight; return height > 0 ? height : null; } - catch + catch (IOException) + { + return null; + } + catch (PlatformNotSupportedException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + catch (System.Security.SecurityException) { return null; } diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index a24a273..07166cc 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -110,9 +110,9 @@ public async Task When_ToolsCallReturnsPagedResult_Then_StructuredContentContain 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; - text.Should().NotBeNull(); - text!.Should().Contain("Returned 1 item(s)."); + 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"); }