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;
- }
}