From aa234c9fc5d128fee711a32ac45489a15a907026 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 28 May 2026 09:46:08 +0000 Subject: [PATCH 1/6] Fix render dupe and text input clear bugs --- .../ConsoleReactiveComponents/AnsiEscapes.cs | 34 +++++++++++++ .../ConsoleReactiveComponents/TextPanel.cs | 51 ++++++++++++++----- .../TextScrollPanel.cs | 43 ++++++++++++---- .../HarnessAppComponent.cs | 11 +++- 4 files changed, 116 insertions(+), 23 deletions(-) diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs index cf916938e7..9e10f313cb 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs @@ -72,6 +72,40 @@ public static class AnsiEscapes /// public static string ResetAttributes => "\x1b[0m"; + /// + /// Returns the visible (printed) length of a string after stripping ANSI escape sequences. + /// Escape sequences are zero-width on screen but occupy characters in the raw string. + /// + public static int VisibleLength(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + int length = 0; + for (int i = 0; i < text.Length; i++) + { + if (text[i] == '\x1b' && i + 1 < text.Length && text[i + 1] == '[') + { + // Skip the ESC[ and all characters up to and including the final byte (0x40–0x7E). + i += 2; + while (i < text.Length && text[i] < 0x40) + { + i++; + } + + // i now points to the final byte of the escape sequence; the for-loop will advance past it. + } + else if (text[i] != '\n' && text[i] != '\r') + { + length++; + } + } + + return length; + } + private static int ConsoleColorToAnsi(ConsoleColor color) => color switch { ConsoleColor.Black => 30, diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs index 5692b58266..6d273d0a99 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs @@ -23,16 +23,18 @@ public record TextPanelProps : ConsoleReactiveProps public class TextPanel : ConsoleReactiveComponent { /// - /// Calculates the height (in lines) needed to render all items. + /// Calculates the height (in lines) needed to render all items, + /// accounting for terminal line wrapping at the specified width. /// /// The items to measure. - /// The total number of lines all items will occupy. - public static int CalculateHeight(IReadOnlyList items) + /// The terminal width in columns. When 0 or negative, wrapping is ignored. + /// The total number of physical lines all items will occupy. + public static int CalculateHeight(IReadOnlyList items, int terminalWidth = 0) { int total = 0; for (int i = 0; i < items.Count; i++) { - total += CountLines(items[i]); + total += CountPhysicalLines(items[i], terminalWidth); } return total; @@ -47,9 +49,9 @@ public override void RenderCore(TextPanelProps props, ConsoleReactiveState state { string text = props.Items[i]; string[] lines = text.Split('\n'); - int lineCount = CountLines(text); + int lineCount = CountPhysicalLines(text, props.Width); - for (int j = 0; j < lineCount; j++) + for (int j = 0; j < lines.Length && currentRow < lineCount; j++) { Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow)); Console.Write(lines[j]); @@ -67,28 +69,51 @@ public override void RenderCore(TextPanelProps props, ConsoleReactiveState state } } - private static int CountLines(string text) + /// + /// Counts the number of physical terminal rows a text item will occupy, + /// accounting for both explicit newlines and terminal line wrapping. + /// + private static int CountPhysicalLines(string text, int terminalWidth) { if (string.IsNullOrEmpty(text)) { return 0; } - int count = 1; - for (int i = 0; i < text.Length; i++) + if (terminalWidth <= 0) + { + terminalWidth = int.MaxValue; + } + + int physicalLines = 0; + int lineStart = 0; + + for (int i = 0; i <= text.Length; i++) { - if (text[i] == '\n') + if (i == text.Length || text[i] == '\n') { - count++; + string logicalLine = text[lineStart..i]; + int visibleWidth = AnsiEscapes.VisibleLength(logicalLine); + + if (visibleWidth == 0) + { + physicalLines += 1; + } + else + { + physicalLines += (visibleWidth + terminalWidth - 1) / terminalWidth; + } + + lineStart = i + 1; } } // If text ends with a newline, don't count the trailing empty line if (text[text.Length - 1] == '\n') { - count--; + physicalLines--; } - return count; + return physicalLines; } } diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs index 3d86a400b3..26755bc369 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs @@ -77,36 +77,61 @@ public override void RenderCore(TextScrollPanelProps props, TextScrollPanelState Console.Write(props.Items[i]); } - // Calculate the offset from bottom for the start of the new last item - int lastItemLines = CountLines(props.Items[^1]); + // Calculate the offset from bottom for the start of the new last item, + // accounting for terminal line wrapping at the available width. + int lastItemLines = CountPhysicalLines(props.Items[^1], props.Width); this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0; // Update rendered count this._renderedCount = props.Items.Count; } - private static int CountLines(string text) + /// + /// Counts the number of physical terminal rows a text item will occupy, + /// accounting for both explicit newlines and terminal line wrapping. + /// + private static int CountPhysicalLines(string text, int terminalWidth) { if (string.IsNullOrEmpty(text)) { return 0; } - int count = 1; - for (int i = 0; i < text.Length; i++) + if (terminalWidth <= 0) + { + terminalWidth = int.MaxValue; + } + + int physicalLines = 0; + int lineStart = 0; + + for (int i = 0; i <= text.Length; i++) { - if (text[i] == '\n') + if (i == text.Length || text[i] == '\n') { - count++; + // End of a logical line — measure its visible width to determine wrapped rows. + string logicalLine = text[lineStart..i]; + int visibleWidth = AnsiEscapes.VisibleLength(logicalLine); + + if (visibleWidth == 0) + { + physicalLines += 1; + } + else + { + physicalLines += (visibleWidth + terminalWidth - 1) / terminalWidth; + } + + lineStart = i + 1; } } // If text ends with a newline, don't count the trailing empty line if (text[text.Length - 1] == '\n') { - count--; + physicalLines--; } - return count; + return physicalLines; } } 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 d100c9d081..b2104aa5fd 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/HarnessAppComponent.cs @@ -28,6 +28,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent /// Initializes a new instance of the class. @@ -341,7 +342,7 @@ public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentS } // Calculate queued items panel height - int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems); + int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems, state.ConsoleWidth); // Build the bottom panel child based on mode ConsoleReactiveComponent bottomChild; @@ -406,6 +407,14 @@ public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentS bottomChild = this._textInput; } + // When the bottom panel mode changes, the new child must repaint even if its + // props haven't changed — the screen area was overwritten by the previous child. + if (state.Mode != this._lastRenderedBottomPanelMode) + { + bottomChild.Invalidate(); + this._lastRenderedBottomPanelMode = state.Mode; + } + var ruleProps = new TopBottomRuleProps { Width = state.ConsoleWidth, From 8e6f9a5797ab176e6b2acb0662111c5fc339ec4b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 28 May 2026 10:31:35 +0000 Subject: [PATCH 2/6] Fix another text rendering issue and improve guardrails messaging --- .../ConsoleReactiveComponent.cs | 6 +- .../OpenAIResponsesErrorObserver.cs | 84 ++++++++++++++++++- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs index d71436e5e6..6fa36f2b8b 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs @@ -40,7 +40,7 @@ public abstract class ConsoleReactiveComponent : ConsoleReactive where TProps : ConsoleReactiveProps where TState : ConsoleReactiveState { - private readonly object _renderLock = new(); + private static readonly object s_renderLock = new(); private TProps? _lastRenderedProps; private TState? _lastRenderedState; @@ -74,7 +74,7 @@ public void SetState(TState newState) /// public override void Render() { - lock (this._renderLock) + lock (s_renderLock) { if (this.Props is null) { @@ -97,7 +97,7 @@ public override void Render() /// public override void Invalidate() { - lock (this._renderLock) + lock (s_renderLock) { this._lastRenderedProps = default; this._lastRenderedState = default; diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs index 9db00c9a65..e366df9efb 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs @@ -2,6 +2,7 @@ #pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage. +using System.Text.Json; using Harness.Shared.Console.Observers; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -53,9 +54,88 @@ public override async Task OnResponseUpdateAsync(IUXStateDriver ux, AgentRespons case StreamingResponseIncompleteUpdate incompleteUpdate: string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString(); - string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}"; - await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow); + if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase)) + { + await ux.WriteInfoLineAsync( + "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short.", + ConsoleColor.Yellow); + + await WriteContentFilterDetailsAsync(ux, incompleteUpdate); + } + else + { + string incompleteText = $"⚠️ Response incomplete: {reason ?? "unknown reason"}"; + await ux.WriteInfoLineAsync(incompleteText, ConsoleColor.Yellow); + } + break; } } + + /// + /// Extracts and displays content filter details from the serialized response JSON. + /// Parses the content_filters[].content_filter_results to show which specific + /// categories were triggered (e.g. hate, sexual, violence, self_harm, protected_material). + /// + private static async Task WriteContentFilterDetailsAsync(IUXStateDriver ux, StreamingResponseIncompleteUpdate incompleteUpdate) + { + try + { + var data = System.ClientModel.Primitives.ModelReaderWriter.Write(incompleteUpdate); + using var doc = JsonDocument.Parse(data.ToString()); + var root = doc.RootElement; + + // Navigate into the nested response object if present. + JsonElement responseElement = root.TryGetProperty("response", out var resp) ? resp : root; + + if (!responseElement.TryGetProperty("content_filters", out var filtersArray) + || filtersArray.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (var filter in filtersArray.EnumerateArray()) + { + if (!filter.TryGetProperty("content_filter_results", out var results) + || results.ValueKind != JsonValueKind.Object) + { + continue; + } + + // Collect category data for aligned output. + var categories = new List<(string Name, bool Filtered, string? Severity)>(); + foreach (var category in results.EnumerateObject()) + { + if (category.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + bool filtered = category.Value.TryGetProperty("filtered", out var f) && f.GetBoolean(); + string? severity = category.Value.TryGetProperty("severity", out var s) ? s.GetString() : null; + categories.Add((category.Name, filtered, severity)); + } + + // Calculate column widths for alignment. + int maxNameLen = categories.Count > 0 ? categories.Max(c => c.Name.Length) : 0; + + foreach (var (name, filtered, severity) in categories) + { + string paddedName = name.PadRight(maxNameLen); + string icon = filtered ? "❌" : "✅"; + string statusText = filtered ? "Filtered " : "Not Filtered"; + string severityText = severity is not null ? $" Severity: {severity}" : ""; + ConsoleColor color = filtered ? ConsoleColor.Red : ConsoleColor.DarkGray; + + await ux.WriteInfoLineAsync( + $" {icon} {paddedName} {statusText}{severityText}", + color); + } + } + } + catch + { + // Parsing not critical — skip silently if it fails. + } + } } From 1c811d594fd0d89e930b9d94df7542017551e00b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 28 May 2026 11:01:54 +0000 Subject: [PATCH 3/6] Address PR comments --- .../ConsoleReactiveComponents/TextPanel.cs | 31 +++++++++++-------- .../TextScrollPanel.cs | 19 +++++------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs index 6d273d0a99..6d888c956f 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs @@ -49,13 +49,20 @@ public override void RenderCore(TextPanelProps props, ConsoleReactiveState state { string text = props.Items[i]; string[] lines = text.Split('\n'); - int lineCount = CountPhysicalLines(text, props.Width); + int itemLineCount = CountPhysicalLines(text, props.Width); + int itemRow = 0; - for (int j = 0; j < lines.Length && currentRow < lineCount; j++) + for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++) { + int linePhysicalRows = props.Width > 0 + ? Math.Max(1, (AnsiEscapes.VisibleLength(lines[j]) - 1) / props.Width + 1) + : 1; + Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow)); Console.Write(lines[j]); - currentRow++; + + currentRow += linePhysicalRows; + itemRow += linePhysicalRows; } } @@ -80,11 +87,6 @@ private static int CountPhysicalLines(string text, int terminalWidth) return 0; } - if (terminalWidth <= 0) - { - terminalWidth = int.MaxValue; - } - int physicalLines = 0; int lineStart = 0; @@ -92,16 +94,19 @@ private static int CountPhysicalLines(string text, int terminalWidth) { if (i == text.Length || text[i] == '\n') { - string logicalLine = text[lineStart..i]; - int visibleWidth = AnsiEscapes.VisibleLength(logicalLine); - - if (visibleWidth == 0) + if (terminalWidth <= 0) { + // No wrapping — each logical line is one physical row physicalLines += 1; } else { - physicalLines += (visibleWidth + terminalWidth - 1) / terminalWidth; + string logicalLine = text[lineStart..i]; + int visibleWidth = AnsiEscapes.VisibleLength(logicalLine); + + physicalLines += visibleWidth == 0 + ? 1 + : (visibleWidth - 1) / terminalWidth + 1; } lineStart = i + 1; diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs index 26755bc369..470c3629e7 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs @@ -97,11 +97,6 @@ private static int CountPhysicalLines(string text, int terminalWidth) return 0; } - if (terminalWidth <= 0) - { - terminalWidth = int.MaxValue; - } - int physicalLines = 0; int lineStart = 0; @@ -109,17 +104,19 @@ private static int CountPhysicalLines(string text, int terminalWidth) { if (i == text.Length || text[i] == '\n') { - // End of a logical line — measure its visible width to determine wrapped rows. - string logicalLine = text[lineStart..i]; - int visibleWidth = AnsiEscapes.VisibleLength(logicalLine); - - if (visibleWidth == 0) + if (terminalWidth <= 0) { + // No wrapping — each logical line is one physical row physicalLines += 1; } else { - physicalLines += (visibleWidth + terminalWidth - 1) / terminalWidth; + string logicalLine = text[lineStart..i]; + int visibleWidth = AnsiEscapes.VisibleLength(logicalLine); + + physicalLines += visibleWidth == 0 + ? 1 + : (visibleWidth - 1) / terminalWidth + 1; } lineStart = i + 1; From 6d18d592c8bbaccf155879fa01ea0849f3d14c60 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 28 May 2026 11:22:04 +0000 Subject: [PATCH 4/6] Improve guardrail rendering and json error handling --- .../Observers/PlanningOutputObserver.cs | 12 ++++--- .../OpenAIResponsesErrorObserver.cs | 32 +++++++++++-------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs index 1e7a73df96..153c11e755 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console/Observers/PlanningOutputObserver.cs @@ -88,16 +88,17 @@ public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, { planningResponse = JsonSerializer.Deserialize(collectedText); } - catch (JsonException ex) + catch (JsonException) { - await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red); - await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow); + // JSON parsing failed — fall back to rendering as regular text output. + await ux.WriteTextAsync(collectedText).ConfigureAwait(false); return null; } if (planningResponse is null) { - await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow); + // Null result — fall back to rendering as regular text output. + await ux.WriteTextAsync(collectedText).ConfigureAwait(false); return null; } @@ -118,7 +119,8 @@ public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, return new List { this.BuildApprovalAction(question, session) }; } - await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow); + // Unexpected type — fall back to rendering as regular text output. + await ux.WriteTextAsync(collectedText).ConfigureAwait(false); return null; } diff --git a/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs b/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs index e366df9efb..8cf5abee57 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs +++ b/dotnet/samples/02-agents/Harness/Harness_Shared_Console_OpenAI/OpenAIResponsesErrorObserver.cs @@ -56,11 +56,11 @@ public override async Task OnResponseUpdateAsync(IUXStateDriver ux, AgentRespons string? reason = incompleteUpdate.Response?.IncompleteStatusDetails?.Reason?.ToString(); if (string.Equals(reason, "content_filter", StringComparison.OrdinalIgnoreCase)) { + string detail = GetContentFilterDetails(incompleteUpdate); + const string Message = "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short."; await ux.WriteInfoLineAsync( - "🛡️ The service's built-in content filter guardrails were triggered and the response was cut short.", + string.IsNullOrEmpty(detail) ? Message : $"{Message}\n{detail}", ConsoleColor.Yellow); - - await WriteContentFilterDetailsAsync(ux, incompleteUpdate); } else { @@ -73,11 +73,11 @@ await ux.WriteInfoLineAsync( } /// - /// Extracts and displays content filter details from the serialized response JSON. - /// Parses the content_filters[].content_filter_results to show which specific - /// categories were triggered (e.g. hate, sexual, violence, self_harm, protected_material). + /// Extracts content filter details from the serialized response JSON and returns + /// a formatted string showing which specific categories were triggered. + /// Returns if details cannot be extracted. /// - private static async Task WriteContentFilterDetailsAsync(IUXStateDriver ux, StreamingResponseIncompleteUpdate incompleteUpdate) + private static string GetContentFilterDetails(StreamingResponseIncompleteUpdate incompleteUpdate) { try { @@ -91,7 +91,7 @@ private static async Task WriteContentFilterDetailsAsync(IUXStateDriver ux, Stre if (!responseElement.TryGetProperty("content_filters", out var filtersArray) || filtersArray.ValueKind != JsonValueKind.Array) { - return; + return string.Empty; } foreach (var filter in filtersArray.EnumerateArray()) @@ -116,8 +116,9 @@ private static async Task WriteContentFilterDetailsAsync(IUXStateDriver ux, Stre categories.Add((category.Name, filtered, severity)); } - // Calculate column widths for alignment. + // Build all category lines into a single string. int maxNameLen = categories.Count > 0 ? categories.Max(c => c.Name.Length) : 0; + var lines = new List(); foreach (var (name, filtered, severity) in categories) { @@ -125,17 +126,22 @@ private static async Task WriteContentFilterDetailsAsync(IUXStateDriver ux, Stre string icon = filtered ? "❌" : "✅"; string statusText = filtered ? "Filtered " : "Not Filtered"; string severityText = severity is not null ? $" Severity: {severity}" : ""; - ConsoleColor color = filtered ? ConsoleColor.Red : ConsoleColor.DarkGray; - await ux.WriteInfoLineAsync( - $" {icon} {paddedName} {statusText}{severityText}", - color); + lines.Add($" {icon} {paddedName} {statusText}{severityText}"); + } + + if (lines.Count > 0) + { + return string.Join("\n", lines); } } + + return string.Empty; } catch { // Parsing not critical — skip silently if it fails. + return string.Empty; } } } From f09c3e29ea751f5ea837905ed5cbcba3e9f727c2 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 28 May 2026 11:35:23 +0000 Subject: [PATCH 5/6] Another tweak for input box render issue --- .../ConsoleReactiveComponent.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs index 6fa36f2b8b..fa923efdf8 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveComponent.cs @@ -13,6 +13,11 @@ internal ConsoleReactiveComponent() { } + /// + /// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving. + /// + protected static object RenderLock { get; } = new(); + /// /// Gets or sets the component's props as the base type. /// Used by parent components to set layout (X, Y, Width, Height) on children without @@ -40,7 +45,6 @@ public abstract class ConsoleReactiveComponent : ConsoleReactive where TProps : ConsoleReactiveProps where TState : ConsoleReactiveState { - private static readonly object s_renderLock = new(); private TProps? _lastRenderedProps; private TState? _lastRenderedState; @@ -74,7 +78,7 @@ public void SetState(TState newState) /// public override void Render() { - lock (s_renderLock) + lock (RenderLock) { if (this.Props is null) { @@ -97,7 +101,7 @@ public override void Render() /// public override void Invalidate() { - lock (s_renderLock) + lock (RenderLock) { this._lastRenderedProps = default; this._lastRenderedState = default; From 9d8224712f2c3094882a174266cb26d7c54daf93 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 28 May 2026 12:40:27 +0000 Subject: [PATCH 6/6] Address PR comments --- .../ConsoleReactiveComponents/AnsiEscapes.cs | 55 +++++++++++++++++++ .../ConsoleReactiveComponents/TextPanel.cs | 50 +---------------- .../TextScrollPanel.cs | 48 +--------------- 3 files changed, 58 insertions(+), 95 deletions(-) diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs index 9e10f313cb..6e5d51380d 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/AnsiEscapes.cs @@ -76,6 +76,12 @@ public static class AnsiEscapes /// Returns the visible (printed) length of a string after stripping ANSI escape sequences. /// Escape sequences are zero-width on screen but occupy characters in the raw string. /// + /// + /// This counts UTF-16 code units (chars) rather than terminal display cells. Emoji, + /// combining characters, variation selectors, and East Asian wide characters may be + /// measured incorrectly. For the console harness this is acceptable since content is + /// predominantly ASCII, and emoji are padded with surrounding spaces. + /// public static int VisibleLength(string text) { if (string.IsNullOrEmpty(text)) @@ -106,6 +112,55 @@ public static int VisibleLength(string text) return length; } + /// + /// Counts the number of physical terminal rows a text item will occupy, + /// accounting for both explicit newlines and terminal line wrapping. + /// + /// The text to measure. + /// The terminal width in columns. If <= 0, wrapping is ignored (1 row per logical line). + /// The number of physical rows the text occupies. + public static int CountPhysicalLines(string text, int terminalWidth) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + int physicalLines = 0; + int lineStart = 0; + + for (int i = 0; i <= text.Length; i++) + { + if (i == text.Length || text[i] == '\n') + { + if (terminalWidth <= 0) + { + // No wrapping — each logical line is one physical row + physicalLines += 1; + } + else + { + string logicalLine = text[lineStart..i]; + int visibleWidth = VisibleLength(logicalLine); + + physicalLines += visibleWidth == 0 + ? 1 + : (visibleWidth - 1) / terminalWidth + 1; + } + + lineStart = i + 1; + } + } + + // If text ends with a newline, don't count the trailing empty line + if (text[text.Length - 1] == '\n') + { + physicalLines--; + } + + return physicalLines; + } + private static int ConsoleColorToAnsi(ConsoleColor color) => color switch { ConsoleColor.Black => 30, diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs index 6d888c956f..d63c243108 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextPanel.cs @@ -34,7 +34,7 @@ public static int CalculateHeight(IReadOnlyList items, int terminalWidth int total = 0; for (int i = 0; i < items.Count; i++) { - total += CountPhysicalLines(items[i], terminalWidth); + total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth); } return total; @@ -49,7 +49,7 @@ public override void RenderCore(TextPanelProps props, ConsoleReactiveState state { string text = props.Items[i]; string[] lines = text.Split('\n'); - int itemLineCount = CountPhysicalLines(text, props.Width); + int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width); int itemRow = 0; for (int j = 0; j < lines.Length && itemRow < itemLineCount; j++) @@ -75,50 +75,4 @@ public override void RenderCore(TextPanelProps props, ConsoleReactiveState state } } } - - /// - /// Counts the number of physical terminal rows a text item will occupy, - /// accounting for both explicit newlines and terminal line wrapping. - /// - private static int CountPhysicalLines(string text, int terminalWidth) - { - if (string.IsNullOrEmpty(text)) - { - return 0; - } - - int physicalLines = 0; - int lineStart = 0; - - for (int i = 0; i <= text.Length; i++) - { - if (i == text.Length || text[i] == '\n') - { - if (terminalWidth <= 0) - { - // No wrapping — each logical line is one physical row - physicalLines += 1; - } - else - { - string logicalLine = text[lineStart..i]; - int visibleWidth = AnsiEscapes.VisibleLength(logicalLine); - - physicalLines += visibleWidth == 0 - ? 1 - : (visibleWidth - 1) / terminalWidth + 1; - } - - lineStart = i + 1; - } - } - - // If text ends with a newline, don't count the trailing empty line - if (text[text.Length - 1] == '\n') - { - physicalLines--; - } - - return physicalLines; - } } diff --git a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs index 470c3629e7..eb5a19d37a 100644 --- a/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs +++ b/dotnet/samples/02-agents/Harness/ConsoleReactiveComponents/TextScrollPanel.cs @@ -79,56 +79,10 @@ public override void RenderCore(TextScrollPanelProps props, TextScrollPanelState // Calculate the offset from bottom for the start of the new last item, // accounting for terminal line wrapping at the available width. - int lastItemLines = CountPhysicalLines(props.Items[^1], props.Width); + int lastItemLines = AnsiEscapes.CountPhysicalLines(props.Items[^1], props.Width); this._lastItemOffsetFromBottom = lastItemLines > 0 ? lastItemLines - 1 : 0; // Update rendered count this._renderedCount = props.Items.Count; } - - /// - /// Counts the number of physical terminal rows a text item will occupy, - /// accounting for both explicit newlines and terminal line wrapping. - /// - private static int CountPhysicalLines(string text, int terminalWidth) - { - if (string.IsNullOrEmpty(text)) - { - return 0; - } - - int physicalLines = 0; - int lineStart = 0; - - for (int i = 0; i <= text.Length; i++) - { - if (i == text.Length || text[i] == '\n') - { - if (terminalWidth <= 0) - { - // No wrapping — each logical line is one physical row - physicalLines += 1; - } - else - { - string logicalLine = text[lineStart..i]; - int visibleWidth = AnsiEscapes.VisibleLength(logicalLine); - - physicalLines += visibleWidth == 0 - ? 1 - : (visibleWidth - 1) / terminalWidth + 1; - } - - lineStart = i + 1; - } - } - - // If text ends with a newline, don't count the trailing empty line - if (text[text.Length - 1] == '\n') - { - physicalLines--; - } - - return physicalLines; - } }