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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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"); } From 6578ce1493532167f44f865a2cac596fc466f0c1 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 5 May 2026 22:25:58 -0400 Subject: [PATCH 22/45] Smooth result flow scroll pager --- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 91 ++++++++++++++++----- src/Repl.Tests/Given_ResultFlowPager.cs | 85 ++++++++++++++++++- 2 files changed, 153 insertions(+), 23 deletions(-) diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 0e278e9..55b93af 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -9,6 +9,7 @@ internal static class ResultFlowPager private const string ShowCursor = "\u001b[?25h"; private const string CursorHome = "\u001b[H"; private const string ClearToEndOfScreen = "\u001b[J"; + private const string ClearLine = "\u001b[2K"; public static int CountLines(string payload) => SplitLines(payload).Length; @@ -197,6 +198,8 @@ private static async ValueTask WriteScrollAsync( await output.WriteAsync(EnterAlternateScreen).ConfigureAwait(false); await output.WriteAsync(HideCursor).ConfigureAwait(false); + await output.WriteAsync(CursorHome).ConfigureAwait(false); + await output.WriteAsync(ClearToEndOfScreen).ConfigureAwait(false); try { await EnsureScrollBufferAsync(state, fetchNextPayload, cancellationToken).ConfigureAwait(false); @@ -204,22 +207,19 @@ private static async ValueTask WriteScrollAsync( { await RenderScrollAsync(state, output, cancellationToken).ConfigureAwait(false); var key = await keyReader.ReadKeyAsync(cancellationToken).ConfigureAwait(false); - if (ApplyScrollKey(state, key)) + var beforeTopLine = state.TopLine; + var action = ApplyScrollKey(state, key); + if (action == ScrollKeyAction.Quit) { return; } - if (state.HasReachedBottom - && state.Buffer.Count > state.ViewportHeight + if (ShouldFetchForScrollKey(state, action, beforeTopLine) && 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); - } + state.TopLine = Math.Min(beforeTopLine + GetScrollDelta(action, state.ViewportHeight), state.MaxTopLine); } } } @@ -357,15 +357,22 @@ private static async ValueTask RenderScrollAsync( CancellationToken cancellationToken) { await output.WriteAsync(CursorHome).ConfigureAwait(false); - await output.WriteAsync(ClearToEndOfScreen).ConfigureAwait(false); + if (state.StickyHeader is { } header) + { + await output.WriteAsync(ClearLine).ConfigureAwait(false); + await output.WriteLineAsync(header).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.WriteAsync(ClearLine).ConfigureAwait(false); await output.WriteLineAsync(state.Buffer[state.TopLine + i]).ConfigureAwait(false); } for (var i = take; i < state.ViewportHeight; i++) { + await output.WriteAsync(ClearLine).ConfigureAwait(false); await output.WriteLineAsync().ConfigureAwait(false); } @@ -375,44 +382,56 @@ private static async ValueTask RenderScrollAsync( 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(ClearLine).ConfigureAwait(false); await output.WriteAsync(status).ConfigureAwait(false); await output.FlushAsync(cancellationToken).ConfigureAwait(false); } - private static bool ApplyScrollKey(ScrollPagerState state, ConsoleKeyInfo key) + private static ScrollKeyAction ApplyScrollKey(ScrollPagerState state, ConsoleKeyInfo key) { switch (key.Key) { case ConsoleKey.Q: case ConsoleKey.Escape: - return true; + return ScrollKeyAction.Quit; case ConsoleKey.Spacebar: case ConsoleKey.PageDown: case ConsoleKey.F: state.TopLine = Math.Min(state.TopLine + state.ViewportHeight, state.MaxTopLine); - return false; + return ScrollKeyAction.PageDown; case ConsoleKey.Enter: case ConsoleKey.DownArrow: case ConsoleKey.J: state.TopLine = Math.Min(state.TopLine + 1, state.MaxTopLine); - return false; + return ScrollKeyAction.LineDown; case ConsoleKey.UpArrow: case ConsoleKey.K: state.TopLine = Math.Max(0, state.TopLine - 1); - return false; + return ScrollKeyAction.Other; case ConsoleKey.PageUp: case ConsoleKey.B: state.TopLine = Math.Max(0, state.TopLine - state.ViewportHeight); - return false; + return ScrollKeyAction.Other; case ConsoleKey.Home: case ConsoleKey.G when key.Modifiers.HasFlag(ConsoleModifiers.Shift): state.TopLine = 0; - return false; + return ScrollKeyAction.Other; default: - return false; + return ScrollKeyAction.Other; } } + private static bool ShouldFetchForScrollKey(ScrollPagerState state, ScrollKeyAction action, int beforeTopLine) => + action switch + { + ScrollKeyAction.PageDown => state.HasReachedBottom && state.Buffer.Count > state.ViewportHeight, + ScrollKeyAction.LineDown => beforeTopLine == state.TopLine && state.HasReachedBottom, + _ => false, + }; + + private static int GetScrollDelta(ScrollKeyAction action, int viewportHeight) => + action == ScrollKeyAction.PageDown ? viewportHeight : 1; + private static bool ShouldUseScrollPager(ReplPagerMode pagerMode, bool ansiEnabled) => ansiEnabled && pagerMode is ReplPagerMode.Auto or ReplPagerMode.Scroll; @@ -461,15 +480,33 @@ public void Reset(string[] lines, bool hasMorePayload) } } - private sealed class ScrollPagerState(string[] lines, int visibleRows, bool hasMorePayload) + private enum ScrollKeyAction { - public List Buffer { get; } = [.. lines]; + Other, + LineDown, + PageDown, + Quit, + } - public int ViewportHeight { get; } = Math.Max(1, visibleRows - 1); + private sealed class ScrollPagerState + { + public ScrollPagerState(string[] lines, int visibleRows, bool hasMorePayload) + { + StickyHeader = TryGetStickyHeader(lines); + Buffer = [.. GetContentLines(lines, StickyHeader)]; + ViewportHeight = Math.Max(1, visibleRows - (StickyHeader is null ? 1 : 2)); + HasMorePayload = hasMorePayload; + } + + public List Buffer { get; } + + public string? StickyHeader { get; } + + public int ViewportHeight { get; } public int TopLine { get; set; } - public bool HasMorePayload { get; set; } = hasMorePayload; + public bool HasMorePayload { get; set; } public int MaxTopLine => Math.Max(0, Buffer.Count - ViewportHeight); @@ -477,8 +514,18 @@ private sealed class ScrollPagerState(string[] lines, int visibleRows, bool hasM public void Append(string[] lines, bool hasMorePayload) { - Buffer.AddRange(lines); + Buffer.AddRange(GetContentLines(lines, StickyHeader)); HasMorePayload = hasMorePayload; } + + private static string? TryGetStickyHeader(string[] lines) => + lines.Length > 1 && lines[0].Contains("\u001b[1m", StringComparison.Ordinal) + ? lines[0] + : null; + + private static IEnumerable GetContentLines(string[] lines, string? stickyHeader) => + stickyHeader is not null && lines.Length > 0 && string.Equals(lines[0], stickyHeader, StringComparison.Ordinal) + ? lines.Skip(1) + : lines; } } diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index ad450d7..fe1395e 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -263,7 +263,7 @@ await ResultFlowPager.WriteAsync( { fetches++; return ValueTask.FromResult( - new ResultFlowPagerPage("three\nfour", HasMore: false)); + new ResultFlowPagerPage("four\nfive", HasMore: false)); }, CancellationToken.None); @@ -412,6 +412,89 @@ await ResultFlowPager.WriteAsync( writer.ToString().Should().Contain("2-3/4"); } + [TestMethod] + [Description("Result-flow scroll pager advances by one line when Down fetches the next payload at a boundary.")] + public async Task When_ScrollPagerDownFetchesNextPayload_Then_ViewportAdvancesOneLine() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree", + writer, + keys, + visibleRows: 3, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => ValueTask.FromResult( + new ResultFlowPagerPage("four\nfive", HasMore: false)), + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain("2-3/3+"); + output.Should().Contain("3-4/5"); + output.Should().NotContain("4-5/5"); + } + + [TestMethod] + [Description("Result-flow scroll pager keeps a rich table header pinned and skips duplicate headers from later payloads.")] + public async Task When_ScrollPagerHasRichTableHeader_Then_HeaderStaysPinned() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + MakeKey(ConsoleKey.Q, 'q'), + ]); + var header = "\u001b[1m#\u001b[0m \u001b[1mAt\u001b[0m"; + + await ResultFlowPager.WriteAsync( + $"{header}\none\ntwo\nthree", + writer, + keys, + visibleRows: 4, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => ValueTask.FromResult( + new ResultFlowPagerPage($"{header}\nfour\nfive", HasMore: false)), + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain($"{header}\r\n\u001b[2Kthree\r\n\u001b[2Kfour"); + output.Should().Contain("3-4/5"); + } + + [TestMethod] + [Description("Result-flow scroll pager does not clear the whole viewport on every redraw.")] + public async Task When_ScrollPagerRedraws_Then_DoesNotClearScreenEveryTime() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree\nfour", + writer, + keys, + visibleRows: 3, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + CancellationToken.None); + + writer.ToString().Split("\u001b[J").Length.Should().Be(2); + } + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => new(keyChar, key, shift: false, alt: false, control: false); } From e68bd7fcf50a8a8722206b67da3b3868376cd7a9 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 5 May 2026 22:37:30 -0400 Subject: [PATCH 23/45] Handle scroll pager viewport changes --- src/Repl.Core/CoreReplApp.Execution.cs | 10 +- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 228 ++++++++++++++++++-- src/Repl.Tests/Given_ResultFlowPager.cs | 96 ++++++++- 3 files changed, 316 insertions(+), 18 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index eb927e4..5a75583 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -762,6 +762,7 @@ await ResultFlowPager.WriteAsync( ReplSessionIO.Output, keyReader, visibleRows, + ResolvePagerVisibleRows, pagerMode, ansiEnabled, page.PageInfo.HasMore, @@ -870,13 +871,18 @@ private bool TryCreatePager( } private bool TryResolvePagerVisibleRows(out int visibleRows) + { + visibleRows = ResolvePagerVisibleRows(); + return visibleRows > 0; + } + + private int ResolvePagerVisibleRows() { var height = ReplSessionIO.WindowSize?.Height ?? TryGetConsoleWindowHeight(); var reservedRows = Math.Max(0, _options.Output.ResultFlow.ReservedVisibleRows); - visibleRows = height is > 0 + return 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) diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 55b93af..95aff49 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -9,7 +9,6 @@ internal static class ResultFlowPager private const string ShowCursor = "\u001b[?25h"; private const string CursorHome = "\u001b[H"; private const string ClearToEndOfScreen = "\u001b[J"; - private const string ClearLine = "\u001b[2K"; public static int CountLines(string payload) => SplitLines(payload).Length; @@ -71,6 +70,7 @@ await WriteAsync( ansiEnabled: false, hasMorePayload, fetchNextPayload, + visibleRowsProvider: null, cancellationToken) .ConfigureAwait(false); } @@ -80,11 +80,40 @@ public static async ValueTask WriteAsync( TextWriter output, IReplKeyReader keyReader, int visibleRows, + Func visibleRowsProvider, ReplPagerMode pagerMode, bool ansiEnabled, bool hasMorePayload, Func>? fetchNextPayload, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(visibleRowsProvider); + + await WriteAsync( + payload, + output, + keyReader, + visibleRows, + pagerMode, + ansiEnabled, + hasMorePayload, + fetchNextPayload, + visibleRowsProvider, + cancellationToken) + .ConfigureAwait(false); + } + + private static async ValueTask WriteAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + ReplPagerMode pagerMode, + bool ansiEnabled, + bool hasMorePayload, + Func>? fetchNextPayload, + Func? visibleRowsProvider, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(output); ArgumentNullException.ThrowIfNull(keyReader); @@ -99,6 +128,7 @@ await WriteScrollAsync( ansiEnabled, hasMorePayload, fetchNextPayload, + visibleRowsProvider, cancellationToken) .ConfigureAwait(false); return; @@ -115,6 +145,31 @@ await WriteMoreAsync( .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) + { + await WriteAsync( + payload, + output, + keyReader, + visibleRows, + pagerMode, + ansiEnabled, + hasMorePayload, + fetchNextPayload, + visibleRowsProvider: null, + cancellationToken) + .ConfigureAwait(false); + } + private static async ValueTask WriteMoreAsync( string payload, TextWriter output, @@ -183,6 +238,7 @@ private static async ValueTask WriteScrollAsync( bool ansiEnabled, bool hasMorePayload, Func>? fetchNextPayload, + Func? visibleRowsProvider, CancellationToken cancellationToken) { if (!ansiEnabled) @@ -205,6 +261,13 @@ private static async ValueTask WriteScrollAsync( await EnsureScrollBufferAsync(state, fetchNextPayload, cancellationToken).ConfigureAwait(false); while (true) { + if (state.UpdateVisibleRows(GetCurrentVisibleRows(visibleRows, visibleRowsProvider))) + { + await output.WriteAsync(CursorHome).ConfigureAwait(false); + await output.WriteAsync(ClearToEndOfScreen).ConfigureAwait(false); + state.ResetRenderedLineLengths(); + } + await RenderScrollAsync(state, output, cancellationToken).ConfigureAwait(false); var key = await keyReader.ReadKeyAsync(cancellationToken).ConfigureAwait(false); var beforeTopLine = state.TopLine; @@ -359,21 +422,19 @@ private static async ValueTask RenderScrollAsync( await output.WriteAsync(CursorHome).ConfigureAwait(false); if (state.StickyHeader is { } header) { - await output.WriteAsync(ClearLine).ConfigureAwait(false); - await output.WriteLineAsync(header).ConfigureAwait(false); + await WriteViewportLineAsync(state, output, row: 0, header).ConfigureAwait(false); } + var row = state.StickyHeader is null ? 0 : 1; var take = Math.Min(state.ViewportHeight, Math.Max(0, state.Buffer.Count - state.TopLine)); for (var i = 0; i < take; i++) { - await output.WriteAsync(ClearLine).ConfigureAwait(false); - await output.WriteLineAsync(state.Buffer[state.TopLine + i]).ConfigureAwait(false); + await WriteViewportLineAsync(state, output, row++, state.Buffer[state.TopLine + i]).ConfigureAwait(false); } for (var i = take; i < state.ViewportHeight; i++) { - await output.WriteAsync(ClearLine).ConfigureAwait(false); - await output.WriteLineAsync().ConfigureAwait(false); + await WriteViewportLineAsync(state, output, row++, string.Empty).ConfigureAwait(false); } var lastLine = state.Buffer.Count == 0 @@ -382,11 +443,31 @@ private static async ValueTask RenderScrollAsync( 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(ClearLine).ConfigureAwait(false); - await output.WriteAsync(status).ConfigureAwait(false); + await WriteViewportLineAsync(state, output, row, status, appendNewLine: false).ConfigureAwait(false); await output.FlushAsync(cancellationToken).ConfigureAwait(false); } + private static async ValueTask WriteViewportLineAsync( + ScrollPagerState state, + TextWriter output, + int row, + string line, + bool appendNewLine = true) + { + await output.WriteAsync(line).ConfigureAwait(false); + var previousLength = state.GetRenderedLineLength(row); + if (previousLength > line.Length) + { + await output.WriteAsync(new string(' ', previousLength - line.Length)).ConfigureAwait(false); + } + + state.SetRenderedLineLength(row, line.Length); + if (appendNewLine) + { + await output.WriteLineAsync().ConfigureAwait(false); + } + } + private static ScrollKeyAction ApplyScrollKey(ScrollPagerState state, ConsoleKeyInfo key) { switch (key.Key) @@ -416,6 +497,9 @@ private static ScrollKeyAction ApplyScrollKey(ScrollPagerState state, ConsoleKey case ConsoleKey.G when key.Modifiers.HasFlag(ConsoleModifiers.Shift): state.TopLine = 0; return ScrollKeyAction.Other; + case ConsoleKey.End: + state.TopLine = state.MaxTopLine; + return ScrollKeyAction.Other; default: return ScrollKeyAction.Other; } @@ -432,6 +516,35 @@ private static bool ShouldFetchForScrollKey(ScrollPagerState state, ScrollKeyAct private static int GetScrollDelta(ScrollKeyAction action, int viewportHeight) => action == ScrollKeyAction.PageDown ? viewportHeight : 1; + private static int GetCurrentVisibleRows(int fallbackVisibleRows, Func? visibleRowsProvider) + { + if (visibleRowsProvider is null) + { + return Math.Max(2, fallbackVisibleRows); + } + + try + { + return Math.Max(2, visibleRowsProvider()); + } + catch (IOException) + { + return Math.Max(2, fallbackVisibleRows); + } + catch (PlatformNotSupportedException) + { + return Math.Max(2, fallbackVisibleRows); + } + catch (InvalidOperationException) + { + return Math.Max(2, fallbackVisibleRows); + } + catch (System.Security.SecurityException) + { + return Math.Max(2, fallbackVisibleRows); + } + } + private static bool ShouldUseScrollPager(ReplPagerMode pagerMode, bool ansiEnabled) => ansiEnabled && pagerMode is ReplPagerMode.Auto or ReplPagerMode.Scroll; @@ -494,15 +607,20 @@ public ScrollPagerState(string[] lines, int visibleRows, bool hasMorePayload) { StickyHeader = TryGetStickyHeader(lines); Buffer = [.. GetContentLines(lines, StickyHeader)]; - ViewportHeight = Math.Max(1, visibleRows - (StickyHeader is null ? 1 : 2)); + VisibleRows = Math.Max(2, visibleRows); + ViewportHeight = CalculateViewportHeight(VisibleRows, StickyHeader); HasMorePayload = hasMorePayload; } public List Buffer { get; } + private List RenderedLineLengths { get; } = []; + public string? StickyHeader { get; } - public int ViewportHeight { get; } + public int VisibleRows { get; private set; } + + public int ViewportHeight { get; private set; } public int TopLine { get; set; } @@ -518,14 +636,94 @@ public void Append(string[] lines, bool hasMorePayload) HasMorePayload = hasMorePayload; } + public bool UpdateVisibleRows(int visibleRows) + { + visibleRows = Math.Max(2, visibleRows); + if (VisibleRows == visibleRows) + { + return false; + } + + VisibleRows = visibleRows; + ViewportHeight = CalculateViewportHeight(visibleRows, StickyHeader); + TopLine = Math.Min(TopLine, MaxTopLine); + return true; + } + + public int GetRenderedLineLength(int row) => + row < RenderedLineLengths.Count ? RenderedLineLengths[row] : 0; + + public void SetRenderedLineLength(int row, int length) + { + while (RenderedLineLengths.Count <= row) + { + RenderedLineLengths.Add(0); + } + + RenderedLineLengths[row] = length; + } + + public void ResetRenderedLineLengths() => RenderedLineLengths.Clear(); + private static string? TryGetStickyHeader(string[] lines) => lines.Length > 1 && lines[0].Contains("\u001b[1m", StringComparison.Ordinal) ? lines[0] : null; - private static IEnumerable GetContentLines(string[] lines, string? stickyHeader) => - stickyHeader is not null && lines.Length > 0 && string.Equals(lines[0], stickyHeader, StringComparison.Ordinal) - ? lines.Skip(1) - : lines; + private static IEnumerable GetContentLines(string[] lines, string? stickyHeader) + { + var start = stickyHeader is not null + && lines.Length > 0 + && AreSameHeaderLine(lines[0], stickyHeader) + ? 1 + : 0; + for (var i = start; i < lines.Length; i++) + { + if (!IsPageFooterLine(lines[i])) + { + yield return lines[i]; + } + } + } + + private static int CalculateViewportHeight(int visibleRows, string? stickyHeader) => + Math.Max(1, visibleRows - (stickyHeader is null ? 1 : 2)); + + private static bool AreSameHeaderLine(string candidate, string stickyHeader) => + string.Equals(NormalizeAnsiLine(candidate), NormalizeAnsiLine(stickyHeader), StringComparison.Ordinal); + + private static bool IsPageFooterLine(string line) => + line.StartsWith("Showing ", StringComparison.Ordinal) + && (line.Contains(" of ", StringComparison.Ordinal) + || line.Contains(" result(s).", StringComparison.Ordinal)) + && (line.EndsWith('.') + || line.Contains("Next data page: rerun with --result:cursor ", StringComparison.Ordinal)); + + private static string NormalizeAnsiLine(string line) + { + if (!line.Contains('\u001b', StringComparison.Ordinal)) + { + return line.Trim(); + } + + var builder = new System.Text.StringBuilder(line.Length); + for (var i = 0; i < line.Length; i++) + { + if (line[i] == '\u001b' && i + 1 < line.Length && line[i + 1] == '[') + { + i += 2; + while (i < line.Length && (line[i] < '@' || line[i] > '~')) + { + i++; + } + + continue; + } + + builder.Append(line[i]); + } + + return builder.ToString().Trim(); + } } } diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index fe1395e..de439e6 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -467,7 +467,7 @@ await ResultFlowPager.WriteAsync( CancellationToken.None); var output = writer.ToString(); - output.Should().Contain($"{header}\r\n\u001b[2Kthree\r\n\u001b[2Kfour"); + output.Should().Contain($"{header}\r\nthree\r\nfour"); output.Should().Contain("3-4/5"); } @@ -493,6 +493,100 @@ await ResultFlowPager.WriteAsync( CancellationToken.None); writer.ToString().Split("\u001b[J").Length.Should().Be(2); + writer.ToString().Should().NotContain("\u001b[2K"); + } + + [TestMethod] + [Description("Result-flow scroll pager strips page footer hints already represented by its own status bar.")] + public async Task When_ScrollPagerReceivesPageFooterLines_Then_FooterLinesAreNotRendered() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nShowing 2 of 5. Next data page: rerun with --result:cursor page-2.", + writer, + keys, + visibleRows: 3, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => ValueTask.FromResult( + new ResultFlowPagerPage("three\nShowing 1 of 5.", HasMore: false)), + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain("one"); + output.Should().Contain("three"); + output.Should().NotContain("Showing 2 of 5"); + output.Should().NotContain("Showing 1 of 5"); + } + + [TestMethod] + [Description("Result-flow scroll pager End moves to the end of the currently buffered content.")] + public async Task When_ScrollPagerEndPressed_Then_MovesToKnownEndWithoutFetching() + { + using var writer = new StringWriter(); + var fetches = 0; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.End, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree\nfour\nfive", + writer, + keys, + visibleRows: 3, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => + { + fetches++; + return ValueTask.FromResult( + new ResultFlowPagerPage("six", HasMore: false)); + }, + CancellationToken.None); + + fetches.Should().Be(0); + writer.ToString().Should().Contain("4-5/5+"); + } + + [TestMethod] + [Description("Result-flow scroll pager recalculates viewport height between redraws.")] + public async Task When_ScrollPagerHeightChanges_Then_ViewportUsesCurrentHeight() + { + using var writer = new StringWriter(); + var visibleRows = 5; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + var reads = 0; + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree\nfour\nfive", + writer, + keys, + visibleRows, + visibleRowsProvider: () => reads++ == 0 ? visibleRows : 3, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + hasMorePayload: false, + fetchNextPayload: null, + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain("1-4/5"); + output.Should().Contain("2-3/5"); + output.Should().Contain("\u001b[H\u001b[J"); } private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => From 3ec8246e9ef0f9fed8ad413b9f80c24ac93339ca Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 5 May 2026 22:46:10 -0400 Subject: [PATCH 24/45] Harden scroll pager terminal rendering --- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 7 ++- src/Repl.Tests/Given_ResultFlowPager.cs | 56 +++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 95aff49..1019bd5 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -9,6 +9,8 @@ internal static class ResultFlowPager private const string ShowCursor = "\u001b[?25h"; private const string CursorHome = "\u001b[H"; private const string ClearToEndOfScreen = "\u001b[J"; + private const string DisableLineWrap = "\u001b[?7l"; + private const string EnableLineWrap = "\u001b[?7h"; public static int CountLines(string payload) => SplitLines(payload).Length; @@ -254,6 +256,7 @@ private static async ValueTask WriteScrollAsync( await output.WriteAsync(EnterAlternateScreen).ConfigureAwait(false); await output.WriteAsync(HideCursor).ConfigureAwait(false); + await output.WriteAsync(DisableLineWrap).ConfigureAwait(false); await output.WriteAsync(CursorHome).ConfigureAwait(false); await output.WriteAsync(ClearToEndOfScreen).ConfigureAwait(false); try @@ -288,6 +291,7 @@ private static async ValueTask WriteScrollAsync( } finally { + await output.WriteAsync(EnableLineWrap).ConfigureAwait(false); await output.WriteAsync(ShowCursor).ConfigureAwait(false); await output.WriteAsync(LeaveAlternateScreen).ConfigureAwait(false); await output.FlushAsync(cancellationToken).ConfigureAwait(false); @@ -679,7 +683,8 @@ private static IEnumerable GetContentLines(string[] lines, string? stick : 0; for (var i = start; i < lines.Length; i++) { - if (!IsPageFooterLine(lines[i])) + if ((stickyHeader is null || !AreSameHeaderLine(lines[i], stickyHeader)) + && !IsPageFooterLine(lines[i])) { yield return lines[i]; } diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index de439e6..5407ccc 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -503,6 +503,7 @@ public async Task When_ScrollPagerReceivesPageFooterLines_Then_FooterLinesAreNot using var writer = new StringWriter(); var keys = new FakeKeyReader( [ + MakeKey(ConsoleKey.DownArrow, '\0'), MakeKey(ConsoleKey.DownArrow, '\0'), MakeKey(ConsoleKey.Q, 'q'), ]); @@ -526,6 +527,35 @@ await ResultFlowPager.WriteAsync( output.Should().NotContain("Showing 1 of 5"); } + [TestMethod] + [Description("Result-flow scroll pager skips duplicate rich table headers even when they are not the first line in a fetched payload.")] + public async Task When_ScrollPagerReceivesIndentedDuplicateHeader_Then_HeaderIsNotBuffered() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + var header = "\u001b[1m#\u001b[0m \u001b[1mAt\u001b[0m"; + + await ResultFlowPager.WriteAsync( + $"{header}\none\ntwo\nShowing 2 of 5.", + writer, + keys, + visibleRows: 3, + pagerMode: ReplPagerMode.Scroll, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => ValueTask.FromResult( + new ResultFlowPagerPage($"Showing 1 of 5.\n{header}\nthree", HasMore: false)), + CancellationToken.None); + + writer.ToString().Should().Contain($"{header}\r\nthree"); + writer.ToString().Split(header).Length.Should().Be(4); + } + [TestMethod] [Description("Result-flow scroll pager End moves to the end of the currently buffered content.")] public async Task When_ScrollPagerEndPressed_Then_MovesToKnownEndWithoutFetching() @@ -589,6 +619,32 @@ await ResultFlowPager.WriteAsync( output.Should().Contain("\u001b[H\u001b[J"); } + [TestMethod] + [Description("Result-flow scroll pager disables terminal line wrapping while the alternate screen is active.")] + public async Task When_ScrollPagerRuns_Then_LineWrappingIsDisabledDuringAlternateScreen() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + 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[?7l"); + output.Should().Contain("\u001b[?7h"); + output.IndexOf("\u001b[?7l", StringComparison.Ordinal) + .Should().BeLessThan(output.IndexOf("\u001b[?7h", StringComparison.Ordinal)); + } + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => new(keyChar, key, shift: false, alt: false, control: false); } From 44611a895b138efc2f0104fa51ea26bf68ae0af5 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 5 May 2026 23:08:56 -0400 Subject: [PATCH 25/45] Refactor result flow pager modes --- docs/commands.md | 2 +- docs/configuration-reference.md | 1 + docs/result-flow.md | 29 +- src/Repl.Core/CoreReplApp.Execution.cs | 5 + .../Help/HelpTextBuilder.Rendering.cs | 2 +- .../ResultFlow/IReplPagerRenderer.cs | 20 + src/Repl.Core/ResultFlow/ReplPagerMode.cs | 8 +- src/Repl.Core/ResultFlow/ReplPagerPayload.cs | 8 + .../ResultFlow/ReplPagerRenderContext.cs | 67 ++ src/Repl.Core/ResultFlow/ResultFlowOptions.cs | 5 + src/Repl.Core/ResultFlow/ResultFlowPager.cs | 721 +++++++++++------- .../Given_HelpDiscovery.cs | 2 +- src/Repl.Tests/Given_GlobalOptionParser.cs | 17 + src/Repl.Tests/Given_ResultFlowPager.cs | 173 ++++- 14 files changed, 712 insertions(+), 348 deletions(-) create mode 100644 src/Repl.Core/ResultFlow/IReplPagerRenderer.cs create mode 100644 src/Repl.Core/ResultFlow/ReplPagerPayload.cs create mode 100644 src/Repl.Core/ResultFlow/ReplPagerRenderContext.cs diff --git a/docs/commands.md b/docs/commands.md index cd172d2..53d245c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -21,7 +21,7 @@ These flags are parsed before route execution: - `--result:page-size ` / `--result:page-size=` - `--result:cursor ` / `--result:cursor=` - `--result:all` -- `--result:pager=auto|off|more|scroll|external` +- `--result:pager=auto|off|more|inline|full` - 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(...)` diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index be26844..473aa8c 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -108,6 +108,7 @@ Accessed via `ReplOptions.Output.ResultFlow`. - `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. +- `PagerRenderers` (`IList`) - Custom interactive pager renderers keyed by pager mode. - `ProgrammaticMaxInlineBytes` (`int`, default: `65536`) - Reserved for programmatic inline payload policy. ### OutputOptions Methods diff --git a/docs/result-flow.md b/docs/result-flow.md index e26504c..d0f93fa 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -386,12 +386,11 @@ 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\|inline\|full` | Pager preference for human formats. | -`auto` uses a `less`-style alternate-screen viewport when ANSI rendering and key +`auto` uses the full-screen alternate-buffer pager 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. +terminals. ## CLI And Pipe Behavior @@ -436,22 +435,30 @@ Supported keys: |---|---| | `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. | +| `UpArrow` | Move up in `full` and `inline`; ignored by `more`. | +| `PageUp` | Move up one page in `full` and `inline`; ignored by `more`. | | `q` / `Esc` | Quit paging. | The integrated pager has two render paths: -- `more` fallback: writes page by page in the normal terminal buffer and never - uses cursor movement. -- `scroll` viewport: enters the terminal alternate screen, keeps an internal - line buffer, redraws a viewport explicitly, and leaves the original scrollback +- `more` fallback: writes page by page in the normal terminal buffer, never + uses cursor movement, and only moves forward. +- `inline` viewport: redraws a controlled region in the normal terminal buffer + with ANSI cursor movement. +- `full` 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 +The full 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. +Applications that need a different terminal experience can register a custom +`IReplPagerRenderer` in `options.Output.ResultFlow.PagerRenderers`. A custom +renderer is selected by its `ReplPagerMode` and receives a +`ReplPagerRenderContext` containing the rendered payload, terminal writer, key +reader, visible row hint, and continuation fetcher. + ## Testing Result Flow Test the cursor contract first. A page source can be exercised without a console: diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 5a75583..e8903ec 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -767,6 +767,7 @@ await ResultFlowPager.WriteAsync( ansiEnabled, page.PageInfo.HasMore, FetchNextPayloadAsync, + _options.Output.ResultFlow.PagerRenderers, cancellationToken) .ConfigureAwait(false); return true; @@ -808,8 +809,12 @@ await ResultFlowPager.WriteAsync( ReplSessionIO.Output, keyReader, visibleRows, + visibleRowsProvider: null, pagerMode, ansiEnabled, + hasMorePayload: false, + fetchNextPayload: null, + _options.Output.ResultFlow.PagerRenderers, cancellationToken) .ConfigureAwait(false); return; diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index 83539d8..5c57d8e 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -12,7 +12,7 @@ internal static partial class HelpTextBuilder 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."), + new("--result:pager=auto|off|more|inline|full", "Control the integrated pager for human output."), ]; private static string BuildCommandHelp(RouteDefinition[] routes, bool useAnsi, AnsiPalette palette) diff --git a/src/Repl.Core/ResultFlow/IReplPagerRenderer.cs b/src/Repl.Core/ResultFlow/IReplPagerRenderer.cs new file mode 100644 index 0000000..c7c1569 --- /dev/null +++ b/src/Repl.Core/ResultFlow/IReplPagerRenderer.cs @@ -0,0 +1,20 @@ +namespace Repl; + +/// +/// Renders result-flow payloads in an interactive human terminal pager. +/// +public interface IReplPagerRenderer +{ + /// + /// Gets the pager mode handled by this renderer. + /// + ReplPagerMode Mode { get; } + + /// + /// Renders the pager session. + /// + /// Pager render context. + /// Cancellation token. + /// A value task that completes when the pager session exits. + ValueTask RenderAsync(ReplPagerRenderContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Repl.Core/ResultFlow/ReplPagerMode.cs b/src/Repl.Core/ResultFlow/ReplPagerMode.cs index 045fc2c..4a6f896 100644 --- a/src/Repl.Core/ResultFlow/ReplPagerMode.cs +++ b/src/Repl.Core/ResultFlow/ReplPagerMode.cs @@ -21,12 +21,12 @@ public enum ReplPagerMode More, /// - /// Use an interactive scrolling pager. + /// Use an inline pager that redraws in the main terminal buffer. /// - Scroll, + Inline, /// - /// Use an external pager process when available. + /// Use a full-screen alternate-buffer pager. /// - External, + Full, } diff --git a/src/Repl.Core/ResultFlow/ReplPagerPayload.cs b/src/Repl.Core/ResultFlow/ReplPagerPayload.cs new file mode 100644 index 0000000..c64eb15 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPagerPayload.cs @@ -0,0 +1,8 @@ +namespace Repl; + +/// +/// Represents a rendered result-flow payload supplied to a pager renderer. +/// +/// Rendered payload text. +/// Whether another result-flow payload can be fetched. +public sealed record ReplPagerPayload(string Payload, bool HasMore); diff --git a/src/Repl.Core/ResultFlow/ReplPagerRenderContext.cs b/src/Repl.Core/ResultFlow/ReplPagerRenderContext.cs new file mode 100644 index 0000000..f05f8e3 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPagerRenderContext.cs @@ -0,0 +1,67 @@ +namespace Repl; + +/// +/// Provides terminal, input, and payload state to a custom result-flow pager renderer. +/// +public sealed class ReplPagerRenderContext +{ + internal ReplPagerRenderContext( + string initialPayload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + Func? visibleRowsProvider, + bool ansiEnabled, + bool hasMorePayload, + Func>? fetchNextPayload) + { + InitialPayload = initialPayload; + Output = output; + KeyReader = keyReader; + VisibleRows = visibleRows; + VisibleRowsProvider = visibleRowsProvider; + AnsiEnabled = ansiEnabled; + HasMorePayload = hasMorePayload; + FetchNextPayload = fetchNextPayload; + } + + /// + /// Gets the first rendered payload. + /// + public string InitialPayload { get; } + + /// + /// Gets the output writer controlled by the pager. + /// + public TextWriter Output { get; } + + /// + /// Gets the key reader used for interactive navigation. + /// + public IReplKeyReader KeyReader { get; } + + /// + /// Gets the initial visible row count available to the pager. + /// + public int VisibleRows { get; } + + /// + /// Gets the current visible row resolver when available. + /// + public Func? VisibleRowsProvider { get; } + + /// + /// Gets whether ANSI terminal control sequences can be used. + /// + public bool AnsiEnabled { get; } + + /// + /// Gets whether another payload can initially be fetched. + /// + public bool HasMorePayload { get; } + + /// + /// Gets the next payload fetcher when the result source can continue. + /// + public Func>? FetchNextPayload { get; } +} diff --git a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs index bbfdb3a..83bd8cd 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs @@ -25,6 +25,11 @@ public sealed class ResultFlowOptions /// public ReplPagerMode DefaultPagerMode { get; set; } = ReplPagerMode.Auto; + /// + /// Gets custom pager renderers keyed by . + /// + public IList PagerRenderers { get; } = []; + /// /// Gets or sets the maximum inline payload size for programmatic clients. /// diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 1019bd5..3926371 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -2,7 +2,8 @@ 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 MorePrompt = "--More-- Space/PageDown: continue, Enter/Down: line, Up/PageUp: ignored, q/Esc: stop"; + private const string FullStatus = "-- result-flow {0}-{1}/{2}{3} Space: next Up/Down: scroll Home/End: known bounds q: quit --"; private const string EnterAlternateScreen = "\u001b[?1049h"; private const string LeaveAlternateScreen = "\u001b[?1049l"; private const string HideCursor = "\u001b[?25l"; @@ -11,8 +12,10 @@ internal static class ResultFlowPager private const string ClearToEndOfScreen = "\u001b[J"; private const string DisableLineWrap = "\u001b[?7l"; private const string EnableLineWrap = "\u001b[?7h"; + private static readonly System.Text.CompositeFormat FullStatusFormat = + System.Text.CompositeFormat.Parse(FullStatus); - public static int CountLines(string payload) => SplitLines(payload).Length; + public static int CountLines(string payload) => PagerPayloadParser.Parse(payload, header: null).TotalLineCount; public static async ValueTask WriteAsync( string payload, @@ -68,11 +71,12 @@ await WriteAsync( output, keyReader, visibleRows, + visibleRowsProvider: null, ReplPagerMode.More, ansiEnabled: false, hasMorePayload, fetchNextPayload, - visibleRowsProvider: null, + pagerRenderers: null, cancellationToken) .ConfigureAwait(false); } @@ -82,67 +86,50 @@ public static async ValueTask WriteAsync( TextWriter output, IReplKeyReader keyReader, int visibleRows, - Func visibleRowsProvider, ReplPagerMode pagerMode, bool ansiEnabled, bool hasMorePayload, Func>? fetchNextPayload, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(visibleRowsProvider); - await WriteAsync( payload, output, keyReader, visibleRows, + visibleRowsProvider: null, pagerMode, ansiEnabled, hasMorePayload, fetchNextPayload, - visibleRowsProvider, + pagerRenderers: null, cancellationToken) .ConfigureAwait(false); } - private static async ValueTask WriteAsync( + public static async ValueTask WriteAsync( string payload, TextWriter output, IReplKeyReader keyReader, int visibleRows, + Func visibleRowsProvider, ReplPagerMode pagerMode, bool ansiEnabled, bool hasMorePayload, Func>? fetchNextPayload, - Func? visibleRowsProvider, - CancellationToken cancellationToken) + CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(output); - ArgumentNullException.ThrowIfNull(keyReader); - - if (ShouldUseScrollPager(pagerMode, ansiEnabled)) - { - await WriteScrollAsync( - payload, - output, - keyReader, - visibleRows, - ansiEnabled, - hasMorePayload, - fetchNextPayload, - visibleRowsProvider, - cancellationToken) - .ConfigureAwait(false); - return; - } - - await WriteMoreAsync( + await WriteAsync( payload, output, keyReader, visibleRows, + visibleRowsProvider, + pagerMode, + ansiEnabled, hasMorePayload, fetchNextPayload, + pagerRenderers: null, cancellationToken) .ConfigureAwait(false); } @@ -152,140 +139,307 @@ public static async ValueTask WriteAsync( TextWriter output, IReplKeyReader keyReader, int visibleRows, + Func? visibleRowsProvider, ReplPagerMode pagerMode, bool ansiEnabled, bool hasMorePayload, Func>? fetchNextPayload, + IEnumerable? pagerRenderers, CancellationToken cancellationToken = default) { - await WriteAsync( + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(keyReader); + + var mode = ResolveMode(pagerMode, ansiEnabled); + if (await TryRenderCustomAsync( + mode, + pagerRenderers, payload, output, keyReader, visibleRows, - pagerMode, + visibleRowsProvider, ansiEnabled, hasMorePayload, fetchNextPayload, - visibleRowsProvider: null, cancellationToken) - .ConfigureAwait(false); + .ConfigureAwait(false)) + { + return; + } + + var session = new PagerSession(payload, hasMorePayload); + switch (mode) + { + case ReplPagerMode.Full: + await RenderViewportAsync( + session, + output, + keyReader, + Math.Max(2, visibleRows), + visibleRowsProvider, + fetchNextPayload, + useAlternateScreen: true, + cancellationToken) + .ConfigureAwait(false); + break; + case ReplPagerMode.Inline: + await RenderViewportAsync( + session, + output, + keyReader, + Math.Max(2, visibleRows), + visibleRowsProvider, + fetchNextPayload, + useAlternateScreen: false, + cancellationToken) + .ConfigureAwait(false); + break; + default: + await RenderMoreAsync( + session, + output, + keyReader, + Math.Max(1, visibleRows), + fetchNextPayload, + cancellationToken) + .ConfigureAwait(false); + break; + } } - private static async ValueTask WriteMoreAsync( + private static ReplPagerMode ResolveMode(ReplPagerMode pagerMode, bool ansiEnabled) => + pagerMode == ReplPagerMode.Auto + ? ansiEnabled ? ReplPagerMode.Full : ReplPagerMode.More + : pagerMode; + + private static async ValueTask TryRenderCustomAsync( + ReplPagerMode mode, + IEnumerable? pagerRenderers, string payload, TextWriter output, IReplKeyReader keyReader, int visibleRows, + Func? visibleRowsProvider, + bool ansiEnabled, bool hasMorePayload, Func>? fetchNextPayload, CancellationToken cancellationToken) { - var state = new PagerState(SplitLines(payload), Math.Max(1, visibleRows), hasMorePayload); - if (state.Lines.Length == 0 && !state.HasMorePayload) + if (pagerRenderers is null) { - return; + return false; + } + + foreach (var renderer in pagerRenderers) + { + if (renderer.Mode != mode) + { + continue; + } + + await renderer.RenderAsync( + new ReplPagerRenderContext( + payload, + output, + keyReader, + visibleRows, + visibleRowsProvider, + ansiEnabled, + hasMorePayload, + fetchNextPayload is null ? null : FetchPublicPayloadAsync), + cancellationToken) + .ConfigureAwait(false); + return true; + } + + return false; + + async ValueTask FetchPublicPayloadAsync(CancellationToken token) + { + var next = await fetchNextPayload!(token).ConfigureAwait(false); + return next is null ? null : new ReplPagerPayload(next.Payload, next.HasMore); } + } + private static async ValueTask RenderMoreAsync( + PagerSession session, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + Func>? fetchNextPayload, + CancellationToken cancellationToken) + { + session.PageSize = Math.Max(1, visibleRows); + session.NextWindow = session.PageSize; + var headerWritten = false; while (true) { - if (state.Lines.Length == 0 && state.HasMorePayload && fetchNextPayload is not null) + if (session.Lines.Count == 0 + && !await TryFetchIntoSessionAsync(session, fetchNextPayload, cancellationToken).ConfigureAwait(false)) { - var payloadPage = await fetchNextPayload(cancellationToken).ConfigureAwait(false); - if (payloadPage is null) + return; + } + + if (!headerWritten) + { + foreach (var headerLine in session.HeaderLines) { - return; + await output.WriteLineAsync(headerLine).ConfigureAwait(false); } - state.Reset(SplitLines(payloadPage.Payload), payloadPage.HasMore); - continue; + headerWritten = true; } - if (await WriteCurrentPayloadAsync(state, output, keyReader, cancellationToken).ConfigureAwait(false)) + while (session.Index < session.Lines.Count) { - return; + await WriteMoreWindowAsync(session, output).ConfigureAwait(false); + if (session.Index >= session.Lines.Count) + { + break; + } + + if (await ReadMoreActionAsync(session, output, keyReader, cancellationToken).ConfigureAwait(false) == PagerAction.Quit) + { + return; + } } - if (!state.HasMorePayload || fetchNextPayload is null) + if (!session.HasMorePayload || fetchNextPayload is null) { - break; + return; } - var boundaryKey = await ReadPromptAsync(output, keyReader, cancellationToken).ConfigureAwait(false); - if (ApplyBoundaryKey(state, boundaryKey)) + if (await ReadMoreActionAsync(session, output, keyReader, cancellationToken).ConfigureAwait(false) == PagerAction.Quit) { return; } - if (state.Index < state.Lines.Length) + if (!await TryFetchIntoSessionAsync(session, fetchNextPayload, cancellationToken).ConfigureAwait(false)) { - continue; + return; } + } + } + + private static async ValueTask WriteMoreWindowAsync(PagerSession session, TextWriter output) + { + var take = Math.Min(session.NextWindow, session.Lines.Count - session.Index); + for (var i = 0; i < take; i++) + { + await output.WriteLineAsync(session.Lines[session.Index + i]).ConfigureAwait(false); + } + + session.Index += take; + } - var nextPayload = await fetchNextPayload(cancellationToken).ConfigureAwait(false); - if (nextPayload is null) + private static async ValueTask TryFetchIntoSessionAsync( + PagerSession session, + Func>? fetchNextPayload, + CancellationToken cancellationToken) + { + if (!session.HasMorePayload || fetchNextPayload is null) + { + return false; + } + + var nextPayload = await fetchNextPayload(cancellationToken).ConfigureAwait(false); + if (nextPayload is null) + { + session.HasMorePayload = false; + return false; + } + + session.Append(nextPayload.Payload, nextPayload.HasMore); + return true; + } + + private static async ValueTask ReadMoreActionAsync( + PagerSession session, + TextWriter output, + IReplKeyReader keyReader, + CancellationToken cancellationToken) + { + while (true) + { + 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) { - break; + case ConsoleKey.Q: + case ConsoleKey.Escape: + return PagerAction.Quit; + case ConsoleKey.Enter: + case ConsoleKey.DownArrow: + session.NextWindow = 1; + return PagerAction.LineDown; + case ConsoleKey.UpArrow: + case ConsoleKey.PageUp: + case ConsoleKey.Home: + case ConsoleKey.End: + continue; + default: + session.NextWindow = session.PageSize; + return PagerAction.PageDown; } - - state.Reset(SplitLines(nextPayload.Payload), nextPayload.HasMore); } } - private static async ValueTask WriteScrollAsync( - string payload, + private static async ValueTask RenderViewportAsync( + PagerSession session, TextWriter output, IReplKeyReader keyReader, int visibleRows, - bool ansiEnabled, - bool hasMorePayload, - Func>? fetchNextPayload, Func? visibleRowsProvider, + Func>? fetchNextPayload, + bool useAlternateScreen, CancellationToken cancellationToken) { - if (!ansiEnabled) + var state = new ViewportState(session, visibleRows); + if (state.Session.Lines.Count == 0 && !state.Session.HasMorePayload) { - throw new InvalidOperationException("The scroll result pager requires ANSI support."); + return; } - var state = new ScrollPagerState(SplitLines(payload), Math.Max(2, visibleRows), hasMorePayload); - if (state.Buffer.Count == 0 && !state.HasMorePayload) + if (useAlternateScreen) { - return; + await output.WriteAsync(EnterAlternateScreen).ConfigureAwait(false); } - await output.WriteAsync(EnterAlternateScreen).ConfigureAwait(false); await output.WriteAsync(HideCursor).ConfigureAwait(false); await output.WriteAsync(DisableLineWrap).ConfigureAwait(false); await output.WriteAsync(CursorHome).ConfigureAwait(false); await output.WriteAsync(ClearToEndOfScreen).ConfigureAwait(false); + try { - await EnsureScrollBufferAsync(state, fetchNextPayload, cancellationToken).ConfigureAwait(false); + await EnsureViewportContentAsync(state.Session, fetchNextPayload, cancellationToken).ConfigureAwait(false); while (true) { - if (state.UpdateVisibleRows(GetCurrentVisibleRows(visibleRows, visibleRowsProvider))) + var currentRows = GetCurrentVisibleRows(visibleRows, visibleRowsProvider); + if (state.UpdateVisibleRows(currentRows)) { - await output.WriteAsync(CursorHome).ConfigureAwait(false); - await output.WriteAsync(ClearToEndOfScreen).ConfigureAwait(false); - state.ResetRenderedLineLengths(); + await ClearViewportAsync(output, state, useAlternateScreen).ConfigureAwait(false); } - await RenderScrollAsync(state, output, cancellationToken).ConfigureAwait(false); + await RenderViewportFrameAsync(state, output, useAlternateScreen, cancellationToken).ConfigureAwait(false); var key = await keyReader.ReadKeyAsync(cancellationToken).ConfigureAwait(false); var beforeTopLine = state.TopLine; - var action = ApplyScrollKey(state, key); - if (action == ScrollKeyAction.Quit) + var action = ApplyViewportKey(state, key); + if (action == PagerAction.Quit) { return; } - if (ShouldFetchForScrollKey(state, action, beforeTopLine) - && state.HasMorePayload + if (ShouldFetchForViewportKey(state, action, beforeTopLine) + && state.Session.HasMorePayload && fetchNextPayload is not null) { - await FetchIntoScrollBufferAsync(state, fetchNextPayload, cancellationToken).ConfigureAwait(false); - state.TopLine = Math.Min(beforeTopLine + GetScrollDelta(action, state.ViewportHeight), state.MaxTopLine); + await FetchIntoSessionAsync(state.Session, fetchNextPayload, cancellationToken).ConfigureAwait(false); + state.TopLine = Math.Min(beforeTopLine + GetViewportDelta(action, state.ViewportHeight), state.MaxTopLine); } } } @@ -293,147 +447,74 @@ private static async ValueTask WriteScrollAsync( { await output.WriteAsync(EnableLineWrap).ConfigureAwait(false); await output.WriteAsync(ShowCursor).ConfigureAwait(false); - await output.WriteAsync(LeaveAlternateScreen).ConfigureAwait(false); - await output.FlushAsync(cancellationToken).ConfigureAwait(false); - } - } - - private static async ValueTask WriteCurrentPayloadAsync( - PagerState state, - TextWriter output, - IReplKeyReader keyReader, - CancellationToken cancellationToken) - { - while (state.Index < state.Lines.Length) - { - cancellationToken.ThrowIfCancellationRequested(); - var windowStart = state.Index; - var take = Math.Min(state.NextWindow, state.Lines.Length - state.Index); - for (var i = 0; i < take; i++) + if (useAlternateScreen) { - await output.WriteLineAsync(state.Lines[state.Index + i]).ConfigureAwait(false); - } - - state.Index += take; - if (state.Index >= state.Lines.Length) - { - break; + await output.WriteAsync(LeaveAlternateScreen).ConfigureAwait(false); } - var key = await ReadPromptAsync(output, keyReader, cancellationToken).ConfigureAwait(false); - if (ApplyWindowKey(state, key, windowStart)) - { - return true; - } + await output.FlushAsync(cancellationToken).ConfigureAwait(false); } - - return false; } - private static bool ApplyWindowKey(PagerState state, ConsoleKeyInfo key, int windowStart) + private static async ValueTask EnsureViewportContentAsync( + PagerSession session, + Func>? fetchNextPayload, + CancellationToken cancellationToken) { - switch (key.Key) + while (session.Lines.Count == 0 && session.HasMorePayload && fetchNextPayload is not null) { - 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; + await FetchIntoSessionAsync(session, fetchNextPayload, cancellationToken).ConfigureAwait(false); } } - private static bool ApplyBoundaryKey(PagerState state, ConsoleKeyInfo key) + private static async ValueTask FetchIntoSessionAsync( + PagerSession session, + Func> fetchNextPayload, + CancellationToken cancellationToken) { - switch (key.Key) + var nextPayload = await fetchNextPayload(cancellationToken).ConfigureAwait(false); + if (nextPayload is null) { - 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; + session.HasMorePayload = false; + return; } - } - 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; + session.Append(nextPayload.Payload, nextPayload.HasMore); } - private static async ValueTask EnsureScrollBufferAsync( - ScrollPagerState state, - Func>? fetchNextPayload, - CancellationToken cancellationToken) + private static async ValueTask ClearViewportAsync(TextWriter output, ViewportState state, bool useAlternateScreen) { - while (state.Buffer.Count == 0 && state.HasMorePayload && fetchNextPayload is not null) + if (useAlternateScreen) { - await FetchIntoScrollBufferAsync(state, fetchNextPayload, cancellationToken).ConfigureAwait(false); + await output.WriteAsync(CursorHome).ConfigureAwait(false); } - } - - private static async ValueTask FetchIntoScrollBufferAsync( - ScrollPagerState state, - Func> fetchNextPayload, - CancellationToken cancellationToken) - { - var nextPayload = await fetchNextPayload(cancellationToken).ConfigureAwait(false); - if (nextPayload is null) + else if (state.RenderedHeight > 0) { - state.HasMorePayload = false; - return; + await output.WriteAsync($"\u001b[{state.RenderedHeight}A").ConfigureAwait(false); + await output.WriteAsync('\r').ConfigureAwait(false); } - state.Append(SplitLines(nextPayload.Payload), nextPayload.HasMore); + await output.WriteAsync(ClearToEndOfScreen).ConfigureAwait(false); + state.ResetRenderedLineLengths(); } - private static async ValueTask RenderScrollAsync( - ScrollPagerState state, + private static async ValueTask RenderViewportFrameAsync( + ViewportState state, TextWriter output, + bool useAlternateScreen, CancellationToken cancellationToken) { - await output.WriteAsync(CursorHome).ConfigureAwait(false); - if (state.StickyHeader is { } header) + await PositionViewportAsync(output, state, useAlternateScreen).ConfigureAwait(false); + var row = 0; + foreach (var headerLine in state.Session.HeaderLines) { - await WriteViewportLineAsync(state, output, row: 0, header).ConfigureAwait(false); + await WriteViewportLineAsync(state, output, row++, headerLine).ConfigureAwait(false); } - var row = state.StickyHeader is null ? 0 : 1; - var take = Math.Min(state.ViewportHeight, Math.Max(0, state.Buffer.Count - state.TopLine)); + var take = Math.Min(state.ViewportHeight, Math.Max(0, state.Session.Lines.Count - state.TopLine)); for (var i = 0; i < take; i++) { - await WriteViewportLineAsync(state, output, row++, state.Buffer[state.TopLine + i]).ConfigureAwait(false); + await WriteViewportLineAsync(state, output, row++, state.Session.Lines[state.TopLine + i]).ConfigureAwait(false); } for (var i = take; i < state.ViewportHeight; i++) @@ -441,18 +522,38 @@ private static async ValueTask RenderScrollAsync( await WriteViewportLineAsync(state, output, row++, string.Empty).ConfigureAwait(false); } - var lastLine = state.Buffer.Count == 0 + var lastLine = state.Session.Lines.Count == 0 ? 0 - : Math.Min(state.Buffer.Count, state.TopLine + state.ViewportHeight); - var status = state.Buffer.Count == 0 + : Math.Min(state.Session.Lines.Count, state.TopLine + state.ViewportHeight); + var status = state.Session.Lines.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 WriteViewportLineAsync(state, output, row, status, appendNewLine: false).ConfigureAwait(false); + : string.Format( + System.Globalization.CultureInfo.InvariantCulture, + FullStatusFormat, + state.TopLine + 1, + lastLine, + state.Session.Lines.Count, + state.Session.HasMorePayload ? "+" : string.Empty); + await WriteViewportLineAsync(state, output, row++, status, appendNewLine: false).ConfigureAwait(false); + state.RenderedHeight = row; await output.FlushAsync(cancellationToken).ConfigureAwait(false); } + private static async ValueTask PositionViewportAsync(TextWriter output, ViewportState state, bool useAlternateScreen) + { + if (useAlternateScreen) + { + await output.WriteAsync(CursorHome).ConfigureAwait(false); + } + else if (state.RenderedHeight > 0) + { + await output.WriteAsync($"\u001b[{state.RenderedHeight}A").ConfigureAwait(false); + await output.WriteAsync('\r').ConfigureAwait(false); + } + } + private static async ValueTask WriteViewportLineAsync( - ScrollPagerState state, + ViewportState state, TextWriter output, int row, string line, @@ -472,53 +573,53 @@ private static async ValueTask WriteViewportLineAsync( } } - private static ScrollKeyAction ApplyScrollKey(ScrollPagerState state, ConsoleKeyInfo key) + private static PagerAction ApplyViewportKey(ViewportState state, ConsoleKeyInfo key) { switch (key.Key) { case ConsoleKey.Q: case ConsoleKey.Escape: - return ScrollKeyAction.Quit; + return PagerAction.Quit; case ConsoleKey.Spacebar: case ConsoleKey.PageDown: case ConsoleKey.F: state.TopLine = Math.Min(state.TopLine + state.ViewportHeight, state.MaxTopLine); - return ScrollKeyAction.PageDown; + return PagerAction.PageDown; case ConsoleKey.Enter: case ConsoleKey.DownArrow: case ConsoleKey.J: state.TopLine = Math.Min(state.TopLine + 1, state.MaxTopLine); - return ScrollKeyAction.LineDown; + return PagerAction.LineDown; case ConsoleKey.UpArrow: case ConsoleKey.K: state.TopLine = Math.Max(0, state.TopLine - 1); - return ScrollKeyAction.Other; + return PagerAction.LineUp; case ConsoleKey.PageUp: case ConsoleKey.B: state.TopLine = Math.Max(0, state.TopLine - state.ViewportHeight); - return ScrollKeyAction.Other; + return PagerAction.PageUp; case ConsoleKey.Home: case ConsoleKey.G when key.Modifiers.HasFlag(ConsoleModifiers.Shift): state.TopLine = 0; - return ScrollKeyAction.Other; + return PagerAction.Home; case ConsoleKey.End: state.TopLine = state.MaxTopLine; - return ScrollKeyAction.Other; + return PagerAction.End; default: - return ScrollKeyAction.Other; + return PagerAction.None; } } - private static bool ShouldFetchForScrollKey(ScrollPagerState state, ScrollKeyAction action, int beforeTopLine) => + private static bool ShouldFetchForViewportKey(ViewportState state, PagerAction action, int beforeTopLine) => action switch { - ScrollKeyAction.PageDown => state.HasReachedBottom && state.Buffer.Count > state.ViewportHeight, - ScrollKeyAction.LineDown => beforeTopLine == state.TopLine && state.HasReachedBottom, + PagerAction.PageDown => state.HasReachedBottom && state.Session.Lines.Count > state.ViewportHeight, + PagerAction.LineDown => beforeTopLine == state.TopLine && state.HasReachedBottom, _ => false, }; - private static int GetScrollDelta(ScrollKeyAction action, int viewportHeight) => - action == ScrollKeyAction.PageDown ? viewportHeight : 1; + private static int GetViewportDelta(PagerAction action, int viewportHeight) => + action == PagerAction.PageDown ? viewportHeight : 1; private static int GetCurrentVisibleRows(int fallbackVisibleRows, Func? visibleRowsProvider) { @@ -549,78 +650,66 @@ private static int GetCurrentVisibleRows(int fallbackVisibleRows, Func? vis } } - private static bool ShouldUseScrollPager(ReplPagerMode pagerMode, bool ansiEnabled) => - ansiEnabled && pagerMode is ReplPagerMode.Auto or ReplPagerMode.Scroll; - - private static string[] SplitLines(string payload) => - string.IsNullOrEmpty(payload) - ? [] - : SplitNonEmptyPayloadLines(payload); + private enum PagerAction + { + None, + LineDown, + LineUp, + PageDown, + PageUp, + Home, + End, + Quit, + } - private static string[] SplitNonEmptyPayloadLines(string payload) + private sealed class PagerSession { - var lines = new List(); - foreach (var line in payload.AsSpan().EnumerateLines()) - { - lines.Add(line.ToString()); - } + private readonly PagerHeader _header; - // 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) + public PagerSession(string initialPayload, bool hasMorePayload) { - lines.RemoveAt(lines.Count - 1); + var parsed = PagerPayloadParser.Parse(initialPayload, header: null); + _header = parsed.Header; + Lines = [.. parsed.ContentLines]; + HasMorePayload = hasMorePayload; + PageSize = 1; + NextWindow = 1; } - return [.. lines]; - } - - private sealed class PagerState(string[] lines, int pageSize, bool hasMorePayload) - { - private string[] _lines = lines; + public IReadOnlyList HeaderLines => _header.Lines; - public string[] Lines => _lines; + public List Lines { get; } - public int PageSize { get; } = pageSize; + public int PageSize { get; set; } - public int NextWindow { get; set; } = pageSize; + public int NextWindow { get; set; } public int Index { get; set; } - public bool HasMorePayload { get; private set; } = hasMorePayload; + public bool HasMorePayload { get; set; } - public void Reset(string[] lines, bool hasMorePayload) + public void Append(string payload, bool hasMorePayload) { - _lines = lines; - Index = 0; + var parsed = PagerPayloadParser.Parse(payload, _header); + Lines.AddRange(parsed.ContentLines); HasMorePayload = hasMorePayload; } } - private enum ScrollKeyAction + private sealed class ViewportState { - Other, - LineDown, - PageDown, - Quit, - } + private readonly List _renderedLineLengths = []; - private sealed class ScrollPagerState - { - public ScrollPagerState(string[] lines, int visibleRows, bool hasMorePayload) + public ViewportState(PagerSession session, int visibleRows) { - StickyHeader = TryGetStickyHeader(lines); - Buffer = [.. GetContentLines(lines, StickyHeader)]; + Session = session; + Session.PageSize = Math.Max(1, CalculateViewportHeight(visibleRows)); + Session.NextWindow = Session.PageSize; VisibleRows = Math.Max(2, visibleRows); - ViewportHeight = CalculateViewportHeight(VisibleRows, StickyHeader); - HasMorePayload = hasMorePayload; + ViewportHeight = CalculateViewportHeight(VisibleRows); } - public List Buffer { get; } - - private List RenderedLineLengths { get; } = []; - - public string? StickyHeader { get; } + public PagerSession Session { get; } public int VisibleRows { get; private set; } @@ -628,18 +717,12 @@ public ScrollPagerState(string[] lines, int visibleRows, bool hasMorePayload) public int TopLine { get; set; } - public bool HasMorePayload { get; set; } + public int RenderedHeight { get; set; } - public int MaxTopLine => Math.Max(0, Buffer.Count - ViewportHeight); + public int MaxTopLine => Math.Max(0, Session.Lines.Count - ViewportHeight); public bool HasReachedBottom => TopLine >= MaxTopLine; - public void Append(string[] lines, bool hasMorePayload) - { - Buffer.AddRange(GetContentLines(lines, StickyHeader)); - HasMorePayload = hasMorePayload; - } - public bool UpdateVisibleRows(int visibleRows) { visibleRows = Math.Max(2, visibleRows); @@ -649,53 +732,92 @@ public bool UpdateVisibleRows(int visibleRows) } VisibleRows = visibleRows; - ViewportHeight = CalculateViewportHeight(visibleRows, StickyHeader); + ViewportHeight = CalculateViewportHeight(visibleRows); TopLine = Math.Min(TopLine, MaxTopLine); + Session.PageSize = ViewportHeight; return true; } public int GetRenderedLineLength(int row) => - row < RenderedLineLengths.Count ? RenderedLineLengths[row] : 0; + row < _renderedLineLengths.Count ? _renderedLineLengths[row] : 0; public void SetRenderedLineLength(int row, int length) { - while (RenderedLineLengths.Count <= row) + while (_renderedLineLengths.Count <= row) { - RenderedLineLengths.Add(0); + _renderedLineLengths.Add(0); } - RenderedLineLengths[row] = length; + _renderedLineLengths[row] = length; } - public void ResetRenderedLineLengths() => RenderedLineLengths.Clear(); + public void ResetRenderedLineLengths() => _renderedLineLengths.Clear(); - private static string? TryGetStickyHeader(string[] lines) => - lines.Length > 1 && lines[0].Contains("\u001b[1m", StringComparison.Ordinal) - ? lines[0] - : null; + private int CalculateViewportHeight(int visibleRows) => + Math.Max(1, visibleRows - Session.HeaderLines.Count - 1); + } - private static IEnumerable GetContentLines(string[] lines, string? stickyHeader) + private sealed record PagerHeader(IReadOnlyList Lines, IReadOnlySet NormalizedLines) + { + public static PagerHeader Empty { get; } = new([], new HashSet(StringComparer.Ordinal)); + } + + private sealed record ParsedPagerPayload(PagerHeader Header, IReadOnlyList ContentLines) + { + public int TotalLineCount => Header.Lines.Count + ContentLines.Count; + } + + private static class PagerPayloadParser + { + public static ParsedPagerPayload Parse(string payload, PagerHeader? header) { - var start = stickyHeader is not null - && lines.Length > 0 - && AreSameHeaderLine(lines[0], stickyHeader) - ? 1 - : 0; - for (var i = start; i < lines.Length; i++) + var lines = SplitLines(payload); + var resolvedHeader = header ?? DetectHeader(lines); + var content = new List(); + var headerLineCount = header is null ? resolvedHeader.Lines.Count : 0; + for (var i = headerLineCount; i < lines.Length; i++) { - if ((stickyHeader is null || !AreSameHeaderLine(lines[i], stickyHeader)) - && !IsPageFooterLine(lines[i])) + var normalized = NormalizeLine(lines[i]); + if (resolvedHeader.NormalizedLines.Contains(normalized) || IsPageFooterLine(lines[i])) { - yield return lines[i]; + continue; } + + content.Add(lines[i]); + } + + return new ParsedPagerPayload(resolvedHeader, content); + } + + private static PagerHeader DetectHeader(string[] lines) + { + if (lines.Length == 0) + { + return PagerHeader.Empty; + } + + if (lines.Length > 1 && IsPlainTableSeparator(lines[1])) + { + return CreateHeader(lines.Take(2).ToArray()); } + + return lines[0].Contains("\u001b[1m", StringComparison.Ordinal) + ? CreateHeader([lines[0]]) + : PagerHeader.Empty; } - private static int CalculateViewportHeight(int visibleRows, string? stickyHeader) => - Math.Max(1, visibleRows - (stickyHeader is null ? 1 : 2)); + private static PagerHeader CreateHeader(string[] lines) => + new( + lines, + lines.Select(NormalizeLine).ToHashSet(StringComparer.Ordinal)); - private static bool AreSameHeaderLine(string candidate, string stickyHeader) => - string.Equals(NormalizeAnsiLine(candidate), NormalizeAnsiLine(stickyHeader), StringComparison.Ordinal); + private static bool IsPlainTableSeparator(string line) + { + var text = line.Trim(); + return text.Length > 0 + && text.All(ch => ch is '-' or ' ' or '\t') + && text.Contains('-', StringComparison.Ordinal); + } private static bool IsPageFooterLine(string line) => line.StartsWith("Showing ", StringComparison.Ordinal) @@ -704,7 +826,28 @@ private static bool IsPageFooterLine(string line) => && (line.EndsWith('.') || line.Contains("Next data page: rerun with --result:cursor ", StringComparison.Ordinal)); - private static string NormalizeAnsiLine(string line) + private static string[] SplitLines(string payload) + { + if (string.IsNullOrEmpty(payload)) + { + return []; + } + + var lines = new List(); + foreach (var line in payload.AsSpan().EnumerateLines()) + { + lines.Add(line.ToString()); + } + + if (lines.Count > 0 && lines[^1].Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + return [.. lines]; + } + + private static string NormalizeLine(string line) { if (!line.Contains('\u001b', StringComparison.Ordinal)) { diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index 68f7c61..5733fe4 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -434,7 +434,7 @@ public void When_RequestingCommandHelpForPagedHandler_Then_ResultFlowOptionsAreS 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"); + output.Text.Should().Contain("--result:pager=auto|off|more|inline|full"); } [TestMethod] diff --git a/src/Repl.Tests/Given_GlobalOptionParser.cs b/src/Repl.Tests/Given_GlobalOptionParser.cs index 342212c..02fb60d 100644 --- a/src/Repl.Tests/Given_GlobalOptionParser.cs +++ b/src/Repl.Tests/Given_GlobalOptionParser.cs @@ -123,6 +123,23 @@ public void When_ResultFlowOptionsArePresent_Then_ParserConsumesThemIntoResultFl parsed.ResultFlow.PagerMode.Should().Be(ReplPagerMode.Off); } + [TestMethod] + [Description("Result-flow pager modes parse the current public mode names.")] + public void When_ResultFlowPagerModeIsFullOrInline_Then_ParserStoresMode() + { + var full = GlobalOptionParser.Parse( + ["users", "list", "--result:pager=full"], + new OutputOptions(), + new ParsingOptions()); + var inline = GlobalOptionParser.Parse( + ["users", "list", "--result:pager=inline"], + new OutputOptions(), + new ParsingOptions()); + + full.ResultFlow.PagerMode.Should().Be(ReplPagerMode.Full); + inline.ResultFlow.PagerMode.Should().Be(ReplPagerMode.Inline); + } + [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() diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index 5407ccc..7ed65e4 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -32,7 +32,7 @@ await ResultFlowPager.WriteAsync( 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("Up/PageUp: ignored"); output.Should().Contain("q/Esc: stop"); } @@ -62,30 +62,28 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow pager UpArrow moves back one line instead of jumping to the header.")] - public async Task When_PagingBackWithUpArrow_Then_DoesNotRepeatHeader() + [Description("Result-flow more pager ignores UpArrow because append-only output cannot redraw previous lines cleanly.")] + public async Task When_MorePagerReceivesUpArrow_Then_DoesNotReplayOrAdvance() { using var writer = new StringWriter(); var keys = new FakeKeyReader( [ - MakeKey(ConsoleKey.Spacebar, ' '), MakeKey(ConsoleKey.UpArrow, '\0'), MakeKey(ConsoleKey.Q, 'q'), ]); await ResultFlowPager.WriteAsync( - "# At Area Event Summary\nr1\nr2\nr3\nr4\nr5", + "# At Area Event Summary\n---\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.Split("# At Area Event Summary", StringSplitOptions.None).Should().HaveCount(2); output.Should().Contain("r1"); output.Should().Contain("r2"); - output.Should().Contain("r3"); + output.Should().NotContain("r3"); } [TestMethod] @@ -184,8 +182,8 @@ await ResultFlowPager.WriteAsync( } [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() + [Description("Result-flow more pager ignores UpArrow at a payload boundary instead of replaying previous lines.")] + public async Task When_MorePagerAtPayloadBoundaryReceivesUpArrow_Then_DoesNotReplayOrFetch() { using var writer = new StringWriter(); var keys = new FakeKeyReader( @@ -205,12 +203,40 @@ await ResultFlowPager.WriteAsync( CancellationToken.None); var output = writer.ToString(); - output.Split("three", StringSplitOptions.None).Should().HaveCount(3); - output.Split("four", StringSplitOptions.None).Should().HaveCount(3); + output.Split("three", StringSplitOptions.None).Should().HaveCount(2); + output.Split("four", StringSplitOptions.None).Should().HaveCount(2); } [TestMethod] - [Description("Result-flow scroll pager owns an alternate-screen viewport instead of relying on terminal scrollback.")] + [Description("Result-flow more pager strips duplicate page headers and page footers from fetched payloads.")] + public async Task When_MorePagerFetchesNextPayload_Then_DuplicateHeadersAndFootersAreSkipped() + { + using var writer = new StringWriter(); + var header = "# At"; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + ]); + + await ResultFlowPager.WriteAsync( + $"{header}\none\ntwo\nShowing 2 of 5.", + writer, + keys, + visibleRows: 4, + hasMorePayload: true, + fetchNextPayload: _ => ValueTask.FromResult( + new ResultFlowPagerPage($"{header}\nthree\nShowing 1 of 5.", HasMore: false)), + CancellationToken.None); + + var output = writer.ToString(); + output.Split(header, StringSplitOptions.None).Should().HaveCount(3); + output.Should().Contain("three"); + output.Should().NotContain("Showing 2 of 5"); + output.Should().NotContain("Showing 1 of 5"); + } + + [TestMethod] + [Description("Result-flow full pager owns an alternate-screen viewport instead of relying on terminal scrollback.")] public async Task When_ScrollPagerRunsWithAnsi_Then_UsesAlternateScreenViewport() { using var writer = new StringWriter(); @@ -225,7 +251,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, CancellationToken.None); @@ -240,7 +266,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager fetches additional payloads into the same viewport when the user pages past the buffered end.")] + [Description("Result-flow full pager fetches additional payloads into the same viewport when the user pages past the buffered end.")] public async Task When_ScrollPagerReachesBufferedEnd_Then_FetchesNextPayload() { using var writer = new StringWriter(); @@ -256,7 +282,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, hasMorePayload: true, fetchNextPayload: _ => @@ -275,7 +301,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager advances to the new buffered end when a fetch returns fewer lines than one viewport.")] + [Description("Result-flow full pager advances to the new buffered end when a fetch returns fewer lines than one viewport.")] public async Task When_ScrollPagerFetchesShortPayload_Then_ViewportAdvances() { using var writer = new StringWriter(); @@ -290,7 +316,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 4, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, hasMorePayload: true, fetchNextPayload: _ => ValueTask.FromResult( @@ -303,7 +329,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager does not fetch another payload when the current payload is exactly visible and the user presses Space once.")] + [Description("Result-flow full pager does not fetch another payload when the current payload is exactly visible and the user presses Space once.")] public async Task When_ScrollPagerContentExactlyFitsViewport_Then_SpaceDoesNotFetchImmediately() { using var writer = new StringWriter(); @@ -319,7 +345,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 4, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, hasMorePayload: true, fetchNextPayload: _ => @@ -349,7 +375,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, CancellationToken.None); @@ -357,7 +383,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager treats unrecognized keys as no-ops and does not advance the viewport or trigger a fetch.")] + [Description("Result-flow full pager treats unrecognized keys as no-ops and does not advance the viewport or trigger a fetch.")] public async Task When_ScrollPagerUnknownKeyPressed_Then_ViewportDoesNotAdvanceAndNoFetch() { using var writer = new StringWriter(); @@ -373,7 +399,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, hasMorePayload: true, fetchNextPayload: _ => @@ -389,7 +415,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager advances the viewport only on Space/PageDown, not on Enter or other keys.")] + [Description("Result-flow full pager advances the viewport only on Space/PageDown, not on Enter or other keys.")] public async Task When_ScrollPagerEnterKeyPressed_Then_ViewportAdvancesByOneLine() { using var writer = new StringWriter(); @@ -404,7 +430,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, CancellationToken.None); @@ -413,7 +439,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager advances by one line when Down fetches the next payload at a boundary.")] + [Description("Result-flow full pager advances by one line when Down fetches the next payload at a boundary.")] public async Task When_ScrollPagerDownFetchesNextPayload_Then_ViewportAdvancesOneLine() { using var writer = new StringWriter(); @@ -429,7 +455,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, hasMorePayload: true, fetchNextPayload: _ => ValueTask.FromResult( @@ -443,7 +469,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager keeps a rich table header pinned and skips duplicate headers from later payloads.")] + [Description("Result-flow full pager keeps a rich table header pinned and skips duplicate headers from later payloads.")] public async Task When_ScrollPagerHasRichTableHeader_Then_HeaderStaysPinned() { using var writer = new StringWriter(); @@ -459,7 +485,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 4, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, hasMorePayload: true, fetchNextPayload: _ => ValueTask.FromResult( @@ -472,7 +498,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager does not clear the whole viewport on every redraw.")] + [Description("Result-flow full pager does not clear the whole viewport on every redraw.")] public async Task When_ScrollPagerRedraws_Then_DoesNotClearScreenEveryTime() { using var writer = new StringWriter(); @@ -488,7 +514,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, CancellationToken.None); @@ -497,7 +523,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager strips page footer hints already represented by its own status bar.")] + [Description("Result-flow full pager strips page footer hints already represented by its own status bar.")] public async Task When_ScrollPagerReceivesPageFooterLines_Then_FooterLinesAreNotRendered() { using var writer = new StringWriter(); @@ -513,7 +539,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, hasMorePayload: true, fetchNextPayload: _ => ValueTask.FromResult( @@ -528,7 +554,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager skips duplicate rich table headers even when they are not the first line in a fetched payload.")] + [Description("Result-flow full pager skips duplicate rich table headers even when they are not the first line in a fetched payload.")] public async Task When_ScrollPagerReceivesIndentedDuplicateHeader_Then_HeaderIsNotBuffered() { using var writer = new StringWriter(); @@ -545,7 +571,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, hasMorePayload: true, fetchNextPayload: _ => ValueTask.FromResult( @@ -557,7 +583,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager End moves to the end of the currently buffered content.")] + [Description("Result-flow full pager End moves to the end of the currently buffered content.")] public async Task When_ScrollPagerEndPressed_Then_MovesToKnownEndWithoutFetching() { using var writer = new StringWriter(); @@ -573,7 +599,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, hasMorePayload: true, fetchNextPayload: _ => @@ -589,7 +615,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager recalculates viewport height between redraws.")] + [Description("Result-flow full pager recalculates viewport height between redraws.")] public async Task When_ScrollPagerHeightChanges_Then_ViewportUsesCurrentHeight() { using var writer = new StringWriter(); @@ -607,7 +633,7 @@ await ResultFlowPager.WriteAsync( keys, visibleRows, visibleRowsProvider: () => reads++ == 0 ? visibleRows : 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, hasMorePayload: false, fetchNextPayload: null, @@ -620,7 +646,7 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow scroll pager disables terminal line wrapping while the alternate screen is active.")] + [Description("Result-flow full pager disables terminal line wrapping while the alternate screen is active.")] public async Task When_ScrollPagerRuns_Then_LineWrappingIsDisabledDuringAlternateScreen() { using var writer = new StringWriter(); @@ -634,7 +660,7 @@ await ResultFlowPager.WriteAsync( writer, keys, visibleRows: 3, - pagerMode: ReplPagerMode.Scroll, + pagerMode: ReplPagerMode.Full, ansiEnabled: true, CancellationToken.None); @@ -645,6 +671,71 @@ await ResultFlowPager.WriteAsync( .Should().BeLessThan(output.IndexOf("\u001b[?7h", StringComparison.Ordinal)); } + [TestMethod] + [Description("Result-flow inline pager redraws in the main terminal buffer without entering the alternate screen.")] + public async Task When_InlinePagerRuns_Then_RedrawsWithoutAlternateScreen() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.UpArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree\nfour", + writer, + keys, + visibleRows: 3, + pagerMode: ReplPagerMode.Inline, + ansiEnabled: true, + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain("\u001b[3A"); + output.Should().Contain("\u001b[J"); + output.Should().NotContain("\u001b[?1049h"); + output.Should().NotContain("\u001b[?1049l"); + } + + [TestMethod] + [Description("Result-flow pager uses a configured custom renderer for the requested mode.")] + public async Task When_CustomPagerRendererIsConfigured_Then_ItHandlesTheMatchingMode() + { + using var writer = new StringWriter(); + var renderer = new RecordingPagerRenderer(ReplPagerMode.Inline); + + await ResultFlowPager.WriteAsync( + "one\ntwo", + writer, + new FakeKeyReader([]), + visibleRows: 3, + visibleRowsProvider: null, + pagerMode: ReplPagerMode.Inline, + ansiEnabled: true, + hasMorePayload: false, + fetchNextPayload: null, + pagerRenderers: [renderer], + CancellationToken.None); + + renderer.Payloads.Should().Equal("one\ntwo"); + writer.ToString().Should().Be("custom"); + } + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => new(keyChar, key, shift: false, alt: false, control: false); + + private sealed class RecordingPagerRenderer(ReplPagerMode mode) : IReplPagerRenderer + { + public List Payloads { get; } = []; + + public ReplPagerMode Mode { get; } = mode; + + public async ValueTask RenderAsync(ReplPagerRenderContext context, CancellationToken cancellationToken = default) + { + Payloads.Add(context.InitialPayload); + await context.Output.WriteAsync("custom").ConfigureAwait(false); + } + } } From e2e61d7a835ac1fa160aecbff661e7d76f707a74 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 5 May 2026 23:13:20 -0400 Subject: [PATCH 26/45] Fix more pager ignored key output --- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 20 ++++++++++-- src/Repl.Tests/Given_ResultFlowPager.cs | 36 ++++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 3926371..855e0b0 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -360,20 +360,21 @@ private static async ValueTask ReadMoreActionAsync( IReplKeyReader keyReader, CancellationToken cancellationToken) { + await output.WriteAsync(MorePrompt).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); while (true) { - 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: + await output.WriteLineAsync().ConfigureAwait(false); return PagerAction.Quit; case ConsoleKey.Enter: case ConsoleKey.DownArrow: + await output.WriteLineAsync().ConfigureAwait(false); session.NextWindow = 1; return PagerAction.LineDown; case ConsoleKey.UpArrow: @@ -382,6 +383,7 @@ private static async ValueTask ReadMoreActionAsync( case ConsoleKey.End: continue; default: + await output.WriteLineAsync().ConfigureAwait(false); session.NextWindow = session.PageSize; return PagerAction.PageDown; } @@ -801,6 +803,11 @@ private static PagerHeader DetectHeader(string[] lines) return CreateHeader(lines.Take(2).ToArray()); } + if (IsPlainHumanTableHeader(lines[0])) + { + return CreateHeader([lines[0]]); + } + return lines[0].Contains("\u001b[1m", StringComparison.Ordinal) ? CreateHeader([lines[0]]) : PagerHeader.Empty; @@ -819,6 +826,13 @@ private static bool IsPlainTableSeparator(string line) && text.Contains('-', StringComparison.Ordinal); } + private static bool IsPlainHumanTableHeader(string line) + { + var text = line.TrimStart(); + return text.StartsWith("# ", StringComparison.Ordinal) + && text.Contains(" ", StringComparison.Ordinal); + } + private static bool IsPageFooterLine(string line) => line.StartsWith("Showing ", StringComparison.Ordinal) && (line.Contains(" of ", StringComparison.Ordinal) diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index 7ed65e4..732cf8f 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -69,6 +69,8 @@ public async Task When_MorePagerReceivesUpArrow_Then_DoesNotReplayOrAdvance() var keys = new FakeKeyReader( [ MakeKey(ConsoleKey.UpArrow, '\0'), + MakeKey(ConsoleKey.UpArrow, '\0'), + MakeKey(ConsoleKey.PageUp, '\0'), MakeKey(ConsoleKey.Q, 'q'), ]); @@ -81,6 +83,7 @@ await ResultFlowPager.WriteAsync( var output = writer.ToString(); output.Split("# At Area Event Summary", StringSplitOptions.None).Should().HaveCount(2); + output.Split("--More--", StringSplitOptions.None).Should().HaveCount(2); output.Should().Contain("r1"); output.Should().Contain("r2"); output.Should().NotContain("r3"); @@ -229,12 +232,43 @@ await ResultFlowPager.WriteAsync( CancellationToken.None); var output = writer.ToString(); - output.Split(header, StringSplitOptions.None).Should().HaveCount(3); + output.Split(header, StringSplitOptions.None).Should().HaveCount(2); output.Should().Contain("three"); output.Should().NotContain("Showing 2 of 5"); output.Should().NotContain("Showing 1 of 5"); } + [TestMethod] + [Description("Result-flow more pager treats plain human-output column headings as headers and strips duplicates from fetched pages.")] + public async Task When_MorePagerFetchesHumanOutput_Then_DuplicateHashHeadersAreSkipped() + { + using var writer = new StringWriter(); + var header = "# At Area Event Summary"; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + ]); + + await ResultFlowPager.WriteAsync( + $"{header}\n1 2026-01-12 identity validated identity batch 1 validated successfully\nShowing 1 of 3.", + writer, + keys, + visibleRows: 2, + hasMorePayload: true, + fetchNextPayload: _ => ValueTask.FromResult( + new ResultFlowPagerPage( + $"{header}\n2 2026-01-12 billing queued billing batch 1 queued successfully\nShowing 2 of 3.", + HasMore: false)), + CancellationToken.None); + + var output = writer.ToString(); + output.Split(header, StringSplitOptions.None).Should().HaveCount(2); + output.Should().Contain("identity batch 1 validated successfully"); + output.Should().Contain("billing batch 1 queued successfully"); + output.Should().NotContain("Showing 1 of 3"); + output.Should().NotContain("Showing 2 of 3"); + } + [TestMethod] [Description("Result-flow full pager owns an alternate-screen viewport instead of relying on terminal scrollback.")] public async Task When_ScrollPagerRunsWithAnsi_Then_UsesAlternateScreenViewport() From 621c5e4f6ff774a8bd683435e04de7e49a38ffcc Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 5 May 2026 23:16:48 -0400 Subject: [PATCH 27/45] Skip repeated pager payload headers --- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 5 +++-- src/Repl.Tests/Given_ResultFlowPager.cs | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 855e0b0..14ed03d 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -774,9 +774,10 @@ private static class PagerPayloadParser public static ParsedPagerPayload Parse(string payload, PagerHeader? header) { var lines = SplitLines(payload); - var resolvedHeader = header ?? DetectHeader(lines); + var payloadHeader = DetectHeader(lines); + var resolvedHeader = header ?? payloadHeader; + var headerLineCount = payloadHeader.Lines.Count; var content = new List(); - var headerLineCount = header is null ? resolvedHeader.Lines.Count : 0; for (var i = headerLineCount; i < lines.Length; i++) { var normalized = NormalizeLine(lines[i]); diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index 732cf8f..694eab1 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -244,25 +244,27 @@ public async Task When_MorePagerFetchesHumanOutput_Then_DuplicateHashHeadersAreS { using var writer = new StringWriter(); var header = "# At Area Event Summary"; + var fetchedHeader = "# At Area Event Summary"; var keys = new FakeKeyReader( [ MakeKey(ConsoleKey.Spacebar, ' '), ]); await ResultFlowPager.WriteAsync( - $"{header}\n1 2026-01-12 identity validated identity batch 1 validated successfully\nShowing 1 of 3.", + $"{header}\n--- ----------------- --------- ---------- ----------------------------------------\n1 2026-01-12 identity validated identity batch 1 validated successfully\nShowing 1 of 3.", writer, keys, visibleRows: 2, hasMorePayload: true, fetchNextPayload: _ => ValueTask.FromResult( new ResultFlowPagerPage( - $"{header}\n2 2026-01-12 billing queued billing batch 1 queued successfully\nShowing 2 of 3.", + $"{fetchedHeader}\n--- ----------------- --------- ---------- ----------------------------------------\n2 2026-01-12 billing queued billing batch 1 queued successfully\nShowing 2 of 3.", HasMore: false)), CancellationToken.None); var output = writer.ToString(); output.Split(header, StringSplitOptions.None).Should().HaveCount(2); + output.Should().NotContain(fetchedHeader); output.Should().Contain("identity batch 1 validated successfully"); output.Should().Contain("billing batch 1 queued successfully"); output.Should().NotContain("Showing 1 of 3"); From 90d89f56d550692cb32d84574e68b810f684c73e Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 6 May 2026 08:54:48 -0400 Subject: [PATCH 28/45] Render pager continuations without headers --- src/Repl.Core/CoreReplApp.Execution.cs | 16 +++- src/Repl.Core/IResultFlowOutputTransformer.cs | 9 ++ .../Output/HumanOutputTransformer.cs | 68 +++++++++++--- src/Repl.Core/ResultFlowPageRenderMode.cs | 7 ++ .../Given_OutputFormatting.cs | 2 + .../SpectreHumanOutputTransformer.cs | 41 +++++++-- .../Given_ResultFlowOutputTransformer.cs | 92 +++++++++++++++++++ 7 files changed, 211 insertions(+), 24 deletions(-) create mode 100644 src/Repl.Core/IResultFlowOutputTransformer.cs create mode 100644 src/Repl.Core/ResultFlowPageRenderMode.cs create mode 100644 src/Repl.Tests/Given_ResultFlowOutputTransformer.cs diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index e8903ec..8d90fd2 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -754,7 +754,7 @@ private async ValueTask RenderPageSourceAsync( } var nextCursor = page.PageInfo.NextCursor; - var pagerPayload = await transformer.TransformAsync(CreatePagerDisplayPage(page), cancellationToken) + var pagerPayload = await TransformPagerPageAsync(transformer, page, ResultFlowPageRenderMode.Initial, cancellationToken) .ConfigureAwait(false); pagerPayload = TryColorizeStructuredPayload(pagerPayload, transformer.Name, isInteractive); await ResultFlowPager.WriteAsync( @@ -782,13 +782,25 @@ await ResultFlowPager.WriteAsync( 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) + var nextPayload = await TransformPagerPageAsync(transformer, nextPage, ResultFlowPageRenderMode.Continuation, token) .ConfigureAwait(false); nextPayload = TryColorizeStructuredPayload(nextPayload, transformer.Name, isInteractive); return new ResultFlowPagerPage(nextPayload, nextPage.PageInfo.HasMore); } } + private static ValueTask TransformPagerPageAsync( + IOutputTransformer transformer, + IReplPage page, + ResultFlowPageRenderMode mode, + CancellationToken cancellationToken) + { + var displayPage = CreatePagerDisplayPage(page); + return transformer is IResultFlowOutputTransformer resultFlowTransformer + ? resultFlowTransformer.TransformPageAsync(displayPage, mode, cancellationToken) + : transformer.TransformAsync(displayPage, cancellationToken); + } + private async ValueTask WritePayloadAsync( string payload, IOutputTransformer transformer, diff --git a/src/Repl.Core/IResultFlowOutputTransformer.cs b/src/Repl.Core/IResultFlowOutputTransformer.cs new file mode 100644 index 0000000..093d790 --- /dev/null +++ b/src/Repl.Core/IResultFlowOutputTransformer.cs @@ -0,0 +1,9 @@ +namespace Repl; + +internal interface IResultFlowOutputTransformer : IOutputTransformer +{ + ValueTask TransformPageAsync( + IReplPage page, + ResultFlowPageRenderMode mode, + CancellationToken cancellationToken = default); +} diff --git a/src/Repl.Core/Output/HumanOutputTransformer.cs b/src/Repl.Core/Output/HumanOutputTransformer.cs index 9d2ce92..cd5e6ee 100644 --- a/src/Repl.Core/Output/HumanOutputTransformer.cs +++ b/src/Repl.Core/Output/HumanOutputTransformer.cs @@ -6,7 +6,7 @@ namespace Repl; -internal sealed class HumanOutputTransformer : IOutputTransformer +internal sealed class HumanOutputTransformer : IResultFlowOutputTransformer { private readonly Func _resolveRenderSettings; @@ -60,7 +60,7 @@ public ValueTask TransformAsync(object? value, CancellationToken cancell return ValueTask.FromResult("No results."); } - if (TryRenderTable(lines, settings, out var tableText)) + if (TryRenderTable(lines, settings, includeHeader: true, out var tableText)) { return ValueTask.FromResult(tableText); } @@ -87,12 +87,36 @@ public ValueTask TransformAsync(object? value, CancellationToken cancell Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); } - private static string RenderPage(IReplPage page, HumanRenderSettings settings) + public ValueTask TransformPageAsync( + IReplPage page, + ResultFlowPageRenderMode mode, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(page); + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(RenderPage(page, _resolveRenderSettings(), mode)); + } + + private static string RenderPage(IReplPage page, HumanRenderSettings settings) => + RenderPage(page, settings, ResultFlowPageRenderMode.Initial, includeFooter: true); + + private static string RenderPage(IReplPage page, HumanRenderSettings settings, ResultFlowPageRenderMode mode) => + RenderPage(page, settings, mode, includeFooter: false); + + private static string RenderPage( + IReplPage page, + HumanRenderSettings settings, + ResultFlowPageRenderMode mode, + bool includeFooter) { var body = page.UntypedItems.Count == 0 ? "No results." - : RenderCollection(page.UntypedItems, depth: 0, settings); - var footer = RenderPageFooter(page); + : RenderCollection( + page.UntypedItems, + depth: 0, + settings, + includeTableHeader: mode == ResultFlowPageRenderMode.Initial); + var footer = includeFooter ? RenderPageFooter(page) : string.Empty; return string.IsNullOrWhiteSpace(footer) ? body : string.Concat(body, Environment.NewLine, footer); @@ -164,7 +188,11 @@ private static bool TryRenderObject(object value, HumanRenderSettings settings, return true; } - private static string RenderCollection(System.Collections.IEnumerable collection, int depth, HumanRenderSettings settings) + private static string RenderCollection( + System.Collections.IEnumerable collection, + int depth, + HumanRenderSettings settings, + bool includeTableHeader = true) { var values = collection.Cast().ToArray(); if (values.Length == 0) @@ -172,7 +200,7 @@ private static string RenderCollection(System.Collections.IEnumerable collection return string.Empty; } - if (TryRenderTable(values, settings, out var tableText)) + if (TryRenderTable(values, settings, includeTableHeader, out var tableText)) { return tableText; } @@ -182,7 +210,11 @@ private static string RenderCollection(System.Collections.IEnumerable collection values.Select(value => $"- {RenderScalar(value, member: null, depth, compactCollection: false, settings.Width, settings)}")); } - private static bool TryRenderTable(object?[] values, HumanRenderSettings settings, out string text) + private static bool TryRenderTable( + object?[] values, + HumanRenderSettings settings, + bool includeHeader, + out string text) { var firstNonNull = values.FirstOrDefault(value => value is not null); if (firstNonNull is null) @@ -206,24 +238,30 @@ private static bool TryRenderTable(object?[] values, HumanRenderSettings setting return false; } - var rows = BuildTableRows(values, members, settings); - var style = settings.UseAnsi + var rows = BuildTableRows(values, members, settings, includeHeader); + var style = includeHeader && settings.UseAnsi ? TextTableStyle.ForHeader(settings.Palette.TableHeaderStyle) : TextTableStyle.None; text = TextTableFormatter.FormatRows( rows, settings.Width, - includeHeaderSeparator: !settings.UseAnsi, + includeHeaderSeparator: includeHeader && !settings.UseAnsi, style); return true; } - private static List BuildTableRows(object?[] values, DisplayMember[] members, HumanRenderSettings settings) + private static List BuildTableRows( + object?[] values, + DisplayMember[] members, + HumanRenderSettings settings, + bool includeHeader) { - var rows = new List(values.Length + 1) + var rows = new List(values.Length + (includeHeader ? 1 : 0)); + if (includeHeader) { - members.Select(member => member.Label).ToArray(), - }; + rows.Add(members.Select(member => member.Label).ToArray()); + } + foreach (var item in values) { if (item is null) diff --git a/src/Repl.Core/ResultFlowPageRenderMode.cs b/src/Repl.Core/ResultFlowPageRenderMode.cs new file mode 100644 index 0000000..cfa4289 --- /dev/null +++ b/src/Repl.Core/ResultFlowPageRenderMode.cs @@ -0,0 +1,7 @@ +namespace Repl; + +internal enum ResultFlowPageRenderMode +{ + Initial, + Continuation, +} diff --git a/src/Repl.IntegrationTests/Given_OutputFormatting.cs b/src/Repl.IntegrationTests/Given_OutputFormatting.cs index 02cf469..446c157 100644 --- a/src/Repl.IntegrationTests/Given_OutputFormatting.cs +++ b/src/Repl.IntegrationTests/Given_OutputFormatting.cs @@ -265,6 +265,8 @@ public void When_RenderingPageSourceInHumanPager_Then_SpaceFetchesNextPageWithou text.Should().Contain("Alice Martin"); text.Should().Contain("Bob Tremblay"); text.Should().NotContain("rerun with --result:cursor"); + text.Split("Name", StringSplitOptions.None).Should().HaveCount(2); + text.Should().NotContain("Showing "); } [TestMethod] diff --git a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs index 0670fb8..b867774 100644 --- a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs +++ b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs @@ -11,7 +11,7 @@ namespace Repl.Spectre; /// /// Output transformer that renders values using light Spectre.Console layouts. /// -internal sealed class SpectreHumanOutputTransformer : IOutputTransformer +internal sealed class SpectreHumanOutputTransformer : IResultFlowOutputTransformer { private readonly Func _resolveRenderSettings; @@ -54,6 +54,16 @@ _ when TryRenderObject(value, out var objectText) => objectText, }); } + public ValueTask TransformPageAsync( + IReplPage page, + ResultFlowPageRenderMode mode, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(page); + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(RenderPage(page, mode, includeFooter: false)); + } + private string RenderHelp(HelpRenderDocument help) { if (help.IsCommandHelp) @@ -179,7 +189,9 @@ private string RenderReplResult(IReplResult result) })); } - private string RenderEnumerable(System.Collections.IEnumerable enumerable) + private string RenderEnumerable( + System.Collections.IEnumerable enumerable, + bool includeTableHeader = true) { var items = enumerable.Cast().ToArray(); if (items.Length == 0) @@ -208,15 +220,23 @@ private string RenderEnumerable(System.Collections.IEnumerable enumerable) items.Select(item => Convert.ToString(item, CultureInfo.InvariantCulture) ?? string.Empty)); } - return RenderToString(BuildObjectTable(items, members)); + return RenderToString(BuildObjectTable(items, members, includeTableHeader)); } - private string RenderPage(IReplPage page) + private string RenderPage(IReplPage page) => + RenderPage(page, ResultFlowPageRenderMode.Initial, includeFooter: true); + + private string RenderPage( + IReplPage page, + ResultFlowPageRenderMode mode, + bool includeFooter) { var body = page.UntypedItems.Count == 0 ? "No results." - : RenderEnumerable(page.UntypedItems); - var footer = RenderPageFooter(page); + : RenderEnumerable( + page.UntypedItems, + includeTableHeader: mode == ResultFlowPageRenderMode.Initial); + var footer = includeFooter ? RenderPageFooter(page) : string.Empty; return string.IsNullOrWhiteSpace(footer) ? body : string.Concat(body, Environment.NewLine, footer); @@ -272,11 +292,18 @@ private Grid BuildObjectGrid(object value, IReadOnlyList members) return grid; } - private static Table BuildObjectTable(object?[] items, IReadOnlyList members) + private static Table BuildObjectTable( + object?[] items, + IReadOnlyList members, + bool includeHeaders = true) { var table = new Table() .Border(TableBorder.None) .Collapse(); + if (!includeHeaders) + { + table.HideHeaders(); + } foreach (var member in members) { diff --git a/src/Repl.Tests/Given_ResultFlowOutputTransformer.cs b/src/Repl.Tests/Given_ResultFlowOutputTransformer.cs new file mode 100644 index 0000000..4298e3e --- /dev/null +++ b/src/Repl.Tests/Given_ResultFlowOutputTransformer.cs @@ -0,0 +1,92 @@ +using System.ComponentModel.DataAnnotations; + +namespace Repl.Tests; + +[TestClass] +public sealed class Given_ResultFlowOutputTransformer +{ + [TestMethod] + [Description("Result-flow continuation payloads render table rows only so pagers do not receive repeated headers or page footers.")] + public async Task When_RenderingHumanContinuationPage_Then_HeaderAndFooterAreOmitted() + { + var transformer = new HumanOutputTransformer( + () => new HumanRenderSettings( + Width: 120, + UseAnsi: false, + Palette: new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark))); + var page = new ReplPage( + [ + new ActivityRow( + Id: 49, + At: "2026-01-12 13:43Z", + Area: "identity", + Event: "validated", + Summary: "identity batch 10 validated successfully"), + new ActivityRow( + Id: 50, + At: "2026-01-12 13:50Z", + Area: "billing", + Event: "queued", + Summary: "billing batch 10 queued successfully"), + ], + new ReplPageInfo( + Cursor: "48", + NextCursor: "50", + TotalCount: 250, + PageSize: 2)); + + var output = await ((IResultFlowOutputTransformer)transformer).TransformPageAsync( + page, + ResultFlowPageRenderMode.Continuation, + CancellationToken.None); + + output.Should().NotContain("#"); + output.Should().NotContain("---"); + output.Should().NotContain("Showing "); + output.Should().Contain("49"); + output.Should().Contain("identity batch 10 validated successfully"); + output.Should().Contain("50"); + output.Should().Contain("billing batch 10 queued successfully"); + } + + [TestMethod] + [Description("Result-flow initial payloads keep the table header so the first page remains readable.")] + public async Task When_RenderingHumanInitialPage_Then_HeaderIsIncluded() + { + var transformer = new HumanOutputTransformer( + () => new HumanRenderSettings( + Width: 120, + UseAnsi: false, + Palette: new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark))); + var page = new ReplPage( + [ + new ActivityRow( + Id: 49, + At: "2026-01-12 13:43Z", + Area: "identity", + Event: "validated", + Summary: "identity batch 10 validated successfully"), + ], + new ReplPageInfo( + Cursor: null, + NextCursor: "49", + TotalCount: 250, + PageSize: 1)); + + var output = await ((IResultFlowOutputTransformer)transformer).TransformPageAsync( + page, + ResultFlowPageRenderMode.Initial, + CancellationToken.None); + + output.Should().Contain("#"); + output.Should().Contain("At"); + output.Should().NotContain("Showing "); + } + + private sealed record ActivityRow( + [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); +} From a099fb0e1a7c764d1aca77b97be4433b63662d2e Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 7 May 2026 09:22:59 -0400 Subject: [PATCH 29/45] Extract terminal surface lifecycle for pager --- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 79 ++++++---------- src/Repl.Core/Terminal/AnsiSequences.cs | 15 ++++ src/Repl.Core/Terminal/TerminalSurfaceHost.cs | 29 ++++++ src/Repl.Core/Terminal/TerminalSurfaceMode.cs | 7 ++ .../Terminal/TerminalSurfaceScope.cs | 56 ++++++++++++ src/Repl.Tests/Given_TerminalSurfaceHost.cs | 89 +++++++++++++++++++ 6 files changed, 225 insertions(+), 50 deletions(-) create mode 100644 src/Repl.Core/Terminal/AnsiSequences.cs create mode 100644 src/Repl.Core/Terminal/TerminalSurfaceHost.cs create mode 100644 src/Repl.Core/Terminal/TerminalSurfaceMode.cs create mode 100644 src/Repl.Core/Terminal/TerminalSurfaceScope.cs create mode 100644 src/Repl.Tests/Given_TerminalSurfaceHost.cs diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 14ed03d..349d637 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -4,14 +4,6 @@ internal static class ResultFlowPager { private const string MorePrompt = "--More-- Space/PageDown: continue, Enter/Down: line, Up/PageUp: ignored, q/Esc: stop"; private const string FullStatus = "-- result-flow {0}-{1}/{2}{3} Space: next Up/Down: scroll Home/End: known bounds q: quit --"; - 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"; - private const string DisableLineWrap = "\u001b[?7l"; - private const string EnableLineWrap = "\u001b[?7h"; private static readonly System.Text.CompositeFormat FullStatusFormat = System.Text.CompositeFormat.Parse(FullStatus); @@ -179,7 +171,7 @@ await RenderViewportAsync( Math.Max(2, visibleRows), visibleRowsProvider, fetchNextPayload, - useAlternateScreen: true, + TerminalSurfaceMode.AlternateScreen, cancellationToken) .ConfigureAwait(false); break; @@ -191,7 +183,7 @@ await RenderViewportAsync( Math.Max(2, visibleRows), visibleRowsProvider, fetchNextPayload, - useAlternateScreen: false, + TerminalSurfaceMode.InlineRegion, cancellationToken) .ConfigureAwait(false); break; @@ -397,7 +389,7 @@ private static async ValueTask RenderViewportAsync( int visibleRows, Func? visibleRowsProvider, Func>? fetchNextPayload, - bool useAlternateScreen, + TerminalSurfaceMode surfaceMode, CancellationToken cancellationToken) { var state = new ViewportState(session, visibleRows); @@ -406,16 +398,11 @@ private static async ValueTask RenderViewportAsync( return; } - if (useAlternateScreen) - { - await output.WriteAsync(EnterAlternateScreen).ConfigureAwait(false); - } - - await output.WriteAsync(HideCursor).ConfigureAwait(false); - await output.WriteAsync(DisableLineWrap).ConfigureAwait(false); - await output.WriteAsync(CursorHome).ConfigureAwait(false); - await output.WriteAsync(ClearToEndOfScreen).ConfigureAwait(false); - + var surface = await TerminalSurfaceHost.EnterAsync( + output, + surfaceMode, + cancellationToken) + .ConfigureAwait(false); try { await EnsureViewportContentAsync(state.Session, fetchNextPayload, cancellationToken).ConfigureAwait(false); @@ -424,10 +411,10 @@ private static async ValueTask RenderViewportAsync( var currentRows = GetCurrentVisibleRows(visibleRows, visibleRowsProvider); if (state.UpdateVisibleRows(currentRows)) { - await ClearViewportAsync(output, state, useAlternateScreen).ConfigureAwait(false); + await ClearViewportAsync(surface, state).ConfigureAwait(false); } - await RenderViewportFrameAsync(state, output, useAlternateScreen, cancellationToken).ConfigureAwait(false); + await RenderViewportFrameAsync(state, surface, cancellationToken).ConfigureAwait(false); var key = await keyReader.ReadKeyAsync(cancellationToken).ConfigureAwait(false); var beforeTopLine = state.TopLine; var action = ApplyViewportKey(state, key); @@ -447,14 +434,7 @@ private static async ValueTask RenderViewportAsync( } finally { - await output.WriteAsync(EnableLineWrap).ConfigureAwait(false); - await output.WriteAsync(ShowCursor).ConfigureAwait(false); - if (useAlternateScreen) - { - await output.WriteAsync(LeaveAlternateScreen).ConfigureAwait(false); - } - - await output.FlushAsync(cancellationToken).ConfigureAwait(false); + await surface.DisposeAsync().ConfigureAwait(false); } } @@ -484,44 +464,43 @@ private static async ValueTask FetchIntoSessionAsync( session.Append(nextPayload.Payload, nextPayload.HasMore); } - private static async ValueTask ClearViewportAsync(TextWriter output, ViewportState state, bool useAlternateScreen) + private static async ValueTask ClearViewportAsync(TerminalSurfaceScope surface, ViewportState state) { - if (useAlternateScreen) + if (surface.Mode == TerminalSurfaceMode.AlternateScreen) { - await output.WriteAsync(CursorHome).ConfigureAwait(false); + await surface.MoveHomeAsync().ConfigureAwait(false); } else if (state.RenderedHeight > 0) { - await output.WriteAsync($"\u001b[{state.RenderedHeight}A").ConfigureAwait(false); - await output.WriteAsync('\r').ConfigureAwait(false); + await surface.MoveCursorUpAsync(state.RenderedHeight).ConfigureAwait(false); + await surface.MoveToColumnStartAsync().ConfigureAwait(false); } - await output.WriteAsync(ClearToEndOfScreen).ConfigureAwait(false); + await surface.ClearToEndOfScreenAsync().ConfigureAwait(false); state.ResetRenderedLineLengths(); } private static async ValueTask RenderViewportFrameAsync( ViewportState state, - TextWriter output, - bool useAlternateScreen, + TerminalSurfaceScope surface, CancellationToken cancellationToken) { - await PositionViewportAsync(output, state, useAlternateScreen).ConfigureAwait(false); + await PositionViewportAsync(surface, state).ConfigureAwait(false); var row = 0; foreach (var headerLine in state.Session.HeaderLines) { - await WriteViewportLineAsync(state, output, row++, headerLine).ConfigureAwait(false); + await WriteViewportLineAsync(state, surface.Output, row++, headerLine).ConfigureAwait(false); } var take = Math.Min(state.ViewportHeight, Math.Max(0, state.Session.Lines.Count - state.TopLine)); for (var i = 0; i < take; i++) { - await WriteViewportLineAsync(state, output, row++, state.Session.Lines[state.TopLine + i]).ConfigureAwait(false); + await WriteViewportLineAsync(state, surface.Output, row++, state.Session.Lines[state.TopLine + i]).ConfigureAwait(false); } for (var i = take; i < state.ViewportHeight; i++) { - await WriteViewportLineAsync(state, output, row++, string.Empty).ConfigureAwait(false); + await WriteViewportLineAsync(state, surface.Output, row++, string.Empty).ConfigureAwait(false); } var lastLine = state.Session.Lines.Count == 0 @@ -536,21 +515,21 @@ private static async ValueTask RenderViewportFrameAsync( lastLine, state.Session.Lines.Count, state.Session.HasMorePayload ? "+" : string.Empty); - await WriteViewportLineAsync(state, output, row++, status, appendNewLine: false).ConfigureAwait(false); + await WriteViewportLineAsync(state, surface.Output, row++, status, appendNewLine: false).ConfigureAwait(false); state.RenderedHeight = row; - await output.FlushAsync(cancellationToken).ConfigureAwait(false); + await surface.FlushAsync(cancellationToken).ConfigureAwait(false); } - private static async ValueTask PositionViewportAsync(TextWriter output, ViewportState state, bool useAlternateScreen) + private static async ValueTask PositionViewportAsync(TerminalSurfaceScope surface, ViewportState state) { - if (useAlternateScreen) + if (surface.Mode == TerminalSurfaceMode.AlternateScreen) { - await output.WriteAsync(CursorHome).ConfigureAwait(false); + await surface.MoveHomeAsync().ConfigureAwait(false); } else if (state.RenderedHeight > 0) { - await output.WriteAsync($"\u001b[{state.RenderedHeight}A").ConfigureAwait(false); - await output.WriteAsync('\r').ConfigureAwait(false); + await surface.MoveCursorUpAsync(state.RenderedHeight).ConfigureAwait(false); + await surface.MoveToColumnStartAsync().ConfigureAwait(false); } } diff --git a/src/Repl.Core/Terminal/AnsiSequences.cs b/src/Repl.Core/Terminal/AnsiSequences.cs new file mode 100644 index 0000000..daa0ed8 --- /dev/null +++ b/src/Repl.Core/Terminal/AnsiSequences.cs @@ -0,0 +1,15 @@ +namespace Repl.Terminal; + +internal static class AnsiSequences +{ + public const string EnterAlternateScreen = "\u001b[?1049h"; + public const string LeaveAlternateScreen = "\u001b[?1049l"; + public const string HideCursor = "\u001b[?25l"; + public const string ShowCursor = "\u001b[?25h"; + public const string CursorHome = "\u001b[H"; + public const string ClearToEndOfScreen = "\u001b[J"; + public const string DisableLineWrap = "\u001b[?7l"; + public const string EnableLineWrap = "\u001b[?7h"; + + public static string CursorUp(int rows) => $"\u001b[{rows}A"; +} diff --git a/src/Repl.Core/Terminal/TerminalSurfaceHost.cs b/src/Repl.Core/Terminal/TerminalSurfaceHost.cs new file mode 100644 index 0000000..3fbf205 --- /dev/null +++ b/src/Repl.Core/Terminal/TerminalSurfaceHost.cs @@ -0,0 +1,29 @@ +namespace Repl.Terminal; + +internal static class TerminalSurfaceHost +{ + public static async ValueTask EnterAsync( + TextWriter output, + TerminalSurfaceMode mode, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(output); + + if (mode == TerminalSurfaceMode.AlternateScreen) + { + await output.WriteAsync(AnsiSequences.EnterAlternateScreen).ConfigureAwait(false); + } + + await output.WriteAsync(AnsiSequences.HideCursor).ConfigureAwait(false); + await output.WriteAsync(AnsiSequences.DisableLineWrap).ConfigureAwait(false); + if (mode == TerminalSurfaceMode.AlternateScreen) + { + await output.WriteAsync(AnsiSequences.CursorHome).ConfigureAwait(false); + } + + await output.WriteAsync(AnsiSequences.ClearToEndOfScreen).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + + return new TerminalSurfaceScope(output, mode); + } +} diff --git a/src/Repl.Core/Terminal/TerminalSurfaceMode.cs b/src/Repl.Core/Terminal/TerminalSurfaceMode.cs new file mode 100644 index 0000000..07c6849 --- /dev/null +++ b/src/Repl.Core/Terminal/TerminalSurfaceMode.cs @@ -0,0 +1,7 @@ +namespace Repl.Terminal; + +internal enum TerminalSurfaceMode +{ + InlineRegion, + AlternateScreen, +} diff --git a/src/Repl.Core/Terminal/TerminalSurfaceScope.cs b/src/Repl.Core/Terminal/TerminalSurfaceScope.cs new file mode 100644 index 0000000..464c6de --- /dev/null +++ b/src/Repl.Core/Terminal/TerminalSurfaceScope.cs @@ -0,0 +1,56 @@ +namespace Repl.Terminal; + +internal sealed class TerminalSurfaceScope(TextWriter output, TerminalSurfaceMode mode) : IAsyncDisposable +{ + private bool _disposed; + + public TextWriter Output { get; } = output; + + public TerminalSurfaceMode Mode { get; } = mode; + + public ValueTask MoveHomeAsync() => + WriteAsync(AnsiSequences.CursorHome); + + public async ValueTask MoveCursorUpAsync(int rows) + { + if (rows <= 0) + { + return; + } + + await Output.WriteAsync(AnsiSequences.CursorUp(rows)).ConfigureAwait(false); + } + + public ValueTask MoveToColumnStartAsync() => + WriteAsync('\r'); + + public ValueTask ClearToEndOfScreenAsync() => + WriteAsync(AnsiSequences.ClearToEndOfScreen); + + public ValueTask FlushAsync(CancellationToken cancellationToken) => + new(Output.FlushAsync(cancellationToken)); + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + await Output.WriteAsync(AnsiSequences.EnableLineWrap).ConfigureAwait(false); + await Output.WriteAsync(AnsiSequences.ShowCursor).ConfigureAwait(false); + if (Mode == TerminalSurfaceMode.AlternateScreen) + { + await Output.WriteAsync(AnsiSequences.LeaveAlternateScreen).ConfigureAwait(false); + } + + await Output.FlushAsync().ConfigureAwait(false); + } + + private ValueTask WriteAsync(string value) => + new(Output.WriteAsync(value)); + + private ValueTask WriteAsync(char value) => + new(Output.WriteAsync(value)); +} diff --git a/src/Repl.Tests/Given_TerminalSurfaceHost.cs b/src/Repl.Tests/Given_TerminalSurfaceHost.cs new file mode 100644 index 0000000..27649f6 --- /dev/null +++ b/src/Repl.Tests/Given_TerminalSurfaceHost.cs @@ -0,0 +1,89 @@ +using Repl.Terminal; + +namespace Repl.Tests; + +[TestClass] +public sealed class Given_TerminalSurfaceHost +{ + [TestMethod] + [Description("Terminal surface enters alternate screen and restores cursor/wrap/screen when disposed.")] + public async Task When_AlternateScreenSurfaceIsDisposed_Then_TerminalStateIsRestored() + { + using var writer = new StringWriter(); + + await using (await TerminalSurfaceHost.EnterAsync( + writer, + TerminalSurfaceMode.AlternateScreen, + CancellationToken.None).ConfigureAwait(false)) + { + await writer.WriteAsync("body").ConfigureAwait(false); + } + + var output = writer.ToString(); + output.Should().Contain(AnsiSequences.EnterAlternateScreen); + output.Should().Contain(AnsiSequences.HideCursor); + output.Should().Contain(AnsiSequences.DisableLineWrap); + output.Should().Contain(AnsiSequences.CursorHome); + output.Should().Contain(AnsiSequences.ClearToEndOfScreen); + output.Should().Contain(AnsiSequences.EnableLineWrap); + output.Should().Contain(AnsiSequences.ShowCursor); + output.Should().Contain(AnsiSequences.LeaveAlternateScreen); + output.IndexOf(AnsiSequences.EnterAlternateScreen, StringComparison.Ordinal) + .Should().BeLessThan(output.IndexOf("body", StringComparison.Ordinal)); + output.IndexOf("body", StringComparison.Ordinal) + .Should().BeLessThan(output.IndexOf(AnsiSequences.LeaveAlternateScreen, StringComparison.Ordinal)); + } + + [TestMethod] + [Description("Terminal inline surface owns the current region without entering the alternate screen.")] + public async Task When_InlineSurfaceIsDisposed_Then_AlternateScreenIsNotUsed() + { + using var writer = new StringWriter(); + + await using (await TerminalSurfaceHost.EnterAsync( + writer, + TerminalSurfaceMode.InlineRegion, + CancellationToken.None).ConfigureAwait(false)) + { + await writer.WriteAsync("body").ConfigureAwait(false); + } + + var output = writer.ToString(); + output.Should().NotContain(AnsiSequences.EnterAlternateScreen); + output.Should().NotContain(AnsiSequences.LeaveAlternateScreen); + output.Should().Contain(AnsiSequences.HideCursor); + output.Should().Contain(AnsiSequences.DisableLineWrap); + output.Should().Contain(AnsiSequences.EnableLineWrap); + output.Should().Contain(AnsiSequences.ShowCursor); + } + + [TestMethod] + [Description("Terminal surface restores cursor/wrap/alternate screen even when rendering fails.")] + public async Task When_SurfaceRenderingThrows_Then_TerminalStateIsRestored() + { + using var writer = new StringWriter(); + + var act = async () => + { + var surface = await TerminalSurfaceHost.EnterAsync( + writer, + TerminalSurfaceMode.AlternateScreen, + CancellationToken.None) + .ConfigureAwait(false); + try + { + throw new InvalidOperationException("boom"); + } + finally + { + await surface.DisposeAsync().ConfigureAwait(false); + } + }; + + await act.Should().ThrowAsync().ConfigureAwait(false); + var output = writer.ToString(); + output.Should().Contain(AnsiSequences.EnableLineWrap); + output.Should().Contain(AnsiSequences.ShowCursor); + output.Should().Contain(AnsiSequences.LeaveAlternateScreen); + } +} From e82ec6644c82f11e18a6fd08b214adc39751dba0 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 7 May 2026 09:27:13 -0400 Subject: [PATCH 30/45] Clear more pager prompt in ANSI terminals --- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 50 ++++++++++++++++++--- src/Repl.Tests/Given_ResultFlowPager.cs | 30 +++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 349d637..af86c21 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -161,6 +161,30 @@ public static async ValueTask WriteAsync( } var session = new PagerSession(payload, hasMorePayload); + await RenderBuiltInAsync( + mode, + session, + output, + keyReader, + visibleRows, + visibleRowsProvider, + ansiEnabled, + fetchNextPayload, + cancellationToken) + .ConfigureAwait(false); + } + + private static async ValueTask RenderBuiltInAsync( + ReplPagerMode mode, + PagerSession session, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + Func? visibleRowsProvider, + bool ansiEnabled, + Func>? fetchNextPayload, + CancellationToken cancellationToken) + { switch (mode) { case ReplPagerMode.Full: @@ -193,6 +217,7 @@ await RenderMoreAsync( output, keyReader, Math.Max(1, visibleRows), + ansiEnabled, fetchNextPayload, cancellationToken) .ConfigureAwait(false); @@ -259,6 +284,7 @@ private static async ValueTask RenderMoreAsync( TextWriter output, IReplKeyReader keyReader, int visibleRows, + bool useTransientPrompt, Func>? fetchNextPayload, CancellationToken cancellationToken) { @@ -291,7 +317,7 @@ private static async ValueTask RenderMoreAsync( break; } - if (await ReadMoreActionAsync(session, output, keyReader, cancellationToken).ConfigureAwait(false) == PagerAction.Quit) + if (await ReadMoreActionAsync(session, output, keyReader, useTransientPrompt, cancellationToken).ConfigureAwait(false) == PagerAction.Quit) { return; } @@ -302,7 +328,7 @@ private static async ValueTask RenderMoreAsync( return; } - if (await ReadMoreActionAsync(session, output, keyReader, cancellationToken).ConfigureAwait(false) == PagerAction.Quit) + if (await ReadMoreActionAsync(session, output, keyReader, useTransientPrompt, cancellationToken).ConfigureAwait(false) == PagerAction.Quit) { return; } @@ -350,6 +376,7 @@ private static async ValueTask ReadMoreActionAsync( PagerSession session, TextWriter output, IReplKeyReader keyReader, + bool useTransientPrompt, CancellationToken cancellationToken) { await output.WriteAsync(MorePrompt).ConfigureAwait(false); @@ -362,11 +389,11 @@ private static async ValueTask ReadMoreActionAsync( { case ConsoleKey.Q: case ConsoleKey.Escape: - await output.WriteLineAsync().ConfigureAwait(false); + await FinishMorePromptAsync(output, useTransientPrompt).ConfigureAwait(false); return PagerAction.Quit; case ConsoleKey.Enter: case ConsoleKey.DownArrow: - await output.WriteLineAsync().ConfigureAwait(false); + await FinishMorePromptAsync(output, useTransientPrompt).ConfigureAwait(false); session.NextWindow = 1; return PagerAction.LineDown; case ConsoleKey.UpArrow: @@ -375,13 +402,26 @@ private static async ValueTask ReadMoreActionAsync( case ConsoleKey.End: continue; default: - await output.WriteLineAsync().ConfigureAwait(false); + await FinishMorePromptAsync(output, useTransientPrompt).ConfigureAwait(false); session.NextWindow = session.PageSize; return PagerAction.PageDown; } } } + private static async ValueTask FinishMorePromptAsync(TextWriter output, bool useTransientPrompt) + { + if (!useTransientPrompt) + { + await output.WriteLineAsync().ConfigureAwait(false); + return; + } + + await output.WriteAsync('\r').ConfigureAwait(false); + await output.WriteAsync(new string(' ', MorePrompt.Length)).ConfigureAwait(false); + await output.WriteAsync('\r').ConfigureAwait(false); + } + private static async ValueTask RenderViewportAsync( PagerSession session, TextWriter output, diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index 694eab1..14df6da 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -61,6 +61,36 @@ await ResultFlowPager.WriteAsync( output.Should().NotContain("four"); } + [TestMethod] + [Description("Result-flow more pager clears the prompt before writing the next row when ANSI rendering is available.")] + public async Task When_MorePagerUsesAnsiPrompt_Then_PromptDoesNotBecomeAVisibleRow() + { + using var writer = new StringWriter(); + const string morePrompt = "--More-- Space/PageDown: continue, Enter/Down: line, Up/PageUp: ignored, q/Esc: stop"; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree\nfour", + writer, + keys, + visibleRows: 2, + pagerMode: ReplPagerMode.More, + ansiEnabled: true, + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain($"two{Environment.NewLine}{morePrompt}"); + output.Should().Contain($"{morePrompt}\r{new string(' ', morePrompt.Length)}\rthree"); + output.Should().Contain($"{morePrompt}\r{new string(' ', morePrompt.Length)}\rfour"); + output.Should().NotContain($"{morePrompt}{Environment.NewLine}three"); + output.Should().NotContain($"{morePrompt}{Environment.NewLine}four"); + } + [TestMethod] [Description("Result-flow more pager ignores UpArrow because append-only output cannot redraw previous lines cleanly.")] public async Task When_MorePagerReceivesUpArrow_Then_DoesNotReplayOrAdvance() From 1ec2c826674ae691ae909edcd80b7fba099da7eb Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 7 May 2026 09:42:32 -0400 Subject: [PATCH 31/45] Continue more pager page down across payloads --- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 26 +++++++++++++----- src/Repl.Tests/Given_ResultFlowPager.cs | 30 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index af86c21..6fd69b5 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -311,7 +311,7 @@ private static async ValueTask RenderMoreAsync( while (session.Index < session.Lines.Count) { - await WriteMoreWindowAsync(session, output).ConfigureAwait(false); + await WriteMoreWindowAsync(session, output, fetchNextPayload, cancellationToken).ConfigureAwait(false); if (session.Index >= session.Lines.Count) { break; @@ -340,15 +340,27 @@ private static async ValueTask RenderMoreAsync( } } - private static async ValueTask WriteMoreWindowAsync(PagerSession session, TextWriter output) + private static async ValueTask WriteMoreWindowAsync( + PagerSession session, + TextWriter output, + Func>? fetchNextPayload, + CancellationToken cancellationToken) { - var take = Math.Min(session.NextWindow, session.Lines.Count - session.Index); - for (var i = 0; i < take; i++) + var written = 0; + while (written < session.NextWindow) { - await output.WriteLineAsync(session.Lines[session.Index + i]).ConfigureAwait(false); - } + if (session.Index >= session.Lines.Count) + { + if (!await TryFetchIntoSessionAsync(session, fetchNextPayload, cancellationToken).ConfigureAwait(false)) + { + break; + } + } - session.Index += take; + await output.WriteLineAsync(session.Lines[session.Index]).ConfigureAwait(false); + session.Index++; + written++; + } } private static async ValueTask TryFetchIntoSessionAsync( diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index 14df6da..2a253e5 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -152,6 +152,36 @@ await ResultFlowPager.WriteAsync( output.Should().Contain("four"); } + [TestMethod] + [Description("Result-flow more pager fills the requested PageDown window across payload boundaries.")] + public async Task When_MorePagerPageDownCrossesPayloadBoundary_Then_FetchesAndContinuesWindow() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.PageDown, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo\nthree", + writer, + keys, + visibleRows: 2, + hasMorePayload: true, + fetchNextPayload: _ => ValueTask.FromResult( + new ResultFlowPagerPage("four\nfive\nsix", HasMore: false)), + 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().NotContain("six"); + } + [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() From a0f8a2fed7a4ff143ae165fecfc2fb91583ec259 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 7 May 2026 10:42:17 -0400 Subject: [PATCH 32/45] Document source versus output paging --- docs/result-flow.md | 68 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/result-flow.md b/docs/result-flow.md index d0f93fa..38ec3a2 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -392,6 +392,74 @@ Result-flow flags are global and use the `--result:` prefix so they do not colli input are available, then falls back to the simple `more` behavior in limited terminals. +## Advanced: Source Paging Vs Output Paging + +Result flow has two different paging layers. They are related, but they solve +different problems: + +| Layer | What it pages | Who controls it | Why it exists | +|---|---|---|---| +| Source paging | Data items fetched by the handler or `IReplPageSource` | The handler/source through `ReplPageRequest.PageSize` and `Cursor` | Avoid loading too many rows, expensive API calls, or unsafe memory growth. | +| Output paging | Rendered terminal lines already produced for human display | The interactive pager through `--result:pager` and terminal height | Avoid flooding the user's screen and provide navigation. | + +Source paging happens before output formatting. Output paging happens after +formatting. A single data item can become zero, one, or many output lines, +depending on the formatter, terminal width, ANSI/Spectre rendering, wrapping, +and object shape. + +For example, this source returns data pages: + +```csharp +app.Map("activity", (IReplPagingContext paging, ActivityStore store) => + paging.CreateSource(async (request, ct) => + { + var page = await store.QueryAsync( + cursor: request.Cursor, + take: request.PageSize, + ct); + + return request.Page( + page.Items, + nextCursor: page.NextCursor, + totalCount: page.TotalCount); + })); +``` + +The source decides how many rows to fetch. The output pager decides how many +rendered lines to show before prompting: + +```bash +myapp activity --result:page-size=100 --result:pager=more +``` + +This means: + +- Repl asks the source for up to 100 `ActivityRow` items at a time. +- The human formatter turns those rows into a table. +- The `more` pager shows only the visible rendered lines, then waits for input. +- If the user pages past the buffered rows, Repl fetches the next source page + using the source cursor. + +Do not use output paging as a substitute for source paging. Returning a +100,000-row list and relying on the pager still allocates and formats the whole +list before the user sees the first screen. Use `IReplPageSource` or +`ReplPage` whenever the data source can page efficiently. + +Do not use source paging as a substitute for output paging either. A source page +of 20 objects can still produce hundreds of terminal lines if rows contain long +strings, nested collections, or narrow-column wrapping. Human terminal output +still benefits from `auto`, `more`, `inline`, or `full`. + +Rules of thumb: + +- Tune `--result:page-size` for data-source cost, API limits, and memory safety. +- Tune `--result:pager` for terminal UX. +- Use `VisibleRowCapacityHint` only as a best-effort hint; it is not the same as + `PageSize` and is not a hard display contract. +- For machine outputs, source paging still matters, but output paging is off. +- For redirected output, Repl writes normal stdout and lets tools such as + `less`, `grep`, `tail`, or `jq` handle downstream paging/filtering. + ## CLI And Pipe Behavior The integrated pager only applies to human terminal formats: From 14b03bef14825dedb7c6f2be05443f7221c7000e Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 8 May 2026 13:33:21 -0400 Subject: [PATCH 33/45] Harden result-flow cursors --- src/Repl.Core/CoreReplApp.Execution.cs | 57 +++++++++++++----- .../Output/HumanOutputTransformer.cs | 4 +- .../Output/MarkdownOutputTransformer.cs | 4 +- .../Parsing/GlobalInvocationOptions.cs | 4 ++ src/Repl.Core/Parsing/GlobalOptionParser.cs | 26 ++++++++- src/Repl.Core/ResultFlow/ReplPageSource.cs | 21 +++++-- .../ResultFlow/ResultFlowCursorPolicy.cs | 58 +++++++++++++++++++ src/Repl.Mcp/McpToolAdapter.cs | 17 +----- src/Repl.McpTests/Given_McpToolAdapter.cs | 15 +++++ .../SpectreHumanOutputTransformer.cs | 4 +- src/Repl.Tests/Given_GlobalOptionParser.cs | 43 ++++++++++++++ src/Repl.Tests/Given_ReplPageSource.cs | 40 +++++++++++++ .../Given_ResultFlowOutputTransformer.cs | 42 ++++++++++++++ 13 files changed, 292 insertions(+), 43 deletions(-) create mode 100644 src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 8d90fd2..daf61b3 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -63,6 +63,23 @@ private async ValueTask ExecuteCoreAsync( try { var globalOptions = GlobalOptionParser.Parse(args, _options.Output, _options.Parsing); + if (await TryHandleGlobalDiagnosticsAsync(globalOptions, cancellationToken).ConfigureAwait(false) is { } globalDiagnosticsExitCode) return globalDiagnosticsExitCode; + + return await ExecuteParsedCoreAsync(globalOptions, serviceProvider, isSubInvocation, cancellationToken) + .ConfigureAwait(false); + } + finally + { + _options.Interaction.SetObserver(observer: null); + } + } + + private async ValueTask ExecuteParsedCoreAsync( + GlobalInvocationOptions globalOptions, + IServiceProvider serviceProvider, + bool isSubInvocation, + CancellationToken cancellationToken) + { _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions); // volatile ref swap — safe under concurrent sub-invocations if (!isSubInvocation) { @@ -71,14 +88,14 @@ private async ValueTask ExecuteCoreAsync( using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; - var ambiguousExitCode = await TryHandleAmbiguousPrefixAsync( + var ambiguousExitCode = await TryHandleAmbiguousPrefixAsync( prefixResolution, globalOptions, resolvedGlobalOptions, serviceProvider, cancellationToken) .ConfigureAwait(false); - if (ambiguousExitCode is not null) return ambiguousExitCode.Value; + if (ambiguousExitCode is not null) return ambiguousExitCode.Value; var preResolvedRouteResolution = TryPreResolveRouteForBanner(resolvedGlobalOptions); if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions, preResolvedRouteResolution?.Match)) @@ -86,26 +103,26 @@ private async ValueTask ExecuteCoreAsync( await TryRenderBannerAsync(resolvedGlobalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); } - var preExecutionExitCode = await TryHandlePreExecutionAsync( + var preExecutionExitCode = await TryHandlePreExecutionAsync( resolvedGlobalOptions, serviceProvider, cancellationToken) .ConfigureAwait(false); - if (preExecutionExitCode is not null) return preExecutionExitCode.Value; + if (preExecutionExitCode is not null) return preExecutionExitCode.Value; var resolution = preResolvedRouteResolution ?? ResolveWithDiagnostics(resolvedGlobalOptions.RemainingTokens); var match = resolution.Match; - if (match is null) - { - return await TryHandleContextDeeplinkAsync( + if (match is null) + { + return await TryHandleContextDeeplinkAsync( resolvedGlobalOptions, serviceProvider, cancellationToken, constraintFailure: resolution.ConstraintFailure, missingArgumentsFailure: resolution.MissingArgumentsFailure) .ConfigureAwait(false); - } + } return await ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( match, @@ -113,11 +130,6 @@ private async ValueTask ExecuteCoreAsync( serviceProvider, cancellationToken) .ConfigureAwait(false); - } - finally - { - _options.Interaction.SetObserver(observer: null); - } } private async ValueTask TryHandleAmbiguousPrefixAsync( @@ -789,6 +801,25 @@ await ResultFlowPager.WriteAsync( } } + private async ValueTask TryHandleGlobalDiagnosticsAsync( + GlobalInvocationOptions globalOptions, + CancellationToken cancellationToken) + { + if (!globalOptions.HasErrors) + { + return null; + } + + var firstError = globalOptions.Diagnostics + .First(diagnostic => diagnostic.Severity == ParseDiagnosticSeverity.Error); + _ = await RenderOutputAsync( + Results.Validation(firstError.Message), + globalOptions.OutputFormat, + cancellationToken) + .ConfigureAwait(false); + return 1; + } + private static ValueTask TransformPagerPageAsync( IOutputTransformer transformer, IReplPage page, diff --git a/src/Repl.Core/Output/HumanOutputTransformer.cs b/src/Repl.Core/Output/HumanOutputTransformer.cs index cd5e6ee..cba9a79 100644 --- a/src/Repl.Core/Output/HumanOutputTransformer.cs +++ b/src/Repl.Core/Output/HumanOutputTransformer.cs @@ -130,7 +130,7 @@ private static string RenderPageFooter(IReplPage page) { var prefix = $"Showing {count.ToString(CultureInfo.InvariantCulture)} of {total.ToString(CultureInfo.InvariantCulture)}."; return info.HasMore - ? $"{prefix} Next data page: rerun with --result:cursor {info.NextCursor}." + ? $"{prefix} Next data page: rerun with {ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}." : prefix; } @@ -139,7 +139,7 @@ private static string RenderPageFooter(IReplPage page) return string.Empty; } - return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Next data page: rerun with --result:cursor {info.NextCursor}."; + return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Next data page: rerun with {ResultFlowCursorPolicy.FormatCliContinuation(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 571b5b6..104877f 100644 --- a/src/Repl.Core/Output/MarkdownOutputTransformer.cs +++ b/src/Repl.Core/Output/MarkdownOutputTransformer.cs @@ -111,7 +111,7 @@ private static string RenderPageFooter(IReplPage page) { var prefix = $"Showing {count} of {total}."; return info.HasMore - ? $"{prefix} Continue with `--result:cursor {info.NextCursor}`." + ? $"{prefix} Continue with `{ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}`." : prefix; } @@ -120,7 +120,7 @@ private static string RenderPageFooter(IReplPage page) return string.Empty; } - return $"Showing {count} result(s). Continue with `--result:cursor {info.NextCursor}`."; + return $"Showing {count} result(s). Continue with `{ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}`."; } private static string RenderEnumerable(System.Collections.IEnumerable enumerable) diff --git a/src/Repl.Core/Parsing/GlobalInvocationOptions.cs b/src/Repl.Core/Parsing/GlobalInvocationOptions.cs index 92c563e..b150674 100644 --- a/src/Repl.Core/Parsing/GlobalInvocationOptions.cs +++ b/src/Repl.Core/Parsing/GlobalInvocationOptions.cs @@ -20,4 +20,8 @@ internal sealed record GlobalInvocationOptions( public IReadOnlyDictionary> CustomGlobalNamedOptions { get; init; } = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Diagnostics { get; init; } = []; + + public bool HasErrors => Diagnostics.Any(static diagnostic => diagnostic.Severity == ParseDiagnosticSeverity.Error); } diff --git a/src/Repl.Core/Parsing/GlobalOptionParser.cs b/src/Repl.Core/Parsing/GlobalOptionParser.cs index 3bbb96a..4546798 100644 --- a/src/Repl.Core/Parsing/GlobalOptionParser.cs +++ b/src/Repl.Core/Parsing/GlobalOptionParser.cs @@ -23,6 +23,7 @@ public static GlobalInvocationOptions Parse( var remaining = new List(args.Count); var promptAnswers = new Dictionary(StringComparer.OrdinalIgnoreCase); var customGlobalValues = new Dictionary>(tokenComparer); + var diagnostics = new List(); var customTokenMap = BuildCustomTokenMap(parsingOptions.GlobalOptions, tokenComparer); var options = new GlobalInvocationOptions(remaining); var optionComparison = parsingOptions.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive @@ -75,6 +76,7 @@ public static GlobalInvocationOptions Parse( optionComparison, options.ResultFlow, outputOptions.ResultFlow.MaxPageSize, + diagnostics, out var resultFlow)) { options = options with { ResultFlow = resultFlow }; @@ -108,6 +110,7 @@ public static GlobalInvocationOptions Parse( { PromptAnswers = promptAnswers, CustomGlobalNamedOptions = readonlyCustomGlobalValues, + Diagnostics = diagnostics, }; } @@ -163,6 +166,7 @@ private static bool TryParseResultFlowOption( StringComparison comparison, ResultFlowInvocationOptions current, int maxPageSize, + List diagnostics, out ResultFlowInvocationOptions resultFlow) { const string prefix = "--result:"; @@ -176,7 +180,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, maxPageSize, out resultFlow); + return ApplyResultFlowOption(name, inlineValue, current, maxPageSize, diagnostics, out resultFlow); } if (string.Equals(token, "all", comparison)) @@ -190,10 +194,16 @@ private static bool TryParseResultFlowOption( && !args[index + 1].StartsWith('-')) { index++; - return ApplyResultFlowOption(token, args[index], current, maxPageSize, out resultFlow); + return ApplyResultFlowOption(token, args[index], current, maxPageSize, diagnostics, out resultFlow); } - return ApplyResultFlowOption(token, "true", current, maxPageSize, out resultFlow); + if (RequiresResultFlowValue(token, comparison)) + { + AddResultFlowDiagnostic(diagnostics, $"The result-flow option '--result:{token}' requires a value."); + return true; + } + + return ApplyResultFlowOption(token, "true", current, maxPageSize, diagnostics, out resultFlow); } private static bool ApplyResultFlowOption( @@ -201,6 +211,7 @@ private static bool ApplyResultFlowOption( string value, ResultFlowInvocationOptions current, int maxPageSize, + List diagnostics, out ResultFlowInvocationOptions resultFlow) { resultFlow = current; @@ -220,6 +231,12 @@ private static bool ApplyResultFlowOption( if (string.Equals(name, "cursor", StringComparison.OrdinalIgnoreCase)) { + if (!ResultFlowCursorPolicy.TryValidate(value, out var error)) + { + AddResultFlowDiagnostic(diagnostics, error); + return true; + } + resultFlow = current with { Cursor = value }; return true; } @@ -251,6 +268,9 @@ private static bool RequiresResultFlowValue(string token, StringComparison compa private static int ClampPageSize(int pageSize, int maxPageSize) => Math.Clamp(pageSize, 1, Math.Max(1, maxPageSize)); + private static void AddResultFlowDiagnostic(List diagnostics, string message) => + diagnostics.Add(new ParseDiagnostic(ParseDiagnosticSeverity.Error, message)); + private static Dictionary BuildCustomTokenMap( IReadOnlyDictionary definitions, StringComparer comparer) diff --git a/src/Repl.Core/ResultFlow/ReplPageSource.cs b/src/Repl.Core/ResultFlow/ReplPageSource.cs index a0dc580..5d698ed 100644 --- a/src/Repl.Core/ResultFlow/ReplPageSource.cs +++ b/src/Repl.Core/ResultFlow/ReplPageSource.cs @@ -362,10 +362,21 @@ private static IAsyncEnumerable CreateStreamAsync( 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 ParseOffset(string? cursor) + { + if (string.IsNullOrEmpty(cursor)) + { + return 0; + } + + if (int.TryParse(cursor, NumberStyles.None, CultureInfo.InvariantCulture, out var offset) && offset >= 0) + { + return offset; + } + + throw new InvalidOperationException( + $"The result cursor '{cursor}' is not a valid non-negative offset cursor for this page source."); + } private static int ResolveMaxSourceItemsToScan(int? value) => value is > 0 ? value.Value : DefaultMaxSourceItemsToScan; @@ -384,7 +395,7 @@ private static void ThrowIfScanLimitExceeded(int scanned, int maxSourceItemsToSc if (scanned >= maxSourceItemsToScan) { throw new InvalidOperationException( - "The client-side filter scan limit was reached before a complete page could be produced."); + $"The client-side filter scan limit was reached before a complete page could be produced. Scanned {scanned.ToString(CultureInfo.InvariantCulture)} item(s); limit is {maxSourceItemsToScan.ToString(CultureInfo.InvariantCulture)}."); } } diff --git a/src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs b/src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs new file mode 100644 index 0000000..5c76bb0 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs @@ -0,0 +1,58 @@ +namespace Repl; + +internal static class ResultFlowCursorPolicy +{ + public const int MaxLength = 512; + + public static bool TryValidate(string? cursor, [System.Diagnostics.CodeAnalysis.NotNullWhen(false)] out string? error) + { + if (string.IsNullOrEmpty(cursor)) + { + error = "The result cursor must be provided with a non-empty value."; + return false; + } + + if (cursor.Length > MaxLength) + { + error = $"The result cursor cannot exceed {MaxLength.ToString(System.Globalization.CultureInfo.InvariantCulture)} characters."; + return false; + } + + if (cursor[0] == '-') + { + error = "The result cursor cannot start like a CLI option."; + return false; + } + + foreach (var ch in cursor) + { + if (char.IsWhiteSpace(ch)) + { + error = "The result cursor cannot contain whitespace."; + return false; + } + + if (ch < 0x20 || ch == 0x7f) + { + error = "The result cursor cannot contain control characters."; + return false; + } + } + + error = null; + return true; + } + + public static void ValidateOrThrow(string? cursor) + { + if (!TryValidate(cursor, out var error)) + { + throw new InvalidOperationException(error); + } + } + + public static string FormatCliContinuation(string? cursor) => + TryValidate(cursor, out _) + ? $"--result:cursor {cursor}" + : "--result:cursor "; +} diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 6413399..8c870b3 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -263,22 +263,7 @@ internal static (List Tokens, Dictionary Prefills) Prepa } 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."); - } - } + => ResultFlowCursorPolicy.ValidateOrThrow(cursor); private static void ValidateResultPageSize(string pageSize) { diff --git a/src/Repl.McpTests/Given_McpToolAdapter.cs b/src/Repl.McpTests/Given_McpToolAdapter.cs index ee87f6e..0810774 100644 --- a/src/Repl.McpTests/Given_McpToolAdapter.cs +++ b/src/Repl.McpTests/Given_McpToolAdapter.cs @@ -148,6 +148,21 @@ public void When_ResultCursorIsTooLong_Then_Rejected() .WithMessage("*cursor*512*"); } + [TestMethod] + [Description("PrepareExecution rejects result cursors that contain control characters.")] + public void When_ResultCursorContainsControlCharacter_Then_Rejected() + { + var action = () => McpToolAdapter.PrepareExecution( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement("abc\u001b[2J"), + }); + + action.Should().Throw() + .WithMessage("*cursor*control*"); + } + [TestMethod] [Description("PrepareExecution accepts compact numeric result page sizes and emits them as result-flow tokens.")] public void When_ResultPageSizeIsValid_Then_ResultFlowTokenIsEmitted() diff --git a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs index b867774..7742a0c 100644 --- a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs +++ b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs @@ -250,7 +250,7 @@ private static string RenderPageFooter(IReplPage page) { var prefix = $"Showing {count.ToString(CultureInfo.InvariantCulture)} of {total.ToString(CultureInfo.InvariantCulture)}."; return info.HasMore - ? $"{prefix} Next data page: rerun with --result:cursor {info.NextCursor}." + ? $"{prefix} Next data page: rerun with {ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}." : prefix; } @@ -259,7 +259,7 @@ private static string RenderPageFooter(IReplPage page) return string.Empty; } - return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Next data page: rerun with --result:cursor {info.NextCursor}."; + return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Next data page: rerun with {ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}."; } private bool TryRenderObject(object value, out string text) diff --git a/src/Repl.Tests/Given_GlobalOptionParser.cs b/src/Repl.Tests/Given_GlobalOptionParser.cs index 02fb60d..cb68452 100644 --- a/src/Repl.Tests/Given_GlobalOptionParser.cs +++ b/src/Repl.Tests/Given_GlobalOptionParser.cs @@ -154,4 +154,47 @@ public void When_ResultFlowPageSizeExceedsMaximum_Then_ParserClampsIt() parsed.ResultFlow.PageSize.Should().Be(50); } + + [TestMethod] + [Description("Result-flow cursor must be explicit so a missing value is reported before command binding.")] + public void When_ResultFlowCursorValueIsMissing_Then_DiagnosticErrorIsProduced() + { + var parsed = GlobalOptionParser.Parse( + ["users", "list", "--result:cursor"], + new OutputOptions(), + new ParsingOptions()); + + parsed.Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Severity == ParseDiagnosticSeverity.Error + && diagnostic.Message.Contains("cursor", StringComparison.OrdinalIgnoreCase)); + } + + [TestMethod] + [Description("Result-flow cursor rejects token-like values that could corrupt downstream CLI reconstruction.")] + public void When_ResultFlowCursorStartsWithDash_Then_DiagnosticErrorIsProduced() + { + var parsed = GlobalOptionParser.Parse( + ["users", "list", "--result:cursor=--result:all"], + new OutputOptions(), + new ParsingOptions()); + + parsed.Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Severity == ParseDiagnosticSeverity.Error + && diagnostic.Message.Contains("cursor", StringComparison.OrdinalIgnoreCase) + && diagnostic.Message.Contains("option", StringComparison.OrdinalIgnoreCase)); + } + + [TestMethod] + [Description("Result-flow cursor rejects control characters so rendered continuation text cannot inject terminal escapes.")] + public void When_ResultFlowCursorContainsControlCharacter_Then_DiagnosticErrorIsProduced() + { + var parsed = GlobalOptionParser.Parse( + ["users", "list", "--result:cursor=abc\u001b[2J"], + new OutputOptions(), + new ParsingOptions()); + + parsed.Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Severity == ParseDiagnosticSeverity.Error + && diagnostic.Message.Contains("control", StringComparison.OrdinalIgnoreCase)); + } } diff --git a/src/Repl.Tests/Given_ReplPageSource.cs b/src/Repl.Tests/Given_ReplPageSource.cs index 701d46b..ce0c82f 100644 --- a/src/Repl.Tests/Given_ReplPageSource.cs +++ b/src/Repl.Tests/Given_ReplPageSource.cs @@ -159,6 +159,46 @@ public async Task When_FromOffsetReceivesZeroCursor_Then_StartsAtFirstItem() page.Items.Should().Equal(0, 1); } + [TestMethod] + [Description("ReplPageSource.FromItems rejects malformed offset cursors instead of silently replaying the first page.")] + public async Task When_FromItemsReceivesMalformedCursor_Then_FailsClearly() + { + var source = ReplPageSource.FromItems(["one", "two", "three"]); + + var action = async () => await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: "abc", + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)).ConfigureAwait(false); + + await action.Should().ThrowAsync() + .WithMessage("*cursor*offset*") + .ConfigureAwait(false); + } + + [TestMethod] + [Description("ReplPageSource.FromOffset rejects negative offset cursors instead of silently replaying the first page.")] + public async Task When_FromOffsetReceivesNegativeCursor_Then_FailsClearly() + { + var source = ReplPageSource.FromOffset( + static (offset, take, _) => ValueTask.FromResult>( + Enumerable.Range(offset, take).ToArray())); + + var action = async () => await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: "-1", + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)).ConfigureAwait(false); + + await action.Should().ThrowAsync() + .WithMessage("*cursor*offset*") + .ConfigureAwait(false); + } + [TestMethod] [Description("ReplPageSource.FromAsyncEnumerable pages async streams with offset cursors.")] public async Task When_FromAsyncEnumerableFetchesPages_Then_EmitsOffsetCursor() diff --git a/src/Repl.Tests/Given_ResultFlowOutputTransformer.cs b/src/Repl.Tests/Given_ResultFlowOutputTransformer.cs index 4298e3e..c1c99a7 100644 --- a/src/Repl.Tests/Given_ResultFlowOutputTransformer.cs +++ b/src/Repl.Tests/Given_ResultFlowOutputTransformer.cs @@ -83,6 +83,48 @@ public async Task When_RenderingHumanInitialPage_Then_HeaderIsIncluded() output.Should().NotContain("Showing "); } + [TestMethod] + [Description("Human page footers never render unsafe cursor text directly.")] + public async Task When_HumanPageFooterHasUnsafeCursor_Then_CursorIsNotRenderedVerbatim() + { + var transformer = new HumanOutputTransformer( + () => new HumanRenderSettings( + Width: 120, + UseAnsi: false, + Palette: new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark))); + var page = new ReplPage( + [new ActivityRow(1, "2026-01-12 12:00Z", "ops", "queued", "queued")], + new ReplPageInfo( + Cursor: null, + NextCursor: "abc\u001b[2J", + TotalCount: 2, + PageSize: 1)); + + var output = await transformer.TransformAsync(page, CancellationToken.None); + + output.Should().NotContain("\u001b[2J"); + output.Should().Contain("cursor"); + } + + [TestMethod] + [Description("Markdown page footers never render unsafe cursor text directly.")] + public async Task When_MarkdownPageFooterHasUnsafeCursor_Then_CursorIsNotRenderedVerbatim() + { + var transformer = new MarkdownOutputTransformer(); + var page = new ReplPage( + [new ActivityRow(1, "2026-01-12 12:00Z", "ops", "queued", "queued")], + new ReplPageInfo( + Cursor: null, + NextCursor: "abc\u001b[2J", + TotalCount: 2, + PageSize: 1)); + + var output = await transformer.TransformAsync(page, CancellationToken.None); + + output.Should().NotContain("\u001b[2J"); + output.Should().Contain("cursor"); + } + private sealed record ActivityRow( [property: Display(Name = "#", Order = 0)] int Id, [property: Display(Name = "At", Order = 1)] string At, From 74ef852a135bee4d43ef26b15bb068467b1fb810 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 8 May 2026 13:35:44 -0400 Subject: [PATCH 34/45] Refine paging public surface --- src/Repl.Core/IReplKeyReader.cs | 3 ++ .../ResultFlow/IReplPagingContext.cs | 21 -------- .../ResultFlow/ReplPagerRenderContext.cs | 27 +++++++++-- src/Repl.Core/ResultFlow/ReplPagingContext.cs | 21 -------- .../ResultFlow/ReplPagingContextExtensions.cs | 48 +++++++++++++++++++ src/Repl.Core/ResultFlow/ResultFlowOptions.cs | 31 +++++++++++- src/Repl.Tests/Given_ResultFlowPager.cs | 37 ++++++++++++++ 7 files changed, 141 insertions(+), 47 deletions(-) create mode 100644 src/Repl.Core/ResultFlow/ReplPagingContextExtensions.cs diff --git a/src/Repl.Core/IReplKeyReader.cs b/src/Repl.Core/IReplKeyReader.cs index 56f4115..183ae80 100644 --- a/src/Repl.Core/IReplKeyReader.cs +++ b/src/Repl.Core/IReplKeyReader.cs @@ -4,6 +4,9 @@ namespace Repl; /// Injectable service for handlers that need raw key input (watch/top pattern). /// When a handler declares this parameter, it owns the console input and decides /// what each key means. +/// Custom implementations can also use this +/// service through to drive interactive +/// navigation without depending directly on . /// public interface IReplKeyReader { diff --git a/src/Repl.Core/ResultFlow/IReplPagingContext.cs b/src/Repl.Core/ResultFlow/IReplPagingContext.cs index 9da6955..93f822d 100644 --- a/src/Repl.Core/ResultFlow/IReplPagingContext.cs +++ b/src/Repl.Core/ResultFlow/IReplPagingContext.cs @@ -40,25 +40,4 @@ public interface IReplPagingContext /// 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/ReplPagerRenderContext.cs b/src/Repl.Core/ResultFlow/ReplPagerRenderContext.cs index f05f8e3..c5076f4 100644 --- a/src/Repl.Core/ResultFlow/ReplPagerRenderContext.cs +++ b/src/Repl.Core/ResultFlow/ReplPagerRenderContext.cs @@ -5,7 +5,12 @@ namespace Repl; /// public sealed class ReplPagerRenderContext { - internal ReplPagerRenderContext( + private readonly Func>? _fetchNextPayload; + + /// + /// Initializes a new instance of the class. + /// + public ReplPagerRenderContext( string initialPayload, TextWriter output, IReplKeyReader keyReader, @@ -15,6 +20,10 @@ internal ReplPagerRenderContext( bool hasMorePayload, Func>? fetchNextPayload) { + ArgumentNullException.ThrowIfNull(initialPayload); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(keyReader); + InitialPayload = initialPayload; Output = output; KeyReader = keyReader; @@ -22,7 +31,7 @@ internal ReplPagerRenderContext( VisibleRowsProvider = visibleRowsProvider; AnsiEnabled = ansiEnabled; HasMorePayload = hasMorePayload; - FetchNextPayload = fetchNextPayload; + _fetchNextPayload = fetchNextPayload; } /// @@ -61,7 +70,17 @@ internal ReplPagerRenderContext( public bool HasMorePayload { get; } /// - /// Gets the next payload fetcher when the result source can continue. + /// Gets a value indicating whether a next payload fetcher is available. + /// + public bool CanFetchNextPayload => _fetchNextPayload is not null; + + /// + /// Fetches the next rendered payload when the result source can continue. /// - public Func>? FetchNextPayload { get; } + /// Cancellation token. + /// The next payload, or null when no fetcher is available or the source ended. + public ValueTask FetchNextPayloadAsync(CancellationToken cancellationToken = default) => + _fetchNextPayload is null + ? ValueTask.FromResult(null) + : _fetchNextPayload(cancellationToken); } diff --git a/src/Repl.Core/ResultFlow/ReplPagingContext.cs b/src/Repl.Core/ResultFlow/ReplPagingContext.cs index 290b8e8..10dfa99 100644 --- a/src/Repl.Core/ResultFlow/ReplPagingContext.cs +++ b/src/Repl.Core/ResultFlow/ReplPagingContext.cs @@ -35,27 +35,6 @@ public ReplPagingContext( public ReplResultSurface Surface { get; } - public ReplPage Page( - IReadOnlyList items, - string? nextCursor = null, - long? totalCount = null) - { - ArgumentNullException.ThrowIfNull(items); - var pageInfo = new ReplPageInfo( - Cursor, - nextCursor, - totalCount, - SuggestedPageSize); - return new ReplPage(items, pageInfo); - } - - public IReplPageSource CreateSource( - Func>> fetch) - { - ArgumentNullException.ThrowIfNull(fetch); - return ReplPageSource.Create(fetch); - } - internal ReplPageRequest CreateRequest() => new(SuggestedPageSize, Cursor, VisibleRowCapacityHint, AllRequested, Surface); diff --git a/src/Repl.Core/ResultFlow/ReplPagingContextExtensions.cs b/src/Repl.Core/ResultFlow/ReplPagingContextExtensions.cs new file mode 100644 index 0000000..0b89863 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplPagingContextExtensions.cs @@ -0,0 +1,48 @@ +namespace Repl; + +/// +/// Convenience helpers for creating result-flow pages from . +/// +public static class ReplPagingContextExtensions +{ + /// + /// Creates a paged result from an already fetched page. + /// + /// Item type. + /// Paging context for the current invocation. + /// 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 IReplPagingContext context, + IReadOnlyList items, + string? nextCursor = null, + long? totalCount = null) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(items); + var pageInfo = new ReplPageInfo( + context.Cursor, + nextCursor, + totalCount, + context.SuggestedPageSize); + return new ReplPage(items, pageInfo); + } + + /// + /// Creates a lazy page source that can fetch additional pages on demand. + /// + /// Item type. + /// Paging context for the current invocation. + /// Page fetch delegate. + /// A page source consumable by interactive renderers. + public static IReplPageSource CreateSource( + this IReplPagingContext context, + Func>> fetch) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(fetch); + return ReplPageSource.Create(fetch); + } +} diff --git a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs index 83bd8cd..87e62d3 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs @@ -5,6 +5,8 @@ namespace Repl; /// public sealed class ResultFlowOptions { + private readonly List _pagerRenderers = []; + /// /// Gets or sets the default page size when no terminal-specific hint is available. /// @@ -28,10 +30,37 @@ public sealed class ResultFlowOptions /// /// Gets custom pager renderers keyed by . /// - public IList PagerRenderers { get; } = []; + public IReadOnlyList PagerRenderers => _pagerRenderers; /// /// Gets or sets the maximum inline payload size for programmatic clients. /// public int ProgrammaticMaxInlineBytes { get; set; } = 64 * 1024; + + /// + /// Registers or replaces the pager renderer for its configured mode. + /// + /// Renderer to register. + /// This options instance. + public ResultFlowOptions UsePagerRenderer(IReplPagerRenderer renderer) + { + ArgumentNullException.ThrowIfNull(renderer); + _ = RemovePagerRenderer(renderer.Mode); + _pagerRenderers.Add(renderer); + return this; + } + + /// + /// Removes the custom pager renderer registered for a mode. + /// + /// Pager mode to remove. + /// True when a renderer was removed. + public bool RemovePagerRenderer(ReplPagerMode mode) => + _pagerRenderers.RemoveAll(renderer => renderer.Mode == mode) > 0; + + /// + /// Removes all custom pager renderers. + /// + public void ClearPagerRenderers() => + _pagerRenderers.Clear(); } diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index 2a253e5..2ba17f6 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -819,6 +819,43 @@ await ResultFlowPager.WriteAsync( writer.ToString().Should().Be("custom"); } + [TestMethod] + [Description("Result-flow options expose pager renderers as a controlled read-only list keyed by mode.")] + public void When_PagerRendererIsRegisteredTwiceForMode_Then_LatestRendererReplacesPrevious() + { + var options = new ResultFlowOptions(); + var first = new RecordingPagerRenderer(ReplPagerMode.Inline); + var second = new RecordingPagerRenderer(ReplPagerMode.Inline); + + options.UsePagerRenderer(first); + options.UsePagerRenderer(second); + + options.PagerRenderers.Should().ContainSingle().Which.Should().BeSameAs(second); + options.RemovePagerRenderer(ReplPagerMode.Inline).Should().BeTrue(); + options.PagerRenderers.Should().BeEmpty(); + } + + [TestMethod] + [Description("ReplPagerRenderContext can be constructed and fetch payloads in custom renderer tests.")] + public async Task When_RenderContextIsCreatedDirectly_Then_FetchNextPayloadReturnsConfiguredPayload() + { + var context = new ReplPagerRenderContext( + initialPayload: "one", + output: new StringWriter(), + keyReader: new FakeKeyReader([]), + visibleRows: 3, + visibleRowsProvider: null, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => ValueTask.FromResult(new ReplPagerPayload("two", HasMore: false))); + + var payload = await context.FetchNextPayloadAsync(CancellationToken.None); + + payload.Should().NotBeNull(); + payload!.Payload.Should().Be("two"); + context.CanFetchNextPayload.Should().BeTrue(); + } + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => new(keyChar, key, shift: false, alt: false, control: false); From 831e7bdfe3288f411ff0c818a119236ace0ea489 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 8 May 2026 13:37:02 -0400 Subject: [PATCH 35/45] Harden terminal surface cleanup --- src/Repl.Core/Terminal/TerminalSurfaceHost.cs | 28 +++++++---- .../Terminal/TerminalSurfaceScope.cs | 38 ++++++++++++-- src/Repl.Tests/Given_TerminalSurfaceHost.cs | 50 +++++++++++++++++++ 3 files changed, 101 insertions(+), 15 deletions(-) diff --git a/src/Repl.Core/Terminal/TerminalSurfaceHost.cs b/src/Repl.Core/Terminal/TerminalSurfaceHost.cs index 3fbf205..ed51a89 100644 --- a/src/Repl.Core/Terminal/TerminalSurfaceHost.cs +++ b/src/Repl.Core/Terminal/TerminalSurfaceHost.cs @@ -9,21 +9,29 @@ public static async ValueTask EnterAsync( { ArgumentNullException.ThrowIfNull(output); - if (mode == TerminalSurfaceMode.AlternateScreen) + try { - await output.WriteAsync(AnsiSequences.EnterAlternateScreen).ConfigureAwait(false); - } + if (mode == TerminalSurfaceMode.AlternateScreen) + { + await output.WriteAsync(AnsiSequences.EnterAlternateScreen).ConfigureAwait(false); + } + + await output.WriteAsync(AnsiSequences.HideCursor).ConfigureAwait(false); + await output.WriteAsync(AnsiSequences.DisableLineWrap).ConfigureAwait(false); + if (mode == TerminalSurfaceMode.AlternateScreen) + { + await output.WriteAsync(AnsiSequences.CursorHome).ConfigureAwait(false); + } - await output.WriteAsync(AnsiSequences.HideCursor).ConfigureAwait(false); - await output.WriteAsync(AnsiSequences.DisableLineWrap).ConfigureAwait(false); - if (mode == TerminalSurfaceMode.AlternateScreen) + await output.WriteAsync(AnsiSequences.ClearToEndOfScreen).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + } + catch { - await output.WriteAsync(AnsiSequences.CursorHome).ConfigureAwait(false); + await TerminalSurfaceScope.RestoreAsync(output, mode).ConfigureAwait(false); + throw; } - await output.WriteAsync(AnsiSequences.ClearToEndOfScreen).ConfigureAwait(false); - await output.FlushAsync(cancellationToken).ConfigureAwait(false); - return new TerminalSurfaceScope(output, mode); } } diff --git a/src/Repl.Core/Terminal/TerminalSurfaceScope.cs b/src/Repl.Core/Terminal/TerminalSurfaceScope.cs index 464c6de..ee22ab5 100644 --- a/src/Repl.Core/Terminal/TerminalSurfaceScope.cs +++ b/src/Repl.Core/Terminal/TerminalSurfaceScope.cs @@ -38,14 +38,28 @@ public async ValueTask DisposeAsync() } _disposed = true; - await Output.WriteAsync(AnsiSequences.EnableLineWrap).ConfigureAwait(false); - await Output.WriteAsync(AnsiSequences.ShowCursor).ConfigureAwait(false); - if (Mode == TerminalSurfaceMode.AlternateScreen) + await RestoreAsync(Output, Mode).ConfigureAwait(false); + } + + internal static async ValueTask RestoreAsync(TextWriter output, TerminalSurfaceMode mode) + { + await TryWriteAsync(output, AnsiSequences.EnableLineWrap).ConfigureAwait(false); + await TryWriteAsync(output, AnsiSequences.ShowCursor).ConfigureAwait(false); + if (mode == TerminalSurfaceMode.AlternateScreen) { - await Output.WriteAsync(AnsiSequences.LeaveAlternateScreen).ConfigureAwait(false); + await TryWriteAsync(output, AnsiSequences.LeaveAlternateScreen).ConfigureAwait(false); } - await Output.FlushAsync().ConfigureAwait(false); + try + { + await output.FlushAsync().ConfigureAwait(false); + } + catch (IOException) + { + } + catch (ObjectDisposedException) + { + } } private ValueTask WriteAsync(string value) => @@ -53,4 +67,18 @@ private ValueTask WriteAsync(string value) => private ValueTask WriteAsync(char value) => new(Output.WriteAsync(value)); + + private static async ValueTask TryWriteAsync(TextWriter output, string value) + { + try + { + await output.WriteAsync(value).ConfigureAwait(false); + } + catch (IOException) + { + } + catch (ObjectDisposedException) + { + } + } } diff --git a/src/Repl.Tests/Given_TerminalSurfaceHost.cs b/src/Repl.Tests/Given_TerminalSurfaceHost.cs index 27649f6..4861e15 100644 --- a/src/Repl.Tests/Given_TerminalSurfaceHost.cs +++ b/src/Repl.Tests/Given_TerminalSurfaceHost.cs @@ -86,4 +86,54 @@ public async Task When_SurfaceRenderingThrows_Then_TerminalStateIsRestored() output.Should().Contain(AnsiSequences.ShowCursor); output.Should().Contain(AnsiSequences.LeaveAlternateScreen); } + + [TestMethod] + [Description("Terminal surface enter attempts cleanup when setup fails after partially entering the surface.")] + public async Task When_SurfaceEnterFailsAfterAlternateScreen_Then_CleanupIsAttempted() + { + using var writer = new ThrowOnceWriter(AnsiSequences.HideCursor); + + var act = async () => await TerminalSurfaceHost.EnterAsync( + writer, + TerminalSurfaceMode.AlternateScreen, + CancellationToken.None) + .ConfigureAwait(false); + + await act.Should().ThrowAsync().ConfigureAwait(false); + var output = writer.ToString(); + output.Should().Contain(AnsiSequences.EnterAlternateScreen); + output.Should().Contain(AnsiSequences.EnableLineWrap); + output.Should().Contain(AnsiSequences.ShowCursor); + output.Should().Contain(AnsiSequences.LeaveAlternateScreen); + } + + [TestMethod] + [Description("Terminal surface dispose keeps restoring remaining state when one restore write fails.")] + public async Task When_SurfaceDisposeRestoreWriteFails_Then_RemainingCleanupIsAttempted() + { + using var writer = new ThrowOnceWriter(AnsiSequences.EnableLineWrap); + var surface = new TerminalSurfaceScope(writer, TerminalSurfaceMode.AlternateScreen); + + await surface.DisposeAsync().ConfigureAwait(false); + + var output = writer.ToString(); + output.Should().Contain(AnsiSequences.ShowCursor); + output.Should().Contain(AnsiSequences.LeaveAlternateScreen); + } + + private sealed class ThrowOnceWriter(string throwOn) : StringWriter + { + private bool _thrown; + + public override Task WriteAsync(string? value) + { + if (!_thrown && string.Equals(value, throwOn, StringComparison.Ordinal)) + { + _thrown = true; + throw new IOException("simulated terminal failure"); + } + + return base.WriteAsync(value); + } + } } From 15decd347d0aaae658ddc8dd4b44bfe46496752c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 8 May 2026 13:44:12 -0400 Subject: [PATCH 36/45] Bound pager buffers and log fetch diagnostics --- src/Repl.Core/CoreReplApp.Execution.cs | 44 ++++++- .../ResultFlow/IReplResultFlowDiagnostics.cs | 13 ++ .../ResultFlow/ReplResultFlowDiagnostic.cs | 11 ++ .../ReplResultFlowDiagnosticKind.cs | 22 ++++ src/Repl.Core/ResultFlow/ResultFlowOptions.cs | 5 + src/Repl.Core/ResultFlow/ResultFlowPager.cs | 113 ++++++++++++++---- .../ReplLoggingServiceCollectionExtensions.cs | 1 + .../ReplResultFlowLoggerDiagnostics.cs | 39 ++++++ src/Repl.Tests/Given_ReplLogging.cs | 34 ++++++ src/Repl.Tests/Given_ResultFlowPager.cs | 33 +++++ 10 files changed, 289 insertions(+), 26 deletions(-) create mode 100644 src/Repl.Core/ResultFlow/IReplResultFlowDiagnostics.cs create mode 100644 src/Repl.Core/ResultFlow/ReplResultFlowDiagnostic.cs create mode 100644 src/Repl.Core/ResultFlow/ReplResultFlowDiagnosticKind.cs create mode 100644 src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index daf61b3..4dd808a 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -780,6 +780,7 @@ await ResultFlowPager.WriteAsync( page.PageInfo.HasMore, FetchNextPayloadAsync, _options.Output.ResultFlow.PagerRenderers, + _options.Output.ResultFlow.MaxBufferedLines, cancellationToken) .ConfigureAwait(false); return true; @@ -858,6 +859,7 @@ await ResultFlowPager.WriteAsync( hasMorePayload: false, fetchNextPayload: null, _options.Output.ResultFlow.PagerRenderers, + _options.Output.ResultFlow.MaxBufferedLines, cancellationToken) .ConfigureAwait(false); return; @@ -976,11 +978,47 @@ private static IReplPage CreatePagerDisplayPage(IReplPage page) return new ReplPageDisplaySnapshot(page, pageInfo); } - private static ValueTask FetchPageSourceAsync( + private async ValueTask FetchPageSourceAsync( IReplPageSource source, ReplPageRequest request, - CancellationToken cancellationToken) => - source.FetchPageAsync(request, cancellationToken); + CancellationToken cancellationToken) + { + var diagnostics = ResolveResultFlowDiagnostics(); + diagnostics?.OnDiagnostic(new ReplResultFlowDiagnostic( + ReplResultFlowDiagnosticKind.PageFetchStarting, + request.Cursor, + request.PageSize)); + + try + { + var page = await source.FetchPageAsync(request, cancellationToken).ConfigureAwait(false); + diagnostics?.OnDiagnostic(new ReplResultFlowDiagnostic( + ReplResultFlowDiagnosticKind.PageFetchSucceeded, + request.Cursor, + request.PageSize, + page.UntypedItems.Count)); + return page; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + diagnostics?.OnDiagnostic(new ReplResultFlowDiagnostic( + ReplResultFlowDiagnosticKind.PageFetchFailed, + request.Cursor, + request.PageSize, + Exception: ex)); + throw; + } + } + + private IReplResultFlowDiagnostics? ResolveResultFlowDiagnostics() + { + var serviceProvider = _runtimeState.Value?.ServiceProvider ?? _services; + return serviceProvider.GetService(typeof(IReplResultFlowDiagnostics)) as IReplResultFlowDiagnostics; + } private string TryColorizeStructuredPayload(string payload, string format, bool isInteractive) { diff --git a/src/Repl.Core/ResultFlow/IReplResultFlowDiagnostics.cs b/src/Repl.Core/ResultFlow/IReplResultFlowDiagnostics.cs new file mode 100644 index 0000000..2f58026 --- /dev/null +++ b/src/Repl.Core/ResultFlow/IReplResultFlowDiagnostics.cs @@ -0,0 +1,13 @@ +namespace Repl; + +/// +/// Receives structured diagnostics for result-flow paging operations. +/// +public interface IReplResultFlowDiagnostics +{ + /// + /// Observes a result-flow diagnostic event. + /// + /// Diagnostic payload. + void OnDiagnostic(ReplResultFlowDiagnostic diagnostic); +} diff --git a/src/Repl.Core/ResultFlow/ReplResultFlowDiagnostic.cs b/src/Repl.Core/ResultFlow/ReplResultFlowDiagnostic.cs new file mode 100644 index 0000000..8043010 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplResultFlowDiagnostic.cs @@ -0,0 +1,11 @@ +namespace Repl; + +/// +/// Describes a result-flow paging diagnostic event. +/// +public sealed record ReplResultFlowDiagnostic( + ReplResultFlowDiagnosticKind Kind, + string? Cursor, + int PageSize, + int? ItemCount = null, + Exception? Exception = null); diff --git a/src/Repl.Core/ResultFlow/ReplResultFlowDiagnosticKind.cs b/src/Repl.Core/ResultFlow/ReplResultFlowDiagnosticKind.cs new file mode 100644 index 0000000..990f568 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplResultFlowDiagnosticKind.cs @@ -0,0 +1,22 @@ +namespace Repl; + +/// +/// Classifies result-flow paging diagnostic events. +/// +public enum ReplResultFlowDiagnosticKind +{ + /// + /// A page source fetch is about to start. + /// + PageFetchStarting, + + /// + /// A page source fetch completed successfully. + /// + PageFetchSucceeded, + + /// + /// A page source fetch failed. + /// + PageFetchFailed, +} diff --git a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs index 87e62d3..5ce2769 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs @@ -32,6 +32,11 @@ public sealed class ResultFlowOptions /// public IReadOnlyList PagerRenderers => _pagerRenderers; + /// + /// Gets or sets the maximum number of content lines an interactive pager buffers in memory. + /// + public int MaxBufferedLines { get; set; } = 10_000; + /// /// Gets or sets the maximum inline payload size for programmatic clients. /// diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 6fd69b5..f4cf722 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -4,28 +4,29 @@ internal static class ResultFlowPager { private const string MorePrompt = "--More-- Space/PageDown: continue, Enter/Down: line, Up/PageUp: ignored, q/Esc: stop"; private const string FullStatus = "-- result-flow {0}-{1}/{2}{3} Space: next Up/Down: scroll Home/End: known bounds q: quit --"; + private const string FullStatusBufferLimit = "-- result-flow {0}-{1}/{2} buffer limit reached Up/Down: scroll q: quit --"; + private const int DefaultMaxBufferedLines = 10_000; private static readonly System.Text.CompositeFormat FullStatusFormat = System.Text.CompositeFormat.Parse(FullStatus); + private static readonly System.Text.CompositeFormat FullStatusBufferLimitFormat = + System.Text.CompositeFormat.Parse(FullStatusBufferLimit); public static int CountLines(string payload) => PagerPayloadParser.Parse(payload, header: null).TotalLineCount; - public static async ValueTask WriteAsync( + public static ValueTask WriteAsync( string payload, TextWriter output, IReplKeyReader keyReader, int visibleRows, CancellationToken cancellationToken = default) - { - await WriteAsync( - payload, - output, - keyReader, - visibleRows, - hasMorePayload: false, - fetchNextPayload: null, - cancellationToken) - .ConfigureAwait(false); - } + => WriteAsync( + payload, + output, + keyReader, + visibleRows, + hasMorePayload: false, + fetchNextPayload: null, + cancellationToken); public static async ValueTask WriteAsync( string payload, @@ -126,6 +127,32 @@ await WriteAsync( .ConfigureAwait(false); } + public static ValueTask WriteAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + Func? visibleRowsProvider, + ReplPagerMode pagerMode, + bool ansiEnabled, + bool hasMorePayload, + Func>? fetchNextPayload, + IEnumerable? pagerRenderers, + CancellationToken cancellationToken = default) + => WriteAsync( + payload, + output, + keyReader, + visibleRows, + visibleRowsProvider, + pagerMode, + ansiEnabled, + hasMorePayload, + fetchNextPayload, + pagerRenderers, + DefaultMaxBufferedLines, + cancellationToken); + public static async ValueTask WriteAsync( string payload, TextWriter output, @@ -137,10 +164,12 @@ public static async ValueTask WriteAsync( bool hasMorePayload, Func>? fetchNextPayload, IEnumerable? pagerRenderers, + int maxBufferedLines, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(output); ArgumentNullException.ThrowIfNull(keyReader); + maxBufferedLines = Math.Max(1, maxBufferedLines); var mode = ResolveMode(pagerMode, ansiEnabled); if (await TryRenderCustomAsync( @@ -160,7 +189,7 @@ public static async ValueTask WriteAsync( return; } - var session = new PagerSession(payload, hasMorePayload); + var session = new PagerSession(payload, hasMorePayload, maxBufferedLines); await RenderBuiltInAsync( mode, session, @@ -558,8 +587,26 @@ private static async ValueTask RenderViewportFrameAsync( var lastLine = state.Session.Lines.Count == 0 ? 0 : Math.Min(state.Session.Lines.Count, state.TopLine + state.ViewportHeight); - var status = state.Session.Lines.Count == 0 - ? "-- result-flow: loading --" + var status = CreateViewportStatus(state, lastLine); + await WriteViewportLineAsync(state, surface.Output, row++, status, appendNewLine: false).ConfigureAwait(false); + state.RenderedHeight = row; + await surface.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private static string CreateViewportStatus(ViewportState state, int lastLine) + { + if (state.Session.Lines.Count == 0) + { + return "-- result-flow: loading --"; + } + + return state.Session.BufferLimitReached + ? string.Format( + System.Globalization.CultureInfo.InvariantCulture, + FullStatusBufferLimitFormat, + state.TopLine + 1, + lastLine, + state.Session.Lines.Count) : string.Format( System.Globalization.CultureInfo.InvariantCulture, FullStatusFormat, @@ -567,9 +614,6 @@ private static async ValueTask RenderViewportFrameAsync( lastLine, state.Session.Lines.Count, state.Session.HasMorePayload ? "+" : string.Empty); - await WriteViewportLineAsync(state, surface.Output, row++, status, appendNewLine: false).ConfigureAwait(false); - state.RenderedHeight = row; - await surface.FlushAsync(cancellationToken).ConfigureAwait(false); } private static async ValueTask PositionViewportAsync(TerminalSurfaceScope surface, ViewportState state) @@ -698,13 +742,15 @@ private enum PagerAction private sealed class PagerSession { private readonly PagerHeader _header; + private readonly int _maxBufferedLines; - public PagerSession(string initialPayload, bool hasMorePayload) + public PagerSession(string initialPayload, bool hasMorePayload, int maxBufferedLines) { + _maxBufferedLines = Math.Max(1, maxBufferedLines); var parsed = PagerPayloadParser.Parse(initialPayload, header: null); _header = parsed.Header; - Lines = [.. parsed.ContentLines]; - HasMorePayload = hasMorePayload; + Lines = []; + AppendContent(parsed.ContentLines, hasMorePayload); PageSize = 1; NextWindow = 1; } @@ -721,11 +767,32 @@ public PagerSession(string initialPayload, bool hasMorePayload) public bool HasMorePayload { get; set; } + public bool BufferLimitReached { get; private set; } + public void Append(string payload, bool hasMorePayload) { var parsed = PagerPayloadParser.Parse(payload, _header); - Lines.AddRange(parsed.ContentLines); - HasMorePayload = hasMorePayload; + AppendContent(parsed.ContentLines, hasMorePayload); + } + + private void AppendContent(IReadOnlyList contentLines, bool hasMorePayload) + { + var available = _maxBufferedLines - Lines.Count; + if (available <= 0) + { + BufferLimitReached = true; + HasMorePayload = false; + return; + } + + var take = Math.Min(available, contentLines.Count); + for (var i = 0; i < take; i++) + { + Lines.Add(contentLines[i]); + } + + BufferLimitReached = take < contentLines.Count; + HasMorePayload = !BufferLimitReached && hasMorePayload; } } diff --git a/src/Repl.Logging/ReplLoggingServiceCollectionExtensions.cs b/src/Repl.Logging/ReplLoggingServiceCollectionExtensions.cs index 07efa12..84b2424 100644 --- a/src/Repl.Logging/ReplLoggingServiceCollectionExtensions.cs +++ b/src/Repl.Logging/ReplLoggingServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static IServiceCollection AddReplLogging(this IServiceCollection services services.AddLogging(); services.TryAddSingleton(); + services.TryAddSingleton(); return services; } } diff --git a/src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs b/src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs new file mode 100644 index 0000000..98d5422 --- /dev/null +++ b/src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Repl; + +internal sealed partial class ReplResultFlowLoggerDiagnostics(IServiceProvider services) : IReplResultFlowDiagnostics +{ + private const string LoggerCategory = "Repl.ResultFlow"; + + public void OnDiagnostic(ReplResultFlowDiagnostic diagnostic) + { + ArgumentNullException.ThrowIfNull(diagnostic); + var loggerFactory = services.GetService(typeof(ILoggerFactory)) as ILoggerFactory + ?? NullLoggerFactory.Instance; + var logger = loggerFactory.CreateLogger(LoggerCategory); + + switch (diagnostic.Kind) + { + case ReplResultFlowDiagnosticKind.PageFetchStarting: + PageFetchStarting(logger, diagnostic.Cursor, diagnostic.PageSize); + break; + case ReplResultFlowDiagnosticKind.PageFetchSucceeded: + PageFetchSucceeded(logger, diagnostic.Cursor, diagnostic.PageSize, diagnostic.ItemCount ?? 0); + break; + case ReplResultFlowDiagnosticKind.PageFetchFailed: + PageFetchFailed(logger, diagnostic.Exception, diagnostic.Cursor, diagnostic.PageSize); + break; + } + } + + [LoggerMessage(EventId = 1001, Level = LogLevel.Debug, Message = "Result-flow page fetch starting. Cursor: {Cursor}; PageSize: {PageSize}.")] + private static partial void PageFetchStarting(ILogger logger, string? cursor, int pageSize); + + [LoggerMessage(EventId = 1002, Level = LogLevel.Debug, Message = "Result-flow page fetch succeeded. Cursor: {Cursor}; PageSize: {PageSize}; ItemCount: {ItemCount}.")] + private static partial void PageFetchSucceeded(ILogger logger, string? cursor, int pageSize, int itemCount); + + [LoggerMessage(EventId = 1003, Level = LogLevel.Warning, Message = "Result-flow page fetch failed. Cursor: {Cursor}; PageSize: {PageSize}.")] + private static partial void PageFetchFailed(ILogger logger, Exception? exception, string? cursor, int pageSize); +} diff --git a/src/Repl.Tests/Given_ReplLogging.cs b/src/Repl.Tests/Given_ReplLogging.cs index 9b1b9a0..42e810f 100644 --- a/src/Repl.Tests/Given_ReplLogging.cs +++ b/src/Repl.Tests/Given_ReplLogging.cs @@ -96,6 +96,40 @@ public void When_HandlerLogsThroughILogger_Then_ReplScopeMetadataIsCaptured() entry.ScopeValues["ReplProtocolPassthrough"].Should().Be(expected: false); } + [TestMethod] + [Description("Result-flow page-source fetch failures are logged through the Repl logging bridge.")] + public void When_ResultFlowPageFetchFails_Then_DiagnosticIsLogged() + { + var provider = new CapturingLoggerProvider(); + var app = ReplApp.Create(services => + { + services.AddSingleton(provider); + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddProvider(provider); + }); + }); + + app.Map("items", () => ReplPageSource.Create((_, _) => + throw new InvalidOperationException("fetch failed"))); + + using var input = new StringReader(string.Empty); + using var output = new StringWriter(); + var host = new InMemoryHost(input, output); + + var exitCode = app.Run( + ["items", "--no-logo"], + host, + new ReplRunOptions { HostedServiceLifecycle = HostedServiceLifecycleMode.None }); + + exitCode.Should().Be(1); + provider.Entries.Should().Contain(entry => + entry.Category == "Repl.ResultFlow" + && entry.Level == LogLevel.Warning + && entry.Message.Contains("page fetch failed", StringComparison.OrdinalIgnoreCase)); + } + [TestMethod] [Description("Regression guard: verifies ambient Repl log context reflects hosted session metadata so apps can route logs to the active session.")] public void When_HostedSessionRuns_Then_LogContextExposesSessionMetadata() diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index 2ba17f6..b3feb19 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -856,6 +856,39 @@ public async Task When_RenderContextIsCreatedDirectly_Then_FetchNextPayloadRetur context.CanFetchNextPayload.Should().BeTrue(); } + [TestMethod] + [Description("Viewport pager stops fetching and reports status when the buffered line limit is reached.")] + public async Task When_ViewportPagerReachesBufferLimit_Then_StatusReportsLimit() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one\ntwo", + writer, + keys, + visibleRows: 3, + visibleRowsProvider: null, + pagerMode: ReplPagerMode.Full, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => ValueTask.FromResult( + new ResultFlowPagerPage("three\nfour\nfive", HasMore: true)), + pagerRenderers: null, + maxBufferedLines: 3, + CancellationToken.None); + + var output = writer.ToString(); + output.Should().Contain("buffer limit"); + output.Should().Contain("three"); + output.Should().NotContain("four"); + output.Should().NotContain("five"); + } + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => new(keyChar, key, shift: false, alt: false, control: false); From 69828c8679f4d8546f820487270499fc1e11223f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 8 May 2026 13:49:26 -0400 Subject: [PATCH 37/45] Separate pager payload state --- src/Repl.Core/CoreReplApp.Execution.cs | 46 ++++- src/Repl.Core/ResultFlow/PagerHeader.cs | 6 + .../ResultFlow/PagerPayloadParser.cs | 123 +++++++++++ src/Repl.Core/ResultFlow/PagerSession.cs | 58 ++++++ .../ResultFlow/ParsedPagerPayload.cs | 6 + src/Repl.Core/ResultFlow/ResultFlowPager.cs | 191 +----------------- .../ResultFlow/ResultFlowPagerPage.cs | 5 +- src/Repl.Tests/Given_ResultFlowPager.cs | 24 +++ 8 files changed, 263 insertions(+), 196 deletions(-) create mode 100644 src/Repl.Core/ResultFlow/PagerHeader.cs create mode 100644 src/Repl.Core/ResultFlow/PagerPayloadParser.cs create mode 100644 src/Repl.Core/ResultFlow/PagerSession.cs create mode 100644 src/Repl.Core/ResultFlow/ParsedPagerPayload.cs diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index 4dd808a..d0e5ed7 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -757,14 +757,45 @@ private async ValueTask RenderPageSourceAsync( out var pagerMode, out var ansiEnabled)) { - if (!string.IsNullOrEmpty(payload)) - { - await ReplSessionIO.Output.WriteLineAsync(payload).ConfigureAwait(false); - } + return await WritePageSourcePayloadAsync(payload).ConfigureAwait(false); + } - return true; + return await RenderPageSourcePagerAsync( + source, + transformer, + isInteractive, + request, + page, + keyReader, + visibleRows, + pagerMode, + ansiEnabled, + cancellationToken) + .ConfigureAwait(false); + } + + private static async ValueTask WritePageSourcePayloadAsync(string payload) + { + if (!string.IsNullOrEmpty(payload)) + { + await ReplSessionIO.Output.WriteLineAsync(payload).ConfigureAwait(false); } + return true; + } + + private async ValueTask RenderPageSourcePagerAsync( + IReplPageSource source, + IOutputTransformer transformer, + bool isInteractive, + ReplPageRequest request, + IReplPage page, + IReplKeyReader keyReader, + int visibleRows, + ReplPagerMode pagerMode, + bool ansiEnabled, + CancellationToken cancellationToken) + { var nextCursor = page.PageInfo.NextCursor; var pagerPayload = await TransformPagerPageAsync(transformer, page, ResultFlowPageRenderMode.Initial, cancellationToken) .ConfigureAwait(false); @@ -798,7 +829,10 @@ await ResultFlowPager.WriteAsync( var nextPayload = await TransformPagerPageAsync(transformer, nextPage, ResultFlowPageRenderMode.Continuation, token) .ConfigureAwait(false); nextPayload = TryColorizeStructuredPayload(nextPayload, transformer.Name, isInteractive); - return new ResultFlowPagerPage(nextPayload, nextPage.PageInfo.HasMore); + return new ResultFlowPagerPage( + nextPayload, + nextPage.PageInfo.HasMore, + ContainsPresentationChrome: false); } } diff --git a/src/Repl.Core/ResultFlow/PagerHeader.cs b/src/Repl.Core/ResultFlow/PagerHeader.cs new file mode 100644 index 0000000..2dd91eb --- /dev/null +++ b/src/Repl.Core/ResultFlow/PagerHeader.cs @@ -0,0 +1,6 @@ +namespace Repl; + +internal sealed record PagerHeader(IReadOnlyList Lines, IReadOnlySet NormalizedLines) +{ + public static PagerHeader Empty { get; } = new([], new HashSet(StringComparer.Ordinal)); +} diff --git a/src/Repl.Core/ResultFlow/PagerPayloadParser.cs b/src/Repl.Core/ResultFlow/PagerPayloadParser.cs new file mode 100644 index 0000000..0fc0fd3 --- /dev/null +++ b/src/Repl.Core/ResultFlow/PagerPayloadParser.cs @@ -0,0 +1,123 @@ +namespace Repl; + +internal static class PagerPayloadParser +{ + public static ParsedPagerPayload Parse(string payload, PagerHeader? header, bool stripPresentationChrome = true) + { + var lines = SplitLines(payload); + var payloadHeader = DetectHeader(lines); + var resolvedHeader = header ?? payloadHeader; + var headerLineCount = payloadHeader.Lines.Count; + var content = new List(); + for (var i = headerLineCount; i < lines.Length; i++) + { + var normalized = NormalizeLine(lines[i]); + if (resolvedHeader.NormalizedLines.Contains(normalized) + || (stripPresentationChrome && IsPageFooterLine(lines[i]))) + { + continue; + } + + content.Add(lines[i]); + } + + return new ParsedPagerPayload(resolvedHeader, content); + } + + private static PagerHeader DetectHeader(string[] lines) + { + if (lines.Length == 0) + { + return PagerHeader.Empty; + } + + if (lines.Length > 1 && IsPlainTableSeparator(lines[1])) + { + return CreateHeader(lines.Take(2).ToArray()); + } + + if (IsPlainHumanTableHeader(lines[0])) + { + return CreateHeader([lines[0]]); + } + + return lines[0].Contains("\u001b[1m", StringComparison.Ordinal) + ? CreateHeader([lines[0]]) + : PagerHeader.Empty; + } + + private static PagerHeader CreateHeader(string[] lines) => + new( + lines, + lines.Select(NormalizeLine).ToHashSet(StringComparer.Ordinal)); + + private static bool IsPlainTableSeparator(string line) + { + var text = line.Trim(); + return text.Length > 0 + && text.All(ch => ch is '-' or ' ' or '\t') + && text.Contains('-', StringComparison.Ordinal); + } + + private static bool IsPlainHumanTableHeader(string line) + { + var text = line.TrimStart(); + return text.StartsWith("# ", StringComparison.Ordinal) + && text.Contains(" ", StringComparison.Ordinal); + } + + private static bool IsPageFooterLine(string line) => + line.StartsWith("Showing ", StringComparison.Ordinal) + && (line.Contains(" of ", StringComparison.Ordinal) + || line.Contains(" result(s).", StringComparison.Ordinal)) + && (line.EndsWith('.') + || line.Contains("Next data page: rerun with --result:cursor ", StringComparison.Ordinal)); + + private static string[] SplitLines(string payload) + { + if (string.IsNullOrEmpty(payload)) + { + return []; + } + + var lines = new List(); + foreach (var line in payload.AsSpan().EnumerateLines()) + { + lines.Add(line.ToString()); + } + + if (lines.Count > 0 && lines[^1].Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + return [.. lines]; + } + + private static string NormalizeLine(string line) + { + if (!line.Contains('\u001b', StringComparison.Ordinal)) + { + return line.Trim(); + } + + var builder = new System.Text.StringBuilder(line.Length); + for (var i = 0; i < line.Length; i++) + { + if (line[i] == '\u001b' && i + 1 < line.Length && line[i + 1] == '[') + { + i += 2; + while (i < line.Length && (line[i] < '@' || line[i] > '~')) + { + i++; + } + + continue; + } + + builder.Append(line[i]); + } + + return builder.ToString().Trim(); + } +} diff --git a/src/Repl.Core/ResultFlow/PagerSession.cs b/src/Repl.Core/ResultFlow/PagerSession.cs new file mode 100644 index 0000000..a6827e0 --- /dev/null +++ b/src/Repl.Core/ResultFlow/PagerSession.cs @@ -0,0 +1,58 @@ +namespace Repl; + +internal sealed class PagerSession +{ + private readonly PagerHeader _header; + private readonly int _maxBufferedLines; + + public PagerSession(string initialPayload, bool hasMorePayload, int maxBufferedLines) + { + _maxBufferedLines = Math.Max(1, maxBufferedLines); + var parsed = PagerPayloadParser.Parse(initialPayload, header: null); + _header = parsed.Header; + Lines = []; + AppendContent(parsed.ContentLines, hasMorePayload); + PageSize = 1; + NextWindow = 1; + } + + public IReadOnlyList HeaderLines => _header.Lines; + + public List Lines { get; } + + public int PageSize { get; set; } + + public int NextWindow { get; set; } + + public int Index { get; set; } + + public bool HasMorePayload { get; set; } + + public bool BufferLimitReached { get; private set; } + + public void Append(string payload, bool hasMorePayload, bool containsPresentationChrome = true) + { + var parsed = PagerPayloadParser.Parse(payload, _header, containsPresentationChrome); + AppendContent(parsed.ContentLines, hasMorePayload); + } + + private void AppendContent(IReadOnlyList contentLines, bool hasMorePayload) + { + var available = _maxBufferedLines - Lines.Count; + if (available <= 0) + { + BufferLimitReached = true; + HasMorePayload = false; + return; + } + + var take = Math.Min(available, contentLines.Count); + for (var i = 0; i < take; i++) + { + Lines.Add(contentLines[i]); + } + + BufferLimitReached = take < contentLines.Count; + HasMorePayload = !BufferLimitReached && hasMorePayload; + } +} diff --git a/src/Repl.Core/ResultFlow/ParsedPagerPayload.cs b/src/Repl.Core/ResultFlow/ParsedPagerPayload.cs new file mode 100644 index 0000000..881b5f9 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ParsedPagerPayload.cs @@ -0,0 +1,6 @@ +namespace Repl; + +internal sealed record ParsedPagerPayload(PagerHeader Header, IReadOnlyList ContentLines) +{ + public int TotalLineCount => Header.Lines.Count + ContentLines.Count; +} diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index f4cf722..0f596ed 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -409,7 +409,7 @@ private static async ValueTask TryFetchIntoSessionAsync( return false; } - session.Append(nextPayload.Payload, nextPayload.HasMore); + session.Append(nextPayload.Payload, nextPayload.HasMore, nextPayload.ContainsPresentationChrome); return true; } @@ -542,7 +542,7 @@ private static async ValueTask FetchIntoSessionAsync( return; } - session.Append(nextPayload.Payload, nextPayload.HasMore); + session.Append(nextPayload.Payload, nextPayload.HasMore, nextPayload.ContainsPresentationChrome); } private static async ValueTask ClearViewportAsync(TerminalSurfaceScope surface, ViewportState state) @@ -739,63 +739,6 @@ private enum PagerAction Quit, } - private sealed class PagerSession - { - private readonly PagerHeader _header; - private readonly int _maxBufferedLines; - - public PagerSession(string initialPayload, bool hasMorePayload, int maxBufferedLines) - { - _maxBufferedLines = Math.Max(1, maxBufferedLines); - var parsed = PagerPayloadParser.Parse(initialPayload, header: null); - _header = parsed.Header; - Lines = []; - AppendContent(parsed.ContentLines, hasMorePayload); - PageSize = 1; - NextWindow = 1; - } - - public IReadOnlyList HeaderLines => _header.Lines; - - public List Lines { get; } - - public int PageSize { get; set; } - - public int NextWindow { get; set; } - - public int Index { get; set; } - - public bool HasMorePayload { get; set; } - - public bool BufferLimitReached { get; private set; } - - public void Append(string payload, bool hasMorePayload) - { - var parsed = PagerPayloadParser.Parse(payload, _header); - AppendContent(parsed.ContentLines, hasMorePayload); - } - - private void AppendContent(IReadOnlyList contentLines, bool hasMorePayload) - { - var available = _maxBufferedLines - Lines.Count; - if (available <= 0) - { - BufferLimitReached = true; - HasMorePayload = false; - return; - } - - var take = Math.Min(available, contentLines.Count); - for (var i = 0; i < take; i++) - { - Lines.Add(contentLines[i]); - } - - BufferLimitReached = take < contentLines.Count; - HasMorePayload = !BufferLimitReached && hasMorePayload; - } - } - private sealed class ViewportState { private readonly List _renderedLineLengths = []; @@ -857,134 +800,4 @@ private int CalculateViewportHeight(int visibleRows) => Math.Max(1, visibleRows - Session.HeaderLines.Count - 1); } - private sealed record PagerHeader(IReadOnlyList Lines, IReadOnlySet NormalizedLines) - { - public static PagerHeader Empty { get; } = new([], new HashSet(StringComparer.Ordinal)); - } - - private sealed record ParsedPagerPayload(PagerHeader Header, IReadOnlyList ContentLines) - { - public int TotalLineCount => Header.Lines.Count + ContentLines.Count; - } - - private static class PagerPayloadParser - { - public static ParsedPagerPayload Parse(string payload, PagerHeader? header) - { - var lines = SplitLines(payload); - var payloadHeader = DetectHeader(lines); - var resolvedHeader = header ?? payloadHeader; - var headerLineCount = payloadHeader.Lines.Count; - var content = new List(); - for (var i = headerLineCount; i < lines.Length; i++) - { - var normalized = NormalizeLine(lines[i]); - if (resolvedHeader.NormalizedLines.Contains(normalized) || IsPageFooterLine(lines[i])) - { - continue; - } - - content.Add(lines[i]); - } - - return new ParsedPagerPayload(resolvedHeader, content); - } - - private static PagerHeader DetectHeader(string[] lines) - { - if (lines.Length == 0) - { - return PagerHeader.Empty; - } - - if (lines.Length > 1 && IsPlainTableSeparator(lines[1])) - { - return CreateHeader(lines.Take(2).ToArray()); - } - - if (IsPlainHumanTableHeader(lines[0])) - { - return CreateHeader([lines[0]]); - } - - return lines[0].Contains("\u001b[1m", StringComparison.Ordinal) - ? CreateHeader([lines[0]]) - : PagerHeader.Empty; - } - - private static PagerHeader CreateHeader(string[] lines) => - new( - lines, - lines.Select(NormalizeLine).ToHashSet(StringComparer.Ordinal)); - - private static bool IsPlainTableSeparator(string line) - { - var text = line.Trim(); - return text.Length > 0 - && text.All(ch => ch is '-' or ' ' or '\t') - && text.Contains('-', StringComparison.Ordinal); - } - - private static bool IsPlainHumanTableHeader(string line) - { - var text = line.TrimStart(); - return text.StartsWith("# ", StringComparison.Ordinal) - && text.Contains(" ", StringComparison.Ordinal); - } - - private static bool IsPageFooterLine(string line) => - line.StartsWith("Showing ", StringComparison.Ordinal) - && (line.Contains(" of ", StringComparison.Ordinal) - || line.Contains(" result(s).", StringComparison.Ordinal)) - && (line.EndsWith('.') - || line.Contains("Next data page: rerun with --result:cursor ", StringComparison.Ordinal)); - - private static string[] SplitLines(string payload) - { - if (string.IsNullOrEmpty(payload)) - { - return []; - } - - var lines = new List(); - foreach (var line in payload.AsSpan().EnumerateLines()) - { - lines.Add(line.ToString()); - } - - if (lines.Count > 0 && lines[^1].Length == 0) - { - lines.RemoveAt(lines.Count - 1); - } - - return [.. lines]; - } - - private static string NormalizeLine(string line) - { - if (!line.Contains('\u001b', StringComparison.Ordinal)) - { - return line.Trim(); - } - - var builder = new System.Text.StringBuilder(line.Length); - for (var i = 0; i < line.Length; i++) - { - if (line[i] == '\u001b' && i + 1 < line.Length && line[i + 1] == '[') - { - i += 2; - while (i < line.Length && (line[i] < '@' || line[i] > '~')) - { - i++; - } - - continue; - } - - builder.Append(line[i]); - } - - return builder.ToString().Trim(); - } - } } diff --git a/src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs b/src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs index fc3257d..4b95bd5 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPagerPage.cs @@ -1,3 +1,6 @@ namespace Repl; -internal sealed record ResultFlowPagerPage(string Payload, bool HasMore); +internal sealed record ResultFlowPagerPage( + string Payload, + bool HasMore, + bool ContainsPresentationChrome = true); diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index b3feb19..cb5810b 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -331,6 +331,30 @@ await ResultFlowPager.WriteAsync( output.Should().NotContain("Showing 2 of 3"); } + [TestMethod] + [Description("Structured continuation payloads do not use presentation-text sniffing, so data lines that look like footers are preserved.")] + public async Task When_ContinuationPayloadIsMarkedClean_Then_FooterLikeDataLineIsPreserved() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.Spacebar, ' '), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await ResultFlowPager.WriteAsync( + "one", + writer, + keys, + visibleRows: 2, + hasMorePayload: true, + fetchNextPayload: _ => ValueTask.FromResult( + new ResultFlowPagerPage("Showing 1 of 5.", HasMore: false, ContainsPresentationChrome: false)), + CancellationToken.None); + + writer.ToString().Should().Contain("Showing 1 of 5."); + } + [TestMethod] [Description("Result-flow full pager owns an alternate-screen viewport instead of relying on terminal scrollback.")] public async Task When_ScrollPagerRunsWithAnsi_Then_UsesAlternateScreenViewport() From fb7d57a66b7b9734fea6a4c24d52f6aa602bd0da Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 8 May 2026 13:52:07 -0400 Subject: [PATCH 38/45] Document complete result-flow paging --- docs/configuration-reference.md | 7 +++++- docs/mcp-reference.md | 8 ++++-- docs/result-flow.md | 43 ++++++++++++++++++++++++++------- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 473aa8c..0919b8f 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -108,9 +108,14 @@ Accessed via `ReplOptions.Output.ResultFlow`. - `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. -- `PagerRenderers` (`IList`) - Custom interactive pager renderers keyed by pager mode. +- `PagerRenderers` (`IReadOnlyList`) - Custom interactive pager renderers keyed by pager mode. +- `MaxBufferedLines` (`int`, default: `10000`) - Maximum content lines buffered by interactive viewport pagers. - `ProgrammaticMaxInlineBytes` (`int`, default: `65536`) - Reserved for programmatic inline payload policy. +Register custom pager renderers with `UsePagerRenderer(renderer)`. Use +`RemovePagerRenderer(mode)` or `ClearPagerRenderers()` to alter the configured +renderer set. + ### OutputOptions Methods - `AddTransformer(name, transformer)` — Register a custom output transformer. diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 489b2cf..d0e09db 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -206,10 +206,14 @@ app.Map("contacts", (IReplPagingContext paging, ContactStore store) => MCP responses for `ReplPage` include: -- `StructuredContent`: `{ items, pageInfo }` -- `Content`: short text summary with the next `_replCursor` when more data exists +- `StructuredContent`: `{ "$type": "page", items, pageInfo }` +- `Content`: short text summary that says a cursor is available in structured content This avoids dumping large JSON arrays into a single `TextContentBlock`. +The raw cursor is not interpolated into MCP text content. Repl accepts compact +cursor tokens only: non-empty, at most 512 characters, no whitespace, no control +characters, and not starting with `-`. Page-size tokens must be numeric and at +most 20 characters before normal result-flow clamping is applied. `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: diff --git a/docs/result-flow.md b/docs/result-flow.md index 38ec3a2..0f99331 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -522,10 +522,16 @@ scrollback. It renders from an internal buffer and fetches additional `IReplPageSource` payloads as the user pages past the buffered end. Applications that need a different terminal experience can register a custom -`IReplPagerRenderer` in `options.Output.ResultFlow.PagerRenderers`. A custom -renderer is selected by its `ReplPagerMode` and receives a -`ReplPagerRenderContext` containing the rendered payload, terminal writer, key -reader, visible row hint, and continuation fetcher. +`IReplPagerRenderer` with +`options.Output.ResultFlow.UsePagerRenderer(renderer)`. A custom renderer is +selected by its `ReplPagerMode` and receives a `ReplPagerRenderContext` +containing the rendered payload, terminal writer, key reader, visible row hint, +and continuation fetcher. + +The built-in `inline` and `full` pagers keep a bounded in-memory line buffer. +`MaxBufferedLines` defaults to `10_000`. When the limit is reached, Repl stops +fetching additional pages, keeps navigation inside the known content, and shows +a `buffer limit reached` status instead of growing memory indefinitely. ## Testing Result Flow @@ -665,11 +671,12 @@ 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. MCP page-size values must be numeric and at most 20 characters before -normal result-flow clamping is applied. +MCP and CLI cursors are expected to be compact opaque values, for example +base64url or another whitespace-free token. Repl rejects cursors that are empty, +contain whitespace or control characters, start with `-`, or exceed 512 +characters before they can be converted to CLI tokens. MCP page-size values must +be numeric and at most 20 characters before normal result-flow clamping is +applied. When a handler returns `ReplPage`, MCP returns: @@ -695,6 +702,7 @@ app.Options(options => options.Output.ResultFlow.MaxPageSize = 1000; options.Output.ResultFlow.ReservedVisibleRows = 2; options.Output.ResultFlow.DefaultPagerMode = ReplPagerMode.Auto; + options.Output.ResultFlow.MaxBufferedLines = 10_000; options.Output.ResultFlow.ProgrammaticMaxInlineBytes = 64 * 1024; }); ``` @@ -705,8 +713,25 @@ app.Options(options => | `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. | +| `MaxBufferedLines` | `10000` | Maximum content lines buffered by interactive viewport pagers. | | `ProgrammaticMaxInlineBytes` | `65536` | Reserved for programmatic inline-size policy. | +Use `UsePagerRenderer(renderer)` to register one custom renderer per +`ReplPagerMode`. Registering another renderer for the same mode replaces the +previous one. `RemovePagerRenderer(mode)` and `ClearPagerRenderers()` are +available for test setup or host-specific composition. + +## Diagnostics + +`Repl.Core` exposes `IReplResultFlowDiagnostics` for dependency-free paging +diagnostics. Implementations receive page-fetch start, success, and failure +events with cursor and page-size metadata. + +`Repl.Logging` registers a bridge automatically when `AddReplLogging()` is used: + +- `Debug`: page fetch starting/succeeded. +- `Warning`: page fetch failed, including the exception. + ## Implementation Notes - Existing handlers that return `IEnumerable` keep their current behavior. From 0937f3fe6d9ea21a20bfc799e1f32c0b22f31c97 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 8 May 2026 14:33:51 -0400 Subject: [PATCH 39/45] Polish result-flow paging internals --- docs/mcp-reference.md | 16 ++++++++ docs/result-flow.md | 5 +++ .../Help/HelpTextBuilder.Rendering.cs | 8 ++-- .../ResultFlow/PagerPayloadParser.cs | 34 ++++++++++++----- src/Repl.Core/ResultFlow/PagerSession.cs | 18 +++++---- src/Repl.Core/ResultFlow/ReplPageSource.cs | 5 ++- .../ResultFlow/ReplResultFlowOptionNames.cs | 37 ++++++++++++++++++ .../ResultFlow/ResultFlowCursorPolicy.cs | 4 +- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 38 +++++++++++-------- .../Given_OutputFormatting.cs | 26 +++++++++++++ .../ReplResultFlowLoggerDiagnostics.cs | 20 ++++++---- src/Repl.Mcp/McpResultFlowArgumentNames.cs | 4 +- src/Repl.Mcp/McpToolAdapter.cs | 4 +- src/Repl.Tests/Given_ResultFlowPager.cs | 25 ++++++++++++ src/Repl.Tests/Given_TerminalSurfaceHost.cs | 32 ++++++++++++++-- 15 files changed, 221 insertions(+), 55 deletions(-) create mode 100644 src/Repl.Core/ResultFlow/ReplResultFlowOptionNames.cs diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index d0e09db..ffc4ea1 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -215,6 +215,22 @@ cursor tokens only: non-empty, at most 512 characters, no whitespace, no control characters, and not starting with `-`. Page-size tokens must be numeric and at most 20 characters before normal result-flow clamping is applied. +MCP paging continuation depends on the client preserving structured tool +content. Repl always returns a short text fallback, but the raw cursor is only +available in `StructuredContent.pageInfo.nextCursor`. + +| Agent/client behavior | Paging support | Repl fallback | +|---|---|---| +| Reads `StructuredContent` and can call the same tool again | Full continuation with `_replCursor` and `_replPageSize` | Not needed | +| Reads only `Content` text | Sees that another cursor exists, but cannot continue automatically | Text says the cursor is available in structured content | +| Ignores custom/reserved input properties | First page still works | Tool returns bounded first page | +| Does not support structured content | No automatic continuation | Use CLI/programmatic output or expose a command-specific cursor option | + +Applications should not rely on all agents supporting continuation equally. +For important workflows, include enough data in the first page summary for the +agent to decide whether it needs a follow-up call, and keep handlers safe when +only the first page is consumed. + `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/result-flow.md b/docs/result-flow.md index 0f99331..cb32625 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -380,6 +380,8 @@ The technical properties used by the renderer, such as `ItemType` and `UntypedIt ## 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`. +The reserved names are also exposed in `ReplResultFlowOptionNames` so hosts can +avoid collisions when composing custom command surfaces. | Flag | Meaning | |---|---| @@ -684,6 +686,9 @@ When a handler returns `ReplPage`, MCP returns: - `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. +Agents that preserve `StructuredContent` can continue by sending `_replCursor` +with the value from `pageInfo.nextCursor`. Agents that only read text receive a +safe fallback summary, but Repl does not place the raw cursor in text content. ## Spectre Behavior diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index 5c57d8e..35ff2b3 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -9,10 +9,10 @@ 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|inline|full", "Control the integrated pager for human output."), + new($"{ReplResultFlowOptionNames.PageSize} ", "Request a page size for paged handlers."), + new($"{ReplResultFlowOptionNames.Cursor} ", "Continue from a cursor returned by a previous page."), + new(ReplResultFlowOptionNames.All, "Request all rows when the handler supports it."), + new($"{ReplResultFlowOptionNames.Pager}=auto|off|more|inline|full", "Control the integrated pager for human output."), ]; private static string BuildCommandHelp(RouteDefinition[] routes, bool useAnsi, AnsiPalette palette) diff --git a/src/Repl.Core/ResultFlow/PagerPayloadParser.cs b/src/Repl.Core/ResultFlow/PagerPayloadParser.cs index 0fc0fd3..88353b8 100644 --- a/src/Repl.Core/ResultFlow/PagerPayloadParser.cs +++ b/src/Repl.Core/ResultFlow/PagerPayloadParser.cs @@ -1,7 +1,11 @@ namespace Repl; +using System.Buffers; + internal static class PagerPayloadParser { + private static readonly SearchValues PlainTableSeparatorChars = SearchValues.Create("- \t"); + public static ParsedPagerPayload Parse(string payload, PagerHeader? header, bool stripPresentationChrome = true) { var lines = SplitLines(payload); @@ -9,7 +13,7 @@ public static ParsedPagerPayload Parse(string payload, PagerHeader? header, bool var resolvedHeader = header ?? payloadHeader; var headerLineCount = payloadHeader.Lines.Count; var content = new List(); - for (var i = headerLineCount; i < lines.Length; i++) + for (var i = headerLineCount; i < lines.Count; i++) { var normalized = NormalizeLine(lines[i]); if (resolvedHeader.NormalizedLines.Contains(normalized) @@ -24,16 +28,16 @@ public static ParsedPagerPayload Parse(string payload, PagerHeader? header, bool return new ParsedPagerPayload(resolvedHeader, content); } - private static PagerHeader DetectHeader(string[] lines) + private static PagerHeader DetectHeader(List lines) { - if (lines.Length == 0) + if (lines.Count == 0) { return PagerHeader.Empty; } - if (lines.Length > 1 && IsPlainTableSeparator(lines[1])) + if (lines.Count > 1 && IsPlainTableSeparator(lines[1])) { - return CreateHeader(lines.Take(2).ToArray()); + return CreateHeader([lines[0], lines[1]]); } if (IsPlainHumanTableHeader(lines[0])) @@ -55,7 +59,7 @@ private static bool IsPlainTableSeparator(string line) { var text = line.Trim(); return text.Length > 0 - && text.All(ch => ch is '-' or ' ' or '\t') + && text.AsSpan().IndexOfAnyExcept(PlainTableSeparatorChars) < 0 && text.Contains('-', StringComparison.Ordinal); } @@ -71,9 +75,9 @@ private static bool IsPageFooterLine(string line) => && (line.Contains(" of ", StringComparison.Ordinal) || line.Contains(" result(s).", StringComparison.Ordinal)) && (line.EndsWith('.') - || line.Contains("Next data page: rerun with --result:cursor ", StringComparison.Ordinal)); + || line.Contains($"Next data page: rerun with {ReplResultFlowOptionNames.Cursor} ", StringComparison.Ordinal)); - private static string[] SplitLines(string payload) + private static List SplitLines(string payload) { if (string.IsNullOrEmpty(payload)) { @@ -91,7 +95,7 @@ private static string[] SplitLines(string payload) lines.RemoveAt(lines.Count - 1); } - return [.. lines]; + return lines; } private static string NormalizeLine(string line) @@ -104,8 +108,18 @@ private static string NormalizeLine(string line) var builder = new System.Text.StringBuilder(line.Length); for (var i = 0; i < line.Length; i++) { - if (line[i] == '\u001b' && i + 1 < line.Length && line[i + 1] == '[') + if (line[i] == '\u001b') { + if (i + 1 >= line.Length) + { + continue; + } + + if (line[i + 1] != '[') + { + continue; + } + i += 2; while (i < line.Length && (line[i] < '@' || line[i] > '~')) { diff --git a/src/Repl.Core/ResultFlow/PagerSession.cs b/src/Repl.Core/ResultFlow/PagerSession.cs index a6827e0..a2612b6 100644 --- a/src/Repl.Core/ResultFlow/PagerSession.cs +++ b/src/Repl.Core/ResultFlow/PagerSession.cs @@ -4,21 +4,23 @@ internal sealed class PagerSession { private readonly PagerHeader _header; private readonly int _maxBufferedLines; + private readonly List _lines = []; + private readonly IReadOnlyList _readOnlyLines; public PagerSession(string initialPayload, bool hasMorePayload, int maxBufferedLines) { _maxBufferedLines = Math.Max(1, maxBufferedLines); + _readOnlyLines = _lines.AsReadOnly(); var parsed = PagerPayloadParser.Parse(initialPayload, header: null); - _header = parsed.Header; - Lines = []; - AppendContent(parsed.ContentLines, hasMorePayload); - PageSize = 1; - NextWindow = 1; + _header = parsed.Header; + AppendContent(parsed.ContentLines, hasMorePayload); + PageSize = 1; + NextWindow = 1; } public IReadOnlyList HeaderLines => _header.Lines; - public List Lines { get; } + public IReadOnlyList Lines => _readOnlyLines; public int PageSize { get; set; } @@ -38,7 +40,7 @@ public void Append(string payload, bool hasMorePayload, bool containsPresentatio private void AppendContent(IReadOnlyList contentLines, bool hasMorePayload) { - var available = _maxBufferedLines - Lines.Count; + var available = _maxBufferedLines - _lines.Count; if (available <= 0) { BufferLimitReached = true; @@ -49,7 +51,7 @@ private void AppendContent(IReadOnlyList contentLines, bool hasMorePaylo var take = Math.Min(available, contentLines.Count); for (var i = 0; i < take; i++) { - Lines.Add(contentLines[i]); + _lines.Add(contentLines[i]); } BufferLimitReached = take < contentLines.Count; diff --git a/src/Repl.Core/ResultFlow/ReplPageSource.cs b/src/Repl.Core/ResultFlow/ReplPageSource.cs index 5d698ed..888bede 100644 --- a/src/Repl.Core/ResultFlow/ReplPageSource.cs +++ b/src/Repl.Core/ResultFlow/ReplPageSource.cs @@ -375,9 +375,12 @@ private static int ParseOffset(string? cursor) } throw new InvalidOperationException( - $"The result cursor '{cursor}' is not a valid non-negative offset cursor for this page source."); + $"The result cursor '{AbbreviateCursor(cursor)}' is not a valid non-negative offset cursor for this page source."); } + private static string AbbreviateCursor(string cursor) => + cursor.Length <= 40 ? cursor : string.Concat(cursor.AsSpan(0, 40), "..."); + private static int ResolveMaxSourceItemsToScan(int? value) => value is > 0 ? value.Value : DefaultMaxSourceItemsToScan; diff --git a/src/Repl.Core/ResultFlow/ReplResultFlowOptionNames.cs b/src/Repl.Core/ResultFlow/ReplResultFlowOptionNames.cs new file mode 100644 index 0000000..9ca56c2 --- /dev/null +++ b/src/Repl.Core/ResultFlow/ReplResultFlowOptionNames.cs @@ -0,0 +1,37 @@ +namespace Repl; + +/// +/// Built-in result-flow option names reserved by Repl. +/// +public static class ReplResultFlowOptionNames +{ + /// + /// CLI option used to continue from a cursor returned by a previous page. + /// + public const string Cursor = "--result:cursor"; + + /// + /// CLI option used to request a page size. + /// + public const string PageSize = "--result:page-size"; + + /// + /// CLI option used to request all rows when supported. + /// + public const string All = "--result:all"; + + /// + /// CLI option used to control the integrated human-output pager. + /// + public const string Pager = "--result:pager"; + + /// + /// MCP argument used to continue from a cursor returned by a previous page. + /// + public const string McpCursor = "_replCursor"; + + /// + /// MCP argument used to request a page size. + /// + public const string McpPageSize = "_replPageSize"; +} diff --git a/src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs b/src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs index 5c76bb0..ddd6a7b 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs @@ -53,6 +53,6 @@ public static void ValidateOrThrow(string? cursor) public static string FormatCliContinuation(string? cursor) => TryValidate(cursor, out _) - ? $"--result:cursor {cursor}" - : "--result:cursor "; + ? $"{ReplResultFlowOptionNames.Cursor} {cursor}" + : $"{ReplResultFlowOptionNames.Cursor} "; } diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 0f596ed..cc3f6cb 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -6,6 +6,8 @@ internal static class ResultFlowPager private const string FullStatus = "-- result-flow {0}-{1}/{2}{3} Space: next Up/Down: scroll Home/End: known bounds q: quit --"; private const string FullStatusBufferLimit = "-- result-flow {0}-{1}/{2} buffer limit reached Up/Down: scroll q: quit --"; private const int DefaultMaxBufferedLines = 10_000; + private static readonly string MorePromptClear = new(' ', MorePrompt.Length); + private static readonly string SpacePadding = new(' ', 256); private static readonly System.Text.CompositeFormat FullStatusFormat = System.Text.CompositeFormat.Parse(FullStatus); private static readonly System.Text.CompositeFormat FullStatusBufferLimitFormat = @@ -459,7 +461,7 @@ private static async ValueTask FinishMorePromptAsync(TextWriter output, bool use } await output.WriteAsync('\r').ConfigureAwait(false); - await output.WriteAsync(new string(' ', MorePrompt.Length)).ConfigureAwait(false); + await output.WriteAsync(MorePromptClear).ConfigureAwait(false); await output.WriteAsync('\r').ConfigureAwait(false); } @@ -640,7 +642,7 @@ private static async ValueTask WriteViewportLineAsync( var previousLength = state.GetRenderedLineLength(row); if (previousLength > line.Length) { - await output.WriteAsync(new string(' ', previousLength - line.Length)).ConfigureAwait(false); + await WriteSpacesAsync(output, previousLength - line.Length).ConfigureAwait(false); } state.SetRenderedLineLength(row, line.Length); @@ -698,30 +700,36 @@ private static bool ShouldFetchForViewportKey(ViewportState state, PagerAction a private static int GetViewportDelta(PagerAction action, int viewportHeight) => action == PagerAction.PageDown ? viewportHeight : 1; - private static int GetCurrentVisibleRows(int fallbackVisibleRows, Func? visibleRowsProvider) + private static async ValueTask WriteSpacesAsync(TextWriter output, int count) { - if (visibleRowsProvider is null) + if (count <= 0) { - return Math.Max(2, fallbackVisibleRows); + return; } - try + while (count > 0) { - return Math.Max(2, visibleRowsProvider()); - } - catch (IOException) - { - return Math.Max(2, fallbackVisibleRows); + var take = Math.Min(count, SpacePadding.Length); + await output.WriteAsync(SpacePadding.AsMemory(0, take)).ConfigureAwait(false); + count -= take; } - catch (PlatformNotSupportedException) + } + + private static int GetCurrentVisibleRows(int fallbackVisibleRows, Func? visibleRowsProvider) + { + if (visibleRowsProvider is null) { return Math.Max(2, fallbackVisibleRows); } - catch (InvalidOperationException) + + try { - return Math.Max(2, fallbackVisibleRows); + return Math.Max(2, visibleRowsProvider()); } - catch (System.Security.SecurityException) + catch (Exception ex) when (ex is IOException + or PlatformNotSupportedException + or InvalidOperationException + or System.Security.SecurityException) { return Math.Max(2, fallbackVisibleRows); } diff --git a/src/Repl.IntegrationTests/Given_OutputFormatting.cs b/src/Repl.IntegrationTests/Given_OutputFormatting.cs index 446c157..366a84f 100644 --- a/src/Repl.IntegrationTests/Given_OutputFormatting.cs +++ b/src/Repl.IntegrationTests/Given_OutputFormatting.cs @@ -269,6 +269,32 @@ public void When_RenderingPageSourceInHumanPager_Then_SpaceFetchesNextPageWithou text.Should().NotContain("Showing "); } + [TestMethod] + [Description("Regression guard: verifies paging.CreateSource receives the caller cursor and suggested page size through the first source request.")] + public void When_PageSourceIsCreatedFromPagingContext_Then_FirstRequestUsesCurrentPagingIntent() + { + var sut = ReplApp.Create(); + ReplPageRequest? capturedRequest = null; + + sut.Map("contact list", (IReplPagingContext paging) => + paging.CreateSource((request, _) => + { + capturedRequest = request; + return ValueTask.FromResult(request.Page( + [new ContactRow("Alice Martin", "alice@example.com")])); + })); + + using var output = new StringWriter(); + using var session = ReplSessionIO.SetSession(output, TextReader.Null); + + var exitCode = sut.Run(["contact", "list", "--result:page-size=7", "--result:cursor=page-2", "--no-logo"]); + + exitCode.Should().Be(0); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Cursor.Should().Be("page-2"); + capturedRequest.PageSize.Should().Be(7); + } + [TestMethod] [Description("Regression guard: verifies paged results serialize to a clean JSON envelope for automation.")] public void When_RenderingPagedResultInJson_Then_ItemsAndPageInfoAreSerialized() diff --git a/src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs b/src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs index 98d5422..04157f3 100644 --- a/src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs +++ b/src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs @@ -3,27 +3,33 @@ namespace Repl; -internal sealed partial class ReplResultFlowLoggerDiagnostics(IServiceProvider services) : IReplResultFlowDiagnostics +internal sealed partial class ReplResultFlowLoggerDiagnostics : IReplResultFlowDiagnostics { private const string LoggerCategory = "Repl.ResultFlow"; + private readonly ILogger _logger; - public void OnDiagnostic(ReplResultFlowDiagnostic diagnostic) + public ReplResultFlowLoggerDiagnostics(IServiceProvider services) { - ArgumentNullException.ThrowIfNull(diagnostic); + ArgumentNullException.ThrowIfNull(services); var loggerFactory = services.GetService(typeof(ILoggerFactory)) as ILoggerFactory ?? NullLoggerFactory.Instance; - var logger = loggerFactory.CreateLogger(LoggerCategory); + _logger = loggerFactory.CreateLogger(LoggerCategory); + } + + public void OnDiagnostic(ReplResultFlowDiagnostic diagnostic) + { + ArgumentNullException.ThrowIfNull(diagnostic); switch (diagnostic.Kind) { case ReplResultFlowDiagnosticKind.PageFetchStarting: - PageFetchStarting(logger, diagnostic.Cursor, diagnostic.PageSize); + PageFetchStarting(_logger, diagnostic.Cursor, diagnostic.PageSize); break; case ReplResultFlowDiagnosticKind.PageFetchSucceeded: - PageFetchSucceeded(logger, diagnostic.Cursor, diagnostic.PageSize, diagnostic.ItemCount ?? 0); + PageFetchSucceeded(_logger, diagnostic.Cursor, diagnostic.PageSize, diagnostic.ItemCount ?? 0); break; case ReplResultFlowDiagnosticKind.PageFetchFailed: - PageFetchFailed(logger, diagnostic.Exception, diagnostic.Cursor, diagnostic.PageSize); + PageFetchFailed(_logger, diagnostic.Exception, diagnostic.Cursor, diagnostic.PageSize); break; } } diff --git a/src/Repl.Mcp/McpResultFlowArgumentNames.cs b/src/Repl.Mcp/McpResultFlowArgumentNames.cs index fbb82f9..495b9f7 100644 --- a/src/Repl.Mcp/McpResultFlowArgumentNames.cs +++ b/src/Repl.Mcp/McpResultFlowArgumentNames.cs @@ -2,6 +2,6 @@ namespace Repl.Mcp; internal static class McpResultFlowArgumentNames { - public const string Cursor = "_replCursor"; - public const string PageSize = "_replPageSize"; + public const string Cursor = ReplResultFlowOptionNames.McpCursor; + public const string PageSize = ReplResultFlowOptionNames.McpPageSize; } diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 8c870b3..8e07c0b 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -242,13 +242,13 @@ internal static (List Tokens, Dictionary Prefills) Prepa else if (string.Equals(key, McpResultFlowArgumentNames.Cursor, StringComparison.Ordinal)) { ValidateResultCursor(strValue); - resultFlowTokens.Add("--result:cursor"); + resultFlowTokens.Add(ReplResultFlowOptionNames.Cursor); resultFlowTokens.Add(strValue); } else if (string.Equals(key, McpResultFlowArgumentNames.PageSize, StringComparison.Ordinal)) { ValidateResultPageSize(strValue); - resultFlowTokens.Add("--result:page-size"); + resultFlowTokens.Add(ReplResultFlowOptionNames.PageSize); resultFlowTokens.Add(strValue); } else diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index cb5810b..990536f 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -913,6 +913,31 @@ await ResultFlowPager.WriteAsync( output.Should().NotContain("five"); } + [TestMethod] + [Description("PagerSession keeps its buffer capped and clears continuation when the cap is reached.")] + public void When_PagerSessionReachesBufferLimit_Then_LinesStayReadOnlyAndHasMoreIsCleared() + { + var session = new PagerSession("one", hasMorePayload: true, maxBufferedLines: 2); + + session.Append("two\nthree", hasMorePayload: true); + + session.Lines.Should().Equal("one", "two"); + session.Lines.Should().NotBeAssignableTo>(); + session.BufferLimitReached.Should().BeTrue(); + session.HasMorePayload.Should().BeFalse(); + } + + [TestMethod] + [Description("Pager payload parser ignores malformed ANSI escape markers when normalizing headers.")] + public void When_HeaderContainsLoneEscape_Then_NormalizationStillDeduplicatesContinuationHeader() + { + var header = "# At Area\u001b"; + var first = PagerPayloadParser.Parse($"{header}\none", header: null); + var second = PagerPayloadParser.Parse($"{header}\ntwo", first.Header); + + second.ContentLines.Should().Equal("two"); + } + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => new(keyChar, key, shift: false, alt: false, control: false); diff --git a/src/Repl.Tests/Given_TerminalSurfaceHost.cs b/src/Repl.Tests/Given_TerminalSurfaceHost.cs index 4861e15..4cc35ca 100644 --- a/src/Repl.Tests/Given_TerminalSurfaceHost.cs +++ b/src/Repl.Tests/Given_TerminalSurfaceHost.cs @@ -125,15 +125,39 @@ private sealed class ThrowOnceWriter(string throwOn) : StringWriter { private bool _thrown; + public override Task WriteAsync(char value) + { + ThrowIfMatches(value.ToString()); + return base.WriteAsync(value); + } + + public override Task WriteAsync(char[] buffer, int index, int count) + { + ThrowIfMatches(new string(buffer, index, count)); + return base.WriteAsync(buffer, index, count); + } + + public override Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ThrowIfMatches(buffer.ToString()); + return base.WriteAsync(buffer, cancellationToken); + } + public override Task WriteAsync(string? value) { - if (!_thrown && string.Equals(value, throwOn, StringComparison.Ordinal)) + ThrowIfMatches(value); + return base.WriteAsync(value); + } + + private void ThrowIfMatches(string? value) + { + if (_thrown || !string.Equals(value, throwOn, StringComparison.Ordinal)) { - _thrown = true; - throw new IOException("simulated terminal failure"); + return; } - return base.WriteAsync(value); + _thrown = true; + throw new IOException("simulated terminal failure"); } } } From 8a7f571096dcff7436f0c53426e956bfa57cbfbb Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 9 May 2026 10:26:42 -0400 Subject: [PATCH 40/45] Harden result-flow paging review fixes --- src/Repl.Core/ResultFlow/IReplPage.cs | 4 + src/Repl.Core/ResultFlow/PagerSession.cs | 4 +- src/Repl.Core/ResultFlow/ReplPageSource.cs | 2 +- .../ResultFlow/ResultFlowCursorPolicy.cs | 2 +- src/Repl.Core/ResultFlow/ResultFlowOptions.cs | 39 +++++++- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 92 +++++++++++++------ .../Terminal/TerminalSurfaceScope.cs | 4 + .../ReplResultFlowLoggerDiagnostics.cs | 2 +- src/Repl.Mcp/McpToolAdapter.cs | 58 +++++++++++- src/Repl.McpTests/Given_McpToolAdapter.cs | 44 ++++++++- src/Repl.Tests/Given_GlobalOptionParser.cs | 15 +++ src/Repl.Tests/Given_ReplLogging.cs | 2 +- src/Repl.Tests/Given_ReplPageSource.cs | 34 ++++++- src/Repl.Tests/Given_ResultFlowPager.cs | 28 ++++-- 14 files changed, 282 insertions(+), 48 deletions(-) diff --git a/src/Repl.Core/ResultFlow/IReplPage.cs b/src/Repl.Core/ResultFlow/IReplPage.cs index df120a9..90ca020 100644 --- a/src/Repl.Core/ResultFlow/IReplPage.cs +++ b/src/Repl.Core/ResultFlow/IReplPage.cs @@ -3,6 +3,10 @@ namespace Repl; /// /// Represents a typed page using an untyped view for the output pipeline. /// +/// +/// Prefer returning or one of the page-source helpers instead of implementing +/// this interface directly; it is primarily a framework contract between result-flow components. +/// public interface IReplPage { /// diff --git a/src/Repl.Core/ResultFlow/PagerSession.cs b/src/Repl.Core/ResultFlow/PagerSession.cs index a2612b6..22d4a7d 100644 --- a/src/Repl.Core/ResultFlow/PagerSession.cs +++ b/src/Repl.Core/ResultFlow/PagerSession.cs @@ -32,6 +32,8 @@ public PagerSession(string initialPayload, bool hasMorePayload, int maxBufferedL public bool BufferLimitReached { get; private set; } + public bool SourceReturnedNoData { get; set; } + public void Append(string payload, bool hasMorePayload, bool containsPresentationChrome = true) { var parsed = PagerPayloadParser.Parse(payload, _header, containsPresentationChrome); @@ -55,6 +57,6 @@ private void AppendContent(IReadOnlyList contentLines, bool hasMorePaylo } BufferLimitReached = take < contentLines.Count; - HasMorePayload = !BufferLimitReached && hasMorePayload; + HasMorePayload = hasMorePayload && !BufferLimitReached; } } diff --git a/src/Repl.Core/ResultFlow/ReplPageSource.cs b/src/Repl.Core/ResultFlow/ReplPageSource.cs index 888bede..29e9fa0 100644 --- a/src/Repl.Core/ResultFlow/ReplPageSource.cs +++ b/src/Repl.Core/ResultFlow/ReplPageSource.cs @@ -259,12 +259,12 @@ private static async ValueTask> CreateAsyncEnumerablePageAsync( .WithCancellation(cancellationToken) .ConfigureAwait(false)) { - ThrowIfScanLimitExceeded(scanned++, maxScan); if (index++ < offset) { continue; } + ThrowIfScanLimitExceeded(scanned++, maxScan); if (filter is not null && !filter(item)) { continue; diff --git a/src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs b/src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs index ddd6a7b..6ba3bd4 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowCursorPolicy.cs @@ -32,7 +32,7 @@ public static bool TryValidate(string? cursor, [System.Diagnostics.CodeAnalysis. return false; } - if (ch < 0x20 || ch == 0x7f) + if (ch < 0x20 || ch == 0x7f || ch is >= '\u0080' and <= '\u009f') { error = "The result cursor cannot contain control characters."; return false; diff --git a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs index 5ce2769..dd564cd 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs @@ -6,16 +6,51 @@ namespace Repl; public sealed class ResultFlowOptions { private readonly List _pagerRenderers = []; + private int _defaultPageSize = 100; + private int _maxPageSize = 1000; /// /// Gets or sets the default page size when no terminal-specific hint is available. /// - public int DefaultPageSize { get; set; } = 100; + public int DefaultPageSize + { + get => _defaultPageSize; + set + { + if (value < 1) + { + throw new ArgumentOutOfRangeException(nameof(value), "Default page size must be greater than zero."); + } + + _defaultPageSize = value; + if (_maxPageSize < _defaultPageSize) + { + _maxPageSize = _defaultPageSize; + } + } + } /// /// Gets or sets the maximum page size a caller can request. /// - public int MaxPageSize { get; set; } = 1000; + public int MaxPageSize + { + get => _maxPageSize; + set + { + if (value < 1) + { + throw new ArgumentOutOfRangeException(nameof(value), "Maximum page size must be greater than zero."); + } + + if (value < _defaultPageSize) + { + throw new ArgumentOutOfRangeException(nameof(value), "Maximum page size must be greater than or equal to the default page size."); + } + + _maxPageSize = value; + } + } /// /// Gets or sets the number of non-data rows reserved in interactive pagers. diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index cc3f6cb..2027153 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -3,6 +3,7 @@ namespace Repl; internal static class ResultFlowPager { private const string MorePrompt = "--More-- Space/PageDown: continue, Enter/Down: line, Up/PageUp: ignored, q/Esc: stop"; + private const string SourceReturnedNoDataStatus = "-- paging stopped: source returned no data --"; private const string FullStatus = "-- result-flow {0}-{1}/{2}{3} Space: next Up/Down: scroll Home/End: known bounds q: quit --"; private const string FullStatusBufferLimit = "-- result-flow {0}-{1}/{2} buffer limit reached Up/Down: scroll q: quit --"; private const int DefaultMaxBufferedLines = 10_000; @@ -13,9 +14,9 @@ internal static class ResultFlowPager private static readonly System.Text.CompositeFormat FullStatusBufferLimitFormat = System.Text.CompositeFormat.Parse(FullStatusBufferLimit); - public static int CountLines(string payload) => PagerPayloadParser.Parse(payload, header: null).TotalLineCount; + internal static int CountLines(string payload) => PagerPayloadParser.Parse(payload, header: null).TotalLineCount; - public static ValueTask WriteAsync( + internal static ValueTask WriteAsync( string payload, TextWriter output, IReplKeyReader keyReader, @@ -30,7 +31,7 @@ public static ValueTask WriteAsync( fetchNextPayload: null, cancellationToken); - public static async ValueTask WriteAsync( + internal static async ValueTask WriteAsync( string payload, TextWriter output, IReplKeyReader keyReader, @@ -52,7 +53,7 @@ await WriteAsync( .ConfigureAwait(false); } - public static async ValueTask WriteAsync( + internal static async ValueTask WriteAsync( string payload, TextWriter output, IReplKeyReader keyReader, @@ -76,7 +77,7 @@ await WriteAsync( .ConfigureAwait(false); } - public static async ValueTask WriteAsync( + internal static async ValueTask WriteAsync( string payload, TextWriter output, IReplKeyReader keyReader, @@ -102,7 +103,7 @@ await WriteAsync( .ConfigureAwait(false); } - public static async ValueTask WriteAsync( + internal static async ValueTask WriteAsync( string payload, TextWriter output, IReplKeyReader keyReader, @@ -129,7 +130,7 @@ await WriteAsync( .ConfigureAwait(false); } - public static ValueTask WriteAsync( + internal static ValueTask WriteAsync( string payload, TextWriter output, IReplKeyReader keyReader, @@ -155,7 +156,7 @@ public static ValueTask WriteAsync( DefaultMaxBufferedLines, cancellationToken); - public static async ValueTask WriteAsync( + internal static async ValueTask WriteAsync( string payload, TextWriter output, IReplKeyReader keyReader, @@ -366,6 +367,7 @@ private static async ValueTask RenderMoreAsync( if (!await TryFetchIntoSessionAsync(session, fetchNextPayload, cancellationToken).ConfigureAwait(false)) { + await WriteSourceReturnedNoDataAsync(session, output).ConfigureAwait(false); return; } } @@ -380,12 +382,11 @@ private static async ValueTask WriteMoreWindowAsync( var written = 0; while (written < session.NextWindow) { - if (session.Index >= session.Lines.Count) + if (session.Index >= session.Lines.Count + && !await TryFetchIntoSessionAsync(session, fetchNextPayload, cancellationToken).ConfigureAwait(false)) { - if (!await TryFetchIntoSessionAsync(session, fetchNextPayload, cancellationToken).ConfigureAwait(false)) - { - break; - } + await WriteSourceReturnedNoDataAsync(session, output).ConfigureAwait(false); + break; } await output.WriteLineAsync(session.Lines[session.Index]).ConfigureAwait(false); @@ -404,9 +405,11 @@ private static async ValueTask TryFetchIntoSessionAsync( return false; } + session.SourceReturnedNoData = false; var nextPayload = await fetchNextPayload(cancellationToken).ConfigureAwait(false); if (nextPayload is null) { + session.SourceReturnedNoData = true; session.HasMorePayload = false; return false; } @@ -452,6 +455,14 @@ private static async ValueTask ReadMoreActionAsync( } } + private static async ValueTask WriteSourceReturnedNoDataAsync(PagerSession session, TextWriter output) + { + if (session.SourceReturnedNoData) + { + await output.WriteLineAsync(SourceReturnedNoDataStatus).ConfigureAwait(false); + } + } + private static async ValueTask FinishMorePromptAsync(TextWriter output, bool useTransientPrompt) { if (!useTransientPrompt) @@ -537,14 +548,7 @@ private static async ValueTask FetchIntoSessionAsync( Func> fetchNextPayload, CancellationToken cancellationToken) { - var nextPayload = await fetchNextPayload(cancellationToken).ConfigureAwait(false); - if (nextPayload is null) - { - session.HasMorePayload = false; - return; - } - - session.Append(nextPayload.Payload, nextPayload.HasMore, nextPayload.ContainsPresentationChrome); + _ = await TryFetchIntoSessionAsync(session, fetchNextPayload, cancellationToken).ConfigureAwait(false); } private static async ValueTask ClearViewportAsync(TerminalSurfaceScope surface, ViewportState state) @@ -599,7 +603,14 @@ private static string CreateViewportStatus(ViewportState state, int lastLine) { if (state.Session.Lines.Count == 0) { - return "-- result-flow: loading --"; + return state.Session.SourceReturnedNoData + ? SourceReturnedNoDataStatus + : "-- result-flow: loading --"; + } + + if (state.Session.SourceReturnedNoData) + { + return SourceReturnedNoDataStatus; } return state.Session.BufferLimitReached @@ -639,13 +650,14 @@ private static async ValueTask WriteViewportLineAsync( bool appendNewLine = true) { await output.WriteAsync(line).ConfigureAwait(false); + var visualLength = GetVisualLength(line); var previousLength = state.GetRenderedLineLength(row); - if (previousLength > line.Length) + if (previousLength > visualLength) { - await WriteSpacesAsync(output, previousLength - line.Length).ConfigureAwait(false); + await WriteSpacesAsync(output, previousLength - visualLength).ConfigureAwait(false); } - state.SetRenderedLineLength(row, line.Length); + state.SetRenderedLineLength(row, visualLength); if (appendNewLine) { await output.WriteLineAsync().ConfigureAwait(false); @@ -692,7 +704,7 @@ private static PagerAction ApplyViewportKey(ViewportState state, ConsoleKeyInfo private static bool ShouldFetchForViewportKey(ViewportState state, PagerAction action, int beforeTopLine) => action switch { - PagerAction.PageDown => state.HasReachedBottom && state.Session.Lines.Count > state.ViewportHeight, + PagerAction.PageDown => state.HasReachedBottom && state.Session.Lines.Count >= state.ViewportHeight, PagerAction.LineDown => beforeTopLine == state.TopLine && state.HasReachedBottom, _ => false, }; @@ -715,6 +727,34 @@ private static async ValueTask WriteSpacesAsync(TextWriter output, int count) } } + private static int GetVisualLength(string line) + { + var length = 0; + for (var i = 0; i < line.Length; i++) + { + if (line[i] == '\u001b') + { + if (i + 1 < line.Length && line[i + 1] == '[') + { + i += 2; + while (i < line.Length && (line[i] < '@' || line[i] > '~')) + { + i++; + } + } + + continue; + } + + if (!char.IsControl(line[i])) + { + length++; + } + } + + return length; + } + private static int GetCurrentVisibleRows(int fallbackVisibleRows, Func? visibleRowsProvider) { if (visibleRowsProvider is null) diff --git a/src/Repl.Core/Terminal/TerminalSurfaceScope.cs b/src/Repl.Core/Terminal/TerminalSurfaceScope.cs index ee22ab5..39f895d 100644 --- a/src/Repl.Core/Terminal/TerminalSurfaceScope.cs +++ b/src/Repl.Core/Terminal/TerminalSurfaceScope.cs @@ -56,9 +56,11 @@ internal static async ValueTask RestoreAsync(TextWriter output, TerminalSurfaceM } catch (IOException) { + // Best-effort terminal restore; output may be closed during process shutdown or pipe teardown. } catch (ObjectDisposedException) { + // Best-effort terminal restore; output may already be disposed by the host. } } @@ -76,9 +78,11 @@ private static async ValueTask TryWriteAsync(TextWriter output, string value) } catch (IOException) { + // Best-effort terminal write; output may be closed during process shutdown or pipe teardown. } catch (ObjectDisposedException) { + // Best-effort terminal write; output may already be disposed by the host. } } } diff --git a/src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs b/src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs index 04157f3..b31504d 100644 --- a/src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs +++ b/src/Repl.Logging/ReplResultFlowLoggerDiagnostics.cs @@ -40,6 +40,6 @@ public void OnDiagnostic(ReplResultFlowDiagnostic diagnostic) [LoggerMessage(EventId = 1002, Level = LogLevel.Debug, Message = "Result-flow page fetch succeeded. Cursor: {Cursor}; PageSize: {PageSize}; ItemCount: {ItemCount}.")] private static partial void PageFetchSucceeded(ILogger logger, string? cursor, int pageSize, int itemCount); - [LoggerMessage(EventId = 1003, Level = LogLevel.Warning, Message = "Result-flow page fetch failed. Cursor: {Cursor}; PageSize: {PageSize}.")] + [LoggerMessage(EventId = 1003, Level = LogLevel.Error, Message = "Result-flow page fetch failed. Cursor: {Cursor}; PageSize: {PageSize}.")] private static partial void PageFetchFailed(ILogger logger, Exception? exception, string? cursor, int pageSize); } diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index 8e07c0b..8057903 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -95,7 +95,7 @@ public async Task InvokeAsync( return ErrorResult($"Unknown tool: {toolName}"); } - var (tokens, prefills) = PrepareExecution(command.Path, arguments); + var (tokens, prefills) = PrepareExecution(command, arguments); return await ExecuteThroughPipelineAsync(tokens, prefills, server, progressToken, ct) .ConfigureAwait(false); } @@ -221,9 +221,23 @@ private static string BuildPagedSummary(int count, JsonElement pageInfo) return summary; } + internal static (List Tokens, Dictionary Prefills) PrepareExecution( + ReplDocCommand command, + IDictionary arguments) + { + var allowedArgumentNames = BuildAllowedArgumentNames(command); + return PrepareExecution(command.Path, arguments, allowedArgumentNames); + } + internal static (List Tokens, Dictionary Prefills) PrepareExecution( string routePath, IDictionary arguments) + => PrepareExecution(routePath, arguments, allowedArgumentNames: null); + + private static (List Tokens, Dictionary Prefills) PrepareExecution( + string routePath, + IDictionary arguments, + HashSet? allowedArgumentNames) { var stringArgs = new Dictionary(StringComparer.OrdinalIgnoreCase); var prefills = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -253,6 +267,12 @@ internal static (List Tokens, Dictionary Prefills) Prepa } else { + if (allowedArgumentNames is not null && !allowedArgumentNames.Contains(key)) + { + throw new InvalidOperationException( + $"The MCP argument '{key}' is not defined by the tool schema."); + } + stringArgs[key] = strValue; } } @@ -267,15 +287,47 @@ private static void ValidateResultCursor(string cursor) private static void ValidateResultPageSize(string pageSize) { - if (pageSize.Length > 20) + if (pageSize.Length > 10) { - throw new InvalidOperationException("The MCP result page size cannot exceed 20 characters."); + throw new InvalidOperationException("The MCP result page size cannot exceed 10 characters."); } if (pageSize.Length == 0 || pageSize.Any(static c => c < '0' || c > '9')) { throw new InvalidOperationException("The MCP result page size must be numeric."); } + + if (!int.TryParse(pageSize, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var value) + || value <= 0) + { + throw new InvalidOperationException("The MCP result page size must fit in a positive 32-bit integer."); + } + } + + private static HashSet BuildAllowedArgumentNames(ReplDocCommand command) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var argument in command.Arguments) + { + names.Add(argument.Name); + } + + foreach (var option in command.Options) + { + names.Add(option.Name); + } + + if (command.Answers is { Count: > 0 }) + { + foreach (var answer in command.Answers) + { + names.Add($"answer.{answer.Name}"); + } + } + + names.Add(McpResultFlowArgumentNames.Cursor); + names.Add(McpResultFlowArgumentNames.PageSize); + return names; } /// diff --git a/src/Repl.McpTests/Given_McpToolAdapter.cs b/src/Repl.McpTests/Given_McpToolAdapter.cs index 0810774..484a566 100644 --- a/src/Repl.McpTests/Given_McpToolAdapter.cs +++ b/src/Repl.McpTests/Given_McpToolAdapter.cs @@ -1,4 +1,5 @@ using Repl.Mcp; +using Repl.Documentation; using System.Text.Json; namespace Repl.McpTests; @@ -200,10 +201,49 @@ public void When_ResultPageSizeTokenIsTooLong_Then_Rejected() "contacts", new Dictionary(StringComparer.Ordinal) { - [McpResultFlowArgumentNames.PageSize] = JsonSerializer.SerializeToElement(new string('1', 21)), + [McpResultFlowArgumentNames.PageSize] = JsonSerializer.SerializeToElement(new string('1', 11)), }); action.Should().Throw() - .WithMessage("*page size*20*"); + .WithMessage("*page size*10*"); + } + + [TestMethod] + [Description("PrepareExecution rejects result page sizes that do not fit in a positive Int32.")] + public void When_ResultPageSizeOverflowsInt32_Then_Rejected() + { + var action = () => McpToolAdapter.PrepareExecution( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + [McpResultFlowArgumentNames.PageSize] = JsonSerializer.SerializeToElement("9999999999"), + }); + + action.Should().Throw() + .WithMessage("*page size*32-bit*"); + } + + [TestMethod] + [Description("PrepareExecution rejects MCP arguments that are not declared by the command schema.")] + public void When_ArgumentIsNotInToolSchema_Then_Rejected() + { + var command = new ReplDocCommand( + Path: "contacts {id}", + Description: null, + Aliases: [], + IsHidden: false, + Arguments: [new ReplDocArgument("id", "string", Required: true, Description: null)], + Options: [new ReplDocOption("format", "string", Required: false, Description: null, Aliases: [], ReverseAliases: [], ValueAliases: [], EnumValues: [], DefaultValue: null)]); + + var action = () => McpToolAdapter.PrepareExecution( + command, + new Dictionary(StringComparer.Ordinal) + { + ["id"] = JsonSerializer.SerializeToElement("abc"), + ["output:xml"] = JsonSerializer.SerializeToElement("true"), + }); + + action.Should().Throw() + .WithMessage("*not defined*schema*"); } } diff --git a/src/Repl.Tests/Given_GlobalOptionParser.cs b/src/Repl.Tests/Given_GlobalOptionParser.cs index cb68452..32ebb55 100644 --- a/src/Repl.Tests/Given_GlobalOptionParser.cs +++ b/src/Repl.Tests/Given_GlobalOptionParser.cs @@ -145,6 +145,7 @@ public void When_ResultFlowPagerModeIsFullOrInline_Then_ParserStoresMode() public void When_ResultFlowPageSizeExceedsMaximum_Then_ParserClampsIt() { var outputOptions = new OutputOptions(); + outputOptions.ResultFlow.DefaultPageSize = 50; outputOptions.ResultFlow.MaxPageSize = 50; var parsed = GlobalOptionParser.Parse( @@ -197,4 +198,18 @@ public void When_ResultFlowCursorContainsControlCharacter_Then_DiagnosticErrorIs diagnostic.Severity == ParseDiagnosticSeverity.Error && diagnostic.Message.Contains("control", StringComparison.OrdinalIgnoreCase)); } + + [TestMethod] + [Description("Result-flow cursor rejects C1 control characters that can be interpreted by legacy terminals.")] + public void When_ResultFlowCursorContainsC1Control_Then_DiagnosticErrorIsProduced() + { + var parsed = GlobalOptionParser.Parse( + ["users", "list", "--result:cursor=abc\u009b2J"], + new OutputOptions(), + new ParsingOptions()); + + parsed.Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Severity == ParseDiagnosticSeverity.Error + && diagnostic.Message.Contains("control", StringComparison.OrdinalIgnoreCase)); + } } diff --git a/src/Repl.Tests/Given_ReplLogging.cs b/src/Repl.Tests/Given_ReplLogging.cs index 42e810f..49b44fe 100644 --- a/src/Repl.Tests/Given_ReplLogging.cs +++ b/src/Repl.Tests/Given_ReplLogging.cs @@ -126,7 +126,7 @@ public void When_ResultFlowPageFetchFails_Then_DiagnosticIsLogged() exitCode.Should().Be(1); provider.Entries.Should().Contain(entry => entry.Category == "Repl.ResultFlow" - && entry.Level == LogLevel.Warning + && entry.Level == LogLevel.Error && entry.Message.Contains("page fetch failed", StringComparison.OrdinalIgnoreCase)); } diff --git a/src/Repl.Tests/Given_ReplPageSource.cs b/src/Repl.Tests/Given_ReplPageSource.cs index ce0c82f..61f5a60 100644 --- a/src/Repl.Tests/Given_ReplPageSource.cs +++ b/src/Repl.Tests/Given_ReplPageSource.cs @@ -276,9 +276,28 @@ public async Task When_FromAsyncEnumerableFactoryIsNotReplayable_Then_SecondPage AllRequested: false, Surface: ReplResultSurface.Console)).ConfigureAwait(false); - await action.Should().ThrowAsync() - .WithMessage("*replayable*") - .ConfigureAwait(false); + await action.Should().ThrowAsync().ConfigureAwait(false); + } + + [TestMethod] + [Description("ReplPageSource.FromAsyncEnumerable starts scan-limit accounting after the raw offset skip.")] + public async Task When_FromAsyncEnumerableUsesDeepOffset_Then_ScanLimitAppliesAfterOffset() + { + var source = ReplPageSource.FromAsyncEnumerable( + _ => ReadIntItemsAsync(Enumerable.Range(0, 100)), + filter: static _ => true, + maxSourceItemsToScan: 3); + + var page = await source.FetchAsync( + new ReplPageRequest( + PageSize: 2, + Cursor: "50", + VisibleRowCapacityHint: null, + AllRequested: false, + Surface: ReplResultSurface.Console)); + + page.Items.Should().Equal(50, 51); + page.PageInfo.NextCursor.Should().Be("52"); } [TestMethod] @@ -444,6 +463,15 @@ private static async IAsyncEnumerable ReadItemsAsync(IEnumerable } } + private static async IAsyncEnumerable ReadIntItemsAsync(IEnumerable items) + { + foreach (var item in items) + { + await Task.Yield(); + yield return item; + } + } + private static async IAsyncEnumerable ReadUntilCancelledAsync( Action observeCancellation, [EnumeratorCancellation] CancellationToken cancellationToken) diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index 990536f..848cb92 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -449,8 +449,8 @@ await ResultFlowPager.WriteAsync( } [TestMethod] - [Description("Result-flow full 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() + [Description("Result-flow full pager fetches another payload when the current payload exactly fills the viewport and the user presses Space.")] + public async Task When_ScrollPagerContentExactlyFitsViewport_Then_SpaceFetchesNextPayload() { using var writer = new StringWriter(); var fetches = 0; @@ -476,8 +476,8 @@ await ResultFlowPager.WriteAsync( }, CancellationToken.None); - fetches.Should().Be(0); - writer.ToString().Should().NotContain("four"); + fetches.Should().Be(1); + writer.ToString().Should().Contain("four"); } [TestMethod] @@ -613,7 +613,9 @@ await ResultFlowPager.WriteAsync( CancellationToken.None); var output = writer.ToString(); - output.Should().Contain($"{header}\r\nthree\r\nfour"); + output.Should().Contain(header); + output.Should().Contain("three"); + output.Should().Contain("four"); output.Should().Contain("3-4/5"); } @@ -698,8 +700,9 @@ await ResultFlowPager.WriteAsync( new ResultFlowPagerPage($"Showing 1 of 5.\n{header}\nthree", HasMore: false)), CancellationToken.None); - writer.ToString().Should().Contain($"{header}\r\nthree"); - writer.ToString().Split(header).Length.Should().Be(4); + writer.ToString().Should().Contain(header); + writer.ToString().Should().Contain("three"); + writer.ToString().Should().Contain("3-3/3"); } [TestMethod] @@ -859,6 +862,17 @@ public void When_PagerRendererIsRegisteredTwiceForMode_Then_LatestRendererReplac options.PagerRenderers.Should().BeEmpty(); } + [TestMethod] + [Description("Result-flow options reject inconsistent page-size bounds.")] + public void When_MaxPageSizeIsSmallerThanDefault_Then_OptionsRejectIt() + { + var options = new ResultFlowOptions(); + + var action = () => options.MaxPageSize = options.DefaultPageSize - 1; + + action.Should().Throw(); + } + [TestMethod] [Description("ReplPagerRenderContext can be constructed and fetch payloads in custom renderer tests.")] public async Task When_RenderContextIsCreatedDirectly_Then_FetchNextPayloadReturnsConfiguredPayload() From 38fb323b25281ca3dff54ccfa08a0f1b1e28315c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 9 May 2026 10:31:49 -0400 Subject: [PATCH 41/45] Update paging docs for MCP hardening --- docs/mcp-reference.md | 5 ++++- docs/result-flow.md | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index ffc4ea1..9a57def 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -213,7 +213,10 @@ This avoids dumping large JSON arrays into a single `TextContentBlock`. The raw cursor is not interpolated into MCP text content. Repl accepts compact cursor tokens only: non-empty, at most 512 characters, no whitespace, no control characters, and not starting with `-`. Page-size tokens must be numeric and at -most 20 characters before normal result-flow clamping is applied. +most 10 characters before normal result-flow clamping is applied. +Tool-call arguments are validated against the generated MCP input schema before +Repl reconstructs CLI tokens. Undeclared keys are rejected instead of being +converted to `--{key}` options. MCP paging continuation depends on the client preserving structured tool content. Repl always returns a short text fallback, but the raw cursor is only diff --git a/docs/result-flow.md b/docs/result-flow.md index cb32625..65ba451 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -677,8 +677,11 @@ MCP and CLI cursors are expected to be compact opaque values, for example base64url or another whitespace-free token. Repl rejects cursors that are empty, contain whitespace or control characters, start with `-`, or exceed 512 characters before they can be converted to CLI tokens. MCP page-size values must -be numeric and at most 20 characters before normal result-flow clamping is +be numeric and at most 10 characters before normal result-flow clamping is applied. +MCP arguments are also validated against the generated tool schema before they +are reconstructed as CLI tokens, so arbitrary JSON keys cannot inject global or +result-flow options. When a handler returns `ReplPage`, MCP returns: @@ -735,7 +738,7 @@ events with cursor and page-size metadata. `Repl.Logging` registers a bridge automatically when `AddReplLogging()` is used: - `Debug`: page fetch starting/succeeded. -- `Warning`: page fetch failed, including the exception. +- `Error`: page fetch failed, including the exception. ## Implementation Notes From bd8ff16e631631b0e7740ffbd70d17b8956ffacf Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 9 May 2026 11:07:22 -0400 Subject: [PATCH 42/45] Harden MCP paging fallbacks --- docs/mcp-agent-capabilities.md | 8 + docs/mcp-reference.md | 54 ++-- docs/result-flow.md | 21 +- src/Repl.Core/CoreReplApp.Execution.cs | 35 +-- .../Documentation/DocumentationEngine.cs | 96 ++++--- src/Repl.Core/Documentation/ReplDocCommand.cs | 4 +- src/Repl.Core/ResultFlow/ResultFlowOptions.cs | 4 +- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 196 ++------------ .../ResultFlow/ResultFlowPagerOptions.cs | 20 ++ src/Repl.Core/Terminal/AnsiTextMetrics.cs | 67 +++++ src/Repl.Mcp/McpPagedResultTextMode.cs | 22 ++ src/Repl.Mcp/McpSchemaGenerator.cs | 46 +++- src/Repl.Mcp/McpToolAdapter.cs | 61 ++++- src/Repl.Mcp/ReplMcpServerOptions.cs | 5 + src/Repl.Mcp/ReplMcpServerTool.cs | 1 + src/Repl.McpTests/Given_McpSchemaGenerator.cs | 63 ++++- src/Repl.McpTests/Given_McpServerEndToEnd.cs | 36 ++- src/Repl.McpTests/Given_McpToolAdapter.cs | 69 +++++ src/Repl.Tests/Given_AnsiTextMetrics.cs | 23 ++ src/Repl.Tests/Given_ResultFlowPager.cs | 249 +++++++++++++++--- 20 files changed, 785 insertions(+), 295 deletions(-) create mode 100644 src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs create mode 100644 src/Repl.Core/Terminal/AnsiTextMetrics.cs create mode 100644 src/Repl.Mcp/McpPagedResultTextMode.cs create mode 100644 src/Repl.Tests/Given_AnsiTextMetrics.cs diff --git a/docs/mcp-agent-capabilities.md b/docs/mcp-agent-capabilities.md index 36b898f..cac9ce4 100644 --- a/docs/mcp-agent-capabilities.md +++ b/docs/mcp-agent-capabilities.md @@ -335,6 +335,14 @@ Not all MCP clients support sampling and elicitation. The table below lists agen Check [mcp-availability.com](https://mcp-availability.com/) for the latest data. Support is expanding rapidly — design your commands to degrade gracefully so they work everywhere even when a capability is missing. +Paged Repl tools do not require a special MCP capability for the first page: +every client that can call tools receives a bounded result. Continuation is +better when the client preserves structured tool results, because the raw cursor +is carried in `StructuredContent.pageInfo.nextCursor`. For clients that mostly +consume text, configure `ReplMcpServerOptions.PagedResultTextMode` to choose +between compatibility (`SerializedJson`) and lower token cost (`SummaryOnly`). +See [Paged tool results](mcp-reference.md#paged-tool-results). + ## Direct MCP interfaces vs IReplInteractionChannel | | `IMcpSampling` / `IMcpElicitation` / `IMcpFeedback` | `IReplInteractionChannel` | diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md index 9a57def..d6f9954 100644 --- a/docs/mcp-reference.md +++ b/docs/mcp-reference.md @@ -189,11 +189,14 @@ app.UseMcpServer(o => o.InteractivityMode = InteractivityMode.PrefillThenElicita ### Paged tool results -Every MCP tool schema includes two reserved Repl result-flow inputs: +Paged MCP tool schemas include two reserved Repl result-flow inputs: - `_replCursor`: opaque continuation cursor returned by a previous paged result. - `_replPageSize`: requested page size. +These inputs are emitted only for commands that accept `IReplPagingContext` or +return a paged result. Other tools reject them like any undeclared MCP argument. + Handlers receive these values through `IReplPagingContext`, not as business parameters. A handler can return `ReplPage`: ```csharp @@ -207,27 +210,45 @@ app.Map("contacts", (IReplPagingContext paging, ContactStore store) => MCP responses for `ReplPage` include: - `StructuredContent`: `{ "$type": "page", items, pageInfo }` -- `Content`: short text summary that says a cursor is available in structured content +- `Content`: configurable text fallback for clients that do not use structured content + +By default, `Content` contains compact serialized JSON so agents that ignore +`StructuredContent` can still see the first page and cursor. Applications can +choose a cheaper text fallback: -This avoids dumping large JSON arrays into a single `TextContentBlock`. -The raw cursor is not interpolated into MCP text content. Repl accepts compact -cursor tokens only: non-empty, at most 512 characters, no whitespace, no control -characters, and not starting with `-`. Page-size tokens must be numeric and at -most 10 characters before normal result-flow clamping is applied. -Tool-call arguments are validated against the generated MCP input schema before -Repl reconstructs CLI tokens. Undeclared keys are rejected instead of being -converted to `--{key}` options. +```csharp +app.UseMcpServer(o => +{ + o.PagedResultTextMode = McpPagedResultTextMode.SummaryOnly; +}); +``` -MCP paging continuation depends on the client preserving structured tool -content. Repl always returns a short text fallback, but the raw cursor is only -available in `StructuredContent.pageInfo.nextCursor`. +| `PagedResultTextMode` | `Content` behavior | Trade-off | +|---|---|---| +| `SerializedJson` (default) | Compact JSON page envelope | Best compatibility; higher token cost | +| `SummaryOnly` | Short summary; cursor value stays only in structured content | Lowest token cost; clients must read structured content to continue | +| `SummaryAndSerializedJson` | Summary plus compact JSON page envelope | Most verbose; useful for diagnostics and weak clients | + +Repl accepts compact cursor tokens only: non-empty, at most 512 characters, no +whitespace, no control characters, and not starting with `-`. Page-size tokens +must be numeric and at most 10 characters before normal result-flow clamping is +applied. Tool-call arguments are validated against the generated MCP input +schema before Repl reconstructs CLI tokens. Undeclared keys are rejected instead +of being converted to `--{key}` options, and business argument values that start +with `--` are rejected because the downstream CLI parser treats those as option +tokens. + +MCP paging continuation works best when the client preserves structured tool +content. When `PagedResultTextMode` includes serialized JSON, the text fallback +also contains the page envelope; when it is `SummaryOnly`, the raw cursor stays +only in `StructuredContent.pageInfo.nextCursor`. | Agent/client behavior | Paging support | Repl fallback | |---|---|---| | Reads `StructuredContent` and can call the same tool again | Full continuation with `_replCursor` and `_replPageSize` | Not needed | -| Reads only `Content` text | Sees that another cursor exists, but cannot continue automatically | Text says the cursor is available in structured content | +| Reads only `Content` text | Sees first-page JSON by default; can continue only if it can reuse the cursor text safely | Configure `SerializedJson` or `SummaryAndSerializedJson` for compatibility, `SummaryOnly` for token savings | | Ignores custom/reserved input properties | First page still works | Tool returns bounded first page | -| Does not support structured content | No automatic continuation | Use CLI/programmatic output or expose a command-specific cursor option | +| Does not support structured content | First page still works through text fallback; automatic continuation depends on `PagedResultTextMode` | Keep `SerializedJson` for weak clients or expose a command-specific cursor option | Applications should not rely on all agents supporting continuation equally. For important workflows, include enough data in the first page summary for the @@ -296,6 +317,7 @@ app.UseMcpServer(o => o.ResourceFallbackToTools = false; // also expose resources as tools o.PromptFallbackToTools = false; // also expose prompts as tools o.DynamicToolCompatibility = DynamicToolCompatibilityMode.Disabled; // shim for weak clients + o.PagedResultTextMode = McpPagedResultTextMode.SerializedJson; // paged result text fallback o.EnableApps = false; // usually auto-enabled by MCP App mappings o.CommandFilter = cmd => true; // filter which commands become tools o.Prompt("summarize", (string topic) => ...); // explicit prompt registration @@ -379,6 +401,8 @@ Feature support varies across agents. Check [mcp-availability.com](https://mcp-a | Feature | Claude Desktop | Claude Code | Codex | VS Code Copilot | Cursor | Continue | |---|---|---|---|---|---|---| | Tools | Yes | Yes | Yes | Yes | Yes | Yes | +| Paged tools, first page | Yes | Yes | Yes | Yes | Yes | Yes | +| Paged tools, automatic continuation | Depends on structured tool-result handling | Depends on structured tool-result handling | Depends on structured tool-result handling | Depends on structured tool-result handling | Depends on structured tool-result handling | Depends on structured tool-result handling | | Resources | Yes | — | — | Yes | Yes | — | | Prompts | Yes | — | — | Yes | — | Yes | | Discovery (`list_changed`) | — | Yes | — | — | — | — | diff --git a/docs/result-flow.md b/docs/result-flow.md index 65ba451..8d14807 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -665,14 +665,16 @@ owns its render area. ## MCP Behavior -MCP tools expose two reserved input properties on every tool schema: +Paged MCP tools expose two reserved input properties in their input 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. +These properties are emitted only for commands that accept `IReplPagingContext` +or return a paged result. They are consumed by the Repl MCP adapter and mapped +to `IReplPagingContext`; they are not forwarded as command business options. MCP and CLI cursors are expected to be compact opaque values, for example base64url or another whitespace-free token. Repl rejects cursors that are empty, contain whitespace or control characters, start with `-`, or exceed 512 @@ -681,17 +683,22 @@ be numeric and at most 10 characters before normal result-flow clamping is applied. MCP arguments are also validated against the generated tool schema before they are reconstructed as CLI tokens, so arbitrary JSON keys cannot inject global or -result-flow options. +result-flow options. Business argument values starting with `--` are rejected +because those values would be ambiguous once Repl reconstructs CLI tokens. When a handler returns `ReplPage`, MCP returns: - `StructuredContent`: the full `{ "$type": "page", items, pageInfo }` envelope. -- `Content`: a short text summary such as `Returned 1 item(s). Total: 2. Continue with _replCursor; cursor available in structured content.` +- `Content`: a configurable text fallback. -This keeps agents from receiving a giant JSON string in `TextContentBlock` while still preserving structured data for clients that support it. +By default, `Content` contains compact serialized JSON for compatibility with +clients that ignore structured content. `ReplMcpServerOptions.PagedResultTextMode` +can switch to `SummaryOnly` to reduce token cost, or `SummaryAndSerializedJson` +for a diagnostic-friendly fallback. Agents that preserve `StructuredContent` can continue by sending `_replCursor` -with the value from `pageInfo.nextCursor`. Agents that only read text receive a -safe fallback summary, but Repl does not place the raw cursor in text content. +with the value from `pageInfo.nextCursor`. Agents that only read text can still +consume the first page when serialized JSON fallback is enabled, but automatic +continuation depends on the client being able to reuse the cursor safely. ## Spectre Behavior diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index d0e5ed7..dc194c9 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -804,14 +804,17 @@ await ResultFlowPager.WriteAsync( pagerPayload, ReplSessionIO.Output, keyReader, - visibleRows, - ResolvePagerVisibleRows, - pagerMode, - ansiEnabled, - page.PageInfo.HasMore, - FetchNextPayloadAsync, - _options.Output.ResultFlow.PagerRenderers, - _options.Output.ResultFlow.MaxBufferedLines, + new ResultFlowPagerOptions + { + VisibleRows = visibleRows, + VisibleRowsProvider = ResolvePagerVisibleRows, + PagerMode = pagerMode, + AnsiEnabled = ansiEnabled, + HasMorePayload = page.PageInfo.HasMore, + FetchNextPayload = FetchNextPayloadAsync, + PagerRenderers = _options.Output.ResultFlow.PagerRenderers, + MaxBufferedLines = _options.Output.ResultFlow.MaxBufferedLines, + }, cancellationToken) .ConfigureAwait(false); return true; @@ -886,14 +889,14 @@ await ResultFlowPager.WriteAsync( payload, ReplSessionIO.Output, keyReader, - visibleRows, - visibleRowsProvider: null, - pagerMode, - ansiEnabled, - hasMorePayload: false, - fetchNextPayload: null, - _options.Output.ResultFlow.PagerRenderers, - _options.Output.ResultFlow.MaxBufferedLines, + new ResultFlowPagerOptions + { + VisibleRows = visibleRows, + PagerMode = pagerMode, + AnsiEnabled = ansiEnabled, + PagerRenderers = _options.Output.ResultFlow.PagerRenderers, + MaxBufferedLines = _options.Output.ResultFlow.MaxBufferedLines, + }, cancellationToken) .ConfigureAwait(false); return; diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 932d6e6..65f41f9 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -220,19 +220,34 @@ private ReplDocCommand BuildDocumentationCommand(RouteDefinition route) .Select(segment => segment.Name) .ToHashSet(StringComparer.OrdinalIgnoreCase); var handlerParams = route.Command.Handler.Method.GetParameters(); - var arguments = dynamicSegments - .Select(segment => - { - var paramInfo = handlerParams.FirstOrDefault(p => - string.Equals(p.Name, segment.Name, StringComparison.OrdinalIgnoreCase)); - var description = paramInfo?.GetCustomAttribute()?.Description; - return new ReplDocArgument( - Name: segment.Name, - Type: GetConstraintTypeName(segment.ConstraintKind), - Required: !segment.IsOptional, - Description: description); - }) - .ToArray(); + var arguments = BuildDocumentationArguments(dynamicSegments, handlerParams); + var options = BuildDocumentationOptions(route, routeParameterNames, handlerParams); + var answers = BuildDocumentationAnswers(route.Command); + var acceptsPagingInput = handlerParams.Any(static parameter => parameter.ParameterType == typeof(IReplPagingContext)); + var emitsPagedResult = IsPagedReturnType(route.Command.Handler.Method.ReturnType); + + return new ReplDocCommand( + Path: route.Template.Template, + Description: route.Command.Description, + Aliases: route.Command.Aliases, + IsHidden: route.Command.IsHidden, + Arguments: arguments, + Options: options, + Details: route.Command.Details, + Annotations: route.Command.Annotations, + Metadata: route.Command.Metadata.Count > 0 ? route.Command.Metadata : null, + Answers: answers.Length > 0 ? answers : null, + IsResource: route.Command.IsResource, + IsPrompt: route.Command.IsPrompt, + AcceptsPagingInput: acceptsPagingInput, + EmitsPagedResult: emitsPagedResult); + } + + private ReplDocOption[] BuildDocumentationOptions( + RouteDefinition route, + HashSet routeParameterNames, + ParameterInfo[] handlerParams) + { var regularOptions = handlerParams .Where(parameter => !string.IsNullOrWhiteSpace(parameter.Name) @@ -252,25 +267,26 @@ private ReplDocCommand BuildDocumentationCommand(RouteDefinition route) .Where(prop => prop.CanWrite) .Select(prop => BuildDocumentationOptionFromProperty(route.OptionSchema, prop, defaultInstance)); }); - var options = regularOptions.Concat(groupOptions).ToArray(); - - var answers = BuildDocumentationAnswers(route.Command); - - return new ReplDocCommand( - Path: route.Template.Template, - Description: route.Command.Description, - Aliases: route.Command.Aliases, - IsHidden: route.Command.IsHidden, - Arguments: arguments, - Options: options, - Details: route.Command.Details, - Annotations: route.Command.Annotations, - Metadata: route.Command.Metadata.Count > 0 ? route.Command.Metadata : null, - Answers: answers.Length > 0 ? answers : null, - IsResource: route.Command.IsResource, - IsPrompt: route.Command.IsPrompt); + return regularOptions.Concat(groupOptions).ToArray(); } + private static ReplDocArgument[] BuildDocumentationArguments( + DynamicRouteSegment[] dynamicSegments, + ParameterInfo[] handlerParams) => + dynamicSegments + .Select(segment => + { + var paramInfo = handlerParams.FirstOrDefault(p => + string.Equals(p.Name, segment.Name, StringComparison.OrdinalIgnoreCase)); + var description = paramInfo?.GetCustomAttribute()?.Description; + return new ReplDocArgument( + Name: segment.Name, + Type: GetConstraintTypeName(segment.ConstraintKind), + Required: !segment.IsOptional, + Description: description); + }) + .ToArray(); + private static ReplDocAnswer[] BuildDocumentationAnswers(CommandBuilder command) { var fluentAnswers = command.Answers @@ -285,6 +301,26 @@ private static ReplDocAnswer[] BuildDocumentationAnswers(CommandBuilder command) .ToArray(); } + 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; + } + internal ReplDocApp BuildDocumentationApp() { var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); diff --git a/src/Repl.Core/Documentation/ReplDocCommand.cs b/src/Repl.Core/Documentation/ReplDocCommand.cs index 9304d64..a9556ce 100644 --- a/src/Repl.Core/Documentation/ReplDocCommand.cs +++ b/src/Repl.Core/Documentation/ReplDocCommand.cs @@ -15,4 +15,6 @@ public sealed record ReplDocCommand( IReadOnlyDictionary? Metadata = null, IReadOnlyList? Answers = null, bool IsResource = false, - bool IsPrompt = false); + bool IsPrompt = false, + bool AcceptsPagingInput = false, + bool EmitsPagedResult = false); diff --git a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs index dd564cd..e85fee1 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs @@ -5,6 +5,8 @@ namespace Repl; /// public sealed class ResultFlowOptions { + internal const int DefaultMaxBufferedLines = 10_000; + private readonly List _pagerRenderers = []; private int _defaultPageSize = 100; private int _maxPageSize = 1000; @@ -70,7 +72,7 @@ public int MaxPageSize /// /// Gets or sets the maximum number of content lines an interactive pager buffers in memory. /// - public int MaxBufferedLines { get; set; } = 10_000; + public int MaxBufferedLines { get; set; } = DefaultMaxBufferedLines; /// /// Gets or sets the maximum inline payload size for programmatic clients. diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 2027153..90cbc67 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -6,9 +6,9 @@ internal static class ResultFlowPager private const string SourceReturnedNoDataStatus = "-- paging stopped: source returned no data --"; private const string FullStatus = "-- result-flow {0}-{1}/{2}{3} Space: next Up/Down: scroll Home/End: known bounds q: quit --"; private const string FullStatusBufferLimit = "-- result-flow {0}-{1}/{2} buffer limit reached Up/Down: scroll q: quit --"; - private const int DefaultMaxBufferedLines = 10_000; + private const int SpacePaddingLength = 256; private static readonly string MorePromptClear = new(' ', MorePrompt.Length); - private static readonly string SpacePadding = new(' ', 256); + private static readonly string SpacePadding = new(' ', SpacePaddingLength); private static readonly System.Text.CompositeFormat FullStatusFormat = System.Text.CompositeFormat.Parse(FullStatus); private static readonly System.Text.CompositeFormat FullStatusBufferLimitFormat = @@ -26,182 +26,50 @@ internal static ValueTask WriteAsync( payload, output, keyReader, - visibleRows, - hasMorePayload: false, - fetchNextPayload: null, + new ResultFlowPagerOptions { VisibleRows = visibleRows }, cancellationToken); internal static async ValueTask WriteAsync( string payload, TextWriter output, IReplKeyReader keyReader, - int visibleRows, - ReplPagerMode pagerMode, - bool ansiEnabled, - CancellationToken cancellationToken = default) - { - await WriteAsync( - payload, - output, - keyReader, - visibleRows, - pagerMode, - ansiEnabled, - hasMorePayload: false, - fetchNextPayload: null, - cancellationToken) - .ConfigureAwait(false); - } - - internal 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, - visibleRowsProvider: null, - ReplPagerMode.More, - ansiEnabled: false, - hasMorePayload, - fetchNextPayload, - pagerRenderers: null, - cancellationToken) - .ConfigureAwait(false); - } - - internal static async ValueTask WriteAsync( - string payload, - TextWriter output, - IReplKeyReader keyReader, - int visibleRows, - ReplPagerMode pagerMode, - bool ansiEnabled, - bool hasMorePayload, - Func>? fetchNextPayload, - CancellationToken cancellationToken = default) - { - await WriteAsync( - payload, - output, - keyReader, - visibleRows, - visibleRowsProvider: null, - pagerMode, - ansiEnabled, - hasMorePayload, - fetchNextPayload, - pagerRenderers: null, - cancellationToken) - .ConfigureAwait(false); - } - - internal static async ValueTask WriteAsync( - string payload, - TextWriter output, - IReplKeyReader keyReader, - int visibleRows, - Func visibleRowsProvider, - ReplPagerMode pagerMode, - bool ansiEnabled, - bool hasMorePayload, - Func>? fetchNextPayload, - CancellationToken cancellationToken = default) - { - await WriteAsync( - payload, - output, - keyReader, - visibleRows, - visibleRowsProvider, - pagerMode, - ansiEnabled, - hasMorePayload, - fetchNextPayload, - pagerRenderers: null, - cancellationToken) - .ConfigureAwait(false); - } - - internal static ValueTask WriteAsync( - string payload, - TextWriter output, - IReplKeyReader keyReader, - int visibleRows, - Func? visibleRowsProvider, - ReplPagerMode pagerMode, - bool ansiEnabled, - bool hasMorePayload, - Func>? fetchNextPayload, - IEnumerable? pagerRenderers, - CancellationToken cancellationToken = default) - => WriteAsync( - payload, - output, - keyReader, - visibleRows, - visibleRowsProvider, - pagerMode, - ansiEnabled, - hasMorePayload, - fetchNextPayload, - pagerRenderers, - DefaultMaxBufferedLines, - cancellationToken); - - internal static async ValueTask WriteAsync( - string payload, - TextWriter output, - IReplKeyReader keyReader, - int visibleRows, - Func? visibleRowsProvider, - ReplPagerMode pagerMode, - bool ansiEnabled, - bool hasMorePayload, - Func>? fetchNextPayload, - IEnumerable? pagerRenderers, - int maxBufferedLines, + ResultFlowPagerOptions options, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(output); ArgumentNullException.ThrowIfNull(keyReader); - maxBufferedLines = Math.Max(1, maxBufferedLines); + ArgumentNullException.ThrowIfNull(options); + var visibleRows = options.VisibleRows; + var maxBufferedLines = Math.Max(1, options.MaxBufferedLines); - var mode = ResolveMode(pagerMode, ansiEnabled); + var mode = ResolveMode(options.PagerMode, options.AnsiEnabled); if (await TryRenderCustomAsync( mode, - pagerRenderers, + options.PagerRenderers, payload, output, keyReader, visibleRows, - visibleRowsProvider, - ansiEnabled, - hasMorePayload, - fetchNextPayload, + options.VisibleRowsProvider, + options.AnsiEnabled, + options.HasMorePayload, + options.FetchNextPayload, cancellationToken) .ConfigureAwait(false)) { return; } - var session = new PagerSession(payload, hasMorePayload, maxBufferedLines); + var session = new PagerSession(payload, options.HasMorePayload, maxBufferedLines); await RenderBuiltInAsync( mode, session, output, keyReader, visibleRows, - visibleRowsProvider, - ansiEnabled, - fetchNextPayload, + options.VisibleRowsProvider, + options.AnsiEnabled, + options.FetchNextPayload, cancellationToken) .ConfigureAwait(false); } @@ -650,7 +518,7 @@ private static async ValueTask WriteViewportLineAsync( bool appendNewLine = true) { await output.WriteAsync(line).ConfigureAwait(false); - var visualLength = GetVisualLength(line); + var visualLength = Repl.Terminal.AnsiTextMetrics.GetVisualLength(line); var previousLength = state.GetRenderedLineLength(row); if (previousLength > visualLength) { @@ -727,34 +595,6 @@ private static async ValueTask WriteSpacesAsync(TextWriter output, int count) } } - private static int GetVisualLength(string line) - { - var length = 0; - for (var i = 0; i < line.Length; i++) - { - if (line[i] == '\u001b') - { - if (i + 1 < line.Length && line[i + 1] == '[') - { - i += 2; - while (i < line.Length && (line[i] < '@' || line[i] > '~')) - { - i++; - } - } - - continue; - } - - if (!char.IsControl(line[i])) - { - length++; - } - } - - return length; - } - private static int GetCurrentVisibleRows(int fallbackVisibleRows, Func? visibleRowsProvider) { if (visibleRowsProvider is null) diff --git a/src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs b/src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs new file mode 100644 index 0000000..8a0ddfd --- /dev/null +++ b/src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs @@ -0,0 +1,20 @@ +namespace Repl; + +internal sealed record ResultFlowPagerOptions +{ + public int VisibleRows { get; init; } + + public Func? VisibleRowsProvider { get; init; } + + public ReplPagerMode PagerMode { get; init; } = ReplPagerMode.More; + + public bool AnsiEnabled { get; init; } + + public bool HasMorePayload { get; init; } + + public Func>? FetchNextPayload { get; init; } + + public IEnumerable? PagerRenderers { get; init; } + + public int MaxBufferedLines { get; init; } = ResultFlowOptions.DefaultMaxBufferedLines; +} diff --git a/src/Repl.Core/Terminal/AnsiTextMetrics.cs b/src/Repl.Core/Terminal/AnsiTextMetrics.cs new file mode 100644 index 0000000..340bd5c --- /dev/null +++ b/src/Repl.Core/Terminal/AnsiTextMetrics.cs @@ -0,0 +1,67 @@ +namespace Repl.Terminal; + +internal static class AnsiTextMetrics +{ + public static int GetVisualLength(string text) + { + var length = 0; + for (var i = 0; i < text.Length; i++) + { + if (text[i] == '\u001b') + { + if (i + 1 >= text.Length) + { + continue; + } + + // ANSI control sequences are terminal protocol bytes, not columns. + // CSI covers styling/cursor controls; OSC covers hyperlinks and titles; SS3 covers special keys. + i = text[i + 1] switch + { + '[' => SkipCsiSequence(text, i + 2), + ']' => SkipOscSequence(text, i + 2), + 'O' => Math.Min(text.Length - 1, i + 2), + _ => i + 1, + }; + + continue; + } + + if (!char.IsControl(text[i])) + { + length++; + } + } + + return length; + } + + private static int SkipCsiSequence(string text, int start) + { + var i = start; + while (i < text.Length && (text[i] < '@' || text[i] > '~')) + { + i++; + } + + return Math.Min(i, text.Length - 1); + } + + private static int SkipOscSequence(string text, int start) + { + for (var i = start; i < text.Length; i++) + { + if (text[i] == '\u0007') + { + return i; + } + + if (text[i] == '\u001b' && i + 1 < text.Length && text[i + 1] == '\\') + { + return i + 1; + } + } + + return text.Length - 1; + } +} diff --git a/src/Repl.Mcp/McpPagedResultTextMode.cs b/src/Repl.Mcp/McpPagedResultTextMode.cs new file mode 100644 index 0000000..475d08e --- /dev/null +++ b/src/Repl.Mcp/McpPagedResultTextMode.cs @@ -0,0 +1,22 @@ +namespace Repl.Mcp; + +/// +/// Controls the text fallback emitted with structured MCP paged results. +/// +public enum McpPagedResultTextMode +{ + /// + /// Emit compact serialized JSON in Content for MCP clients that ignore structured content. + /// + SerializedJson, + + /// + /// Emit only a short summary, minimizing token cost and keeping raw cursors out of text content. + /// + SummaryOnly, + + /// + /// Emit a short summary followed by compact serialized JSON. + /// + SummaryAndSerializedJson, +} diff --git a/src/Repl.Mcp/McpSchemaGenerator.cs b/src/Repl.Mcp/McpSchemaGenerator.cs index 970b77c..e3b5098 100644 --- a/src/Repl.Mcp/McpSchemaGenerator.cs +++ b/src/Repl.Mcp/McpSchemaGenerator.cs @@ -57,7 +57,10 @@ public static JsonElement BuildInputSchema(ReplDocCommand command) } AddAnswerProperties(command, properties); - AddResultFlowProperties(properties); + if (command.AcceptsPagingInput || command.EmitsPagedResult) + { + AddResultFlowProperties(properties); + } var schema = new JsonObject { @@ -88,6 +91,47 @@ private static void AddResultFlowProperties(JsonObject properties) }; } + public static JsonElement? BuildOutputSchema(ReplDocCommand command) + { + if (!command.EmitsPagedResult) + { + return null; + } + + var schema = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["$type"] = new JsonObject + { + ["type"] = "string", + ["const"] = "page", + }, + ["items"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject(), + }, + ["pageInfo"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["cursor"] = new JsonObject { ["type"] = "string" }, + ["nextCursor"] = new JsonObject { ["type"] = "string" }, + ["totalCount"] = new JsonObject { ["type"] = "integer" }, + ["pageSize"] = new JsonObject { ["type"] = "integer" }, + ["hasMore"] = new JsonObject { ["type"] = "boolean" }, + }, + }, + }, + ["required"] = new JsonArray(["$type", "items", "pageInfo"]), + }; + + return JsonSerializer.SerializeToElement(schema, McpJsonContext.Default.JsonObject); + } + 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 8057903..e8d1ff6 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -145,17 +145,17 @@ private async Task ExecuteThroughPipelineAsync( output = exitCode == 0 ? "OK" : $"Command failed with exit code {exitCode}."; } - return BuildToolResult(output, exitCode); + return BuildToolResult(output, exitCode, _options.PagedResultTextMode); } } - private static CallToolResult BuildToolResult(string output, int exitCode) + private static CallToolResult BuildToolResult(string output, int exitCode, McpPagedResultTextMode pagedTextMode) { if (exitCode == 0 && TryCreatePagedStructuredResult(output, out var structuredContent, out var summary)) { return new CallToolResult { - Content = [new TextContentBlock { Text = summary }], + Content = [new TextContentBlock { Text = BuildPagedTextContent(output, summary, pagedTextMode) }], StructuredContent = structuredContent, IsError = false, }; @@ -168,6 +168,17 @@ private static CallToolResult BuildToolResult(string output, int exitCode) }; } + private static string BuildPagedTextContent( + string serializedPage, + string summary, + McpPagedResultTextMode mode) => + mode switch + { + McpPagedResultTextMode.SummaryOnly => summary, + McpPagedResultTextMode.SummaryAndSerializedJson => string.Concat(summary, Environment.NewLine, serializedPage), + _ => serializedPage, + }; + private static bool TryCreatePagedStructuredResult( string output, out JsonElement structuredContent, @@ -249,6 +260,12 @@ private static (List Tokens, Dictionary Prefills) Prepar ? value.GetString() ?? "" : value.GetRawText(); + if (allowedArgumentNames is not null && !allowedArgumentNames.Contains(key)) + { + throw new InvalidOperationException( + $"The MCP argument '{key}' is not defined by the tool schema."); + } + if (key.StartsWith("answer.", StringComparison.OrdinalIgnoreCase)) { prefills[key["answer.".Length..]] = strValue; @@ -267,12 +284,7 @@ private static (List Tokens, Dictionary Prefills) Prepar } else { - if (allowedArgumentNames is not null && !allowedArgumentNames.Contains(key)) - { - throw new InvalidOperationException( - $"The MCP argument '{key}' is not defined by the tool schema."); - } - + ValidateCommandArgumentValue(strValue); stringArgs[key] = strValue; } } @@ -292,7 +304,7 @@ private static void ValidateResultPageSize(string pageSize) throw new InvalidOperationException("The MCP result page size cannot exceed 10 characters."); } - if (pageSize.Length == 0 || pageSize.Any(static c => c < '0' || c > '9')) + if (pageSize.Length == 0 || ContainsNonDigit(pageSize)) { throw new InvalidOperationException("The MCP result page size must be numeric."); } @@ -304,6 +316,27 @@ private static void ValidateResultPageSize(string pageSize) } } + private static bool ContainsNonDigit(string value) + { + foreach (var c in value) + { + if (c < '0' || c > '9') + { + return true; + } + } + + return false; + } + + private static void ValidateCommandArgumentValue(string value) + { + if (value.StartsWith("--", StringComparison.Ordinal)) + { + throw new InvalidOperationException("The MCP argument value cannot start like a CLI option."); + } + } + private static HashSet BuildAllowedArgumentNames(ReplDocCommand command) { var names = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -325,8 +358,12 @@ private static HashSet BuildAllowedArgumentNames(ReplDocCommand command) } } - names.Add(McpResultFlowArgumentNames.Cursor); - names.Add(McpResultFlowArgumentNames.PageSize); + if (command.AcceptsPagingInput || command.EmitsPagedResult) + { + names.Add(McpResultFlowArgumentNames.Cursor); + names.Add(McpResultFlowArgumentNames.PageSize); + } + return names; } diff --git a/src/Repl.Mcp/ReplMcpServerOptions.cs b/src/Repl.Mcp/ReplMcpServerOptions.cs index 5d033ba..6f363fe 100644 --- a/src/Repl.Mcp/ReplMcpServerOptions.cs +++ b/src/Repl.Mcp/ReplMcpServerOptions.cs @@ -52,6 +52,11 @@ public sealed class ReplMcpServerOptions /// public InteractivityMode InteractivityMode { get; set; } = InteractivityMode.PrefillThenFail; + /// + /// Controls the text fallback emitted alongside structured paged tool results. + /// + public McpPagedResultTextMode PagedResultTextMode { get; set; } = McpPagedResultTextMode.SerializedJson; + /// /// Optional filter controlling which commands are exposed as MCP tools. /// When null, all non-hidden, non- commands are exposed. diff --git a/src/Repl.Mcp/ReplMcpServerTool.cs b/src/Repl.Mcp/ReplMcpServerTool.cs index 6267560..cb4b1c0 100644 --- a/src/Repl.Mcp/ReplMcpServerTool.cs +++ b/src/Repl.Mcp/ReplMcpServerTool.cs @@ -29,6 +29,7 @@ public ReplMcpServerTool( Name = toolName, Description = McpSchemaGenerator.BuildDescription(command), InputSchema = McpSchemaGenerator.BuildInputSchema(command), + OutputSchema = McpSchemaGenerator.BuildOutputSchema(command), Annotations = McpSchemaGenerator.MapAnnotations(command.Annotations), Execution = command.Annotations?.LongRunning == true ? new ToolExecution { TaskSupport = ToolTaskSupport.Optional } diff --git a/src/Repl.McpTests/Given_McpSchemaGenerator.cs b/src/Repl.McpTests/Given_McpSchemaGenerator.cs index e9b3cf6..52e7e1d 100644 --- a/src/Repl.McpTests/Given_McpSchemaGenerator.cs +++ b/src/Repl.McpTests/Given_McpSchemaGenerator.cs @@ -134,6 +134,56 @@ public void When_ArgumentHasDescription_Then_SchemaContainsDescription() prop.GetProperty("description").GetString().Should().Be("Contact name"); } + [TestMethod] + [Description("Paged commands expose result-flow continuation inputs.")] + public void When_CommandUsesResultFlow_Then_InputSchemaContainsContinuationInputs() + { + var cmd = CreateCommand(usesResultFlow: true); + + var schema = McpSchemaGenerator.BuildInputSchema(cmd); + var properties = schema.GetProperty("properties"); + + properties.TryGetProperty("_replCursor", out _).Should().BeTrue(); + properties.TryGetProperty("_replPageSize", out _).Should().BeTrue(); + } + + [TestMethod] + [Description("Non-paged commands do not expose result-flow continuation inputs.")] + public void When_CommandDoesNotUseResultFlow_Then_InputSchemaDoesNotContainContinuationInputs() + { + var cmd = CreateCommand(usesResultFlow: false); + + var schema = McpSchemaGenerator.BuildInputSchema(cmd); + var properties = schema.GetProperty("properties"); + + properties.TryGetProperty("_replCursor", out _).Should().BeFalse(); + properties.TryGetProperty("_replPageSize", out _).Should().BeFalse(); + } + + [TestMethod] + [Description("Paged commands expose an output schema for structured page results.")] + public void When_CommandEmitsPagedResult_Then_OutputSchemaContainsPageEnvelope() + { + var cmd = CreateCommand(emitsPagedResult: true); + + var schema = McpSchemaGenerator.BuildOutputSchema(cmd); + + schema.Should().NotBeNull(); + schema!.Value.GetProperty("properties").GetProperty("$type").GetProperty("const").GetString() + .Should().Be("page"); + schema.Value.GetProperty("properties").TryGetProperty("items", out _).Should().BeTrue(); + schema.Value.GetProperty("properties").TryGetProperty("pageInfo", out _).Should().BeTrue(); + } + + [TestMethod] + [Description("Non-paged commands do not expose a result-flow output schema.")] + public void When_CommandDoesNotEmitPagedResult_Then_OutputSchemaIsNull() + { + var cmd = CreateCommand(emitsPagedResult: false); + + McpSchemaGenerator.BuildOutputSchema(cmd).Should().BeNull(); + } + // ── Annotation mapping ───────────────────────────────────────────── [TestMethod] @@ -208,7 +258,9 @@ private static ReplDocCommand CreateCommand( string? description = null, string? details = null, ReplDocArgument[]? arguments = null, - ReplDocOption[]? options = null) => + ReplDocOption[]? options = null, + bool usesResultFlow = false, + bool emitsPagedResult = false) => new( Path: path, Description: description, @@ -216,7 +268,14 @@ private static ReplDocCommand CreateCommand( IsHidden: false, Arguments: arguments ?? [], Options: options ?? [], - Details: details); + Details: details, + AcceptsPagingInput: usesResultFlow, + EmitsPagedResult: emitsPagedResult, + Metadata: new Dictionary(StringComparer.Ordinal) + { + ["ResultFlow.AcceptsPagingInput"] = usesResultFlow, + ["ResultFlow.EmitsPagedResult"] = emitsPagedResult, + }); private static ReplDocOption CreateOption( string name, diff --git a/src/Repl.McpTests/Given_McpServerEndToEnd.cs b/src/Repl.McpTests/Given_McpServerEndToEnd.cs index 07166cc..0fff0fe 100644 --- a/src/Repl.McpTests/Given_McpServerEndToEnd.cs +++ b/src/Repl.McpTests/Given_McpServerEndToEnd.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; +using Repl.Mcp; using Repl.Parameters; namespace Repl.McpTests; @@ -52,9 +53,9 @@ public async Task When_ToolsList_Then_SchemaIsCorrect() 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"); + .Should().BeFalse("non-paged MCP tools should not expose Repl continuation cursors"); schema.GetProperty("properties").TryGetProperty("_replPageSize", out _) - .Should().BeTrue("MCP tools should expose Repl page sizing for large data"); + .Should().BeFalse("non-paged MCP tools should not expose Repl page sizing"); schema.GetProperty("required")[0].GetString() .Should().Be("id"); } @@ -110,11 +111,42 @@ 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 + ?? throw new AssertFailedException("Expected a text content block."); + text.Should().Contain("page-2"); + text.Should().Contain("\"items\""); + } + + [TestMethod] + [Description("tools/call can use summary-only paged text content for low-token clients.")] + public async Task When_PagedResultTextModeIsSummaryOnly_Then_RawCursorStaysOutOfText() + { + 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(); + }, + options => options.PagedResultTextMode = McpPagedResultTextMode.SummaryOnly); + + var result = await fixture.Client.CallToolAsync( + "contacts", + new Dictionary(StringComparer.Ordinal) + { + ["_replPageSize"] = 1, + }); + + result.StructuredContent.Should().NotBeNull(); 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"); + text.Should().NotContain("\"items\""); } [TestMethod] diff --git a/src/Repl.McpTests/Given_McpToolAdapter.cs b/src/Repl.McpTests/Given_McpToolAdapter.cs index 484a566..2daa31d 100644 --- a/src/Repl.McpTests/Given_McpToolAdapter.cs +++ b/src/Repl.McpTests/Given_McpToolAdapter.cs @@ -246,4 +246,73 @@ public void When_ArgumentIsNotInToolSchema_Then_Rejected() action.Should().Throw() .WithMessage("*not defined*schema*"); } + + [TestMethod] + [Description("PrepareExecution rejects token-like MCP argument values before reconstructing CLI tokens.")] + public void When_ArgumentValueStartsWithDashDash_Then_Rejected() + { + var command = new ReplDocCommand( + Path: "contacts", + Description: null, + Aliases: [], + IsHidden: false, + Arguments: [], + Options: [new ReplDocOption("format", "string", Required: false, Description: null, Aliases: [], ReverseAliases: [], ValueAliases: [], EnumValues: [], DefaultValue: null)]); + + var action = () => McpToolAdapter.PrepareExecution( + command, + new Dictionary(StringComparer.Ordinal) + { + ["format"] = JsonSerializer.SerializeToElement("--result:all"), + }); + + action.Should().Throw() + .WithMessage("*argument value*CLI option*"); + } + + [TestMethod] + [Description("PrepareExecution rejects answer prefills that are not declared by the command schema.")] + public void When_AnswerPrefillIsNotInToolSchema_Then_Rejected() + { + var command = new ReplDocCommand( + Path: "wizard", + Description: null, + Aliases: [], + IsHidden: false, + Arguments: [], + Options: []); + + var action = () => McpToolAdapter.PrepareExecution( + command, + new Dictionary(StringComparer.Ordinal) + { + ["answer.confirm"] = JsonSerializer.SerializeToElement("yes"), + }); + + action.Should().Throw() + .WithMessage("*not defined*schema*"); + } + + [TestMethod] + [Description("PrepareExecution rejects result-flow inputs for commands that do not expose paging in their schema.")] + public void When_ResultFlowInputIsNotInToolSchema_Then_Rejected() + { + var command = new ReplDocCommand( + Path: "contacts", + Description: null, + Aliases: [], + IsHidden: false, + Arguments: [], + Options: []); + + var action = () => McpToolAdapter.PrepareExecution( + command, + new Dictionary(StringComparer.Ordinal) + { + [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement("abc123"), + }); + + action.Should().Throw() + .WithMessage("*not defined*schema*"); + } } diff --git a/src/Repl.Tests/Given_AnsiTextMetrics.cs b/src/Repl.Tests/Given_AnsiTextMetrics.cs new file mode 100644 index 0000000..3f34a08 --- /dev/null +++ b/src/Repl.Tests/Given_AnsiTextMetrics.cs @@ -0,0 +1,23 @@ +using Repl.Terminal; + +namespace Repl.Tests; + +[TestClass] +public sealed class Given_AnsiTextMetrics +{ + [TestMethod] + [Description("Visible length ignores ANSI CSI styling bytes.")] + public void When_TextContainsCsiSequence_Then_VisibleLengthIgnoresControlBytes() + { + AnsiTextMetrics.GetVisualLength("\u001b[1mhello\u001b[0m").Should().Be(5); + } + + [TestMethod] + [Description("Visible length ignores ANSI OSC hyperlinks.")] + public void When_TextContainsOscHyperlink_Then_VisibleLengthIgnoresControlBytes() + { + var link = "\u001b]8;;https://example.invalid\u0007link\u001b]8;;\u0007"; + + AnsiTextMetrics.GetVisualLength(link).Should().Be(4); + } +} diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index 848cb92..edc9126 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -16,7 +16,7 @@ public async Task When_PagingWithSpaceAndQuit_Then_WritesOnlyRequestedPages() MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree\nfour\nfive", writer, keys, @@ -47,7 +47,7 @@ public async Task When_PagingWithEnter_Then_AdvancesSingleLine() MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree\nfour", writer, keys, @@ -74,7 +74,7 @@ public async Task When_MorePagerUsesAnsiPrompt_Then_PromptDoesNotBecomeAVisibleR MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree\nfour", writer, keys, @@ -104,7 +104,7 @@ public async Task When_MorePagerReceivesUpArrow_Then_DoesNotReplayOrAdvance() MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "# At Area Event Summary\n---\nr1\nr2\nr3\nr4\nr5", writer, keys, @@ -130,7 +130,7 @@ public async Task When_CurrentPayloadEndsAndMoreDataExists_Then_SpaceFetchesNext MakeKey(ConsoleKey.Spacebar, ' '), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo", writer, keys, @@ -163,7 +163,7 @@ public async Task When_MorePagerPageDownCrossesPayloadBoundary_Then_FetchesAndCo MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree", writer, keys, @@ -193,7 +193,7 @@ public async Task When_CurrentPayloadEndsAndUserQuits_Then_DoesNotFetchNextPaylo MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo", writer, keys, @@ -223,7 +223,7 @@ public async Task When_CurrentPayloadIsEmptyAndMoreDataExists_Then_FetchesNextPa var fetches = 0; var keys = new FakeKeyReader([]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( string.Empty, writer, keys, @@ -256,7 +256,7 @@ public async Task When_MorePagerAtPayloadBoundaryReceivesUpArrow_Then_DoesNotRep MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree\nfour", writer, keys, @@ -281,7 +281,7 @@ public async Task When_MorePagerFetchesNextPayload_Then_DuplicateHeadersAndFoote MakeKey(ConsoleKey.Spacebar, ' '), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( $"{header}\none\ntwo\nShowing 2 of 5.", writer, keys, @@ -310,7 +310,7 @@ public async Task When_MorePagerFetchesHumanOutput_Then_DuplicateHashHeadersAreS MakeKey(ConsoleKey.Spacebar, ' '), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( $"{header}\n--- ----------------- --------- ---------- ----------------------------------------\n1 2026-01-12 identity validated identity batch 1 validated successfully\nShowing 1 of 3.", writer, keys, @@ -342,7 +342,7 @@ public async Task When_ContinuationPayloadIsMarkedClean_Then_FooterLikeDataLineI MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one", writer, keys, @@ -366,7 +366,7 @@ public async Task When_ScrollPagerRunsWithAnsi_Then_UsesAlternateScreenViewport( MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree", writer, keys, @@ -397,7 +397,7 @@ public async Task When_ScrollPagerReachesBufferedEnd_Then_FetchesNextPayload() MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree", writer, keys, @@ -431,7 +431,7 @@ public async Task When_ScrollPagerFetchesShortPayload_Then_ViewportAdvances() MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree\nfour", writer, keys, @@ -460,7 +460,7 @@ public async Task When_ScrollPagerContentExactlyFitsViewport_Then_SpaceFetchesNe MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree", writer, keys, @@ -490,7 +490,7 @@ public async Task When_PayloadEndsWithNewline_Then_LineCountExcludesTrailingEmpt MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\n", writer, keys, @@ -514,7 +514,7 @@ public async Task When_ScrollPagerUnknownKeyPressed_Then_ViewportDoesNotAdvanceA MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree\nfour", writer, keys, @@ -545,7 +545,7 @@ public async Task When_ScrollPagerEnterKeyPressed_Then_ViewportAdvancesByOneLine MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree\nfour", writer, keys, @@ -570,7 +570,7 @@ public async Task When_ScrollPagerDownFetchesNextPayload_Then_ViewportAdvancesOn MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree", writer, keys, @@ -600,7 +600,7 @@ public async Task When_ScrollPagerHasRichTableHeader_Then_HeaderStaysPinned() ]); var header = "\u001b[1m#\u001b[0m \u001b[1mAt\u001b[0m"; - await ResultFlowPager.WriteAsync( + await WritePagerAsync( $"{header}\none\ntwo\nthree", writer, keys, @@ -631,7 +631,7 @@ public async Task When_ScrollPagerRedraws_Then_DoesNotClearScreenEveryTime() MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree\nfour", writer, keys, @@ -644,6 +644,34 @@ await ResultFlowPager.WriteAsync( writer.ToString().Should().NotContain("\u001b[2K"); } + [TestMethod] + [Description("Result-flow full pager measures OSC hyperlink escapes as zero-width terminal control data.")] + public async Task When_ScrollPagerRedrawsOverOscHyperlink_Then_PadsUsingVisibleWidth() + { + using var writer = new StringWriter(); + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.DownArrow, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + var link = "\u001b]8;;https://example.invalid\u0007link\u001b]8;;\u0007"; + + await WritePagerAsync( + $"{link}\nx", + writer, + keys, + new ResultFlowPagerOptions + { + VisibleRows = 2, + PagerMode = ReplPagerMode.Full, + AnsiEnabled = true, + }, + CancellationToken.None); + + writer.ToString().Should().Contain($"x {Environment.NewLine}"); + writer.ToString().Should().NotContain("x "); + } + [TestMethod] [Description("Result-flow full pager strips page footer hints already represented by its own status bar.")] public async Task When_ScrollPagerReceivesPageFooterLines_Then_FooterLinesAreNotRendered() @@ -656,7 +684,7 @@ public async Task When_ScrollPagerReceivesPageFooterLines_Then_FooterLinesAreNot MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nShowing 2 of 5. Next data page: rerun with --result:cursor page-2.", writer, keys, @@ -688,7 +716,7 @@ public async Task When_ScrollPagerReceivesIndentedDuplicateHeader_Then_HeaderIsN ]); var header = "\u001b[1m#\u001b[0m \u001b[1mAt\u001b[0m"; - await ResultFlowPager.WriteAsync( + await WritePagerAsync( $"{header}\none\ntwo\nShowing 2 of 5.", writer, keys, @@ -717,7 +745,7 @@ public async Task When_ScrollPagerEndPressed_Then_MovesToKnownEndWithoutFetching MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree\nfour\nfive", writer, keys, @@ -750,7 +778,7 @@ public async Task When_ScrollPagerHeightChanges_Then_ViewportUsesCurrentHeight() ]); var reads = 0; - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree\nfour\nfive", writer, keys, @@ -778,7 +806,7 @@ public async Task When_ScrollPagerRuns_Then_LineWrappingIsDisabledDuringAlternat MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree", writer, keys, @@ -806,7 +834,7 @@ public async Task When_InlinePagerRuns_Then_RedrawsWithoutAlternateScreen() MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo\nthree\nfour", writer, keys, @@ -829,7 +857,7 @@ public async Task When_CustomPagerRendererIsConfigured_Then_ItHandlesTheMatching using var writer = new StringWriter(); var renderer = new RecordingPagerRenderer(ReplPagerMode.Inline); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo", writer, new FakeKeyReader([]), @@ -905,7 +933,7 @@ public async Task When_ViewportPagerReachesBufferLimit_Then_StatusReportsLimit() MakeKey(ConsoleKey.Q, 'q'), ]); - await ResultFlowPager.WriteAsync( + await WritePagerAsync( "one\ntwo", writer, keys, @@ -952,6 +980,167 @@ public void When_HeaderContainsLoneEscape_Then_NormalizationStillDeduplicatesCon second.ContentLines.Should().Equal("two"); } + private static ValueTask WritePagerAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + CancellationToken cancellationToken = default) + => ResultFlowPager.WriteAsync(payload, output, keyReader, visibleRows, cancellationToken); + + private static ValueTask WritePagerAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + ReplPagerMode pagerMode, + bool ansiEnabled, + CancellationToken cancellationToken = default) + => ResultFlowPager.WriteAsync( + payload, + output, + keyReader, + new ResultFlowPagerOptions + { + VisibleRows = visibleRows, + PagerMode = pagerMode, + AnsiEnabled = ansiEnabled, + }, + cancellationToken); + + private static ValueTask WritePagerAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + bool hasMorePayload, + Func>? fetchNextPayload, + CancellationToken cancellationToken = default) + => WritePagerAsync( + payload, + output, + keyReader, + visibleRows, + ReplPagerMode.More, + ansiEnabled: false, + hasMorePayload, + fetchNextPayload, + cancellationToken); + + private static ValueTask WritePagerAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + ReplPagerMode pagerMode, + bool ansiEnabled, + bool hasMorePayload, + Func>? fetchNextPayload, + CancellationToken cancellationToken = default) + => ResultFlowPager.WriteAsync( + payload, + output, + keyReader, + new ResultFlowPagerOptions + { + VisibleRows = visibleRows, + PagerMode = pagerMode, + AnsiEnabled = ansiEnabled, + HasMorePayload = hasMorePayload, + FetchNextPayload = fetchNextPayload, + }, + cancellationToken); + + private static ValueTask WritePagerAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + Func visibleRowsProvider, + ReplPagerMode pagerMode, + bool ansiEnabled, + bool hasMorePayload, + Func>? fetchNextPayload, + CancellationToken cancellationToken = default) + => WritePagerAsync( + payload, + output, + keyReader, + visibleRows, + visibleRowsProvider, + pagerMode, + ansiEnabled, + hasMorePayload, + fetchNextPayload, + pagerRenderers: null, + cancellationToken); + + private static ValueTask WritePagerAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + Func? visibleRowsProvider, + ReplPagerMode pagerMode, + bool ansiEnabled, + bool hasMorePayload, + Func>? fetchNextPayload, + IEnumerable? pagerRenderers, + CancellationToken cancellationToken = default) + => ResultFlowPager.WriteAsync( + payload, + output, + keyReader, + new ResultFlowPagerOptions + { + VisibleRows = visibleRows, + VisibleRowsProvider = visibleRowsProvider, + PagerMode = pagerMode, + AnsiEnabled = ansiEnabled, + HasMorePayload = hasMorePayload, + FetchNextPayload = fetchNextPayload, + PagerRenderers = pagerRenderers, + }, + cancellationToken); + + private static ValueTask WritePagerAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + ResultFlowPagerOptions options, + CancellationToken cancellationToken = default) + => ResultFlowPager.WriteAsync(payload, output, keyReader, options, cancellationToken); + + private static ValueTask WritePagerAsync( + string payload, + TextWriter output, + IReplKeyReader keyReader, + int visibleRows, + Func? visibleRowsProvider, + ReplPagerMode pagerMode, + bool ansiEnabled, + bool hasMorePayload, + Func>? fetchNextPayload, + IEnumerable? pagerRenderers, + int maxBufferedLines, + CancellationToken cancellationToken = default) + => ResultFlowPager.WriteAsync( + payload, + output, + keyReader, + new ResultFlowPagerOptions + { + VisibleRows = visibleRows, + VisibleRowsProvider = visibleRowsProvider, + PagerMode = pagerMode, + AnsiEnabled = ansiEnabled, + HasMorePayload = hasMorePayload, + FetchNextPayload = fetchNextPayload, + PagerRenderers = pagerRenderers, + MaxBufferedLines = maxBufferedLines, + }, + cancellationToken); + private static ConsoleKeyInfo MakeKey(ConsoleKey key, char keyChar) => new(keyChar, key, shift: false, alt: false, control: false); From e3fb48e320f84f29477aed6d52999972262e6291 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 9 May 2026 11:41:12 -0400 Subject: [PATCH 43/45] Harden paging review fixes --- docs/result-flow.md | 5 ++ src/Repl.Core/CoreReplApp.Execution.cs | 11 +++- .../Documentation/DocumentationEngine.cs | 60 ++++--------------- src/Repl.Core/ISubInvocableReplApp.cs | 9 +++ .../Output/HumanOutputTransformer.cs | 22 +------ .../Output/MarkdownOutputTransformer.cs | 22 +------ .../Output/ResultFlowPageFooterBuilder.cs | 41 +++++++++++++ src/Repl.Core/ResultFlow/ResultFlowOptions.cs | 37 ++++++++++-- src/Repl.Core/ResultFlow/ResultFlowPager.cs | 2 +- .../ResultFlow/ResultFlowPagerOptions.cs | 2 +- src/Repl.Defaults/ReplApp.cs | 4 ++ src/Repl.Mcp/McpPagedResultTextMode.cs | 6 +- src/Repl.Mcp/McpSchemaGenerator.cs | 22 +++---- src/Repl.Mcp/McpToolAdapter.cs | 42 ++++--------- src/Repl.Mcp/ReplPageWireNames.cs | 14 +++++ src/Repl.McpTests/Given_McpToolAdapter.cs | 45 +++++++++++--- src/Repl.Tests/Given_AnsiTextMetrics.cs | 9 +++ src/Repl.Tests/Given_ReplLogging.cs | 35 +++++++++++ src/Repl.Tests/Given_ResultFlowPager.cs | 60 ++++++++++++++++++- 19 files changed, 292 insertions(+), 156 deletions(-) create mode 100644 src/Repl.Core/ISubInvocableReplApp.cs create mode 100644 src/Repl.Core/Output/ResultFlowPageFooterBuilder.cs create mode 100644 src/Repl.Mcp/ReplPageWireNames.cs diff --git a/docs/result-flow.md b/docs/result-flow.md index 8d14807..d9a81c9 100644 --- a/docs/result-flow.md +++ b/docs/result-flow.md @@ -747,6 +747,11 @@ events with cursor and page-size metadata. - `Debug`: page fetch starting/succeeded. - `Error`: page fetch failed, including the exception. +Apps created through `ReplApp.Create()` or `AddRepl(...)` get this bridge by +default through the defaults package. Apps built directly on `Repl.Core` remain +dependency-free and can opt in by registering their own +`IReplResultFlowDiagnostics` implementation. + ## Implementation Notes - Existing handlers that return `IEnumerable` keep their current behavior. diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index dc194c9..9d5ba98 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -4,7 +4,7 @@ namespace Repl; -public sealed partial class CoreReplApp +public sealed partial class CoreReplApp : ISubInvocableReplApp { /// /// Runs the app in synchronous mode. @@ -53,6 +53,12 @@ internal ValueTask RunSubInvocationAsync( CancellationToken cancellationToken = default) => ExecuteCoreAsync(args, serviceProvider, isSubInvocation: true, cancellationToken); + ValueTask ISubInvocableReplApp.RunSubInvocationAsync( + string[] args, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) => + RunSubInvocationAsync(args, serviceProvider, cancellationToken); + private async ValueTask ExecuteCoreAsync( IReadOnlyList args, IServiceProvider serviceProvider, @@ -1054,7 +1060,8 @@ private async ValueTask FetchPageSourceAsync( private IReplResultFlowDiagnostics? ResolveResultFlowDiagnostics() { var serviceProvider = _runtimeState.Value?.ServiceProvider ?? _services; - return serviceProvider.GetService(typeof(IReplResultFlowDiagnostics)) as IReplResultFlowDiagnostics; + return serviceProvider.GetService(typeof(IReplResultFlowDiagnostics)) as IReplResultFlowDiagnostics + ?? _services.GetService(typeof(IReplResultFlowDiagnostics)) as IReplResultFlowDiagnostics; } private string TryColorizeStructuredPayload(string payload, string format, bool isInteractive) diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs index 65f41f9..324b795 100644 --- a/src/Repl.Core/Documentation/DocumentationEngine.cs +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -15,51 +15,8 @@ internal sealed class DocumentationEngine(CoreReplApp app) /// public ReplDocumentationModel CreateDocumentationModel(string? targetPath = null) { - var activeGraph = app.ResolveActiveRoutingGraph(); - var normalizedTargetPath = NormalizePath(targetPath); - var targetTokens = string.IsNullOrWhiteSpace(normalizedTargetPath) - ? [] - : normalizedTargetPath - .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var discoverableRoutes = app.ResolveDiscoverableRoutes( - activeGraph.Routes, - activeGraph.Contexts, - targetTokens, - StringComparison.OrdinalIgnoreCase); - var discoverableContexts = app.ResolveDiscoverableContexts( - activeGraph.Contexts, - targetTokens, - StringComparison.OrdinalIgnoreCase); - var commands = SelectDocumentationCommands( - normalizedTargetPath, - discoverableRoutes, - discoverableContexts, - out _); - - var contexts = SelectDocumentationContexts(normalizedTargetPath, commands, discoverableContexts); - var commandDocs = commands.Select(BuildDocumentationCommand).ToArray(); - var contextDocs = contexts - .Select(context => new ReplDocContext( - Path: context.Template.Template, - Description: context.Description, - IsDynamic: context.Template.Segments.Any(segment => segment is DynamicRouteSegment), - IsHidden: context.IsHidden, - Details: context.Details)) - .ToArray(); - var resourceDocs = commandDocs - .Where(cmd => cmd.IsResource || cmd.Annotations?.ReadOnly == true) - .Select(cmd => new ReplDocResource( - Path: cmd.Path, - Description: cmd.Description, - Details: cmd.Details, - Arguments: cmd.Arguments, - Options: cmd.Options)) - .ToArray(); - return new ReplDocumentationModel( - App: BuildDocumentationApp(), - Contexts: contextDocs, - Commands: commandDocs, - Resources: resourceDocs); + var (model, _) = CreateDocumentationModelCore(targetPath); + return model; } /// @@ -79,6 +36,12 @@ public ReplDocumentationModel CreateDocumentationModel( /// Internal documentation model creation that supports not-found result for help rendering. /// public object CreateDocumentationModelInternal(string? targetPath) + { + var (model, notFoundResult) = CreateDocumentationModelCore(targetPath); + return notFoundResult is null ? model : notFoundResult; + } + + private (ReplDocumentationModel Model, IReplResult? NotFoundResult) CreateDocumentationModelCore(string? targetPath) { var activeGraph = app.ResolveActiveRoutingGraph(); var normalizedTargetPath = NormalizePath(targetPath); @@ -100,10 +63,6 @@ public object CreateDocumentationModelInternal(string? targetPath) discoverableRoutes, discoverableContexts, out var notFoundResult); - if (notFoundResult is not null) - { - return notFoundResult; - } var contexts = SelectDocumentationContexts(normalizedTargetPath, commands, discoverableContexts); var commandDocs = commands.Select(BuildDocumentationCommand).ToArray(); @@ -124,11 +83,12 @@ public object CreateDocumentationModelInternal(string? targetPath) Arguments: cmd.Arguments, Options: cmd.Options)) .ToArray(); - return new ReplDocumentationModel( + var model = new ReplDocumentationModel( App: BuildDocumentationApp(), Contexts: contextDocs, Commands: commandDocs, Resources: resourceDocs); + return (model, notFoundResult); } private static RouteDefinition[] SelectDocumentationCommands( diff --git a/src/Repl.Core/ISubInvocableReplApp.cs b/src/Repl.Core/ISubInvocableReplApp.cs new file mode 100644 index 0000000..6846e1c --- /dev/null +++ b/src/Repl.Core/ISubInvocableReplApp.cs @@ -0,0 +1,9 @@ +namespace Repl; + +internal interface ISubInvocableReplApp +{ + ValueTask RunSubInvocationAsync( + string[] args, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default); +} diff --git a/src/Repl.Core/Output/HumanOutputTransformer.cs b/src/Repl.Core/Output/HumanOutputTransformer.cs index cba9a79..80f5662 100644 --- a/src/Repl.Core/Output/HumanOutputTransformer.cs +++ b/src/Repl.Core/Output/HumanOutputTransformer.cs @@ -116,32 +116,12 @@ private static string RenderPage( depth: 0, settings, includeTableHeader: mode == ResultFlowPageRenderMode.Initial); - var footer = includeFooter ? RenderPageFooter(page) : string.Empty; + var footer = includeFooter ? ResultFlowPageFooterBuilder.RenderHuman(page) : string.Empty; return string.IsNullOrWhiteSpace(footer) ? body : string.Concat(body, Environment.NewLine, footer); } - private static string RenderPageFooter(IReplPage page) - { - var info = page.PageInfo; - var count = page.UntypedItems.Count; - if (info.TotalCount is { } total) - { - var prefix = $"Showing {count.ToString(CultureInfo.InvariantCulture)} of {total.ToString(CultureInfo.InvariantCulture)}."; - return info.HasMore - ? $"{prefix} Next data page: rerun with {ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}." - : prefix; - } - - if (!info.HasMore) - { - return string.Empty; - } - - return $"Showing {count.ToString(CultureInfo.InvariantCulture)} result(s). Next data page: rerun with {ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}."; - } - private static bool TryRenderObject(object value, HumanRenderSettings settings, out string text) { var members = GetDisplayMembers(value.GetType()); diff --git a/src/Repl.Core/Output/MarkdownOutputTransformer.cs b/src/Repl.Core/Output/MarkdownOutputTransformer.cs index 104877f..c1f3119 100644 --- a/src/Repl.Core/Output/MarkdownOutputTransformer.cs +++ b/src/Repl.Core/Output/MarkdownOutputTransformer.cs @@ -97,32 +97,12 @@ private static string RenderPage(IReplPage page) var body = page.UntypedItems.Count == 0 ? "No results." : RenderEnumerable(page.UntypedItems); - var footer = RenderPageFooter(page); + var footer = ResultFlowPageFooterBuilder.RenderMarkdown(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 `{ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}`." - : prefix; - } - - if (!info.HasMore) - { - return string.Empty; - } - - return $"Showing {count} result(s). Continue with `{ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}`."; - } - private static string RenderEnumerable(System.Collections.IEnumerable enumerable) { var items = ToObjectArray(enumerable); diff --git a/src/Repl.Core/Output/ResultFlowPageFooterBuilder.cs b/src/Repl.Core/Output/ResultFlowPageFooterBuilder.cs new file mode 100644 index 0000000..a7ac86b --- /dev/null +++ b/src/Repl.Core/Output/ResultFlowPageFooterBuilder.cs @@ -0,0 +1,41 @@ +using System.Globalization; + +namespace Repl; + +internal static class ResultFlowPageFooterBuilder +{ + public static string RenderHuman(IReplPage page) + { + var info = page.PageInfo; + var count = page.UntypedItems.Count; + var countText = count.ToString(CultureInfo.InvariantCulture); + if (info.TotalCount is { } total) + { + var prefix = $"Showing {countText} of {total.ToString(CultureInfo.InvariantCulture)}."; + return info.HasMore + ? $"{prefix} Next data page: rerun with {ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}." + : prefix; + } + + return info.HasMore + ? $"Showing {countText} result(s). Next data page: rerun with {ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}." + : string.Empty; + } + + public static string RenderMarkdown(IReplPage page) + { + var info = page.PageInfo; + var count = page.UntypedItems.Count.ToString(CultureInfo.InvariantCulture); + if (info.TotalCount is { } total) + { + var prefix = $"Showing {count} of {total.ToString(CultureInfo.InvariantCulture)}."; + return info.HasMore + ? $"{prefix} Continue with `{ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}`." + : prefix; + } + + return info.HasMore + ? $"Showing {count} result(s). Continue with `{ResultFlowCursorPolicy.FormatCliContinuation(info.NextCursor)}`." + : string.Empty; + } +} diff --git a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs index e85fee1..c8fe640 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowOptions.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowOptions.cs @@ -10,6 +10,8 @@ public sealed class ResultFlowOptions private readonly List _pagerRenderers = []; private int _defaultPageSize = 100; private int _maxPageSize = 1000; + private int _maxBufferedLines = DefaultMaxBufferedLines; + private int _programmaticMaxInlineBytes = 64 * 1024; /// /// Gets or sets the default page size when no terminal-specific hint is available. @@ -24,11 +26,12 @@ public int DefaultPageSize throw new ArgumentOutOfRangeException(nameof(value), "Default page size must be greater than zero."); } - _defaultPageSize = value; - if (_maxPageSize < _defaultPageSize) + if (value > _maxPageSize) { - _maxPageSize = _defaultPageSize; + throw new ArgumentOutOfRangeException(nameof(value), "Default page size must be less than or equal to the maximum page size."); } + + _defaultPageSize = value; } } @@ -72,12 +75,36 @@ public int MaxPageSize /// /// Gets or sets the maximum number of content lines an interactive pager buffers in memory. /// - public int MaxBufferedLines { get; set; } = DefaultMaxBufferedLines; + public int MaxBufferedLines + { + get => _maxBufferedLines; + set + { + if (value < 1) + { + throw new ArgumentOutOfRangeException(nameof(value), "Maximum buffered lines must be greater than zero."); + } + + _maxBufferedLines = value; + } + } /// /// Gets or sets the maximum inline payload size for programmatic clients. /// - public int ProgrammaticMaxInlineBytes { get; set; } = 64 * 1024; + public int ProgrammaticMaxInlineBytes + { + get => _programmaticMaxInlineBytes; + set + { + if (value < 1) + { + throw new ArgumentOutOfRangeException(nameof(value), "Programmatic maximum inline bytes must be greater than zero."); + } + + _programmaticMaxInlineBytes = value; + } + } /// /// Registers or replaces the pager renderer for its configured mode. diff --git a/src/Repl.Core/ResultFlow/ResultFlowPager.cs b/src/Repl.Core/ResultFlow/ResultFlowPager.cs index 90cbc67..4ca68dc 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPager.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPager.cs @@ -572,7 +572,7 @@ private static PagerAction ApplyViewportKey(ViewportState state, ConsoleKeyInfo private static bool ShouldFetchForViewportKey(ViewportState state, PagerAction action, int beforeTopLine) => action switch { - PagerAction.PageDown => state.HasReachedBottom && state.Session.Lines.Count >= state.ViewportHeight, + PagerAction.PageDown => state.HasReachedBottom, PagerAction.LineDown => beforeTopLine == state.TopLine && state.HasReachedBottom, _ => false, }; diff --git a/src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs b/src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs index 8a0ddfd..76c8758 100644 --- a/src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs +++ b/src/Repl.Core/ResultFlow/ResultFlowPagerOptions.cs @@ -14,7 +14,7 @@ internal sealed record ResultFlowPagerOptions public Func>? FetchNextPayload { get; init; } - public IEnumerable? PagerRenderers { get; init; } + public IReadOnlyList? PagerRenderers { get; init; } public int MaxBufferedLines { get; init; } = ResultFlowOptions.DefaultMaxBufferedLines; } diff --git a/src/Repl.Defaults/ReplApp.cs b/src/Repl.Defaults/ReplApp.cs index 4bf0e0e..a669299 100644 --- a/src/Repl.Defaults/ReplApp.cs +++ b/src/Repl.Defaults/ReplApp.cs @@ -640,6 +640,10 @@ private SessionOverlayServiceProvider CreateSessionOverlay(IServiceProvider exte external.GetService(typeof(TimeProvider)) as TimeProvider); defaults[typeof(IReplInteractionChannel)] = channel; defaults[typeof(IReplSessionInfo)] = new LiveSessionInfo(); + if (EnsureSharedProvider().GetService() is { } diagnostics) + { + defaults[typeof(IReplResultFlowDiagnostics)] = diagnostics; + } return new SessionOverlayServiceProvider(external, defaults); } diff --git a/src/Repl.Mcp/McpPagedResultTextMode.cs b/src/Repl.Mcp/McpPagedResultTextMode.cs index 475d08e..929331d 100644 --- a/src/Repl.Mcp/McpPagedResultTextMode.cs +++ b/src/Repl.Mcp/McpPagedResultTextMode.cs @@ -8,15 +8,15 @@ public enum McpPagedResultTextMode /// /// Emit compact serialized JSON in Content for MCP clients that ignore structured content. /// - SerializedJson, + SerializedJson = 0, /// /// Emit only a short summary, minimizing token cost and keeping raw cursors out of text content. /// - SummaryOnly, + SummaryOnly = 1, /// /// Emit a short summary followed by compact serialized JSON. /// - SummaryAndSerializedJson, + SummaryAndSerializedJson = 2, } diff --git a/src/Repl.Mcp/McpSchemaGenerator.cs b/src/Repl.Mcp/McpSchemaGenerator.cs index e3b5098..a277b27 100644 --- a/src/Repl.Mcp/McpSchemaGenerator.cs +++ b/src/Repl.Mcp/McpSchemaGenerator.cs @@ -103,30 +103,30 @@ private static void AddResultFlowProperties(JsonObject properties) ["type"] = "object", ["properties"] = new JsonObject { - ["$type"] = new JsonObject + [ReplPageWireNames.Type] = new JsonObject { ["type"] = "string", - ["const"] = "page", + ["const"] = ReplPageWireNames.PageType, }, - ["items"] = new JsonObject + [ReplPageWireNames.Items] = new JsonObject { ["type"] = "array", - ["items"] = new JsonObject(), + [ReplPageWireNames.Items] = new JsonObject(), }, - ["pageInfo"] = new JsonObject + [ReplPageWireNames.PageInfo] = new JsonObject { ["type"] = "object", ["properties"] = new JsonObject { - ["cursor"] = new JsonObject { ["type"] = "string" }, - ["nextCursor"] = new JsonObject { ["type"] = "string" }, - ["totalCount"] = new JsonObject { ["type"] = "integer" }, - ["pageSize"] = new JsonObject { ["type"] = "integer" }, - ["hasMore"] = new JsonObject { ["type"] = "boolean" }, + [ReplPageWireNames.Cursor] = new JsonObject { ["type"] = "string" }, + [ReplPageWireNames.NextCursor] = new JsonObject { ["type"] = "string" }, + [ReplPageWireNames.TotalCount] = new JsonObject { ["type"] = "integer" }, + [ReplPageWireNames.PageSize] = new JsonObject { ["type"] = "integer" }, + [ReplPageWireNames.HasMore] = new JsonObject { ["type"] = "boolean" }, }, }, }, - ["required"] = new JsonArray(["$type", "items", "pageInfo"]), + ["required"] = new JsonArray([ReplPageWireNames.Type, ReplPageWireNames.Items, ReplPageWireNames.PageInfo]), }; return JsonSerializer.SerializeToElement(schema, McpJsonContext.Default.JsonObject); diff --git a/src/Repl.Mcp/McpToolAdapter.cs b/src/Repl.Mcp/McpToolAdapter.cs index e8d1ff6..132153f 100644 --- a/src/Repl.Mcp/McpToolAdapter.cs +++ b/src/Repl.Mcp/McpToolAdapter.cs @@ -107,8 +107,8 @@ private async Task ExecuteThroughPipelineAsync( ProgressToken? progressToken, CancellationToken ct) { - var coreApp = _app as CoreReplApp - ?? throw new InvalidOperationException("MCP tool adapter requires CoreReplApp."); + var invocableApp = _app as ISubInvocableReplApp + ?? throw new InvalidOperationException("MCP tool adapter requires an app that supports sub-invocation."); var outputWriter = new StringWriter(); var inputReader = new StringReader(string.Empty); @@ -136,7 +136,7 @@ private async Task ExecuteThroughPipelineAsync( isHostedSession: true)) { ReplSessionIO.IsProgrammatic = true; - var exitCode = await coreApp.RunSubInvocationAsync( + var exitCode = await invocableApp.RunSubInvocationAsync( effectiveTokens.ToArray(), mcpServices, ct).ConfigureAwait(false); var output = outputWriter.ToString().Trim(); @@ -191,12 +191,12 @@ 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) + || !root.TryGetProperty(ReplPageWireNames.Type, out var type) || type.ValueKind != JsonValueKind.String - || !string.Equals(type.GetString(), "page", StringComparison.Ordinal) - || !root.TryGetProperty("items", out var items) + || !string.Equals(type.GetString(), ReplPageWireNames.PageType, StringComparison.Ordinal) + || !root.TryGetProperty(ReplPageWireNames.Items, out var items) || items.ValueKind != JsonValueKind.Array - || !root.TryGetProperty("pageInfo", out var pageInfo) + || !root.TryGetProperty(ReplPageWireNames.PageInfo, out var pageInfo) || pageInfo.ValueKind != JsonValueKind.Object) { return false; @@ -215,14 +215,14 @@ private static bool TryCreatePagedStructuredResult( 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) + if (pageInfo.TryGetProperty(ReplPageWireNames.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) + if (pageInfo.TryGetProperty(ReplPageWireNames.NextCursor, out var nextCursor) && nextCursor.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(nextCursor.GetString())) { @@ -240,11 +240,6 @@ internal static (List Tokens, Dictionary Prefills) Prepa return PrepareExecution(command.Path, arguments, allowedArgumentNames); } - internal static (List Tokens, Dictionary Prefills) PrepareExecution( - string routePath, - IDictionary arguments) - => PrepareExecution(routePath, arguments, allowedArgumentNames: null); - private static (List Tokens, Dictionary Prefills) PrepareExecution( string routePath, IDictionary arguments, @@ -270,13 +265,13 @@ private static (List Tokens, Dictionary Prefills) Prepar { prefills[key["answer.".Length..]] = strValue; } - else if (string.Equals(key, McpResultFlowArgumentNames.Cursor, StringComparison.Ordinal)) + else if (string.Equals(key, McpResultFlowArgumentNames.Cursor, StringComparison.OrdinalIgnoreCase)) { ValidateResultCursor(strValue); resultFlowTokens.Add(ReplResultFlowOptionNames.Cursor); resultFlowTokens.Add(strValue); } - else if (string.Equals(key, McpResultFlowArgumentNames.PageSize, StringComparison.Ordinal)) + else if (string.Equals(key, McpResultFlowArgumentNames.PageSize, StringComparison.OrdinalIgnoreCase)) { ValidateResultPageSize(strValue); resultFlowTokens.Add(ReplResultFlowOptionNames.PageSize); @@ -304,7 +299,7 @@ private static void ValidateResultPageSize(string pageSize) throw new InvalidOperationException("The MCP result page size cannot exceed 10 characters."); } - if (pageSize.Length == 0 || ContainsNonDigit(pageSize)) + if (pageSize.Length == 0 || pageSize.AsSpan().IndexOfAnyExceptInRange('0', '9') >= 0) { throw new InvalidOperationException("The MCP result page size must be numeric."); } @@ -316,19 +311,6 @@ private static void ValidateResultPageSize(string pageSize) } } - private static bool ContainsNonDigit(string value) - { - foreach (var c in value) - { - if (c < '0' || c > '9') - { - return true; - } - } - - return false; - } - private static void ValidateCommandArgumentValue(string value) { if (value.StartsWith("--", StringComparison.Ordinal)) diff --git a/src/Repl.Mcp/ReplPageWireNames.cs b/src/Repl.Mcp/ReplPageWireNames.cs new file mode 100644 index 0000000..38ae91a --- /dev/null +++ b/src/Repl.Mcp/ReplPageWireNames.cs @@ -0,0 +1,14 @@ +namespace Repl.Mcp; + +internal static class ReplPageWireNames +{ + public const string Type = "$type"; + public const string PageType = "page"; + public const string Items = "items"; + public const string PageInfo = "pageInfo"; + public const string Cursor = "cursor"; + public const string NextCursor = "nextCursor"; + public const string TotalCount = "totalCount"; + public const string PageSize = "pageSize"; + public const string HasMore = "hasMore"; +} diff --git a/src/Repl.McpTests/Given_McpToolAdapter.cs b/src/Repl.McpTests/Given_McpToolAdapter.cs index 2daa31d..f2b682b 100644 --- a/src/Repl.McpTests/Given_McpToolAdapter.cs +++ b/src/Repl.McpTests/Given_McpToolAdapter.cs @@ -95,7 +95,7 @@ public void When_MixedArguments_Then_ReconstructedCorrectly() public void When_ResultCursorIsValid_Then_ResultFlowTokenIsEmitted() { var (tokens, _) = McpToolAdapter.PrepareExecution( - "contacts", + CreatePagedCommand("contacts"), new Dictionary(StringComparer.Ordinal) { [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement("abc_DEF-123"), @@ -109,7 +109,7 @@ public void When_ResultCursorIsValid_Then_ResultFlowTokenIsEmitted() public void When_ResultCursorContainsWhitespace_Then_Rejected() { var action = () => McpToolAdapter.PrepareExecution( - "contacts", + CreatePagedCommand("contacts"), new Dictionary(StringComparer.Ordinal) { [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement("abc def"), @@ -124,7 +124,7 @@ public void When_ResultCursorContainsWhitespace_Then_Rejected() public void When_ResultCursorStartsWithDash_Then_Rejected() { var action = () => McpToolAdapter.PrepareExecution( - "contacts", + CreatePagedCommand("contacts"), new Dictionary(StringComparer.Ordinal) { [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement("--result:all"), @@ -139,7 +139,7 @@ public void When_ResultCursorStartsWithDash_Then_Rejected() public void When_ResultCursorIsTooLong_Then_Rejected() { var action = () => McpToolAdapter.PrepareExecution( - "contacts", + CreatePagedCommand("contacts"), new Dictionary(StringComparer.Ordinal) { [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement(new string('a', 513)), @@ -154,7 +154,7 @@ public void When_ResultCursorIsTooLong_Then_Rejected() public void When_ResultCursorContainsControlCharacter_Then_Rejected() { var action = () => McpToolAdapter.PrepareExecution( - "contacts", + CreatePagedCommand("contacts"), new Dictionary(StringComparer.Ordinal) { [McpResultFlowArgumentNames.Cursor] = JsonSerializer.SerializeToElement("abc\u001b[2J"), @@ -169,7 +169,7 @@ public void When_ResultCursorContainsControlCharacter_Then_Rejected() public void When_ResultPageSizeIsValid_Then_ResultFlowTokenIsEmitted() { var (tokens, _) = McpToolAdapter.PrepareExecution( - "contacts", + CreatePagedCommand("contacts"), new Dictionary(StringComparer.Ordinal) { [McpResultFlowArgumentNames.PageSize] = JsonSerializer.SerializeToElement(25), @@ -183,7 +183,7 @@ public void When_ResultPageSizeIsValid_Then_ResultFlowTokenIsEmitted() public void When_ResultPageSizeIsNotNumeric_Then_Rejected() { var action = () => McpToolAdapter.PrepareExecution( - "contacts", + CreatePagedCommand("contacts"), new Dictionary(StringComparer.Ordinal) { [McpResultFlowArgumentNames.PageSize] = JsonSerializer.SerializeToElement("abc"), @@ -198,7 +198,7 @@ public void When_ResultPageSizeIsNotNumeric_Then_Rejected() public void When_ResultPageSizeTokenIsTooLong_Then_Rejected() { var action = () => McpToolAdapter.PrepareExecution( - "contacts", + CreatePagedCommand("contacts"), new Dictionary(StringComparer.Ordinal) { [McpResultFlowArgumentNames.PageSize] = JsonSerializer.SerializeToElement(new string('1', 11)), @@ -213,7 +213,7 @@ public void When_ResultPageSizeTokenIsTooLong_Then_Rejected() public void When_ResultPageSizeOverflowsInt32_Then_Rejected() { var action = () => McpToolAdapter.PrepareExecution( - "contacts", + CreatePagedCommand("contacts"), new Dictionary(StringComparer.Ordinal) { [McpResultFlowArgumentNames.PageSize] = JsonSerializer.SerializeToElement("9999999999"), @@ -223,6 +223,23 @@ public void When_ResultPageSizeOverflowsInt32_Then_Rejected() .WithMessage("*page size*32-bit*"); } + [TestMethod] + [Description("PrepareExecution treats reserved result-flow argument names case-insensitively.")] + public void When_ResultFlowInputsUseDifferentCase_Then_ReservedDispatchStillValidatesAndEmitsTokens() + { + var (tokens, _) = McpToolAdapter.PrepareExecution( + CreatePagedCommand("contacts"), + new Dictionary(StringComparer.Ordinal) + { + ["_replcursor"] = JsonSerializer.SerializeToElement("abc_DEF-123"), + ["_replpagesize"] = JsonSerializer.SerializeToElement(25), + }); + + tokens.Should().ContainInOrder("--result:cursor", "abc_DEF-123", "--result:page-size", "25", "contacts"); + tokens.Should().NotContain("--_replcursor"); + tokens.Should().NotContain("--_replpagesize"); + } + [TestMethod] [Description("PrepareExecution rejects MCP arguments that are not declared by the command schema.")] public void When_ArgumentIsNotInToolSchema_Then_Rejected() @@ -315,4 +332,14 @@ public void When_ResultFlowInputIsNotInToolSchema_Then_Rejected() action.Should().Throw() .WithMessage("*not defined*schema*"); } + + private static ReplDocCommand CreatePagedCommand(string path) => + new( + Path: path, + Description: null, + Aliases: [], + IsHidden: false, + Arguments: [], + Options: [], + AcceptsPagingInput: true); } diff --git a/src/Repl.Tests/Given_AnsiTextMetrics.cs b/src/Repl.Tests/Given_AnsiTextMetrics.cs index 3f34a08..1b22105 100644 --- a/src/Repl.Tests/Given_AnsiTextMetrics.cs +++ b/src/Repl.Tests/Given_AnsiTextMetrics.cs @@ -20,4 +20,13 @@ public void When_TextContainsOscHyperlink_Then_VisibleLengthIgnoresControlBytes( AnsiTextMetrics.GetVisualLength(link).Should().Be(4); } + + [TestMethod] + [Description("Visible length ignores OSC sequences terminated by ST.")] + public void When_TextContainsOscSequenceTerminatedByStringTerminator_Then_VisibleLengthIgnoresControlBytes() + { + var link = "\u001b]8;;https://example.invalid\u001b\\link\u001b]8;;\u001b\\"; + + AnsiTextMetrics.GetVisualLength(link).Should().Be(4); + } } diff --git a/src/Repl.Tests/Given_ReplLogging.cs b/src/Repl.Tests/Given_ReplLogging.cs index 49b44fe..3ee6d5f 100644 --- a/src/Repl.Tests/Given_ReplLogging.cs +++ b/src/Repl.Tests/Given_ReplLogging.cs @@ -130,6 +130,41 @@ public void When_ResultFlowPageFetchFails_Then_DiagnosticIsLogged() && entry.Message.Contains("page fetch failed", StringComparison.OrdinalIgnoreCase)); } + [TestMethod] + [Description("Result-flow diagnostics fall back to the app root provider when a hosted runtime provider does not register diagnostics.")] + public void When_RuntimeProviderDoesNotRegisterResultFlowDiagnostics_Then_AppProviderStillLogsFetchFailure() + { + var provider = new CapturingLoggerProvider(); + var app = ReplApp.Create(services => + { + services.AddSingleton(provider); + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddProvider(provider); + }); + }); + app.Map("items", () => ReplPageSource.Create((_, _) => + throw new InvalidOperationException("fetch failed"))); + + using var input = new StringReader(string.Empty); + using var output = new StringWriter(); + var host = new InMemoryHost(input, output); + using var runtimeProvider = new ServiceCollection().BuildServiceProvider(); + + var exitCode = app.Run( + ["items", "--no-logo"], + host, + runtimeProvider, + new ReplRunOptions { HostedServiceLifecycle = HostedServiceLifecycleMode.None }); + + exitCode.Should().Be(1); + provider.Entries.Should().Contain(entry => + entry.Category == "Repl.ResultFlow" + && entry.Level == LogLevel.Error + && entry.Message.Contains("page fetch failed", StringComparison.OrdinalIgnoreCase)); + } + [TestMethod] [Description("Regression guard: verifies ambient Repl log context reflects hosted session metadata so apps can route logs to the active session.")] public void When_HostedSessionRuns_Then_LogContextExposesSessionMetadata() diff --git a/src/Repl.Tests/Given_ResultFlowPager.cs b/src/Repl.Tests/Given_ResultFlowPager.cs index edc9126..eae06bc 100644 --- a/src/Repl.Tests/Given_ResultFlowPager.cs +++ b/src/Repl.Tests/Given_ResultFlowPager.cs @@ -480,6 +480,62 @@ await WritePagerAsync( writer.ToString().Should().Contain("four"); } + [TestMethod] + [Description("Result-flow full pager fetches when all known content is visible but more payloads exist.")] + public async Task When_ScrollPagerContentIsShorterThanViewport_Then_PageDownFetchesNextPayload() + { + using var writer = new StringWriter(); + var fetches = 0; + var keys = new FakeKeyReader( + [ + MakeKey(ConsoleKey.PageDown, '\0'), + MakeKey(ConsoleKey.Q, 'q'), + ]); + + await WritePagerAsync( + "one\ntwo", + writer, + keys, + visibleRows: 5, + pagerMode: ReplPagerMode.Full, + ansiEnabled: true, + hasMorePayload: true, + fetchNextPayload: _ => + { + fetches++; + return ValueTask.FromResult( + new ResultFlowPagerPage("three", HasMore: false)); + }, + CancellationToken.None); + + fetches.Should().Be(1); + writer.ToString().Should().Contain("three"); + } + + [TestMethod] + [Description("Result-flow options reject invalid pager buffer and inline payload limits.")] + public void When_ResultFlowLimitsAreInvalid_Then_SettersThrow() + { + var options = new ResultFlowOptions(); + + var setMaxBufferedLines = () => options.MaxBufferedLines = 0; + var setProgrammaticBytes = () => options.ProgrammaticMaxInlineBytes = 0; + + setMaxBufferedLines.Should().Throw(); + setProgrammaticBytes.Should().Throw(); + } + + [TestMethod] + [Description("Result-flow options reject a default page size greater than the configured maximum.")] + public void When_DefaultPageSizeExceedsMaxPageSize_Then_SetterThrows() + { + var options = new ResultFlowOptions { MaxPageSize = 150 }; + + var action = () => options.DefaultPageSize = 151; + + action.Should().Throw(); + } + [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() @@ -1085,7 +1141,7 @@ private static ValueTask WritePagerAsync( bool ansiEnabled, bool hasMorePayload, Func>? fetchNextPayload, - IEnumerable? pagerRenderers, + IReadOnlyList? pagerRenderers, CancellationToken cancellationToken = default) => ResultFlowPager.WriteAsync( payload, @@ -1121,7 +1177,7 @@ private static ValueTask WritePagerAsync( bool ansiEnabled, bool hasMorePayload, Func>? fetchNextPayload, - IEnumerable? pagerRenderers, + IReadOnlyList? pagerRenderers, int maxBufferedLines, CancellationToken cancellationToken = default) => ResultFlowPager.WriteAsync( From d85020f1d2ac8630044321fc9c989e3873429c18 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 9 May 2026 12:12:42 -0400 Subject: [PATCH 44/45] Optimize ANSI text parsing --- .../ResultFlow/PagerPayloadParser.cs | 38 ++------- src/Repl.Core/Terminal/AnsiTextMetrics.cs | 77 +++++++++++++++---- src/Repl.Tests/Given_AnsiTextMetrics.cs | 18 +++++ 3 files changed, 88 insertions(+), 45 deletions(-) diff --git a/src/Repl.Core/ResultFlow/PagerPayloadParser.cs b/src/Repl.Core/ResultFlow/PagerPayloadParser.cs index 88353b8..81ffc2b 100644 --- a/src/Repl.Core/ResultFlow/PagerPayloadParser.cs +++ b/src/Repl.Core/ResultFlow/PagerPayloadParser.cs @@ -1,6 +1,7 @@ namespace Repl; using System.Buffers; +using Repl.Terminal; internal static class PagerPayloadParser { @@ -57,15 +58,15 @@ private static PagerHeader CreateHeader(string[] lines) => private static bool IsPlainTableSeparator(string line) { - var text = line.Trim(); + var text = line.AsSpan().Trim(); return text.Length > 0 - && text.AsSpan().IndexOfAnyExcept(PlainTableSeparatorChars) < 0 - && text.Contains('-', StringComparison.Ordinal); + && text.IndexOfAnyExcept(PlainTableSeparatorChars) < 0 + && text.Contains('-'); } private static bool IsPlainHumanTableHeader(string line) { - var text = line.TrimStart(); + var text = line.AsSpan().TrimStart(); return text.StartsWith("# ", StringComparison.Ordinal) && text.Contains(" ", StringComparison.Ordinal); } @@ -105,33 +106,6 @@ private static string NormalizeLine(string line) return line.Trim(); } - var builder = new System.Text.StringBuilder(line.Length); - for (var i = 0; i < line.Length; i++) - { - if (line[i] == '\u001b') - { - if (i + 1 >= line.Length) - { - continue; - } - - if (line[i + 1] != '[') - { - continue; - } - - i += 2; - while (i < line.Length && (line[i] < '@' || line[i] > '~')) - { - i++; - } - - continue; - } - - builder.Append(line[i]); - } - - return builder.ToString().Trim(); + return AnsiTextMetrics.StripControlSequences(line).Trim(); } } diff --git a/src/Repl.Core/Terminal/AnsiTextMetrics.cs b/src/Repl.Core/Terminal/AnsiTextMetrics.cs index 340bd5c..52b7250 100644 --- a/src/Repl.Core/Terminal/AnsiTextMetrics.cs +++ b/src/Repl.Core/Terminal/AnsiTextMetrics.cs @@ -2,7 +2,10 @@ namespace Repl.Terminal; internal static class AnsiTextMetrics { - public static int GetVisualLength(string text) + public static int GetVisualLength(string text) => + GetVisualLength(text.AsSpan()); + + public static int GetVisualLength(ReadOnlySpan text) { var length = 0; for (var i = 0; i < text.Length; i++) @@ -14,16 +17,7 @@ public static int GetVisualLength(string text) continue; } - // ANSI control sequences are terminal protocol bytes, not columns. - // CSI covers styling/cursor controls; OSC covers hyperlinks and titles; SS3 covers special keys. - i = text[i + 1] switch - { - '[' => SkipCsiSequence(text, i + 2), - ']' => SkipOscSequence(text, i + 2), - 'O' => Math.Min(text.Length - 1, i + 2), - _ => i + 1, - }; - + i = SkipEscapeSequence(text, i); continue; } @@ -36,7 +30,64 @@ public static int GetVisualLength(string text) return length; } - private static int SkipCsiSequence(string text, int start) + public static string StripControlSequences(string text) + { + if (!text.Contains('\u001b', StringComparison.Ordinal)) + { + return text; + } + + return StripControlSequences(text.AsSpan()); + } + + public static string StripControlSequences(ReadOnlySpan text) + { + var escapeIndex = text.IndexOf('\u001b'); + if (escapeIndex < 0) + { + return text.ToString(); + } + + var builder = new System.Text.StringBuilder(text.Length); + builder.Append(text[..escapeIndex]); + for (var i = escapeIndex; i < text.Length; i++) + { + if (text[i] == '\u001b') + { + if (i + 1 >= text.Length) + { + continue; + } + + i = SkipEscapeSequence(text, i); + continue; + } + + builder.Append(text[i]); + } + + return builder.ToString(); + } + + private static int SkipEscapeSequence(ReadOnlySpan text, int escapeIndex) + { + if (escapeIndex + 1 >= text.Length) + { + return escapeIndex; + } + + // ANSI escape sequences are terminal protocol bytes, not columns. + // CSI covers styling/cursor controls; OSC covers hyperlinks and titles; SS3 covers special keys. + return text[escapeIndex + 1] switch + { + '[' => SkipCsiSequence(text, escapeIndex + 2), + ']' => SkipOscSequence(text, escapeIndex + 2), + 'O' => Math.Min(text.Length - 1, escapeIndex + 2), + _ => escapeIndex + 1, + }; + } + + private static int SkipCsiSequence(ReadOnlySpan text, int start) { var i = start; while (i < text.Length && (text[i] < '@' || text[i] > '~')) @@ -47,7 +98,7 @@ private static int SkipCsiSequence(string text, int start) return Math.Min(i, text.Length - 1); } - private static int SkipOscSequence(string text, int start) + private static int SkipOscSequence(ReadOnlySpan text, int start) { for (var i = start; i < text.Length; i++) { diff --git a/src/Repl.Tests/Given_AnsiTextMetrics.cs b/src/Repl.Tests/Given_AnsiTextMetrics.cs index 1b22105..fbddead 100644 --- a/src/Repl.Tests/Given_AnsiTextMetrics.cs +++ b/src/Repl.Tests/Given_AnsiTextMetrics.cs @@ -12,6 +12,15 @@ public void When_TextContainsCsiSequence_Then_VisibleLengthIgnoresControlBytes() AnsiTextMetrics.GetVisualLength("\u001b[1mhello\u001b[0m").Should().Be(5); } + [TestMethod] + [Description("Visible length can be computed from a caller-owned text slice without materializing a string.")] + public void When_TextIsProvidedAsSpan_Then_VisibleLengthIsComputedFromSlice() + { + var text = "xx\u001b[1mhello\u001b[0myy"; + + AnsiTextMetrics.GetVisualLength(text.AsSpan(2, text.Length - 4)).Should().Be(5); + } + [TestMethod] [Description("Visible length ignores ANSI OSC hyperlinks.")] public void When_TextContainsOscHyperlink_Then_VisibleLengthIgnoresControlBytes() @@ -29,4 +38,13 @@ public void When_TextContainsOscSequenceTerminatedByStringTerminator_Then_Visibl AnsiTextMetrics.GetVisualLength(link).Should().Be(4); } + + [TestMethod] + [Description("ANSI stripping can consume text slices without forcing the caller to allocate first.")] + public void When_StrippingAnsiFromSpan_Then_ControlSequencesAreRemoved() + { + var text = "xx\u001b[1m#\u001b[0m \u001b[1mAt\u001b[0myy"; + + AnsiTextMetrics.StripControlSequences(text.AsSpan(2, text.Length - 4)).Should().Be("# At"); + } } From 52b0a11d6d49b48acd81227b97ebe118a6fa5b58 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 9 May 2026 12:17:47 -0400 Subject: [PATCH 45/45] Address paging PR review nits --- src/Repl.Core/ResultFlow/ReplPageSource.cs | 2 +- src/Repl.McpTests/Given_McpSchemaGenerator.cs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Repl.Core/ResultFlow/ReplPageSource.cs b/src/Repl.Core/ResultFlow/ReplPageSource.cs index 29e9fa0..5f50212 100644 --- a/src/Repl.Core/ResultFlow/ReplPageSource.cs +++ b/src/Repl.Core/ResultFlow/ReplPageSource.cs @@ -7,7 +7,7 @@ namespace Repl; /// public static class ReplPageSource { - private const int DefaultMaxSourceItemsToScan = 10000; + private const int DefaultMaxSourceItemsToScan = 10_000; /// /// Creates a page source from a fetch delegate. diff --git a/src/Repl.McpTests/Given_McpSchemaGenerator.cs b/src/Repl.McpTests/Given_McpSchemaGenerator.cs index 52e7e1d..71d6bfd 100644 --- a/src/Repl.McpTests/Given_McpSchemaGenerator.cs +++ b/src/Repl.McpTests/Given_McpSchemaGenerator.cs @@ -169,10 +169,11 @@ public void When_CommandEmitsPagedResult_Then_OutputSchemaContainsPageEnvelope() var schema = McpSchemaGenerator.BuildOutputSchema(cmd); schema.Should().NotBeNull(); - schema!.Value.GetProperty("properties").GetProperty("$type").GetProperty("const").GetString() + var schemaValue = schema!.Value; + schemaValue.GetProperty("properties").GetProperty("$type").GetProperty("const").GetString() .Should().Be("page"); - schema.Value.GetProperty("properties").TryGetProperty("items", out _).Should().BeTrue(); - schema.Value.GetProperty("properties").TryGetProperty("pageInfo", out _).Should().BeTrue(); + schemaValue.GetProperty("properties").TryGetProperty("items", out _).Should().BeTrue(); + schemaValue.GetProperty("properties").TryGetProperty("pageInfo", out _).Should().BeTrue(); } [TestMethod]