mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Fix render dupe and text input clear bugs, and improve guardrail error messaging (#6136)
* Fix render dupe and text input clear bugs * Fix another text rendering issue and improve guardrails messaging * Address PR comments * Improve guardrail rendering and json error handling * Another tweak for input box render issue * Address PR comments
This commit is contained in:
@@ -72,6 +72,95 @@ public static class AnsiEscapes
|
||||
/// </summary>
|
||||
public static string ResetAttributes => "\x1b[0m";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of physical terminal rows a text item will occupy,
|
||||
/// accounting for both explicit newlines and terminal line wrapping.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to measure.</param>
|
||||
/// <param name="terminalWidth">The terminal width in columns. If <= 0, wrapping is ignored (1 row per logical line).</param>
|
||||
/// <returns>The number of physical rows the text occupies.</returns>
|
||||
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,
|
||||
|
||||
@@ -23,16 +23,18 @@ public record TextPanelProps : ConsoleReactiveProps
|
||||
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to measure.</param>
|
||||
/// <returns>The total number of lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<string> items)
|
||||
/// <param name="terminalWidth">The terminal width in columns. When 0 or negative, wrapping is ignored.</param>
|
||||
/// <returns>The total number of physical lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<string> items, int terminalWidth = 0)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
total += CountLines(items[i]);
|
||||
total += AnsiEscapes.CountPhysicalLines(items[i], terminalWidth);
|
||||
}
|
||||
|
||||
return total;
|
||||
@@ -47,13 +49,20 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
||||
{
|
||||
string text = props.Items[i];
|
||||
string[] lines = text.Split('\n');
|
||||
int lineCount = CountLines(text);
|
||||
int itemLineCount = AnsiEscapes.CountPhysicalLines(text, props.Width);
|
||||
int itemRow = 0;
|
||||
|
||||
for (int j = 0; j < 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,29 +75,4 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,36 +77,12 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
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 = AnsiEscapes.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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user