mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Refactor harness console rendering (#5751)
* Refactor harness console rendering * Fix formatting issues * Address PR comments
This commit is contained in:
@@ -123,6 +123,8 @@
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithSubAgents/Harness_Step02_Research_WithSubAgents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
|
||||
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Provides descriptive helpers for common ANSI/VT100 escape sequences used
|
||||
/// in the split-console layout (DECSTBM scroll regions, cursor movement, line erasure).
|
||||
/// </summary>
|
||||
public static class AnsiEscapes
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets the scrollable region to rows 1 through <paramref name="bottom"/> (DECSTBM).
|
||||
/// Content outside this region will not scroll.
|
||||
/// </summary>
|
||||
public static string SetScrollRegion(int bottom) => $"\x1b[1;{bottom}r";
|
||||
|
||||
/// <summary>
|
||||
/// Resets the scroll region to the full terminal height (DECSTBM reset).
|
||||
/// </summary>
|
||||
public static string ResetScrollRegion => "\x1b[r";
|
||||
|
||||
/// <summary>
|
||||
/// Moves the cursor to the specified 1-based <paramref name="row"/> and <paramref name="column"/> (CUP).
|
||||
/// </summary>
|
||||
public static string MoveCursor(int row, int column) => $"\x1b[{row};{column}H";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the entire current line (EL 2).
|
||||
/// </summary>
|
||||
public static string EraseEntireLine => "\x1b[2K";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the entire screen.
|
||||
/// </summary>
|
||||
public static string EraseEntireScreen => "\x1b[2J";
|
||||
|
||||
/// <summary>
|
||||
/// Erases the scrollback buffer (ESC[3J). Use alongside <see cref="EraseEntireScreen"/>
|
||||
/// to fully clear both the visible screen and the scroll history.
|
||||
/// </summary>
|
||||
public static string EraseScrollbackBuffer => "\x1b[3J";
|
||||
|
||||
/// <summary>
|
||||
/// Saves the current cursor position (DECSC / SCP).
|
||||
/// Note: most terminals have a single save slot — nested saves are not supported.
|
||||
/// </summary>
|
||||
public static string SaveCursor => "\x1b[s";
|
||||
|
||||
/// <summary>
|
||||
/// Restores the previously saved cursor position (DECRC / RCP).
|
||||
/// </summary>
|
||||
public static string RestoreCursor => "\x1b[u";
|
||||
|
||||
/// <summary>
|
||||
/// Moves the cursor to the specified 1-based <paramref name="row"/> at column 1, then erases the entire line.
|
||||
/// Convenience combination of <see cref="MoveCursor"/> and <see cref="EraseEntireLine"/>.
|
||||
/// </summary>
|
||||
public static string MoveAndEraseLine(int row) => $"\x1b[{row};1H\x1b[2K";
|
||||
|
||||
/// <summary>
|
||||
/// Sets the foreground text color using a <see cref="ConsoleColor"/> value.
|
||||
/// </summary>
|
||||
public static string SetForegroundColor(ConsoleColor color) => $"\x1b[{ConsoleColorToAnsi(color)}m";
|
||||
|
||||
/// <summary>
|
||||
/// Resets all text attributes (color, bold, etc.) to their defaults.
|
||||
/// </summary>
|
||||
public static string ResetAttributes => "\x1b[0m";
|
||||
|
||||
private static int ConsoleColorToAnsi(ConsoleColor color) => color switch
|
||||
{
|
||||
ConsoleColor.Black => 30,
|
||||
ConsoleColor.DarkRed => 31,
|
||||
ConsoleColor.DarkGreen => 32,
|
||||
ConsoleColor.DarkYellow => 33,
|
||||
ConsoleColor.DarkBlue => 34,
|
||||
ConsoleColor.DarkMagenta => 35,
|
||||
ConsoleColor.DarkCyan => 36,
|
||||
ConsoleColor.Gray => 37,
|
||||
ConsoleColor.DarkGray => 90,
|
||||
ConsoleColor.Red => 91,
|
||||
ConsoleColor.Green => 92,
|
||||
ConsoleColor.Yellow => 93,
|
||||
ConsoleColor.Blue => 94,
|
||||
ConsoleColor.Magenta => 95,
|
||||
ConsoleColor.Cyan => 96,
|
||||
ConsoleColor.White => 97,
|
||||
_ => 37
|
||||
};
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a selectable list of items with a cursor indicator.
|
||||
/// The selected item is indicated with a ">" prefix and rendered in the highlight color.
|
||||
/// Optionally includes a title above the list and a custom text input option at the bottom.
|
||||
/// </summary>
|
||||
public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height (in rows) required to render the list,
|
||||
/// including the optional title and custom text input row.
|
||||
/// </summary>
|
||||
/// <param name="props">The list selection props.</param>
|
||||
/// <returns>The number of rows needed.</returns>
|
||||
public static int CalculateHeight(ListSelectionProps props)
|
||||
{
|
||||
int height = props.Items.Count;
|
||||
if (props.CustomTextPlaceholder != null)
|
||||
{
|
||||
height++;
|
||||
}
|
||||
|
||||
height += GetTitleLineCount(props.Title);
|
||||
return height;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(ListSelectionProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int row = 0;
|
||||
|
||||
// Render the title lines (if any)
|
||||
if (props.Title is not null)
|
||||
{
|
||||
foreach (string line in props.Title.Split('\n'))
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(line);
|
||||
row++;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the list items + optional custom text row
|
||||
int totalItems = props.Items.Count + (props.CustomTextPlaceholder != null ? 1 : 0);
|
||||
|
||||
for (int i = 0; i < totalItems; i++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
|
||||
bool isSelected = i == props.SelectedIndex;
|
||||
bool isCustomTextOption = props.CustomTextPlaceholder != null && i == props.Items.Count;
|
||||
|
||||
// Cursor indicator
|
||||
Console.Write(isSelected ? "> " : " ");
|
||||
|
||||
if (isCustomTextOption)
|
||||
{
|
||||
this.RenderCustomTextOption(props, isSelected);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||
}
|
||||
|
||||
Console.Write(props.Items[i]);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
row++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of lines the title occupies, or 0 if no title is set.
|
||||
/// </summary>
|
||||
private static int GetTitleLineCount(string? title) =>
|
||||
title is null ? 0 : title.Split('\n').Length;
|
||||
|
||||
private void RenderCustomTextOption(ListSelectionProps props, bool isSelected)
|
||||
{
|
||||
if (props.CustomText.Length > 0)
|
||||
{
|
||||
// User has typed text — render in highlight color if selected
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||
}
|
||||
|
||||
Console.Write(props.CustomText);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(props.CustomTextPlaceholder))
|
||||
{
|
||||
// No text — show placeholder in dark grey (or highlight color if selected)
|
||||
if (isSelected)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.HighlightColor));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||
}
|
||||
|
||||
Console.Write(" ");
|
||||
Console.Write(props.CustomTextPlaceholder);
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="ListSelection"/>.
|
||||
/// </summary>
|
||||
public record ListSelectionProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the title text displayed above the list items. May contain newlines for multi-line titles.</summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>Gets the items to display in the list.</summary>
|
||||
public IReadOnlyList<string> Items { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Gets the zero-based index of the currently selected item.</summary>
|
||||
public int SelectedIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the highlight color for the active item. Defaults to <see cref="ConsoleColor.Cyan"/>.</summary>
|
||||
public ConsoleColor HighlightColor { get; init; } = ConsoleColor.Cyan;
|
||||
|
||||
/// <summary>Gets the placeholder text for the custom text input option. If <c>null</c>, no custom option is shown.</summary>
|
||||
public string? CustomTextPlaceholder { get; init; }
|
||||
|
||||
/// <summary>Gets the text being typed into the custom text input option.</summary>
|
||||
public string CustomText { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TextInput"/>.
|
||||
/// </summary>
|
||||
public record TextInputProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the prompt string displayed on the left (e.g. "> " or "user > ").</summary>
|
||||
public string Prompt { get; init; } = "> ";
|
||||
|
||||
/// <summary>Gets the text content to render to the right of the prompt.</summary>
|
||||
public string Text { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the placeholder text shown in dark grey when <see cref="Text"/> is empty.</summary>
|
||||
public string Placeholder { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a prompt with text input. Supports multi-line text
|
||||
/// where continuation lines are indented to align with the text start position
|
||||
/// (i.e. the column after the prompt).
|
||||
/// </summary>
|
||||
public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height (in rows) required to render the prompt and text
|
||||
/// given the available width.
|
||||
/// </summary>
|
||||
/// <param name="props">The text input props.</param>
|
||||
/// <param name="availableWidth">The total available width in columns.</param>
|
||||
/// <returns>The number of rows needed.</returns>
|
||||
public static int CalculateHeight(TextInputProps props, int availableWidth)
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int textWidth = availableWidth - promptLength;
|
||||
|
||||
if (textWidth <= 0 || props.Text.Length == 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
int lines = 1;
|
||||
int remaining = props.Text.Length - textWidth;
|
||||
while (remaining > 0)
|
||||
{
|
||||
lines++;
|
||||
remaining -= textWidth;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TextInputProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int textWidth = this.Width - promptLength;
|
||||
string indent = new(' ', promptLength);
|
||||
|
||||
// First line: prompt + start of text
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(props.Prompt);
|
||||
|
||||
if (textWidth <= 0 || props.Text.Length == 0)
|
||||
{
|
||||
// Show placeholder if text is empty
|
||||
if (props.Text.Length == 0 && props.Placeholder.Length > 0 && textWidth > 0)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||
Console.Write(" ");
|
||||
Console.Write(props.Placeholder[..Math.Min(props.Placeholder.Length, textWidth - 1)]);
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
int offset = 0;
|
||||
int firstChunk = Math.Min(textWidth, props.Text.Length);
|
||||
Console.Write(props.Text[offset..firstChunk]);
|
||||
offset = firstChunk;
|
||||
|
||||
// Continuation lines: indented to align with text start
|
||||
int row = 1;
|
||||
while (offset < props.Text.Length)
|
||||
{
|
||||
int chunk = Math.Min(textWidth, props.Text.Length - offset);
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
|
||||
Console.Write(AnsiEscapes.EraseEntireLine);
|
||||
Console.Write(indent);
|
||||
Console.Write(props.Text[offset..(offset + chunk)]);
|
||||
offset += chunk;
|
||||
row++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TextPanel"/>.
|
||||
/// </summary>
|
||||
public record TextPanelProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the items to render in the panel.</summary>
|
||||
public IReadOnlyList<object> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a list of items vertically using a custom render delegate.
|
||||
/// Designed for rendering dynamic items in a non-scroll region that may be
|
||||
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveComponent.Height"/>
|
||||
/// exceeds the number of output lines, leftover lines are erased.
|
||||
/// </summary>
|
||||
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
|
||||
{
|
||||
private readonly Func<object, string> _renderItem;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextPanel"/> class.
|
||||
/// </summary>
|
||||
/// <param name="renderItem">A delegate that renders an item and returns the text to display (may contain newlines).</param>
|
||||
public TextPanel(Func<object, string> renderItem)
|
||||
{
|
||||
this._renderItem = renderItem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the height (in lines) needed to render all items.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to measure.</param>
|
||||
/// <param name="renderItem">The render delegate to use for measuring.</param>
|
||||
/// <returns>The total number of lines all items will occupy.</returns>
|
||||
public static int CalculateHeight(IReadOnlyList<object> items, Func<object, string> renderItem)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
string text = renderItem(items[i]);
|
||||
total += CountLines(text);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TextPanelProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int currentRow = 0;
|
||||
|
||||
for (int i = 0; i < props.Items.Count; i++)
|
||||
{
|
||||
string text = this._renderItem(props.Items[i]);
|
||||
string[] lines = text.Split('\n');
|
||||
int lineCount = CountLines(text);
|
||||
|
||||
for (int j = 0; j < lineCount; j++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + currentRow));
|
||||
Console.Write(lines[j]);
|
||||
currentRow++;
|
||||
}
|
||||
}
|
||||
|
||||
// If the component height exceeds the output, erase leftover lines
|
||||
if (this.Height > currentRow)
|
||||
{
|
||||
for (int i = currentRow; i < this.Height; i++)
|
||||
{
|
||||
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TextScrollPanel"/>.
|
||||
/// </summary>
|
||||
public record TextScrollPanelProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the items to render in the scroll panel.</summary>
|
||||
public IReadOnlyList<object> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State for <see cref="TextScrollPanel"/>.
|
||||
/// </summary>
|
||||
/// <param name="RenderedCount">The number of items already rendered.</param>
|
||||
public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders items within a scroll area using a custom render delegate.
|
||||
/// All items are considered finalized — only new items since the last render are output.
|
||||
/// Use <see cref="Reset"/> to force a full re-render.
|
||||
/// </summary>
|
||||
public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, TextScrollPanelState>
|
||||
{
|
||||
private readonly Func<object, string> _renderItem;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextScrollPanel"/> class.
|
||||
/// </summary>
|
||||
/// <param name="renderItem">A delegate that renders a single item and returns the text to display (may contain newlines).</param>
|
||||
public TextScrollPanel(Func<object, string> renderItem)
|
||||
{
|
||||
this._renderItem = renderItem;
|
||||
this.State = new TextScrollPanelState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the panel so all items will be re-rendered on the next Render call.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
this.State = new TextScrollPanelState();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TextScrollPanelProps props, TextScrollPanelState state)
|
||||
{
|
||||
if (props.Items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Move cursor to the bottom of the scroll area
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y + this.Height - 1, this.X));
|
||||
|
||||
// Output only new items since last rendered
|
||||
for (int i = state.RenderedCount; i < props.Items.Count; i++)
|
||||
{
|
||||
string text = this._renderItem(props.Items[i]);
|
||||
Console.Write(text);
|
||||
}
|
||||
|
||||
// Update state to track what we've rendered
|
||||
this.State = new TextScrollPanelState(props.Items.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleReactiveComponents;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="TopBottomRule"/>.
|
||||
/// </summary>
|
||||
public record TopBottomRuleProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the width of the horizontal rules in characters.</summary>
|
||||
public int Width { get; init; }
|
||||
|
||||
/// <summary>Gets the foreground color of the horizontal rules. If <c>null</c>, the default terminal color is used.</summary>
|
||||
public ConsoleColor? Color { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a top and bottom horizontal rule (─) with children
|
||||
/// stacked vertically between them.
|
||||
/// </summary>
|
||||
public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the total height including the top rule, children, and bottom rule.
|
||||
/// </summary>
|
||||
/// <param name="props">The component props containing children.</param>
|
||||
/// <returns>2 (for the rules) plus the sum of all children heights.</returns>
|
||||
public static int CalculateHeight(TopBottomRuleProps props)
|
||||
{
|
||||
int childrenHeight = 0;
|
||||
foreach (var child in props.Children)
|
||||
{
|
||||
childrenHeight += child.Height;
|
||||
}
|
||||
|
||||
// Top rule + children + bottom rule
|
||||
return 2 + childrenHeight;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(TopBottomRuleProps props, ConsoleReactiveState state)
|
||||
{
|
||||
int ruleWidth = props.Width;
|
||||
string rule = new('─', ruleWidth);
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.Color.Value));
|
||||
}
|
||||
|
||||
// Top rule
|
||||
Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X));
|
||||
Console.Write(rule);
|
||||
|
||||
// Render children stacked below the top rule
|
||||
int currentY = this.Y + 1;
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
foreach (var child in props.Children)
|
||||
{
|
||||
child.X = this.X;
|
||||
child.Y = currentY;
|
||||
child.Render();
|
||||
currentY += child.Height;
|
||||
}
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.SetForegroundColor(props.Color.Value));
|
||||
}
|
||||
|
||||
// Bottom rule
|
||||
Console.Write(AnsiEscapes.MoveCursor(currentY, this.X));
|
||||
Console.Write(rule);
|
||||
|
||||
if (props.Color.HasValue)
|
||||
{
|
||||
Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract base class for all console UI components. Provides layout properties
|
||||
/// (position and size) and a <see cref="Render"/> method for drawing to the console.
|
||||
/// Derive from <see cref="ConsoleReactiveComponent{TProps, TState}"/> instead of this class directly.
|
||||
/// </summary>
|
||||
public abstract class ConsoleReactiveComponent
|
||||
{
|
||||
internal ConsoleReactiveComponent()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the 1-based column position of the component.</summary>
|
||||
public int X { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the 1-based row position of the component.</summary>
|
||||
public int Y { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the width of the component in columns.</summary>
|
||||
public int Width { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the height of the component in rows.</summary>
|
||||
public int Height { get; set; }
|
||||
|
||||
/// <summary>Renders the component to the console at its current position.</summary>
|
||||
public abstract void Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic base class for console UI components with typed props and state.
|
||||
/// Props represent externally supplied configuration; state represents internal mutable data.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProps">The type of the component's props (external configuration).</typeparam>
|
||||
/// <typeparam name="TState">The type of the component's internal state.</typeparam>
|
||||
public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactiveComponent
|
||||
where TProps : ConsoleReactiveProps
|
||||
where TState : ConsoleReactiveState
|
||||
{
|
||||
private readonly object _renderLock = new();
|
||||
private TProps? _lastRenderedProps;
|
||||
private TState? _lastRenderedState;
|
||||
|
||||
/// <summary>Gets or sets the component's props (external configuration).</summary>
|
||||
public TProps? Props { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the component's internal state.</summary>
|
||||
protected TState? State { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Updates the component's state and triggers a re-render.
|
||||
/// </summary>
|
||||
/// <param name="newState">The new state value.</param>
|
||||
public void SetState(TState newState)
|
||||
{
|
||||
this.State = newState;
|
||||
this.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the component using the current props and state.
|
||||
/// Uses a lock to prevent concurrent renders from multiple sources.
|
||||
/// Skips rendering if neither props nor state have changed since the last render.
|
||||
/// </summary>
|
||||
public override void Render()
|
||||
{
|
||||
lock (this._renderLock)
|
||||
{
|
||||
if (this.Props is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ReferenceEquals(this.Props, this._lastRenderedProps)
|
||||
&& ReferenceEquals(this.State, this._lastRenderedState))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.RenderCore(this.Props, this.State!);
|
||||
|
||||
this._lastRenderedProps = this.Props;
|
||||
this._lastRenderedState = this.State;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by <see cref="Render"/> to perform the actual rendering. Override this in derived classes.
|
||||
/// </summary>
|
||||
/// <param name="props">The current props.</param>
|
||||
/// <param name="state">The current state.</param>
|
||||
public abstract void RenderCore(TProps props, TState state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base record for component props. Provides an optional <see cref="Children"/> collection
|
||||
/// for composing child components.
|
||||
/// </summary>
|
||||
public record ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the child components to render within this component.</summary>
|
||||
public IReadOnlyList<ConsoleReactiveComponent> Children { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base record for component state.
|
||||
/// </summary>
|
||||
public record ConsoleReactiveState;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Event args for console resize events, containing the old and new dimensions.
|
||||
/// </summary>
|
||||
public class ConsoleResizeEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>Gets the previous console width.</summary>
|
||||
public int OldWidth { get; }
|
||||
|
||||
/// <summary>Gets the previous console height.</summary>
|
||||
public int OldHeight { get; }
|
||||
|
||||
/// <summary>Gets the new console width.</summary>
|
||||
public int NewWidth { get; }
|
||||
|
||||
/// <summary>Gets the new console height.</summary>
|
||||
public int NewHeight { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConsoleResizeEventArgs"/> class.
|
||||
/// </summary>
|
||||
/// <param name="oldWidth">The previous width.</param>
|
||||
/// <param name="oldHeight">The previous height.</param>
|
||||
/// <param name="newWidth">The new width.</param>
|
||||
/// <param name="newHeight">The new height.</param>
|
||||
public ConsoleResizeEventArgs(int oldWidth, int oldHeight, int newWidth, int newHeight)
|
||||
{
|
||||
this.OldWidth = oldWidth;
|
||||
this.OldHeight = oldHeight;
|
||||
this.NewWidth = newWidth;
|
||||
this.NewHeight = newHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Singleton that polls console dimensions every 16ms and raises the
|
||||
/// <see cref="ConsoleResized"/> event when the window size changes.
|
||||
/// </summary>
|
||||
public sealed class ConsoleResizeListener
|
||||
{
|
||||
#pragma warning disable IDE0052 // Remove unread private members
|
||||
private readonly Task _task;
|
||||
#pragma warning restore IDE0052 // Remove unread private members
|
||||
|
||||
private int _lastWidth;
|
||||
private int _lastHeight;
|
||||
|
||||
private ConsoleResizeListener()
|
||||
{
|
||||
this._lastWidth = Console.WindowWidth;
|
||||
this._lastHeight = Console.WindowHeight;
|
||||
this._task = this.ListenForResizeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Gets the singleton instance of <see cref="ConsoleResizeListener"/>.</summary>
|
||||
public static ConsoleResizeListener Instance { get; } = new ConsoleResizeListener();
|
||||
|
||||
/// <summary>Raised when the console window is resized.</summary>
|
||||
public event EventHandler<ConsoleResizeEventArgs>? ConsoleResized;
|
||||
|
||||
private async Task ListenForResizeAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int currentWidth = Console.WindowWidth;
|
||||
int currentHeight = Console.WindowHeight;
|
||||
|
||||
if (currentWidth != this._lastWidth || currentHeight != this._lastHeight)
|
||||
{
|
||||
int oldWidth = this._lastWidth;
|
||||
int oldHeight = this._lastHeight;
|
||||
this._lastWidth = currentWidth;
|
||||
this._lastHeight = currentHeight;
|
||||
this.ConsoleResized?.Invoke(this, new ConsoleResizeEventArgs(oldWidth, oldHeight, currentWidth, currentHeight));
|
||||
}
|
||||
|
||||
await Task.Delay(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.ConsoleReactiveFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Event args for key press events, wrapping a <see cref="ConsoleKeyInfo"/>.
|
||||
/// </summary>
|
||||
public class KeyPressEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>Gets the key information for the pressed key.</summary>
|
||||
public ConsoleKeyInfo KeyInfo { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="KeyPressEventArgs"/> class.
|
||||
/// </summary>
|
||||
/// <param name="keyInfo">The key information.</param>
|
||||
public KeyPressEventArgs(ConsoleKeyInfo keyInfo)
|
||||
{
|
||||
this.KeyInfo = keyInfo;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Singleton that polls for console key presses every 16ms and raises the
|
||||
/// <see cref="KeyPressed"/> event when a key is detected.
|
||||
/// </summary>
|
||||
public sealed class KeyEventListener
|
||||
{
|
||||
#pragma warning disable IDE0052 // Remove unread private members
|
||||
private readonly Task _task;
|
||||
#pragma warning restore IDE0052 // Remove unread private members
|
||||
|
||||
private KeyEventListener()
|
||||
{
|
||||
this._task = this.ListenForKeyPressesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Gets the singleton instance of <see cref="KeyEventListener"/>.</summary>
|
||||
public static KeyEventListener Instance { get; } = new KeyEventListener();
|
||||
|
||||
/// <summary>Raised when a key is pressed in the console.</summary>
|
||||
public event EventHandler<KeyPressEventArgs>? KeyPressed;
|
||||
|
||||
private async Task ListenForKeyPressesAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
while (Console.KeyAvailable)
|
||||
{
|
||||
var keyInfo = Console.ReadKey(intercept: true);
|
||||
this.KeyPressed?.Invoke(this, new KeyPressEventArgs(keyInfo));
|
||||
}
|
||||
|
||||
await Task.Delay(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.ConsoleSandbox;
|
||||
|
||||
/// <summary>
|
||||
/// Determines which component is shown in the bottom panel.
|
||||
/// </summary>
|
||||
public enum BottomPanelMode
|
||||
{
|
||||
/// <summary>Show the list selection component.</summary>
|
||||
ListSelection,
|
||||
|
||||
/// <summary>Show the text input component.</summary>
|
||||
TextInput
|
||||
}
|
||||
|
||||
public record AppComponentProps : ConsoleReactiveProps
|
||||
{
|
||||
public IReadOnlyList<string> Items { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<object> ScrollItems { get; init; } = [];
|
||||
|
||||
/// <summary>Gets the bottom panel mode.</summary>
|
||||
public BottomPanelMode Mode { get; init; } = BottomPanelMode.ListSelection;
|
||||
|
||||
/// <summary>Gets the prompt string for text input mode.</summary>
|
||||
public string Prompt { get; init; } = "> ";
|
||||
|
||||
/// <summary>Gets the placeholder text shown when the input is empty.</summary>
|
||||
public string Placeholder { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the highlight color for the active list item. Defaults to <see cref="ConsoleColor.Cyan"/>.</summary>
|
||||
public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan;
|
||||
|
||||
/// <summary>Gets the placeholder text for the custom text input option in the list. If <c>null</c>, no custom option is shown.</summary>
|
||||
public string? ListCustomTextPlaceholder { get; init; }
|
||||
|
||||
/// <summary>Gets the foreground color for the rule borders. If <c>null</c>, uses the default terminal color.</summary>
|
||||
public ConsoleColor? RuleColor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal state for the <see cref="AppComponent"/>.
|
||||
/// </summary>
|
||||
public record AppComponentState : ConsoleReactiveState
|
||||
{
|
||||
/// <summary>Gets the selected index in list selection mode.</summary>
|
||||
public int SelectedIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the current input text being typed in text input mode.</summary>
|
||||
public string InputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
|
||||
public string ListInputText { get; init; } = "";
|
||||
}
|
||||
|
||||
public class AppComponent : ConsoleReactiveComponent<AppComponentProps, AppComponentState>
|
||||
{
|
||||
private readonly TopBottomRule _rule = new();
|
||||
private readonly ListSelection _listSelection = new();
|
||||
private readonly TextInput _textInput = new();
|
||||
private readonly TextScrollPanel _textScrollPanel;
|
||||
private readonly TextPanel _textPanel;
|
||||
private readonly Func<object, string> _renderItem;
|
||||
private readonly Action<string> _onTextInputSubmit;
|
||||
private readonly Action<string> _onListInputSubmit;
|
||||
private bool _resizedSinceLastRender;
|
||||
private int _lastScrollBottom;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AppComponent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="renderScrollItem">A delegate that renders a single scroll panel item and returns the text to display.</param>
|
||||
/// <param name="onTextInputSubmit">A callback invoked with the input text when the user presses Enter in text input mode.</param>
|
||||
/// <param name="onListInputSubmit">A callback invoked with the selected or typed text when the user presses Enter in list selection mode.</param>
|
||||
public AppComponent(Func<object, string> renderScrollItem, Action<string> onTextInputSubmit, Action<string> onListInputSubmit)
|
||||
{
|
||||
this._renderItem = renderScrollItem;
|
||||
this._onTextInputSubmit = onTextInputSubmit;
|
||||
this._onListInputSubmit = onListInputSubmit;
|
||||
this._textScrollPanel = new TextScrollPanel(renderScrollItem);
|
||||
this._textPanel = new TextPanel(renderScrollItem);
|
||||
this.State = new AppComponentState();
|
||||
KeyEventListener.Instance.KeyPressed += this.OnKeyPressed;
|
||||
ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized;
|
||||
}
|
||||
|
||||
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
|
||||
{
|
||||
if (this.Props!.Mode == BottomPanelMode.TextInput)
|
||||
{
|
||||
this.HandleTextInputKey(e);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.HandleListSelectionKey(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleTextInputKey(KeyPressEventArgs e)
|
||||
{
|
||||
if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
string text = this.State!.InputText;
|
||||
this.SetState(this.State with { InputText = "" });
|
||||
this._onTextInputSubmit(text);
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (this.State!.InputText.Length > 0)
|
||||
{
|
||||
this.SetState(this.State with { InputText = this.State.InputText[..^1] });
|
||||
}
|
||||
}
|
||||
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||
{
|
||||
this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar });
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleListSelectionKey(KeyPressEventArgs e)
|
||||
{
|
||||
int maxIndex = this.Props!.Items.Count - 1;
|
||||
if (this.Props.ListCustomTextPlaceholder != null)
|
||||
{
|
||||
maxIndex = this.Props.Items.Count; // extra option at the end
|
||||
}
|
||||
|
||||
bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null
|
||||
&& this.State!.SelectedIndex == this.Props.Items.Count;
|
||||
|
||||
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
|
||||
{
|
||||
this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
|
||||
{
|
||||
this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
if (isOnCustomTextOption)
|
||||
{
|
||||
string text = this.State!.ListInputText;
|
||||
this.SetState(this.State with { ListInputText = "" });
|
||||
this._onListInputSubmit(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._onListInputSubmit(this.Props.Items[this.State!.SelectedIndex]);
|
||||
}
|
||||
}
|
||||
else if (isOnCustomTextOption)
|
||||
{
|
||||
// Typing only works when on the custom text option
|
||||
if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (this.State!.ListInputText.Length > 0)
|
||||
{
|
||||
this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] });
|
||||
}
|
||||
}
|
||||
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||
{
|
||||
this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e)
|
||||
{
|
||||
this._resizedSinceLastRender = true;
|
||||
this.Render();
|
||||
}
|
||||
|
||||
public override void RenderCore(AppComponentProps props, AppComponentState state)
|
||||
{
|
||||
// Determine the text panel height for the last scroll item
|
||||
object? lastItem = props.ScrollItems.Count > 0 ? props.ScrollItems[^1] : null;
|
||||
IReadOnlyList<object> lastItems = lastItem != null ? [lastItem] : [];
|
||||
int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem);
|
||||
if (textPanelHeight > 0)
|
||||
{
|
||||
textPanelHeight++; // Extra line for spacing between text panel and rule
|
||||
}
|
||||
|
||||
// Build the bottom panel child based on mode
|
||||
ConsoleReactiveComponent bottomChild;
|
||||
int bottomChildHeight;
|
||||
|
||||
if (props.Mode == BottomPanelMode.TextInput)
|
||||
{
|
||||
var textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = props.Prompt,
|
||||
Text = state.InputText,
|
||||
Placeholder = props.Placeholder
|
||||
};
|
||||
|
||||
bottomChildHeight = TextInput.CalculateHeight(textInputProps, Console.WindowWidth);
|
||||
this._textInput.Width = Console.WindowWidth;
|
||||
this._textInput.Height = bottomChildHeight;
|
||||
this._textInput.Props = textInputProps;
|
||||
bottomChild = this._textInput;
|
||||
}
|
||||
else
|
||||
{
|
||||
var listProps = new ListSelectionProps
|
||||
{
|
||||
Items = props.Items,
|
||||
SelectedIndex = state.SelectedIndex,
|
||||
HighlightColor = props.ListHighlightColor,
|
||||
CustomTextPlaceholder = props.ListCustomTextPlaceholder,
|
||||
CustomText = state.ListInputText
|
||||
};
|
||||
|
||||
bottomChildHeight = ListSelection.CalculateHeight(listProps);
|
||||
this._listSelection.Height = bottomChildHeight;
|
||||
this._listSelection.Props = listProps;
|
||||
bottomChild = this._listSelection;
|
||||
}
|
||||
|
||||
var ruleProps = new TopBottomRuleProps
|
||||
{
|
||||
Width = Console.WindowWidth,
|
||||
Color = props.RuleColor,
|
||||
Children = [bottomChild]
|
||||
};
|
||||
|
||||
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
|
||||
int scrollBottom = Console.WindowHeight - ruleHeight - textPanelHeight;
|
||||
|
||||
// If scroll region changed or a clear is needed, reset everything
|
||||
if (this._resizedSinceLastRender || (this._lastScrollBottom != 0 && scrollBottom != this._lastScrollBottom))
|
||||
{
|
||||
Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
this._textScrollPanel.Reset();
|
||||
this._resizedSinceLastRender = false;
|
||||
}
|
||||
|
||||
this._lastScrollBottom = scrollBottom;
|
||||
|
||||
Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
|
||||
|
||||
// Render text scroll panel in the scroll area (all items except the last)
|
||||
IReadOnlyList<object> scrollItems = props.ScrollItems.Count > 1
|
||||
? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList()
|
||||
: [];
|
||||
|
||||
this._textScrollPanel.X = 1;
|
||||
this._textScrollPanel.Y = 1;
|
||||
this._textScrollPanel.Width = Console.WindowWidth;
|
||||
this._textScrollPanel.Height = scrollBottom;
|
||||
this._textScrollPanel.Props = new TextScrollPanelProps
|
||||
{
|
||||
Items = scrollItems
|
||||
};
|
||||
this._textScrollPanel.Render();
|
||||
|
||||
// Render the text panel for the last (dynamic) item just below the scroll region
|
||||
this._textPanel.X = 1;
|
||||
this._textPanel.Y = scrollBottom + 1;
|
||||
this._textPanel.Width = Console.WindowWidth;
|
||||
this._textPanel.Height = textPanelHeight;
|
||||
this._textPanel.Props = new TextPanelProps
|
||||
{
|
||||
Items = lastItems,
|
||||
};
|
||||
this._textPanel.Render();
|
||||
|
||||
// Render the bottom rule + child below the text panel
|
||||
this._rule.X = 1;
|
||||
this._rule.Y = scrollBottom + textPanelHeight + 1;
|
||||
this._rule.Props = ruleProps;
|
||||
this._rule.Render();
|
||||
|
||||
// Position cursor for natural typing appearance
|
||||
if (props.Mode == BottomPanelMode.TextInput)
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int textWidth = Console.WindowWidth - promptLength;
|
||||
int textLength = state.InputText.Length;
|
||||
|
||||
// The TextInput starts at rule.Y + 1 (first row inside the rule)
|
||||
int textInputY = this._rule.Y + 1;
|
||||
|
||||
if (textWidth <= 0 || textLength == 0)
|
||||
{
|
||||
// Cursor right after the prompt
|
||||
Console.Write(AnsiEscapes.MoveCursor(textInputY, promptLength + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculate which row and column the cursor lands on
|
||||
int cursorRow = textLength < textWidth ? 0 : 1 + ((textLength - textWidth) / textWidth);
|
||||
int cursorCol = textLength < textWidth ? textLength : (textLength - textWidth) % textWidth;
|
||||
Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1));
|
||||
}
|
||||
}
|
||||
else if (props.Mode == BottomPanelMode.ListSelection
|
||||
&& props.ListCustomTextPlaceholder != null
|
||||
&& state.SelectedIndex == props.Items.Count)
|
||||
{
|
||||
// Cursor after the typed text in the custom text option
|
||||
// The custom text option is at rule.Y + 1 + Items.Count (0-based row inside rule)
|
||||
int customOptionY = this._rule.Y + 1 + props.Items.Count;
|
||||
// "> " prefix is 2 chars, then the typed text
|
||||
int cursorCol = 2 + state.ListInputText.Length + 1;
|
||||
Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -5,24 +5,25 @@ using Microsoft.Agents.AI;
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles a console command (e.g., /todos, /mode). Command handlers are checked
|
||||
/// in order before user input is sent to the agent. The first handler that
|
||||
/// accepts the input prevents further handlers from being checked.
|
||||
/// Base class for console command handlers (e.g., /todos, /mode). Command handlers
|
||||
/// are checked in order before user input is sent to the agent. The first handler
|
||||
/// that accepts the input prevents further handlers from being checked.
|
||||
/// </summary>
|
||||
public interface ICommandHandler
|
||||
public abstract class CommandHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the help text for this command, displayed in the console header.
|
||||
/// Gets the help text for this command, displayed in the mode-and-help bar.
|
||||
/// Returns <see langword="null"/> if the command is not currently available.
|
||||
/// </summary>
|
||||
/// <returns>Help text like <c>"/todos (show todo list)"</c>, or <see langword="null"/>.</returns>
|
||||
string? GetHelpText();
|
||||
public abstract string? GetHelpText();
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to handle the given user input.
|
||||
/// </summary>
|
||||
/// <param name="input">The raw user input string.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
/// <param name="ux">The UX container for rendering output.</param>
|
||||
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
|
||||
ValueTask<bool> TryHandleAsync(string input, AgentSession session);
|
||||
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux);
|
||||
}
|
||||
+12
-15
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
|
||||
/// <summary>
|
||||
/// Handles the <c>/mode</c> command to display or switch the current agent mode.
|
||||
/// </summary>
|
||||
internal sealed class ModeCommandHandler : ICommandHandler
|
||||
internal sealed class ModeCommandHandler : CommandHandler
|
||||
{
|
||||
private readonly AgentModeProvider? _modeProvider;
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
@@ -24,28 +24,28 @@ internal sealed class ModeCommandHandler : ICommandHandler
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
|
||||
public override string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<bool> TryHandleAsync(string input, AgentSession session)
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux)
|
||||
{
|
||||
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._modeProvider is null)
|
||||
{
|
||||
System.Console.WriteLine("AgentModeProvider is not available.");
|
||||
return ValueTask.FromResult(true);
|
||||
await ux.WriteInfoLineAsync("AgentModeProvider is not available.").ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
string current = this._modeProvider.GetMode(session);
|
||||
System.Console.WriteLine($"\n Current mode: {current}\n");
|
||||
return ValueTask.FromResult(true);
|
||||
await ux.WriteInfoLineAsync($"Current mode: {current}").ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
string newMode = parts[1];
|
||||
@@ -53,17 +53,14 @@ internal sealed class ModeCommandHandler : ICommandHandler
|
||||
try
|
||||
{
|
||||
this._modeProvider.SetMode(session, newMode);
|
||||
System.Console.ForegroundColor = ConsoleWriter.GetModeColor(newMode, this._modeColors);
|
||||
System.Console.WriteLine($"\n Switched to {newMode} mode.\n");
|
||||
System.Console.ResetColor();
|
||||
ux.CurrentMode = newMode;
|
||||
await ux.WriteInfoLineAsync($"Switched to {newMode} mode.", ModeColors.Get(newMode, this._modeColors)).ConfigureAwait(false);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
System.Console.ForegroundColor = ConsoleColor.Red;
|
||||
System.Console.WriteLine($"\n {ex}\n");
|
||||
System.Console.ResetColor();
|
||||
await ux.WriteInfoLineAsync(ex.Message, ConsoleColor.Red).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+11
-17
@@ -7,7 +7,7 @@ namespace Harness.Shared.Console.Commands;
|
||||
/// <summary>
|
||||
/// Handles the <c>/todos</c> command to display the current todo list.
|
||||
/// </summary>
|
||||
internal sealed class TodoCommandHandler : ICommandHandler
|
||||
internal sealed class TodoCommandHandler : CommandHandler
|
||||
{
|
||||
private readonly TodoProvider? _todoProvider;
|
||||
|
||||
@@ -21,10 +21,10 @@ internal sealed class TodoCommandHandler : ICommandHandler
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
|
||||
public override string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask<bool> TryHandleAsync(string input, AgentSession session)
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux)
|
||||
{
|
||||
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -33,34 +33,28 @@ internal sealed class TodoCommandHandler : ICommandHandler
|
||||
|
||||
if (this._todoProvider is null)
|
||||
{
|
||||
System.Console.WriteLine("TodoProvider is not available.");
|
||||
await ux.WriteInfoLineAsync("TodoProvider is not available.").ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
var todos = await this._todoProvider.GetAllTodosAsync(session).ConfigureAwait(false);
|
||||
if (todos.Count == 0)
|
||||
{
|
||||
System.Console.WriteLine("\n No todos yet.\n");
|
||||
await ux.WriteInfoLineAsync("No todos yet.").ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine(" ── Todo List ──");
|
||||
await ux.WriteInfoLineAsync("── Todo List ──").ConfigureAwait(false);
|
||||
foreach (var item in todos)
|
||||
{
|
||||
string status = item.IsComplete ? "✓" : "○";
|
||||
System.Console.ForegroundColor = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White;
|
||||
System.Console.Write($" [{status}] #{item.Id} {item.Title}");
|
||||
if (!string.IsNullOrWhiteSpace(item.Description))
|
||||
{
|
||||
System.Console.Write($" — {item.Description}");
|
||||
}
|
||||
|
||||
System.Console.WriteLine();
|
||||
ConsoleColor color = item.IsComplete ? ConsoleColor.DarkGray : ConsoleColor.White;
|
||||
string description = string.IsNullOrWhiteSpace(item.Description)
|
||||
? string.Empty
|
||||
: $" — {item.Description}";
|
||||
await ux.WriteInfoLineAsync($"[{status}] #{item.Id} {item.Title}{description}", color).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
System.Console.ResetColor();
|
||||
System.Console.WriteLine();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.Shared.Console.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="AgentModeAndHelp"/>.
|
||||
/// </summary>
|
||||
public record AgentModeAndHelpProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets or sets the current mode name (e.g. "plan", "execute"), or <see langword="null"/> if no mode is active.</summary>
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the foreground color for the mode label.</summary>
|
||||
public ConsoleColor? ModeColor { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the help text to display (e.g. available commands and exit info).</summary>
|
||||
public string? HelpText { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a single fixed line below the bottom rule showing
|
||||
/// the current agent mode (in the mode colour) and available commands (in dark grey).
|
||||
/// </summary>
|
||||
public class AgentModeAndHelp : ConsoleReactiveComponent<AgentModeAndHelpProps, ConsoleReactiveState>
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the height of the component.
|
||||
/// </summary>
|
||||
/// <param name="props">The component props.</param>
|
||||
/// <returns>1 if there is content to display; otherwise 0.</returns>
|
||||
public static int CalculateHeight(AgentModeAndHelpProps props) =>
|
||||
(props.Mode is not null || !string.IsNullOrEmpty(props.HelpText)) ? 1 : 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(AgentModeAndHelpProps props, ConsoleReactiveState state)
|
||||
{
|
||||
if (props.Mode is null && string.IsNullOrEmpty(props.HelpText))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
System.Console.Write(AnsiEscapes.SaveCursor);
|
||||
System.Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y));
|
||||
|
||||
bool hasMode = props.Mode is not null;
|
||||
|
||||
if (hasMode)
|
||||
{
|
||||
if (props.ModeColor.HasValue)
|
||||
{
|
||||
System.Console.Write(AnsiEscapes.SetForegroundColor(props.ModeColor.Value));
|
||||
}
|
||||
|
||||
System.Console.Write($" [{props.Mode}]");
|
||||
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(props.HelpText))
|
||||
{
|
||||
string prefix = hasMode ? " " : " ";
|
||||
System.Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||
System.Console.Write($"{prefix}{props.HelpText}");
|
||||
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
System.Console.Write(AnsiEscapes.RestoreCursor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
|
||||
namespace Harness.Shared.Console.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="AgentStatus"/>.
|
||||
/// </summary>
|
||||
public record AgentStatusProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether the spinner is visible.</summary>
|
||||
public bool ShowSpinner { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the formatted token usage text to display.</summary>
|
||||
public string? UsageText { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State for <see cref="AgentStatus"/>.
|
||||
/// </summary>
|
||||
/// <param name="SpinnerIndex">The current spinner animation frame index.</param>
|
||||
public record AgentStatusState(int SpinnerIndex = 0) : ConsoleReactiveState;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a single-line agent status bar with an animated spinner
|
||||
/// and token usage statistics. Positioned above the rule in the non-scrolling area.
|
||||
/// </summary>
|
||||
public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatusState>, IDisposable
|
||||
{
|
||||
private static readonly string[] s_spinnerFrames =
|
||||
[
|
||||
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
|
||||
];
|
||||
|
||||
private readonly Timer _timer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentStatus"/> class.
|
||||
/// </summary>
|
||||
public AgentStatus()
|
||||
{
|
||||
this.State = new AgentStatusState();
|
||||
this._timer = new Timer(this.OnTimerTick, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the height of the agent status component.
|
||||
/// </summary>
|
||||
/// <param name="props">The component props.</param>
|
||||
/// <returns>1 if the spinner or usage text is visible; otherwise 0.</returns>
|
||||
public static int CalculateHeight(AgentStatusProps props)
|
||||
{
|
||||
return (props.ShowSpinner || !string.IsNullOrEmpty(props.UsageText)) ? 1 : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the internal spinner timer.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
this.Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing"><c>true</c> to release managed resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this._timer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(AgentStatusProps props, AgentStatusState state)
|
||||
{
|
||||
if (!props.ShowSpinner && string.IsNullOrEmpty(props.UsageText))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
System.Console.Write(AnsiEscapes.SaveCursor);
|
||||
System.Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y));
|
||||
|
||||
if (props.ShowSpinner)
|
||||
{
|
||||
string frame = s_spinnerFrames[state.SpinnerIndex];
|
||||
System.Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.Cyan));
|
||||
System.Console.Write($" {frame} ");
|
||||
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Console.Write(" ");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(props.UsageText))
|
||||
{
|
||||
System.Console.Write(AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray));
|
||||
System.Console.Write(props.UsageText);
|
||||
System.Console.Write(AnsiEscapes.ResetAttributes);
|
||||
}
|
||||
|
||||
System.Console.Write(AnsiEscapes.RestoreCursor);
|
||||
}
|
||||
|
||||
private void OnTimerTick(object? timerState)
|
||||
{
|
||||
if (this.Props is { ShowSpinner: true })
|
||||
{
|
||||
int nextIndex = ((this.State?.SpinnerIndex ?? 0) + 1) % s_spinnerFrames.Length;
|
||||
this.SetState(new AgentStatusState(nextIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Centralizes all console output and spinner management for the harness console.
|
||||
/// Observers write through this class so the spinner is automatically paused before output.
|
||||
/// </summary>
|
||||
public sealed class ConsoleWriter : IDisposable
|
||||
{
|
||||
private readonly Spinner _spinner = new();
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
|
||||
private bool _lastWasText;
|
||||
private bool _hasReceivedAnyText;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConsoleWriter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public ConsoleWriter(IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._modeColors = modeColors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current agent mode (e.g., "plan", "execute").
|
||||
/// Used to determine the console color for mode-prefixed output.
|
||||
/// </summary>
|
||||
public string? CurrentMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Writes the agent response header (e.g., "[plan] Agent: ") and starts the spinner.
|
||||
/// </summary>
|
||||
public void WriteResponseHeader()
|
||||
{
|
||||
if (this.CurrentMode is not null)
|
||||
{
|
||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
||||
System.Console.Write($"\n[{this.CurrentMode}] Agent: ");
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Console.Write("\nAgent: ");
|
||||
}
|
||||
|
||||
this._lastWasText = true;
|
||||
this._hasReceivedAnyText = false;
|
||||
this._spinner.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output with automatic prefix spacing, without a trailing newline.
|
||||
/// Use when continuation content will be appended on the same line.
|
||||
/// </summary>
|
||||
/// <param name="text">The informational text to write (without leading newline/indent — added automatically).</param>
|
||||
/// <param name="color">Optional console color for the text.</param>
|
||||
public async Task WriteInfoAsync(string text, ConsoleColor? color = null)
|
||||
{
|
||||
await this.WriteInfoCoreAsync(text, color, newLine: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output with automatic prefix spacing, followed by a newline.
|
||||
/// </summary>
|
||||
/// <param name="text">The informational text to write (without leading newline/indent — added automatically).</param>
|
||||
/// <param name="color">Optional console color for the text.</param>
|
||||
public async Task WriteInfoLineAsync(string text, ConsoleColor? color = null)
|
||||
{
|
||||
await this.WriteInfoCoreAsync(text, color, newLine: true);
|
||||
}
|
||||
|
||||
private async Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
|
||||
{
|
||||
await this._spinner.StopAsync();
|
||||
|
||||
string prefix = this._lastWasText ? "\n\n " : " ";
|
||||
this._lastWasText = false;
|
||||
|
||||
System.Console.ForegroundColor = color ?? GetModeColor(this.CurrentMode, this._modeColors);
|
||||
|
||||
if (newLine)
|
||||
{
|
||||
System.Console.WriteLine(prefix + text);
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Console.Write(prefix + text);
|
||||
}
|
||||
|
||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
||||
|
||||
this._spinner.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes text output from the agent, managing line break state.
|
||||
/// Ensures a newline is written before the first text output.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to write.</param>
|
||||
/// <param name="color">Optional console color override for this text.</param>
|
||||
public async Task WriteTextAsync(string text, ConsoleColor? color = null)
|
||||
{
|
||||
await this._spinner.StopAsync();
|
||||
|
||||
if (!this._lastWasText)
|
||||
{
|
||||
System.Console.Write("\n");
|
||||
this._lastWasText = true;
|
||||
}
|
||||
|
||||
this._hasReceivedAnyText = true;
|
||||
|
||||
if (color.HasValue)
|
||||
{
|
||||
System.Console.ForegroundColor = color.Value;
|
||||
}
|
||||
|
||||
System.Console.Write(text);
|
||||
|
||||
if (color.HasValue)
|
||||
{
|
||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
||||
}
|
||||
|
||||
this._spinner.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a line of input from the console, pausing the spinner while waiting for input.
|
||||
/// Optionally displays a prompt before reading. The prompt is rendered between
|
||||
/// two horizontal rules for visual clarity.
|
||||
/// </summary>
|
||||
/// <param name="prompt">Optional prompt text to display before reading input.</param>
|
||||
/// <param name="promptColor">Optional console color for the prompt text.</param>
|
||||
/// <returns>The line read from the console, or <c>null</c> if no input is available.</returns>
|
||||
public async Task<string?> ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null)
|
||||
{
|
||||
await this._spinner.StopAsync();
|
||||
|
||||
if (prompt is not null)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
AnsiConsole.Write(this.CreateModeRule());
|
||||
|
||||
if (promptColor.HasValue)
|
||||
{
|
||||
System.Console.ForegroundColor = promptColor.Value;
|
||||
}
|
||||
|
||||
System.Console.Write($" {prompt}");
|
||||
|
||||
if (promptColor.HasValue)
|
||||
{
|
||||
System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors);
|
||||
}
|
||||
}
|
||||
|
||||
string? input = System.Console.ReadLine();
|
||||
|
||||
if (prompt is not null)
|
||||
{
|
||||
AnsiConsole.Write(this.CreateModeRule());
|
||||
}
|
||||
|
||||
this._lastWasText = false;
|
||||
return input;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presents a selection prompt with the given choices, plus an option to type a custom response.
|
||||
/// Uses Spectre.Console <see cref="SelectionPrompt{T}"/> for interactive arrow-key selection.
|
||||
/// </summary>
|
||||
/// <param name="title">The title/question displayed above the selection list.</param>
|
||||
/// <param name="choices">The list of choices to present.</param>
|
||||
/// <returns>The selected choice text, or the custom-typed response.</returns>
|
||||
public async Task<string> ReadSelectionAsync(string title, IList<string> choices)
|
||||
{
|
||||
await this._spinner.StopAsync();
|
||||
|
||||
AnsiConsole.Write(this.CreateModeRule());
|
||||
|
||||
const string FreeformOption = "✏️ Type a custom response...";
|
||||
var allChoices = choices.Concat([FreeformOption]).ToList();
|
||||
|
||||
var prompt = new SelectionPrompt<string>()
|
||||
.Title($" [bold]{Markup.Escape(title)}[/]")
|
||||
.PageSize(10)
|
||||
.AddChoices(allChoices);
|
||||
|
||||
string selection = AnsiConsole.Prompt(prompt);
|
||||
|
||||
if (selection == FreeformOption)
|
||||
{
|
||||
var textPrompt = new TextPrompt<string>(" [grey]Response:[/]");
|
||||
selection = AnsiConsole.Prompt(textPrompt);
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($" [dim]→ {Markup.Escape(selection)}[/]");
|
||||
AnsiConsole.Write(this.CreateModeRule());
|
||||
|
||||
this._lastWasText = false;
|
||||
return selection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the stream-complete footer (handles "no text response" fallback, resets color).
|
||||
/// </summary>
|
||||
public async Task WriteStreamFooterAsync(bool hasFollowUpMessages)
|
||||
{
|
||||
await this._spinner.StopAsync();
|
||||
|
||||
if (!this._hasReceivedAnyText && !hasFollowUpMessages)
|
||||
{
|
||||
System.Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
System.Console.Write("\n (no text response from agent)");
|
||||
}
|
||||
|
||||
System.Console.ResetColor();
|
||||
System.Console.WriteLine();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this._spinner.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the console color associated with a mode name, using the provided color map.
|
||||
/// </summary>
|
||||
internal static ConsoleColor GetModeColor(string? mode, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
if (mode is null)
|
||||
{
|
||||
return ConsoleColor.Gray;
|
||||
}
|
||||
|
||||
if (modeColors is not null && modeColors.TryGetValue(mode, out var color))
|
||||
{
|
||||
return color;
|
||||
}
|
||||
|
||||
return ConsoleColor.Gray;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Rule"/> styled with the current mode color.
|
||||
/// </summary>
|
||||
internal Rule CreateModeRule()
|
||||
{
|
||||
var spectreColor = ToSpectreColor(GetModeColor(this.CurrentMode, this._modeColors));
|
||||
return new Rule().RuleStyle(new Style(spectreColor));
|
||||
}
|
||||
|
||||
internal static Color ToSpectreColor(ConsoleColor consoleColor) => consoleColor switch
|
||||
{
|
||||
ConsoleColor.Black => Color.Black,
|
||||
ConsoleColor.DarkBlue => Color.Blue,
|
||||
ConsoleColor.DarkGreen => Color.Green,
|
||||
ConsoleColor.DarkCyan => Color.Teal,
|
||||
ConsoleColor.DarkRed => Color.Red,
|
||||
ConsoleColor.DarkMagenta => Color.Purple,
|
||||
ConsoleColor.DarkYellow => Color.Olive,
|
||||
ConsoleColor.Gray => Color.Silver,
|
||||
ConsoleColor.DarkGray => Color.Grey,
|
||||
ConsoleColor.Blue => Color.Blue1,
|
||||
ConsoleColor.Green => Color.Green1,
|
||||
ConsoleColor.Cyan => Color.Aqua,
|
||||
ConsoleColor.Red => Color.Red1,
|
||||
ConsoleColor.Magenta => Color.Fuchsia,
|
||||
ConsoleColor.Yellow => Color.Yellow,
|
||||
ConsoleColor.White => Color.White,
|
||||
_ => Color.Silver,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
using Harness.Shared.Console.Components;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Determines which component is shown in the bottom panel.
|
||||
/// </summary>
|
||||
public enum BottomPanelMode
|
||||
{
|
||||
/// <summary>Show the text input component for user input.</summary>
|
||||
TextInput,
|
||||
|
||||
/// <summary>Show the list selection component for interactive prompts.</summary>
|
||||
ListSelection,
|
||||
|
||||
/// <summary>Show a disabled input indicator during agent streaming.</summary>
|
||||
Streaming,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments for the <see cref="HarnessAppComponent.InputSubmitted"/> event.
|
||||
/// </summary>
|
||||
public sealed class InputSubmittedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InputSubmittedEventArgs"/> class.
|
||||
/// </summary>
|
||||
/// <param name="text">The submitted text.</param>
|
||||
/// <param name="mode">The bottom panel mode in which the input was submitted.</param>
|
||||
public InputSubmittedEventArgs(string text, BottomPanelMode mode)
|
||||
{
|
||||
this.Text = text;
|
||||
this.Mode = mode;
|
||||
}
|
||||
|
||||
/// <summary>Gets the submitted text.</summary>
|
||||
public string Text { get; }
|
||||
|
||||
/// <summary>Gets the bottom panel mode in which the input was submitted.</summary>
|
||||
public BottomPanelMode Mode { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Props for <see cref="HarnessAppComponent"/>.
|
||||
/// </summary>
|
||||
public record HarnessAppComponentProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets or sets the list selection choices (for ListSelection mode).</summary>
|
||||
public IReadOnlyList<string> Items { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Gets or sets the scroll items (output entries) to render in the scroll panel.</summary>
|
||||
public IReadOnlyList<object> ScrollItems { get; set; } = [];
|
||||
|
||||
/// <summary>Gets or sets the bottom panel mode.</summary>
|
||||
public BottomPanelMode Mode { get; set; } = BottomPanelMode.TextInput;
|
||||
|
||||
/// <summary>Gets or sets the prompt string for text input mode.</summary>
|
||||
public string Prompt { get; set; } = "You: ";
|
||||
|
||||
/// <summary>Gets or sets the placeholder text shown when the input is empty.</summary>
|
||||
public string Placeholder { get; set; } = "";
|
||||
|
||||
/// <summary>Gets or sets the highlight color for the active list item.</summary>
|
||||
public ConsoleColor ListHighlightColor { get; set; } = ConsoleColor.Cyan;
|
||||
|
||||
/// <summary>Gets or sets the placeholder text for the custom text input option in the list.</summary>
|
||||
public string? ListCustomTextPlaceholder { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the foreground color for the rule borders and mode label.</summary>
|
||||
public ConsoleColor? ModeColor { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the current mode name displayed below the bottom rule (e.g. "plan").</summary>
|
||||
public string? ModeText { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the help text displayed below the bottom rule (available commands).</summary>
|
||||
public string? HelpText { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the title text displayed above the list selection (for interactive prompts).</summary>
|
||||
public string? ListTitle { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether input is enabled during streaming.</summary>
|
||||
public bool InputEnabled { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the prompt to show during streaming when input is disabled.</summary>
|
||||
public string StreamingPrompt { get; set; } = "(agent is running...)";
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the agent status spinner is visible.</summary>
|
||||
public bool ShowSpinner { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the formatted token usage text to display in the status bar.</summary>
|
||||
public string? UsageText { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the queued input items to display above the rule.</summary>
|
||||
public IReadOnlyList<object> QueuedItems { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal state for <see cref="HarnessAppComponent"/>.
|
||||
/// </summary>
|
||||
public record HarnessAppComponentState : ConsoleReactiveState
|
||||
{
|
||||
/// <summary>Gets the selected index in list selection mode.</summary>
|
||||
public int SelectedIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the current input text being typed.</summary>
|
||||
public string InputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
|
||||
public string ListInputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the current console width in columns.</summary>
|
||||
public int ConsoleWidth { get; init; }
|
||||
|
||||
/// <summary>Gets the current console height in rows.</summary>
|
||||
public int ConsoleHeight { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The main application component for the Harness console. Manages the scroll region
|
||||
/// and bottom panel (text input, list selection, or streaming indicator), and emits
|
||||
/// an <see cref="InputSubmitted"/> event when the user submits text in any mode.
|
||||
/// </summary>
|
||||
public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentProps, HarnessAppComponentState>, IDisposable
|
||||
{
|
||||
private readonly TopBottomRule _rule = new();
|
||||
private readonly ListSelection _listSelection = new();
|
||||
private readonly TextInput _textInput = new();
|
||||
private readonly TextScrollPanel _textScrollPanel;
|
||||
private readonly TextPanel _textPanel;
|
||||
private readonly TextPanel _queuedPanel;
|
||||
private readonly AgentStatus _agentStatus = new();
|
||||
private readonly AgentModeAndHelp _modeAndHelp = new();
|
||||
private readonly Func<object, string> _renderItem;
|
||||
private bool _resizedSinceLastRender;
|
||||
private bool _deactivated;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessAppComponent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="renderScrollItem">A delegate that renders a single output entry and returns the text to display.</param>
|
||||
public HarnessAppComponent(Func<object, string> renderScrollItem)
|
||||
{
|
||||
this._renderItem = renderScrollItem;
|
||||
this._textScrollPanel = new TextScrollPanel(renderScrollItem);
|
||||
this._textPanel = new TextPanel(renderScrollItem);
|
||||
this._queuedPanel = new TextPanel(renderScrollItem);
|
||||
this.State = new HarnessAppComponentState
|
||||
{
|
||||
ConsoleWidth = System.Console.WindowWidth,
|
||||
ConsoleHeight = System.Console.WindowHeight,
|
||||
};
|
||||
KeyEventListener.Instance.KeyPressed += this.OnKeyPressed;
|
||||
ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the 1-based row number of the last row in the output scroll region.
|
||||
/// </summary>
|
||||
public int ScrollRegionBottom { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the user submits input via Enter, in any mode (text input, list selection,
|
||||
/// or streaming injection). Consumers inspect <see cref="InputSubmittedEventArgs.Mode"/>
|
||||
/// to decide how to handle the submission.
|
||||
/// </summary>
|
||||
public event EventHandler<InputSubmittedEventArgs>? InputSubmitted;
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates the component, resetting the scroll region and unsubscribing from events.
|
||||
/// This method is idempotent and safe to call multiple times.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
if (this._deactivated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._deactivated = true;
|
||||
this._agentStatus.Dispose();
|
||||
KeyEventListener.Instance.KeyPressed -= this.OnKeyPressed;
|
||||
ConsoleResizeListener.Instance.ConsoleResized -= this.OnConsoleResized;
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(System.Console.WindowHeight, 1));
|
||||
System.Console.WriteLine();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this.Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing"><c>true</c> to release managed resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this.Deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
|
||||
{
|
||||
if (this.Props!.Mode == BottomPanelMode.TextInput)
|
||||
{
|
||||
this.HandleTextInputKey(e);
|
||||
}
|
||||
else if (this.Props.Mode == BottomPanelMode.ListSelection)
|
||||
{
|
||||
this.HandleListSelectionKey(e);
|
||||
}
|
||||
else if (this.Props.Mode == BottomPanelMode.Streaming && this.Props.InputEnabled)
|
||||
{
|
||||
this.HandleStreamingInputKey(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleTextInputKey(KeyPressEventArgs e)
|
||||
{
|
||||
if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
string text = this.State!.InputText;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.SetState(this.State with { InputText = "" });
|
||||
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.TextInput));
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (this.State!.InputText.Length > 0)
|
||||
{
|
||||
this.SetState(this.State with { InputText = this.State.InputText[..^1] });
|
||||
}
|
||||
}
|
||||
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||
{
|
||||
this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar });
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleListSelectionKey(KeyPressEventArgs e)
|
||||
{
|
||||
int maxIndex = this.Props!.Items.Count - 1;
|
||||
if (this.Props.ListCustomTextPlaceholder != null)
|
||||
{
|
||||
maxIndex = this.Props.Items.Count;
|
||||
}
|
||||
|
||||
bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null
|
||||
&& this.State!.SelectedIndex == this.Props.Items.Count;
|
||||
|
||||
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
|
||||
{
|
||||
this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
|
||||
{
|
||||
this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
string result = isOnCustomTextOption
|
||||
? this.State!.ListInputText
|
||||
: this.Props.Items[this.State!.SelectedIndex];
|
||||
|
||||
this.SetState(this.State with { ListInputText = "", SelectedIndex = 0 });
|
||||
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(result, BottomPanelMode.ListSelection));
|
||||
}
|
||||
else if (isOnCustomTextOption)
|
||||
{
|
||||
if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (this.State!.ListInputText.Length > 0)
|
||||
{
|
||||
this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] });
|
||||
}
|
||||
}
|
||||
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||
{
|
||||
this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStreamingInputKey(KeyPressEventArgs e)
|
||||
{
|
||||
// During streaming with input enabled, capture text for message injection
|
||||
if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
string text = this.State!.InputText;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.SetState(this.State with { InputText = "" });
|
||||
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.Streaming));
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (this.State!.InputText.Length > 0)
|
||||
{
|
||||
this.SetState(this.State with { InputText = this.State.InputText[..^1] });
|
||||
}
|
||||
}
|
||||
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||
{
|
||||
this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar });
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e)
|
||||
{
|
||||
this._resizedSinceLastRender = true;
|
||||
this.SetState(this.State! with
|
||||
{
|
||||
ConsoleWidth = e.NewWidth,
|
||||
ConsoleHeight = e.NewHeight,
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(HarnessAppComponentProps props, HarnessAppComponentState state)
|
||||
{
|
||||
// Determine the text panel height for the last scroll item
|
||||
IReadOnlyList<object> lastItems = props.ScrollItems.Count > 0
|
||||
? [props.ScrollItems[^1]]
|
||||
: [];
|
||||
int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem);
|
||||
if (textPanelHeight > 0)
|
||||
{
|
||||
textPanelHeight++; // Extra line for spacing between text panel and rule
|
||||
}
|
||||
|
||||
// Calculate queued items panel height
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(props.QueuedItems, this._renderItem);
|
||||
|
||||
// Build the bottom panel child based on mode
|
||||
ConsoleReactiveComponent bottomChild;
|
||||
int bottomChildHeight;
|
||||
|
||||
if (props.Mode == BottomPanelMode.ListSelection)
|
||||
{
|
||||
var listProps = new ListSelectionProps
|
||||
{
|
||||
Title = props.ListTitle,
|
||||
Items = props.Items,
|
||||
SelectedIndex = state.SelectedIndex,
|
||||
HighlightColor = props.ListHighlightColor,
|
||||
CustomTextPlaceholder = props.ListCustomTextPlaceholder,
|
||||
CustomText = state.ListInputText,
|
||||
};
|
||||
|
||||
bottomChildHeight = ListSelection.CalculateHeight(listProps);
|
||||
this._listSelection.Height = bottomChildHeight;
|
||||
this._listSelection.Props = listProps;
|
||||
bottomChild = this._listSelection;
|
||||
}
|
||||
else if (props.Mode == BottomPanelMode.Streaming)
|
||||
{
|
||||
TextInputProps textInputProps;
|
||||
if (props.InputEnabled)
|
||||
{
|
||||
textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = props.Prompt,
|
||||
Text = state.InputText,
|
||||
Placeholder = props.Placeholder,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = props.Prompt,
|
||||
Text = "",
|
||||
Placeholder = props.StreamingPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
|
||||
this._textInput.Width = state.ConsoleWidth;
|
||||
this._textInput.Height = bottomChildHeight;
|
||||
this._textInput.Props = textInputProps;
|
||||
bottomChild = this._textInput;
|
||||
}
|
||||
else
|
||||
{
|
||||
var textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = props.Prompt,
|
||||
Text = state.InputText,
|
||||
Placeholder = props.Placeholder,
|
||||
};
|
||||
|
||||
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
|
||||
this._textInput.Width = state.ConsoleWidth;
|
||||
this._textInput.Height = bottomChildHeight;
|
||||
this._textInput.Props = textInputProps;
|
||||
bottomChild = this._textInput;
|
||||
}
|
||||
|
||||
var ruleProps = new TopBottomRuleProps
|
||||
{
|
||||
Width = state.ConsoleWidth,
|
||||
Color = props.ModeColor,
|
||||
Children = [bottomChild],
|
||||
};
|
||||
|
||||
// Calculate the agent status height
|
||||
var agentStatusProps = new AgentStatusProps
|
||||
{
|
||||
ShowSpinner = props.ShowSpinner,
|
||||
UsageText = props.UsageText,
|
||||
};
|
||||
int agentStatusHeight = AgentStatus.CalculateHeight(agentStatusProps);
|
||||
|
||||
// Calculate the mode-and-help height
|
||||
var modeAndHelpProps = new AgentModeAndHelpProps
|
||||
{
|
||||
Mode = props.ModeText,
|
||||
ModeColor = props.ModeColor,
|
||||
HelpText = props.HelpText,
|
||||
};
|
||||
int modeAndHelpHeight = AgentModeAndHelp.CalculateHeight(modeAndHelpProps);
|
||||
|
||||
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
|
||||
int scrollBottom = Math.Max(1, state.ConsoleHeight - ruleHeight - textPanelHeight - agentStatusHeight - queuedPanelHeight - modeAndHelpHeight);
|
||||
|
||||
// If scroll region changed or a clear is needed, reset everything
|
||||
if (this._resizedSinceLastRender || (this.ScrollRegionBottom != 0 && scrollBottom != this.ScrollRegionBottom))
|
||||
{
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
this._textScrollPanel.Reset();
|
||||
this._resizedSinceLastRender = false;
|
||||
}
|
||||
|
||||
this.ScrollRegionBottom = scrollBottom;
|
||||
|
||||
System.Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom));
|
||||
|
||||
// Render text scroll panel in the scroll area (all items except the last)
|
||||
IReadOnlyList<object> scrollItems = props.ScrollItems.Count > 1
|
||||
? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList()
|
||||
: [];
|
||||
|
||||
this._textScrollPanel.X = 1;
|
||||
this._textScrollPanel.Y = 1;
|
||||
this._textScrollPanel.Width = state.ConsoleWidth;
|
||||
this._textScrollPanel.Height = scrollBottom;
|
||||
this._textScrollPanel.Props = new TextScrollPanelProps
|
||||
{
|
||||
Items = scrollItems,
|
||||
};
|
||||
this._textScrollPanel.Render();
|
||||
|
||||
// Render the text panel for the last (dynamic) item just below the scroll region
|
||||
this._textPanel.X = 1;
|
||||
this._textPanel.Y = scrollBottom + 1;
|
||||
this._textPanel.Width = state.ConsoleWidth;
|
||||
this._textPanel.Height = textPanelHeight;
|
||||
this._textPanel.Props = new TextPanelProps
|
||||
{
|
||||
Items = lastItems,
|
||||
};
|
||||
this._textPanel.Render();
|
||||
|
||||
// Render queued input items between text panel and agent status
|
||||
int queuedPanelY = scrollBottom + textPanelHeight + 1;
|
||||
this._queuedPanel.X = 1;
|
||||
this._queuedPanel.Y = queuedPanelY;
|
||||
this._queuedPanel.Width = state.ConsoleWidth;
|
||||
this._queuedPanel.Height = queuedPanelHeight;
|
||||
this._queuedPanel.Props = new TextPanelProps
|
||||
{
|
||||
Items = props.QueuedItems,
|
||||
};
|
||||
this._queuedPanel.Render();
|
||||
|
||||
// Render the agent status line between queued items and rule
|
||||
int agentStatusY = queuedPanelY + queuedPanelHeight;
|
||||
this._agentStatus.X = 1;
|
||||
this._agentStatus.Y = agentStatusY;
|
||||
this._agentStatus.Width = state.ConsoleWidth;
|
||||
this._agentStatus.Height = agentStatusHeight;
|
||||
this._agentStatus.Props = agentStatusProps;
|
||||
this._agentStatus.Render();
|
||||
|
||||
// Render the bottom rule + child below the agent status
|
||||
this._rule.X = 1;
|
||||
this._rule.Y = agentStatusY + agentStatusHeight;
|
||||
this._rule.Props = ruleProps;
|
||||
this._rule.Render();
|
||||
|
||||
// Render the mode-and-help line below the bottom rule
|
||||
int modeAndHelpY = this._rule.Y + ruleHeight;
|
||||
this._modeAndHelp.X = 1;
|
||||
this._modeAndHelp.Y = modeAndHelpY;
|
||||
this._modeAndHelp.Width = state.ConsoleWidth;
|
||||
this._modeAndHelp.Height = modeAndHelpHeight;
|
||||
this._modeAndHelp.Props = modeAndHelpProps;
|
||||
this._modeAndHelp.Render();
|
||||
|
||||
// Position cursor for natural typing appearance
|
||||
this.PositionCursor(props, state);
|
||||
}
|
||||
|
||||
private void PositionCursor(HarnessAppComponentProps props, HarnessAppComponentState state)
|
||||
{
|
||||
if (props.Mode == BottomPanelMode.TextInput
|
||||
|| (props.Mode == BottomPanelMode.Streaming && props.InputEnabled))
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int textWidth = state.ConsoleWidth - promptLength;
|
||||
int textLength = state.InputText.Length;
|
||||
|
||||
int textInputY = this._rule.Y + 1;
|
||||
|
||||
if (textWidth <= 0 || textLength == 0)
|
||||
{
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(textInputY, promptLength + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
int cursorRow = textLength < textWidth ? 0 : 1 + ((textLength - textWidth) / textWidth);
|
||||
int cursorCol = textLength < textWidth ? textLength : (textLength - textWidth) % textWidth;
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1));
|
||||
}
|
||||
}
|
||||
else if (props.Mode == BottomPanelMode.ListSelection
|
||||
&& props.ListCustomTextPlaceholder != null
|
||||
&& state.SelectedIndex == props.Items.Count)
|
||||
{
|
||||
int titleLines = props.ListTitle?.Split('\n').Length ?? 0;
|
||||
int customOptionY = this._rule.Y + 1 + titleLines + props.Items.Count;
|
||||
int cursorCol = 2 + state.ListInputText.Length + 1;
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,42 +34,55 @@ public static class HarnessConsole
|
||||
nameof(options));
|
||||
}
|
||||
|
||||
System.Console.WriteLine($"=== {title} ===");
|
||||
System.Console.WriteLine(userPrompt);
|
||||
|
||||
var todoProvider = agent.GetService<TodoProvider>();
|
||||
var modeProvider = agent.GetService<AgentModeProvider>();
|
||||
var messageInjector = agent.GetService<MessageInjectingChatClient>();
|
||||
|
||||
// Build command handlers.
|
||||
var commandHandlers = new List<ICommandHandler>
|
||||
var commandHandlers = new List<CommandHandler>
|
||||
{
|
||||
new TodoCommandHandler(todoProvider),
|
||||
new ModeCommandHandler(modeProvider, options.ModeColors),
|
||||
};
|
||||
|
||||
var commands = commandHandlers
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
using var ux = new HarnessUXContainer(
|
||||
placeholder: userPrompt,
|
||||
initialMode: modeProvider?.GetMode(session),
|
||||
inputEnabled: messageInjector is not null,
|
||||
modeColors: options.ModeColors);
|
||||
|
||||
// Streaming-mode submissions are enqueued for injection; the queued display
|
||||
// is then refreshed from the injector's current pending list.
|
||||
ux.StreamingInputReceived += (sender, e) =>
|
||||
{
|
||||
if (messageInjector is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
messageInjector.EnqueueMessages(session, [new ChatMessage(ChatRole.User, e.Text)]);
|
||||
ux.ShowQueuedMessages(messageInjector.GetPendingMessages(session));
|
||||
};
|
||||
|
||||
var commandHelp = commandHandlers
|
||||
.Select(h => h.GetHelpText())
|
||||
.Where(t => t is not null)
|
||||
.Append("exit (quit)");
|
||||
.Append("exit (quit)")!;
|
||||
|
||||
System.Console.WriteLine($"Commands: {string.Join(", ", commands)}");
|
||||
System.Console.WriteLine();
|
||||
ux.Initialize(title, commandHelp!, messageInjector is not null);
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
using var writer = new ConsoleWriter(options.ModeColors);
|
||||
writer.CurrentMode = modeProvider?.GetMode(session);
|
||||
string userInput = await ux.WaitForInputAsync();
|
||||
|
||||
string prompt = BuildUserPrompt(modeProvider, session);
|
||||
string? userInput = await writer.ReadLineAsync(prompt);
|
||||
|
||||
// Main loop to run a command or agent and get the next user command/input.
|
||||
while (!string.IsNullOrWhiteSpace(userInput) && !userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ux.WriteUserInputEcho(userInput);
|
||||
|
||||
// Check command handlers first — first one to handle wins.
|
||||
bool handled = false;
|
||||
foreach (var handler in commandHandlers)
|
||||
{
|
||||
if (await handler.TryHandleAsync(userInput, session).ConfigureAwait(false))
|
||||
if (await handler.TryHandleAsync(userInput, session, ux).ConfigureAwait(false))
|
||||
{
|
||||
handled = true;
|
||||
break;
|
||||
@@ -78,14 +91,14 @@ public static class HarnessConsole
|
||||
|
||||
if (!handled)
|
||||
{
|
||||
await RunAgentTurnAsync(agent, session, modeProvider, options, writer, userInput);
|
||||
await RunAgentTurnAsync(agent, session, modeProvider, messageInjector, options, ux, userInput);
|
||||
}
|
||||
|
||||
writer.CurrentMode = modeProvider?.GetMode(session);
|
||||
prompt = BuildUserPrompt(modeProvider, session);
|
||||
userInput = await writer.ReadLineAsync(prompt);
|
||||
ux.CurrentMode = modeProvider?.GetMode(session);
|
||||
userInput = await ux.WaitForInputAsync();
|
||||
}
|
||||
|
||||
ux.Deactivate();
|
||||
System.Console.ResetColor();
|
||||
System.Console.WriteLine("Goodbye!");
|
||||
}
|
||||
@@ -99,27 +112,27 @@ public static class HarnessConsole
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
AgentModeProvider? modeProvider,
|
||||
MessageInjectingChatClient? messageInjector,
|
||||
HarnessConsoleOptions options,
|
||||
ConsoleWriter writer,
|
||||
HarnessUXContainer ux,
|
||||
string userInput)
|
||||
{
|
||||
IList<ChatMessage>? nextMessages = [new ChatMessage(ChatRole.User, userInput)];
|
||||
IReadOnlyList<ChatMessage> lastPendingMessages = messageInjector?.GetPendingMessages(session) ?? [];
|
||||
|
||||
while (nextMessages is not null)
|
||||
{
|
||||
// Build observers for this invocation (may change between iterations due to mode changes).
|
||||
var observers = CreateObservers(options, modeProvider, session);
|
||||
|
||||
// Build run options — observers may inject ResponseFormat, etc.
|
||||
var runOptions = new AgentRunOptions();
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
observer.ConfigureRunOptions(runOptions);
|
||||
}
|
||||
|
||||
// Stream the response, fanning out to all observers.
|
||||
writer.CurrentMode = modeProvider?.GetMode(session);
|
||||
writer.WriteResponseHeader();
|
||||
ux.CurrentMode = modeProvider?.GetMode(session);
|
||||
ux.BeginStreaming();
|
||||
ux.BeginStreamingOutput();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -129,9 +142,9 @@ public static class HarnessConsole
|
||||
if (modeProvider is not null)
|
||||
{
|
||||
string currentMode = modeProvider.GetMode(session);
|
||||
if (currentMode != writer.CurrentMode)
|
||||
if (currentMode != ux.CurrentMode)
|
||||
{
|
||||
writer.CurrentMode = currentMode;
|
||||
ux.CurrentMode = currentMode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +152,7 @@ public static class HarnessConsole
|
||||
{
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
await observer.OnContentAsync(writer, content);
|
||||
await observer.OnContentAsync(ux, content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,22 +160,32 @@ public static class HarnessConsole
|
||||
{
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
await observer.OnTextAsync(writer, update.Text);
|
||||
await observer.OnTextAsync(ux, update.Text);
|
||||
}
|
||||
}
|
||||
|
||||
SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await writer.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red);
|
||||
await ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red);
|
||||
}
|
||||
|
||||
// Collect messages from all observers.
|
||||
// Final sync after streaming — messages may have been consumed during the last iteration.
|
||||
SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages);
|
||||
|
||||
// Stop spinner before observer completions (which may prompt for input).
|
||||
ux.StopSpinner();
|
||||
|
||||
// Close the streaming output to provide visual separation from observer output.
|
||||
await ux.EndStreamingOutputAsync();
|
||||
|
||||
var combinedMessages = new List<ChatMessage>();
|
||||
bool hasObserverMessages = false;
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
var messages = await observer.OnStreamCompleteAsync(writer, agent, session, options);
|
||||
var messages = await observer.OnStreamCompleteAsync(ux, agent, session, options);
|
||||
if (messages is { Count: > 0 })
|
||||
{
|
||||
combinedMessages.AddRange(messages);
|
||||
@@ -170,11 +193,44 @@ public static class HarnessConsole
|
||||
}
|
||||
}
|
||||
|
||||
await writer.WriteStreamFooterAsync(hasFollowUpMessages: hasObserverMessages);
|
||||
await ux.WriteNoTextWarningAsync(hasFollowUpMessages: hasObserverMessages);
|
||||
|
||||
ux.EndStreaming();
|
||||
|
||||
nextMessages = combinedMessages.Count > 0 ? combinedMessages : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronizes the queued items display with the message injector's pending messages.
|
||||
/// Messages that have been consumed (drained by the service) are echoed to the output
|
||||
/// area as regular user-input entries.
|
||||
/// </summary>
|
||||
private static void SyncQueuedMessageDisplay(
|
||||
MessageInjectingChatClient? messageInjector,
|
||||
AgentSession session,
|
||||
HarnessUXContainer ux,
|
||||
ref IReadOnlyList<ChatMessage> lastPendingMessages)
|
||||
{
|
||||
if (messageInjector is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pending = messageInjector.GetPendingMessages(session);
|
||||
|
||||
// If previously pending messages exceed current pending count, some were consumed.
|
||||
int consumedCount = lastPendingMessages.Count - pending.Count;
|
||||
for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++)
|
||||
{
|
||||
string text = lastPendingMessages[i].Text ?? string.Empty;
|
||||
ux.WriteUserInputEcho(text);
|
||||
}
|
||||
|
||||
lastPendingMessages = pending;
|
||||
ux.ShowQueuedMessages(pending);
|
||||
}
|
||||
|
||||
private static List<ConsoleObserver> CreateObservers(HarnessConsoleOptions options, AgentModeProvider? modeProvider, AgentSession session)
|
||||
{
|
||||
var observers = new List<ConsoleObserver>
|
||||
@@ -186,7 +242,6 @@ public static class HarnessConsole
|
||||
new UsageDisplayObserver(options.MaxContextWindowTokens, options.MaxOutputTokens),
|
||||
};
|
||||
|
||||
// Add the appropriate output observer based on the current mode.
|
||||
if (options.EnablePlanningUx
|
||||
&& modeProvider is not null
|
||||
&& string.Equals(modeProvider.GetMode(session), options.PlanningModeName, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -200,15 +255,4 @@ public static class HarnessConsole
|
||||
|
||||
return observers;
|
||||
}
|
||||
|
||||
private static string BuildUserPrompt(AgentModeProvider? modeProvider, AgentSession session)
|
||||
{
|
||||
if (modeProvider is not null)
|
||||
{
|
||||
string mode = modeProvider.GetMode(session);
|
||||
return $"[{mode}] You: ";
|
||||
}
|
||||
|
||||
return "You: ";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments raised when the user submits text while the bottom panel is in
|
||||
/// streaming mode (i.e. an agent turn is in progress).
|
||||
/// </summary>
|
||||
public sealed class StreamingInputReceivedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StreamingInputReceivedEventArgs"/> class.
|
||||
/// </summary>
|
||||
/// <param name="text">The submitted text.</param>
|
||||
public StreamingInputReceivedEventArgs(string text)
|
||||
{
|
||||
this.Text = text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the submitted text.
|
||||
/// </summary>
|
||||
public string Text { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Façade over the harness UI: owns the <see cref="HarnessAppComponent"/>, manages
|
||||
/// its props, dispatches input submissions, and provides the high-level read/write
|
||||
/// operations used by observers, command handlers, and the harness loop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All callers interact with the UI exclusively through this class. The underlying
|
||||
/// <see cref="HarnessAppComponent"/> and its props are an implementation detail and
|
||||
/// must not be exposed.
|
||||
/// </remarks>
|
||||
public sealed class HarnessUXContainer : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The prompt displayed in the bottom-panel input area.
|
||||
/// </summary>
|
||||
private const string UserPrompt = "> ";
|
||||
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
private readonly List<object> _outputItems = [];
|
||||
private readonly HarnessAppComponent _appComponent;
|
||||
private readonly object _outputLock = new();
|
||||
|
||||
private TaskCompletionSource<string>? _pendingInputTcs;
|
||||
private OutputEntryType? _lastEntryType;
|
||||
private bool _hasReceivedAnyText;
|
||||
private OutputEntry? _currentStreamingEntry;
|
||||
private string? _currentMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessUXContainer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="placeholder">Placeholder text shown when the input is empty.</param>
|
||||
/// <param name="initialMode">The current agent mode, used to colour the rule and prompt.</param>
|
||||
/// <param name="inputEnabled">Whether the bottom-panel input accepts keystrokes during streaming.</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public HarnessUXContainer(
|
||||
string placeholder,
|
||||
string? initialMode,
|
||||
bool inputEnabled,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._modeColors = modeColors;
|
||||
this._currentMode = initialMode;
|
||||
|
||||
this._appComponent = new HarnessAppComponent(RenderOutputEntry)
|
||||
{
|
||||
Props = new HarnessAppComponentProps
|
||||
{
|
||||
ScrollItems = this._outputItems,
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
Prompt = UserPrompt,
|
||||
Placeholder = placeholder,
|
||||
ModeColor = ModeColors.Get(initialMode, modeColors),
|
||||
ModeText = initialMode,
|
||||
InputEnabled = inputEnabled,
|
||||
},
|
||||
};
|
||||
|
||||
this._appComponent.InputSubmitted += this.OnInputSubmitted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user submits text while the bottom panel is in streaming mode.
|
||||
/// Subscribers typically enqueue the text into a message-injecting chat client.
|
||||
/// </summary>
|
||||
public event EventHandler<StreamingInputReceivedEventArgs>? StreamingInputReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current agent mode (e.g. "plan", "execute"). Updating this
|
||||
/// also refreshes the rule colour and bottom-panel prompt to match the new mode.
|
||||
/// </summary>
|
||||
public string? CurrentMode
|
||||
{
|
||||
get => this._currentMode;
|
||||
set
|
||||
{
|
||||
this._currentMode = value;
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
ModeColor = ModeColors.Get(value, this._modeColors),
|
||||
ModeText = value,
|
||||
};
|
||||
this._appComponent.Render();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs the initial screen clear, sets the help text in the mode-and-help bar,
|
||||
/// and adds the title to the output area.
|
||||
/// </summary>
|
||||
/// <param name="title">The title displayed in the console header.</param>
|
||||
/// <param name="commandHelpTexts">The command help strings displayed in the mode-and-help bar.</param>
|
||||
/// <param name="messageInjectionActive">Whether streaming-time message injection is enabled.</param>
|
||||
public void Initialize(string title, IEnumerable<string> commandHelpTexts, bool messageInjectionActive)
|
||||
{
|
||||
// Set the help text on the mode-and-help bar (persists below the rule).
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
HelpText = string.Join(", ", commandHelpTexts),
|
||||
ModeText = this._currentMode,
|
||||
};
|
||||
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
this._appComponent.Render();
|
||||
|
||||
this.AppendOutputEntries(
|
||||
new OutputEntry(OutputEntryType.InfoLine, $"=== {title} ===\n", ConsoleColor.White),
|
||||
new OutputEntry(OutputEntryType.InfoLine, "\n"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores the cursor and exits the alternate screen, ending the interactive UI.
|
||||
/// </summary>
|
||||
public void Deactivate() => this._appComponent.Deactivate();
|
||||
|
||||
/// <summary>
|
||||
/// Switches the bottom panel to streaming mode and starts the spinner.
|
||||
/// </summary>
|
||||
public void BeginStreaming()
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
Mode = BottomPanelMode.Streaming,
|
||||
ShowSpinner = true,
|
||||
};
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the spinner without leaving streaming mode. Use between the end of the
|
||||
/// stream and any observer-driven prompts (e.g. tool approvals).
|
||||
/// </summary>
|
||||
public void StopSpinner()
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with { ShowSpinner = false };
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switches the bottom panel back to text-input mode and stops the spinner.
|
||||
/// </summary>
|
||||
public void EndStreaming()
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
ShowSpinner = false,
|
||||
};
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets per-turn streaming bookkeeping in preparation for a new agent turn.
|
||||
/// </summary>
|
||||
public void BeginStreamingOutput()
|
||||
{
|
||||
this._hasReceivedAnyText = false;
|
||||
this._currentStreamingEntry = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the formatted usage text shown on the agent status bar.
|
||||
/// </summary>
|
||||
public void SetUsageText(string usageText)
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with { UsageText = usageText };
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the usage text from the agent status bar.
|
||||
/// </summary>
|
||||
public void ClearUsageText()
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with { UsageText = null };
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the queued-message display with one entry per pending message.
|
||||
/// </summary>
|
||||
public void ShowQueuedMessages(IReadOnlyList<ChatMessage> pending)
|
||||
{
|
||||
var newQueued = new List<object>(pending.Count);
|
||||
foreach (var msg in pending)
|
||||
{
|
||||
string text = msg.Text ?? string.Empty;
|
||||
newQueued.Add(new OutputEntry(OutputEntryType.UserInput, $" 💬 {text}\n", ConsoleColor.DarkGray));
|
||||
}
|
||||
|
||||
this._appComponent.Props = this._appComponent.Props! with { QueuedItems = newQueued };
|
||||
this._appComponent.Render();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Echoes a submitted user input as a regular user-input entry in the output area,
|
||||
/// using the current mode-aware prompt prefix.
|
||||
/// </summary>
|
||||
/// <param name="text">The user-entered text.</param>
|
||||
public void WriteUserInputEcho(string text)
|
||||
{
|
||||
this.AppendOutputEntries(new OutputEntry(
|
||||
OutputEntryType.UserInput,
|
||||
$"\nYou: {text}\n",
|
||||
ConsoleColor.Green));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output as an output entry, without a trailing newline.
|
||||
/// </summary>
|
||||
public Task WriteInfoAsync(string text, ConsoleColor? color = null) =>
|
||||
this.WriteInfoCoreAsync(text, color, newLine: false);
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output as an output entry, followed by a newline.
|
||||
/// </summary>
|
||||
public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) =>
|
||||
this.WriteInfoCoreAsync(text, color, newLine: true);
|
||||
|
||||
private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
|
||||
{
|
||||
// Add a blank line separator when transitioning from streaming text or user input.
|
||||
string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter
|
||||
? "\n\n "
|
||||
: " ";
|
||||
|
||||
string fullText = newLine ? prefix + text + "\n" : prefix + text;
|
||||
this.AppendOutputEntries(new OutputEntry(
|
||||
OutputEntryType.InfoLine,
|
||||
fullText,
|
||||
color ?? ModeColors.Get(this.CurrentMode, this._modeColors)));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes streaming text output from the agent. Successive calls accumulate into a
|
||||
/// single streaming entry that is re-rendered by the text panel.
|
||||
/// </summary>
|
||||
public Task WriteTextAsync(string text, ConsoleColor? color = null)
|
||||
{
|
||||
lock (this._outputLock)
|
||||
{
|
||||
this._lastEntryType = OutputEntryType.StreamingText;
|
||||
this._hasReceivedAnyText = true;
|
||||
|
||||
ConsoleColor effectiveColor = color ?? ModeColors.Get(this.CurrentMode, this._modeColors);
|
||||
|
||||
if (this._currentStreamingEntry is not null)
|
||||
{
|
||||
this._currentStreamingEntry = this._currentStreamingEntry with
|
||||
{
|
||||
Text = this._currentStreamingEntry.Text + text,
|
||||
};
|
||||
this._outputItems[^1] = this._currentStreamingEntry;
|
||||
}
|
||||
else
|
||||
{
|
||||
const string Prefix = "\n";
|
||||
this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor);
|
||||
this._outputItems.Add(this._currentStreamingEntry);
|
||||
}
|
||||
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
ScrollItems = new List<object>(this._outputItems),
|
||||
};
|
||||
}
|
||||
|
||||
this._appComponent.Render();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a blank-line separator to visually close the streaming output section.
|
||||
/// Call before observer completions so their output is visually separated.
|
||||
/// </summary>
|
||||
public Task EndStreamingOutputAsync()
|
||||
{
|
||||
lock (this._outputLock)
|
||||
{
|
||||
this._outputItems.Add(new OutputEntry(OutputEntryType.StreamFooter, "\n"));
|
||||
this._currentStreamingEntry = null;
|
||||
this._lastEntryType = OutputEntryType.StreamFooter;
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
ScrollItems = new List<object>(this._outputItems),
|
||||
};
|
||||
}
|
||||
|
||||
this._appComponent.Render();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a "(no text response from agent)" warning if no text was received
|
||||
/// and no observer produced follow-up messages. Call after observer completions.
|
||||
/// </summary>
|
||||
/// <param name="hasFollowUpMessages">Whether any observer produced follow-up messages.</param>
|
||||
public Task WriteNoTextWarningAsync(bool hasFollowUpMessages)
|
||||
{
|
||||
if (!this._hasReceivedAnyText && !hasFollowUpMessages)
|
||||
{
|
||||
this.AppendOutputEntries(new OutputEntry(
|
||||
OutputEntryType.StreamFooter,
|
||||
" (no text response from agent)\n",
|
||||
ConsoleColor.DarkYellow));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a line of input from the user. If <paramref name="prompt"/> is supplied
|
||||
/// it is rendered as an info line above the input row before reading.
|
||||
/// </summary>
|
||||
public async Task<string?> ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null)
|
||||
{
|
||||
if (prompt is not null)
|
||||
{
|
||||
ConsoleColor ruleColor = ModeColors.Get(this.CurrentMode, this._modeColors);
|
||||
this.AppendOutputEntries(
|
||||
new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor),
|
||||
new OutputEntry(OutputEntryType.InfoLine, $" {prompt}", promptColor ?? ruleColor));
|
||||
}
|
||||
|
||||
this._appComponent.Props = this._appComponent.Props! with { Mode = BottomPanelMode.TextInput };
|
||||
this._appComponent.Render();
|
||||
|
||||
string input = await this.WaitForInputAsync();
|
||||
|
||||
this.AppendOutputEntries(new OutputEntry(
|
||||
OutputEntryType.UserInput,
|
||||
$"\nYou: {input}\n",
|
||||
ConsoleColor.Green));
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presents a selection prompt with the given choices and waits for the user's
|
||||
/// selection. The title is displayed above the list in the bottom panel. After
|
||||
/// selection the bottom panel is restored to text-input mode and both the question
|
||||
/// and selection are echoed in the output area.
|
||||
/// </summary>
|
||||
public async Task<string> ReadSelectionAsync(string title, IList<string> choices)
|
||||
{
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
Mode = BottomPanelMode.ListSelection,
|
||||
Items = choices.ToList(),
|
||||
ListTitle = title,
|
||||
ListCustomTextPlaceholder = "✏️ Type a custom response...",
|
||||
};
|
||||
this._appComponent.Render();
|
||||
|
||||
string selection = await this.WaitForInputAsync();
|
||||
|
||||
this._appComponent.Props = this._appComponent.Props with { Mode = BottomPanelMode.TextInput };
|
||||
|
||||
this.AppendOutputEntries(
|
||||
new OutputEntry(
|
||||
OutputEntryType.InfoLine,
|
||||
$"\n {title}\n",
|
||||
ModeColors.Get(this.CurrentMode, this._modeColors)),
|
||||
new OutputEntry(
|
||||
OutputEntryType.UserInput,
|
||||
$"\nYou: {selection}\n",
|
||||
ConsoleColor.Green));
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Awaits the next non-streaming user input submission.
|
||||
/// </summary>
|
||||
public Task<string> WaitForInputAsync()
|
||||
{
|
||||
this._pendingInputTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
return this._pendingInputTcs.Task;
|
||||
}
|
||||
|
||||
private void OnInputSubmitted(object? sender, InputSubmittedEventArgs e)
|
||||
{
|
||||
if (e.Mode == BottomPanelMode.Streaming)
|
||||
{
|
||||
this.StreamingInputReceived?.Invoke(this, new StreamingInputReceivedEventArgs(e.Text));
|
||||
}
|
||||
else
|
||||
{
|
||||
var waiter = this._pendingInputTcs;
|
||||
this._pendingInputTcs = null;
|
||||
waiter?.TrySetResult(e.Text);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this._appComponent.InputSubmitted -= this.OnInputSubmitted;
|
||||
this._appComponent.Deactivate();
|
||||
this._appComponent.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders an <see cref="OutputEntry"/> to a string with ANSI color codes.
|
||||
/// Used as the render delegate for the <see cref="HarnessAppComponent"/>.
|
||||
/// </summary>
|
||||
private static string RenderOutputEntry(object item)
|
||||
{
|
||||
if (item is not OutputEntry entry)
|
||||
{
|
||||
return item?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
if (entry.Color.HasValue)
|
||||
{
|
||||
return $"{AnsiEscapes.SetForegroundColor(entry.Color.Value)}{entry.Text}{AnsiEscapes.ResetAttributes}";
|
||||
}
|
||||
|
||||
return entry.Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one or more output entries to the output list under lock,
|
||||
/// updates <see cref="_lastEntryType"/> to the last entry's type, and renders.
|
||||
/// </summary>
|
||||
private void AppendOutputEntries(params OutputEntry[] entries)
|
||||
{
|
||||
lock (this._outputLock)
|
||||
{
|
||||
foreach (OutputEntry entry in entries)
|
||||
{
|
||||
this._outputItems.Add(entry);
|
||||
}
|
||||
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
this._lastEntryType = entries[^1].Type;
|
||||
}
|
||||
|
||||
this._appComponent.Props = this._appComponent.Props! with
|
||||
{
|
||||
ScrollItems = new List<object>(this._outputItems),
|
||||
};
|
||||
}
|
||||
|
||||
this._appComponent.Render();
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -7,12 +7,10 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\ConsoleReactiveFramework\ConsoleReactiveFramework.csproj" />
|
||||
<ProjectReference Include="..\ConsoleReactiveComponents\ConsoleReactiveComponents.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for resolving console colours associated with agent modes.
|
||||
/// </summary>
|
||||
internal static class ModeColors
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the console color associated with a mode name, using the provided color map.
|
||||
/// Falls back to <see cref="ConsoleColor.Gray"/> when the mode is <see langword="null"/>
|
||||
/// or not present in the map.
|
||||
/// </summary>
|
||||
/// <param name="mode">The mode name, or <see langword="null"/> if no mode is active.</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public static ConsoleColor Get(string? mode, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
if (mode is null)
|
||||
{
|
||||
return ConsoleColor.Gray;
|
||||
}
|
||||
|
||||
if (modeColors is not null && modeColors.TryGetValue(mode, out var color))
|
||||
{
|
||||
return color;
|
||||
}
|
||||
|
||||
return ConsoleColor.Gray;
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -25,28 +25,28 @@ public abstract class ConsoleObserver
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AIContent"/> item in the response stream.
|
||||
/// </summary>
|
||||
/// <param name="writer">The console writer for rendering output.</param>
|
||||
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
|
||||
/// <param name="content">The content item from the stream.</param>
|
||||
public virtual Task OnContentAsync(ConsoleWriter writer, AIContent content) => Task.CompletedTask;
|
||||
public virtual Task OnContentAsync(HarnessUXContainer ux, AIContent content) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called for each text update in the response stream.
|
||||
/// </summary>
|
||||
/// <param name="writer">The console writer for rendering output.</param>
|
||||
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
|
||||
/// <param name="text">The text from the update.</param>
|
||||
public virtual Task OnTextAsync(ConsoleWriter writer, string text) => Task.CompletedTask;
|
||||
public virtual Task OnTextAsync(HarnessUXContainer ux, string text) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called after the response stream completes. Returns messages to include in the
|
||||
/// next agent invocation, or <see langword="null"/> if no re-invocation is needed.
|
||||
/// </summary>
|
||||
/// <param name="writer">The console writer for rendering output.</param>
|
||||
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
/// <param name="options">The console options.</param>
|
||||
/// <returns>Messages to send to the agent, or <see langword="null"/> if no action is needed.</returns>
|
||||
public virtual Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
||||
ConsoleWriter writer,
|
||||
HarnessUXContainer ux,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options) => Task.FromResult<IList<ChatMessage>?>(null);
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ namespace Harness.Shared.Console.Observers;
|
||||
internal sealed class ErrorDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
{
|
||||
if (content is ErrorContent errorContent)
|
||||
{
|
||||
@@ -25,7 +25,7 @@ internal sealed class ErrorDisplayObserver : ConsoleObserver
|
||||
errorText += $" details: {errorContent.Details}";
|
||||
}
|
||||
|
||||
await writer.WriteInfoLineAsync(errorText, ConsoleColor.Red);
|
||||
await ux.WriteInfoLineAsync(errorText, ConsoleColor.Red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-38
@@ -33,7 +33,7 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task OnTextAsync(ConsoleWriter writer, string text)
|
||||
public override Task OnTextAsync(HarnessUXContainer ux, string text)
|
||||
{
|
||||
// Collect text silently instead of displaying it.
|
||||
this._textCollector.Append(text);
|
||||
@@ -42,7 +42,7 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
||||
ConsoleWriter writer,
|
||||
HarnessUXContainer ux,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options)
|
||||
@@ -64,21 +64,21 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
await writer.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
|
||||
await writer.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
|
||||
await ux.WriteInfoLineAsync($"❌ Failed to parse planning response: {ex.Message}", ConsoleColor.Red);
|
||||
await ux.WriteInfoLineAsync($"(raw response) {collectedText}", ConsoleColor.DarkYellow);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (planningResponse is null)
|
||||
{
|
||||
await writer.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
|
||||
await ux.WriteInfoLineAsync("(no structured response from agent)", ConsoleColor.DarkYellow);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render based on response type.
|
||||
if (planningResponse.Type == PlanningResponseType.Clarification)
|
||||
{
|
||||
return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(writer, planningResponse));
|
||||
return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(ux, planningResponse));
|
||||
}
|
||||
|
||||
if (planningResponse.Type == PlanningResponseType.Approval)
|
||||
@@ -86,48 +86,45 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
var question = planningResponse.Questions.FirstOrDefault();
|
||||
if (question is null)
|
||||
{
|
||||
await writer.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
|
||||
await ux.WriteInfoLineAsync("(approval response had no content)", ConsoleColor.DarkYellow);
|
||||
return null;
|
||||
}
|
||||
|
||||
string response = await this.RenderApprovalAndCollectResponseAsync(writer, question, options);
|
||||
string response = await this.RenderApprovalAndCollectResponseAsync(ux, question, options);
|
||||
if (response == "Approved")
|
||||
{
|
||||
this._modeProvider.SetMode(session, options.ExecutionModeName!);
|
||||
|
||||
await writer.WriteInfoLineAsync($"✅ Switched to {options.ExecutionModeName} mode.",
|
||||
ConsoleWriter.GetModeColor(options.ExecutionModeName, options.ModeColors));
|
||||
await ux.WriteInfoLineAsync($"✅ Switched to {options.ExecutionModeName} mode.",
|
||||
ModeColors.Get(options.ExecutionModeName, options.ModeColors));
|
||||
}
|
||||
|
||||
return AsUserMessages(response);
|
||||
}
|
||||
|
||||
await writer.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
|
||||
await ux.WriteInfoLineAsync($"(unexpected response type: {planningResponse.Type})", ConsoleColor.DarkYellow);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IList<ChatMessage>? AsUserMessages(string? text) =>
|
||||
text is not null ? [new ChatMessage(ChatRole.User, text)] : null;
|
||||
|
||||
private async Task<string?> RenderClarificationsAndCollectResponsesAsync(ConsoleWriter writer, PlanningResponse response)
|
||||
private async Task<string?> RenderClarificationsAndCollectResponsesAsync(HarnessUXContainer ux, PlanningResponse response)
|
||||
{
|
||||
var answers = new List<string>();
|
||||
|
||||
foreach (var question in response.Questions)
|
||||
{
|
||||
await writer.WriteInfoLineAsync(string.Empty);
|
||||
await writer.WriteInfoLineAsync(question.Message);
|
||||
|
||||
string? answer;
|
||||
if (question.Choices is { Count: > 0 })
|
||||
{
|
||||
answer = await writer.ReadSelectionAsync(
|
||||
"Choose an option:",
|
||||
answer = await ux.ReadSelectionAsync(
|
||||
question.Message,
|
||||
question.Choices);
|
||||
}
|
||||
else
|
||||
{
|
||||
answer = (await writer.ReadLineAsync("Response: "))?.Trim();
|
||||
answer = (await ux.ReadLineAsync(question.Message))?.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(answer))
|
||||
@@ -139,38 +136,20 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
return answers.Count > 0 ? string.Join("\n\n", answers) : null;
|
||||
}
|
||||
|
||||
private async Task<string> RenderApprovalAndCollectResponseAsync(ConsoleWriter writer, PlanningQuestion question, HarnessConsoleOptions options)
|
||||
private async Task<string> RenderApprovalAndCollectResponseAsync(HarnessUXContainer ux, PlanningQuestion question, HarnessConsoleOptions options)
|
||||
{
|
||||
await writer.WriteInfoLineAsync(question.Message);
|
||||
|
||||
var choices = new List<string>
|
||||
{
|
||||
"Approve and switch to execute mode",
|
||||
"Suggest changes",
|
||||
};
|
||||
|
||||
string selection = await writer.ReadSelectionAsync("What would you like to do?", choices);
|
||||
string selection = await ux.ReadSelectionAsync(question.Message, choices);
|
||||
|
||||
if (selection == choices[0])
|
||||
{
|
||||
return "Approved";
|
||||
}
|
||||
|
||||
if (selection == choices[1])
|
||||
{
|
||||
string? feedback = await writer.ReadLineAsync(
|
||||
"Your feedback: ",
|
||||
ConsoleWriter.GetModeColor(options.PlanningModeName, options.ModeColors));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(feedback))
|
||||
{
|
||||
// Treat empty feedback as no changes — re-prompt the agent with the plan.
|
||||
return "No changes suggested. Please re-present the plan for approval.";
|
||||
}
|
||||
|
||||
return feedback;
|
||||
}
|
||||
|
||||
// Custom freeform input — treat as suggested changes.
|
||||
return selection;
|
||||
}
|
||||
|
||||
+2
-2
@@ -10,11 +10,11 @@ namespace Harness.Shared.Console.Observers;
|
||||
internal sealed class ReasoningDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
{
|
||||
if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
|
||||
{
|
||||
await writer.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
|
||||
await ux.WriteTextAsync(reasoning.Text, ConsoleColor.DarkMagenta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ namespace Harness.Shared.Console.Observers;
|
||||
internal sealed class TextOutputObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnTextAsync(ConsoleWriter writer, string text)
|
||||
public override async Task OnTextAsync(HarnessUXContainer ux, string text)
|
||||
{
|
||||
await writer.WriteTextAsync(text);
|
||||
await ux.WriteTextAsync(text);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -15,7 +15,7 @@ internal sealed class ToolApprovalObserver : ConsoleObserver
|
||||
private readonly List<ToolApprovalRequestContent> _approvalRequests = [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
{
|
||||
if (content is ToolApprovalRequestContent approvalRequest)
|
||||
{
|
||||
@@ -23,13 +23,13 @@ internal sealed class ToolApprovalObserver : ConsoleObserver
|
||||
string toolName = approvalRequest.ToolCall is FunctionCallContent fc
|
||||
? ToolCallFormatter.Format(fc)
|
||||
: approvalRequest.ToolCall?.ToString() ?? "unknown";
|
||||
await writer.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
|
||||
await ux.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
||||
ConsoleWriter writer,
|
||||
HarnessUXContainer ux,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options)
|
||||
@@ -39,12 +39,12 @@ internal sealed class ToolApprovalObserver : ConsoleObserver
|
||||
return null;
|
||||
}
|
||||
|
||||
var messages = await PromptForApprovalsAsync(writer, this._approvalRequests);
|
||||
var messages = await PromptForApprovalsAsync(ux, this._approvalRequests);
|
||||
this._approvalRequests.Clear();
|
||||
return messages;
|
||||
}
|
||||
|
||||
private static async Task<List<ChatMessage>?> PromptForApprovalsAsync(ConsoleWriter writer, List<ToolApprovalRequestContent> approvalRequests)
|
||||
private static async Task<List<ChatMessage>?> PromptForApprovalsAsync(HarnessUXContainer ux, List<ToolApprovalRequestContent> approvalRequests)
|
||||
{
|
||||
if (approvalRequests.Count == 0)
|
||||
{
|
||||
@@ -66,7 +66,7 @@ internal sealed class ToolApprovalObserver : ConsoleObserver
|
||||
"Deny",
|
||||
};
|
||||
|
||||
string selection = await writer.ReadSelectionAsync($"🔐 Tool approval: {toolName}", choices);
|
||||
string selection = await ux.ReadSelectionAsync($"🔐 Tool approval: {toolName}", choices);
|
||||
AIContent response = selection switch
|
||||
{
|
||||
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
|
||||
@@ -82,7 +82,7 @@ internal sealed class ToolApprovalObserver : ConsoleObserver
|
||||
"Deny" => "❌ Denied",
|
||||
_ => "✅ Approved",
|
||||
};
|
||||
await writer.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray);
|
||||
await ux.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray);
|
||||
|
||||
responses.Add(response);
|
||||
}
|
||||
|
||||
+3
-3
@@ -11,15 +11,15 @@ namespace Harness.Shared.Console.Observers;
|
||||
internal sealed class ToolCallDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
{
|
||||
if (content is FunctionCallContent functionCall)
|
||||
{
|
||||
await writer.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow);
|
||||
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow);
|
||||
}
|
||||
else if (content is ToolCallContent toolCall)
|
||||
{
|
||||
await writer.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
|
||||
await ux.WriteInfoLineAsync($"🔧 Calling tool: {toolCall}...", ConsoleColor.DarkYellow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -24,19 +24,21 @@ internal sealed class UsageDisplayObserver : ConsoleObserver
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(ConsoleWriter writer, AIContent content)
|
||||
public override Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
{
|
||||
if (content is UsageContent usage)
|
||||
{
|
||||
if (usage.Details is not null)
|
||||
{
|
||||
await writer.WriteInfoLineAsync(this.FormatUsageBreakdown(usage.Details), ConsoleColor.DarkGray);
|
||||
ux.SetUsageText(this.FormatUsageBreakdown(usage.Details));
|
||||
}
|
||||
else
|
||||
{
|
||||
await writer.WriteInfoLineAsync("📊 Tokens —", ConsoleColor.DarkGray);
|
||||
ux.SetUsageText("📊 Tokens —");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string FormatUsageBreakdown(UsageDetails details)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the type of an output entry in the console conversation.
|
||||
/// </summary>
|
||||
public enum OutputEntryType
|
||||
{
|
||||
/// <summary>User input echo (e.g. "You: hello").</summary>
|
||||
UserInput,
|
||||
|
||||
/// <summary>In-progress streaming text from the agent (accumulated chunk by chunk).</summary>
|
||||
StreamingText,
|
||||
|
||||
/// <summary>Informational line (tool calls, errors, usage, approval requests, etc.).</summary>
|
||||
InfoLine,
|
||||
|
||||
/// <summary>Stream footer (e.g. "(no text response from agent)").</summary>
|
||||
StreamFooter,
|
||||
|
||||
/// <summary>Pending injected message notification.</summary>
|
||||
PendingMessage,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single output entry in the console conversation history.
|
||||
/// These entries are rendered by the <see cref="HarnessAppComponent"/> via its render delegate.
|
||||
/// </summary>
|
||||
/// <param name="Type">The type of output entry.</param>
|
||||
/// <param name="Text">The text content of the entry.</param>
|
||||
/// <param name="Color">Optional foreground color for rendering.</param>
|
||||
public record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null);
|
||||
@@ -1,77 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// A restartable spinner that can be started and stopped multiple times.
|
||||
/// </summary>
|
||||
internal sealed class Spinner : IDisposable
|
||||
{
|
||||
private static readonly string[] s_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _task;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (this._task is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._cts = new CancellationTokenSource();
|
||||
this._task = RunAsync(this._cts.Token);
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (this._cts is null || this._task is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._cts.Cancel();
|
||||
await this._task;
|
||||
this._cts.Dispose();
|
||||
this._cts = null;
|
||||
this._task = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (this._cts is not null && this._task is not null)
|
||||
{
|
||||
this._cts.Cancel();
|
||||
|
||||
// Block briefly to let the spinner task clean up.
|
||||
// This prevents the background task from writing to the console after disposal.
|
||||
#pragma warning disable VSTHRD002 // Synchronous wait in Dispose is acceptable here — the spinner task completes quickly on cancellation.
|
||||
this._task.Wait();
|
||||
#pragma warning restore VSTHRD002
|
||||
}
|
||||
|
||||
this._cts?.Dispose();
|
||||
this._cts = null;
|
||||
this._task = null;
|
||||
}
|
||||
|
||||
private static async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
int i = 0;
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
System.Console.Write(s_frames[i % s_frames.Length]);
|
||||
await Task.Delay(80, cancellationToken);
|
||||
System.Console.Write("\b \b");
|
||||
i++;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Clear the last spinner frame left on screen.
|
||||
System.Console.Write("\b \b");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ AIAgent agent =
|
||||
// Build a ChatClient Pipeline
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation() // We are building our own stack from scratch so we need to include Function Invocation ourselves.
|
||||
.UseMessageInjection() // Allow message injection during the function call loop.
|
||||
.UsePerServiceCallChatHistoryPersistence() // Save chat history updates to the session after each service call, rather than only at the end of the run.
|
||||
.UseAIContextProviders(new CompactionProvider(compactionStrategy)) // Add Compaction before each service call to responses so that long function invocation loops don't overflow the context.
|
||||
|
||||
|
||||
@@ -206,6 +206,31 @@ public sealed class MessageInjectingChatClient : DelegatingChatClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot of the pending injected messages for the specified session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returns a copy of the current pending messages that have not yet been consumed by the
|
||||
/// injection loop. This can be used to display pending messages to the user. The returned
|
||||
/// list is a point-in-time snapshot; messages may be consumed between calls.
|
||||
/// </remarks>
|
||||
/// <param name="session">The agent session to check.</param>
|
||||
/// <returns>A read-only list of pending messages, or an empty list if none are pending.</returns>
|
||||
public IReadOnlyList<ChatMessage> GetPendingMessages(AgentSession session)
|
||||
{
|
||||
Throw.IfNull(session);
|
||||
|
||||
if (!session.StateBag.TryGetValue<List<ChatMessage>>(PendingMessagesStateKey, out var queue) || queue is null)
|
||||
{
|
||||
return Array.Empty<ChatMessage>();
|
||||
}
|
||||
|
||||
lock (queue)
|
||||
{
|
||||
return queue.Count == 0 ? Array.Empty<ChatMessage>() : queue.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates the pending injected messages queue from the session's <see cref="AgentSessionStateBag"/>.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user