From 0099a6e2fa4c626629d0a6aaf53c085ead52490d Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 25 May 2026 10:25:58 +0100 Subject: [PATCH] .NET: HarnessConsole: Improve rendering perf / reduce flickering (#6014) * HarnessConsole: Improve rendering perf / reduce flickering * Address PR comments --- .../ListSelection.cs | 6 +- .../TextScrollPanel.cs | 76 +++++++++++++++---- .../ConsoleReactiveMemo.cs | 36 +++++++++ .../HarnessAppComponent.cs | 41 ++-------- 4 files changed, 108 insertions(+), 51 deletions(-) create mode 100644 dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveMemo.cs diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/ListSelection.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/ListSelection.cs index eedc76a145..cdb13c6930 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/ListSelection.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/ListSelection.cs @@ -40,8 +40,8 @@ public class ListSelection : ConsoleReactiveComponent /// State for . /// -/// The number of items already rendered. -public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState; +public record TextScrollPanelState : ConsoleReactiveState; /// /// A component that renders pre-rendered string items within a scroll area. -/// All items are considered finalized — only new items since the last render are output. -/// Use to force a full re-render. +/// The last rendered item is considered dynamic and will be re-rendered on each call. +/// All prior items are considered finalized and are not re-rendered. +/// Use to force a full re-render. /// public class TextScrollPanel : ConsoleReactiveComponent { + private int _renderedCount; + private int _lastItemOffsetFromBottom; + /// /// Initializes a new instance of the class. /// @@ -35,12 +38,12 @@ public class TextScrollPanel : ConsoleReactiveComponent - /// Resets the panel so all items will be re-rendered on the next Render call. - /// - public void Reset() + /// + public override void Invalidate() { - this.State = new TextScrollPanelState(); + this._renderedCount = 0; + this._lastItemOffsetFromBottom = 0; + base.Invalidate(); } /// @@ -51,16 +54,59 @@ public class TextScrollPanel : ConsoleReactiveComponent 0 ? this._renderedCount - 1 : 0; + + if (this._renderedCount > 0 && this._lastItemOffsetFromBottom > 0) + { + // Reposition cursor to where the last rendered item began + Console.Write(AnsiEscapes.MoveCursor(bottomRow - this._lastItemOffsetFromBottom, props.X)); + } + else + { + // First render — position at the bottom of the scroll area + Console.Write(AnsiEscapes.MoveCursor(bottomRow, props.X)); + } + + // Render from startIndex onwards + for (int i = startIndex; i < props.Items.Count; i++) { Console.Write(props.Items[i]); } - // Update state to track what we've rendered - this.State = new TextScrollPanelState(props.Items.Count); + // Calculate the offset from bottom for the start of the new last item + int lastItemLines = CountLines(props.Items[^1]); + this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0; + + // Update rendered count + this._renderedCount = props.Items.Count; + } + + private static int CountLines(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + int count = 1; + for (int i = 0; i < text.Length; i++) + { + if (text[i] == '\n') + { + count++; + } + } + + // If text ends with a newline, don't count the trailing empty line + if (text[text.Length - 1] == '\n') + { + count--; + } + + return count; } } diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveMemo.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveMemo.cs new file mode 100644 index 0000000000..1e278cd6e7 --- /dev/null +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveMemo.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Harness.ConsoleReactiveFramework; + +/// +/// Caches the result of a mapping function and only recomputes when the input changes. +/// +/// The type of the input value. +/// The type of the mapped output value. +public class ConsoleReactiveMemo +{ + private TInput? _previousInput; + private TOutput? _cachedOutput; + private bool _hasValue; + + /// + /// Returns the cached output if equals the previously stored input; + /// otherwise invokes to compute and cache a new output. + /// + /// The current input value. + /// A function that maps the input to an output value. + /// The cached or newly computed output. + public TOutput Map(TInput input, Func mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + + if (!this._hasValue || !EqualityComparer.Default.Equals(input, this._previousInput)) + { + this._previousInput = input; + this._cachedOutput = mapper(input); + this._hasValue = true; + } + + return this._cachedOutput!; + } +} diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs index 9521715467..d100c9d081 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs @@ -19,7 +19,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent lastItems = state.ScrollAreaContentItems.Count > 0 - ? [state.ScrollAreaContentItems[^1]] - : []; - int textPanelHeight = TextPanel.CalculateHeight(lastItems); - if (textPanelHeight > 0) - { - textPanelHeight++; // Extra line for spacing between text panel and rule - } - // Calculate queued items panel height int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems); @@ -444,7 +433,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent scrollItems = state.ScrollAreaContentItems.Count > 1 - ? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList() - : []; - + // Render text scroll panel in the scroll area this._textScrollPanel.Props = new TextScrollPanelProps { X = 1, Y = 1, Width = state.ConsoleWidth, Height = scrollBottom, - Items = scrollItems, + Items = state.ScrollAreaContentItems, }; this._textScrollPanel.Render(); - // Render the text panel for the last (dynamic) item just below the scroll region - this._textPanel.Props = new TextPanelProps - { - X = 1, - Y = scrollBottom + 1, - Width = state.ConsoleWidth, - Height = textPanelHeight, - Items = lastItems, - }; - this._textPanel.Render(); - - // Render queued input items between text panel and agent status - int queuedPanelY = scrollBottom + textPanelHeight + 1; + // Render queued input items between scroll area and agent status + int queuedPanelY = scrollBottom + 1; this._queuedPanel.Props = new TextPanelProps { X = 1,