Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Comment thread
westey-m marked this conversation as resolved.
}
}

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 &lt;= 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,13 +49,20 @@ public override void RenderCore(TextPanelProps props, ConsoleReactiveState state
{
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;
}
}

Expand All @@ -66,29 +75,4 @@ public override void RenderCore(TextPanelProps props, ConsoleReactiveState state
}
}
}

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,36 +77,12 @@ 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 = 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ internal ConsoleReactiveComponent()
{
}

/// <summary>
/// Gets the shared render lock across all component types to prevent ANSI escape sequence interleaving.
/// </summary>
protected static object RenderLock { get; } = new();

/// <summary>
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
/// Used by parent components to set layout (X, Y, Width, Height) on children without
Expand Down Expand Up @@ -40,7 +45,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
where TProps : ConsoleReactiveProps
where TState : ConsoleReactiveState
{
private readonly object _renderLock = new();
private TProps? _lastRenderedProps;
private TState? _lastRenderedState;

Expand Down Expand Up @@ -74,7 +78,7 @@ public void SetState(TState newState)
/// </summary>
public override void Render()
{
lock (this._renderLock)
lock (RenderLock)
{
if (this.Props is null)
{
Expand All @@ -97,7 +101,7 @@ public override void Render()
/// <inheritdoc/>
public override void Invalidate()
{
lock (this._renderLock)
lock (RenderLock)
{
this._lastRenderedProps = default;
this._lastRenderedState = default;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
private int _scrollRegionBottom;
private bool _resizedSinceLastRender = true;
private bool _deactivated;
private BottomPanelMode _lastRenderedBottomPanelMode;

/// <summary>
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,17 @@ public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent,
{
planningResponse = JsonSerializer.Deserialize<PlanningResponse>(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;
}

Expand All @@ -118,7 +119,8 @@ public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent,
return new List<FollowUpAction> { 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;
}

Expand Down
Loading
Loading