mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into copilot/fix-path-traversal-issue
This commit is contained in:
@@ -9,42 +9,30 @@ namespace Harness.ConsoleReactiveComponents;
|
||||
/// </summary>
|
||||
public record TextPanelProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the items to render in the panel.</summary>
|
||||
public IReadOnlyList<object> Items { get; init; } = [];
|
||||
/// <summary>Gets the items to render in the panel. Each item is a pre-rendered
|
||||
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders a list of items vertically using a custom render delegate.
|
||||
/// A component that renders a list of pre-rendered string items vertically.
|
||||
/// 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)
|
||||
public static int CalculateHeight(IReadOnlyList<string> items)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
string text = renderItem(items[i]);
|
||||
total += CountLines(text);
|
||||
total += CountLines(items[i]);
|
||||
}
|
||||
|
||||
return total;
|
||||
@@ -57,7 +45,7 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
|
||||
|
||||
for (int i = 0; i < props.Items.Count; i++)
|
||||
{
|
||||
string text = this._renderItem(props.Items[i]);
|
||||
string text = props.Items[i];
|
||||
string[] lines = text.Split('\n');
|
||||
int lineCount = CountLines(text);
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ namespace Harness.ConsoleReactiveComponents;
|
||||
/// </summary>
|
||||
public record TextScrollPanelProps : ConsoleReactiveProps
|
||||
{
|
||||
/// <summary>Gets the items to render in the scroll panel.</summary>
|
||||
public IReadOnlyList<object> Items { get; init; } = [];
|
||||
/// <summary>Gets the items to render in the scroll panel. Each item is a pre-rendered
|
||||
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -20,21 +21,17 @@ public record TextScrollPanelProps : ConsoleReactiveProps
|
||||
public record TextScrollPanelState(int RenderedCount = 0) : ConsoleReactiveState;
|
||||
|
||||
/// <summary>
|
||||
/// A component that renders items within a scroll area using a custom render delegate.
|
||||
/// A component that renders pre-rendered string items within a scroll area.
|
||||
/// 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)
|
||||
public TextScrollPanel()
|
||||
{
|
||||
this._renderItem = renderItem;
|
||||
this.State = new TextScrollPanelState();
|
||||
}
|
||||
|
||||
@@ -60,8 +57,7 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
|
||||
// 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);
|
||||
Console.Write(props.Items[i]);
|
||||
}
|
||||
|
||||
// Update state to track what we've rendered
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public abstract class CommandHandler
|
||||
/// </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>
|
||||
/// <param name="ux">The UX state driver for rendering output.</param>
|
||||
/// <returns><see langword="true"/> if this handler handled the input; <see langword="false"/> otherwise.</returns>
|
||||
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux);
|
||||
public abstract ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux);
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <c>/exit</c> command to shut down the console application.
|
||||
/// </summary>
|
||||
public sealed class ExitCommandHandler : CommandHandler
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override string? GetHelpText() => "/exit (quit)";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
if (!input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new ValueTask<bool>(false);
|
||||
}
|
||||
|
||||
ux.RequestShutdown();
|
||||
return new ValueTask<bool>(true);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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 : CommandHandler
|
||||
public sealed class ModeCommandHandler : CommandHandler
|
||||
{
|
||||
private readonly AgentModeProvider? _modeProvider;
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
@@ -27,7 +27,7 @@ internal sealed class ModeCommandHandler : CommandHandler
|
||||
public override string? GetHelpText() => this._modeProvider is not null ? "/mode [plan|execute] (show or switch mode)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux)
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
if (!input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase) && !input.Equals("/mode", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
||||
+2
-2
@@ -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 : CommandHandler
|
||||
public sealed class TodoCommandHandler : CommandHandler
|
||||
{
|
||||
private readonly TodoProvider? _todoProvider;
|
||||
|
||||
@@ -24,7 +24,7 @@ internal sealed class TodoCommandHandler : CommandHandler
|
||||
public override string? GetHelpText() => this._todoProvider is not null ? "/todos (show todo list)" : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, HarnessUXContainer ux)
|
||||
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
|
||||
{
|
||||
if (!input.Equals("/todos", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an action returned by an observer at the end of an agent turn.
|
||||
/// Subtypes describe either a question to ask the user (<see cref="FollowUpQuestion"/>)
|
||||
/// or a message to add directly to the next agent input (<see cref="FollowUpMessage"/>).
|
||||
/// </summary>
|
||||
public abstract record FollowUpAction;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a question that should be presented to the user. The
|
||||
/// <see cref="Continuation"/> delegate is invoked with the user's answer and the
|
||||
/// UX state driver, and returns an optional <see cref="ChatMessage"/> to add to the
|
||||
/// next agent invocation.
|
||||
/// </summary>
|
||||
/// <param name="Prompt">The question text shown to the user.</param>
|
||||
/// <param name="Continuation">
|
||||
/// Invoked with the user's answer and the UX state driver. The driver lets the
|
||||
/// continuation write output (e.g., an action label like "Approved") in addition
|
||||
/// to producing an optional <see cref="ChatMessage"/> for the next agent invocation.
|
||||
/// </param>
|
||||
public abstract record FollowUpQuestion(
|
||||
string Prompt,
|
||||
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation) : FollowUpAction;
|
||||
|
||||
/// <summary>
|
||||
/// A free-form text question. The user may type any response.
|
||||
/// </summary>
|
||||
/// <param name="Prompt">The question text shown to the user.</param>
|
||||
/// <param name="Continuation">Continuation that builds the response message.</param>
|
||||
public sealed record TextFollowUpQuestion(
|
||||
string Prompt,
|
||||
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
|
||||
: FollowUpQuestion(Prompt, Continuation);
|
||||
|
||||
/// <summary>
|
||||
/// A choice question. The user picks from <paramref name="Choices"/>, optionally with
|
||||
/// the ability to enter custom text when <paramref name="AllowCustomText"/> is true.
|
||||
/// </summary>
|
||||
/// <param name="Prompt">The question text shown to the user.</param>
|
||||
/// <param name="Choices">The list of pre-defined choices.</param>
|
||||
/// <param name="AllowCustomText">If true, the user may type a custom response in addition to the listed choices.</param>
|
||||
/// <param name="Continuation">Continuation that builds the response message.</param>
|
||||
public sealed record ChoiceFollowUpQuestion(
|
||||
string Prompt,
|
||||
IReadOnlyList<string> Choices,
|
||||
bool AllowCustomText,
|
||||
Func<string, IUXStateDriver, Task<ChatMessage?>> Continuation)
|
||||
: FollowUpQuestion(Prompt, Continuation);
|
||||
|
||||
/// <summary>
|
||||
/// A message to add directly to the next agent invocation without prompting the user.
|
||||
/// </summary>
|
||||
/// <param name="Message">The chat message to add.</param>
|
||||
public sealed record FollowUpMessage(ChatMessage Message) : FollowUpAction;
|
||||
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.Shared.Console.Commands;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates agent invocations driven by user-input events from the UI.
|
||||
/// The component invokes the runner's input handlers (<see cref="OnUserInputAsync"/>,
|
||||
/// <see cref="OnStreamingInputAsync"/>, <see cref="StartAgentTurnAsync"/>) directly;
|
||||
/// the runner mutates UI state through the supplied <see cref="IUXStateDriver"/>.
|
||||
/// All per-turn follow-up state (pending questions and accumulated responses) lives
|
||||
/// in the component's state record — the runner reads/writes it exclusively through
|
||||
/// the driver and holds no per-turn fields itself.
|
||||
/// </summary>
|
||||
public sealed class HarnessAgentRunner : IDisposable
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private readonly AgentSession _session;
|
||||
private readonly AgentModeProvider? _modeProvider;
|
||||
private readonly MessageInjectingChatClient? _messageInjector;
|
||||
private readonly IReadOnlyList<CommandHandler> _commandHandlers;
|
||||
private readonly IReadOnlyList<ConsoleObserver> _observers;
|
||||
private readonly IUXStateDriver _ux;
|
||||
|
||||
private readonly SemaphoreSlim _inputGate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessAgentRunner"/> class.
|
||||
/// </summary>
|
||||
public HarnessAgentRunner(
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
AgentModeProvider? modeProvider,
|
||||
MessageInjectingChatClient? messageInjector,
|
||||
IReadOnlyList<CommandHandler> commandHandlers,
|
||||
IReadOnlyList<ConsoleObserver> observers,
|
||||
IUXStateDriver ux)
|
||||
{
|
||||
this._agent = agent;
|
||||
this._session = session;
|
||||
this._modeProvider = modeProvider;
|
||||
this._messageInjector = messageInjector;
|
||||
this._commandHandlers = commandHandlers;
|
||||
this._observers = observers;
|
||||
this._ux = ux;
|
||||
|
||||
this.HelpText = string.Join(
|
||||
", ",
|
||||
commandHandlers
|
||||
.Select(h => h.GetHelpText())
|
||||
.Where(t => t is not null)!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the help text describing all available commands (joined by ", "), suitable
|
||||
/// for display in the mode-and-help bar. Computed from the supplied
|
||||
/// <c>commandHandlers</c>.
|
||||
/// </summary>
|
||||
public string HelpText { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => this._inputGate.Dispose();
|
||||
|
||||
/// <summary>
|
||||
/// Handles a top-level user input submission (TextInput mode, no pending question).
|
||||
/// Dispatches to command handlers, or starts an agent turn.
|
||||
/// </summary>
|
||||
internal async Task OnUserInputAsync(string text)
|
||||
{
|
||||
await this._inputGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
this._ux.WriteUserInputEcho(text);
|
||||
|
||||
foreach (var handler in this._commandHandlers)
|
||||
{
|
||||
if (await handler.TryHandleAsync(text, this._session, this._ux).ConfigureAwait(false))
|
||||
{
|
||||
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.RunAgentLoopAsync([new ChatMessage(ChatRole.User, text)]).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._inputGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a user input submission while an agent turn is streaming. The text is
|
||||
/// enqueued via the <see cref="MessageInjectingChatClient"/> so it can be picked up
|
||||
/// by the agent on its next opportunity.
|
||||
/// </summary>
|
||||
internal Task OnStreamingInputAsync(string text)
|
||||
{
|
||||
if (this._messageInjector is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
this._messageInjector.EnqueueMessages(this._session, [new ChatMessage(ChatRole.User, text)]);
|
||||
this._ux.SetQueuedMessages(this._messageInjector.GetPendingMessages(this._session));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes (or completes) a turn after the user has answered all pending follow-up
|
||||
/// questions. The component invokes this with the messages drained from
|
||||
/// <see cref="IUXStateDriver.TakeFollowUpResponses"/>; an empty list simply ends
|
||||
/// the streaming display state without invoking the agent.
|
||||
/// </summary>
|
||||
internal async Task StartAgentTurnAsync(IList<ChatMessage> messages)
|
||||
{
|
||||
await this._inputGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (messages.Count == 0)
|
||||
{
|
||||
this.CompleteTurn();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.RunAgentLoopAsync(messages).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._inputGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAgentLoopAsync(IList<ChatMessage> messages)
|
||||
{
|
||||
IList<ChatMessage>? nextMessages = messages;
|
||||
IReadOnlyList<ChatMessage> lastPendingMessages = this._messageInjector?.GetPendingMessages(this._session) ?? [];
|
||||
|
||||
while (nextMessages is not null)
|
||||
{
|
||||
var runOptions = new AgentRunOptions();
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
observer.ConfigureRunOptions(runOptions, this._agent, this._session);
|
||||
}
|
||||
|
||||
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
|
||||
this._ux.BeginStreaming();
|
||||
this._ux.BeginStreamingOutput();
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var update in this._agent.RunStreamingAsync(nextMessages, this._session, runOptions))
|
||||
{
|
||||
if (this._modeProvider is not null)
|
||||
{
|
||||
string currentMode = this._modeProvider.GetMode(this._session);
|
||||
if (currentMode != this._ux.CurrentMode)
|
||||
{
|
||||
this._ux.CurrentMode = currentMode;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
await observer.OnContentAsync(this._ux, content, this._agent, this._session).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
await observer.OnTextAsync(this._ux, update.Text, this._agent, this._session).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this._ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Final sync after streaming.
|
||||
this.SyncQueuedMessageDisplay(ref lastPendingMessages);
|
||||
|
||||
this._ux.StopSpinner();
|
||||
await this._ux.EndStreamingOutputAsync().ConfigureAwait(false);
|
||||
|
||||
// Collect FollowUpActions from each observer.
|
||||
var directMessages = new List<ChatMessage>();
|
||||
var questions = new List<FollowUpQuestion>();
|
||||
foreach (var observer in this._observers)
|
||||
{
|
||||
var actions = await observer.OnStreamCompleteAsync(this._ux, this._agent, this._session).ConfigureAwait(false);
|
||||
if (actions is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case FollowUpMessage msg:
|
||||
directMessages.Add(msg.Message);
|
||||
break;
|
||||
case FollowUpQuestion q:
|
||||
questions.Add(q);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool hasFollowUpActions = directMessages.Count > 0 || questions.Count > 0;
|
||||
await this._ux.WriteNoTextWarningAsync(hasFollowUpActions).ConfigureAwait(false);
|
||||
|
||||
// Add any direct messages to the accumulator regardless of whether questions follow —
|
||||
// they're sent on the next agent invocation, either by us (if no questions) or by
|
||||
// the component (after the user finishes answering, via StartAgentTurnAsync).
|
||||
foreach (var msg in directMessages)
|
||||
{
|
||||
this._ux.AddFollowUpResponse(msg);
|
||||
}
|
||||
|
||||
if (questions.Count > 0)
|
||||
{
|
||||
// Pause: hand control back to the UX to collect answers.
|
||||
this._ux.QueueFollowUpQuestions(questions);
|
||||
return;
|
||||
}
|
||||
|
||||
// No questions to ask — drain anything we just accumulated and loop with it.
|
||||
IReadOnlyList<ChatMessage> drained = this._ux.TakeFollowUpResponses();
|
||||
nextMessages = drained.Count > 0 ? [.. drained] : null;
|
||||
}
|
||||
|
||||
this.CompleteTurn();
|
||||
}
|
||||
|
||||
private void CompleteTurn()
|
||||
{
|
||||
this._ux.EndStreaming();
|
||||
this._ux.CurrentMode = this._modeProvider?.GetMode(this._session);
|
||||
}
|
||||
|
||||
/// <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 void SyncQueuedMessageDisplay(ref IReadOnlyList<ChatMessage> lastPendingMessages)
|
||||
{
|
||||
if (this._messageInjector is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pending = this._messageInjector.GetPendingMessages(this._session);
|
||||
|
||||
int consumedCount = lastPendingMessages.Count - pending.Count;
|
||||
for (int i = 0; i < consumedCount && i < lastPendingMessages.Count; i++)
|
||||
{
|
||||
string text = lastPendingMessages[i].Text ?? string.Empty;
|
||||
this._ux.WriteUserInputEcho(text);
|
||||
}
|
||||
|
||||
lastPendingMessages = pending;
|
||||
this._ux.SetQueuedMessages(pending);
|
||||
}
|
||||
}
|
||||
@@ -3,171 +3,89 @@
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
using Harness.Shared.Console.Components;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
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.
|
||||
/// and bottom panel (text input, list selection, or streaming indicator). Owns the
|
||||
/// <see cref="HarnessConsoleUXStateDriver"/> and routes user input events to the
|
||||
/// registered <see cref="HarnessAgentRunner"/>.
|
||||
/// </summary>
|
||||
public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentProps, HarnessAppComponentState>, IDisposable
|
||||
public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps, 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 TextScrollPanel _textScrollPanel = new();
|
||||
private readonly TextPanel _textPanel = new();
|
||||
private readonly TextPanel _queuedPanel = new();
|
||||
private readonly AgentStatus _agentStatus = new();
|
||||
private readonly AgentModeAndHelp _modeAndHelp = new();
|
||||
private readonly Func<object, string> _renderItem;
|
||||
private bool _resizedSinceLastRender;
|
||||
private readonly HarnessConsoleUXStateDriver _uxDriver;
|
||||
private readonly TaskCompletionSource<bool> _shutdownTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly SemaphoreSlim _followUpGate = new(1, 1);
|
||||
private int _scrollRegionBottom;
|
||||
private bool _resizedSinceLastRender = true;
|
||||
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)
|
||||
/// <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="runnerFactory">Factory invoked with the component's <see cref="IUXStateDriver"/>
|
||||
/// to construct the <see cref="HarnessAgentRunner"/> that owns the agent loop.</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public HarnessAppComponent(
|
||||
string placeholder,
|
||||
string? initialMode,
|
||||
bool inputEnabled,
|
||||
Func<IUXStateDriver, HarnessAgentRunner> runnerFactory,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._renderItem = renderScrollItem;
|
||||
this._textScrollPanel = new TextScrollPanel(renderScrollItem);
|
||||
this._textPanel = new TextPanel(renderScrollItem);
|
||||
this._queuedPanel = new TextPanel(renderScrollItem);
|
||||
this.Props = new ConsoleReactiveProps();
|
||||
this.State = new HarnessAppComponentState
|
||||
{
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
Prompt = "> ",
|
||||
Placeholder = placeholder,
|
||||
ModeColor = ModeColors.Get(initialMode, modeColors),
|
||||
ModeText = initialMode,
|
||||
InputEnabled = inputEnabled,
|
||||
ConsoleWidth = System.Console.WindowWidth,
|
||||
ConsoleHeight = System.Console.WindowHeight,
|
||||
};
|
||||
|
||||
this._uxDriver = new HarnessConsoleUXStateDriver(
|
||||
getState: () => this.State!,
|
||||
setState: s => this.SetState(s),
|
||||
requestShutdown: () => this._shutdownTcs.TrySetResult(true),
|
||||
modeColors: modeColors);
|
||||
|
||||
this.Runner = runnerFactory(this._uxDriver);
|
||||
|
||||
// Seed help text now that the runner (which knows the registered command handlers)
|
||||
// is available. Direct assignment — no Render is triggered until the caller invokes Render().
|
||||
this.State = this.State with { HelpText = this.Runner.HelpText };
|
||||
|
||||
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.
|
||||
/// Gets the agent runner that owns the agent loop. Constructed by the factory
|
||||
/// passed to the component's constructor.
|
||||
/// </summary>
|
||||
public int ScrollRegionBottom { get; private set; }
|
||||
public HarnessAgentRunner Runner { get; }
|
||||
|
||||
/// <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.
|
||||
/// Completes when a command handler requests application shutdown (e.g. the user types <c>/exit</c>).
|
||||
/// Awaited by <see cref="HarnessConsole.RunAgentAsync"/>.
|
||||
/// </summary>
|
||||
public event EventHandler<InputSubmittedEventArgs>? InputSubmitted;
|
||||
public Task ShutdownTask => this._shutdownTcs.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates the component, resetting the scroll region and unsubscribing from events.
|
||||
@@ -184,9 +102,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
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/>
|
||||
@@ -205,20 +120,23 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
if (disposing)
|
||||
{
|
||||
this.Deactivate();
|
||||
this._followUpGate.Dispose();
|
||||
this.Runner.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnKeyPressed(object? sender, KeyPressEventArgs e)
|
||||
{
|
||||
if (this.Props!.Mode == BottomPanelMode.TextInput)
|
||||
BottomPanelMode mode = this.State!.Mode;
|
||||
if (mode == BottomPanelMode.TextInput)
|
||||
{
|
||||
this.HandleTextInputKey(e);
|
||||
}
|
||||
else if (this.Props.Mode == BottomPanelMode.ListSelection)
|
||||
else if (mode == BottomPanelMode.ListSelection)
|
||||
{
|
||||
this.HandleListSelectionKey(e);
|
||||
}
|
||||
else if (this.Props.Mode == BottomPanelMode.Streaming && this.Props.InputEnabled)
|
||||
else if (mode == BottomPanelMode.Streaming && this.State.InputEnabled)
|
||||
{
|
||||
this.HandleStreamingInputKey(e);
|
||||
}
|
||||
@@ -235,7 +153,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
}
|
||||
|
||||
this.SetState(this.State with { InputText = "" });
|
||||
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.TextInput));
|
||||
this.DispatchTextInputSubmission(text);
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
@@ -252,51 +170,50 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
|
||||
private void HandleListSelectionKey(KeyPressEventArgs e)
|
||||
{
|
||||
int maxIndex = this.Props!.Items.Count - 1;
|
||||
if (this.Props.ListCustomTextPlaceholder != null)
|
||||
int maxIndex = this.State!.ListSelectionOptions.Count - 1;
|
||||
if (this.State.ListSelectionCustomTextPlaceholder != null)
|
||||
{
|
||||
maxIndex = this.Props.Items.Count;
|
||||
maxIndex = this.State.ListSelectionOptions.Count;
|
||||
}
|
||||
|
||||
bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null
|
||||
&& this.State!.SelectedIndex == this.Props.Items.Count;
|
||||
bool isOnCustomTextOption = this.State.ListSelectionCustomTextPlaceholder != null
|
||||
&& this.State.ListSelectionIndex == this.State.ListSelectionOptions.Count;
|
||||
|
||||
if (e.KeyInfo.Key == ConsoleKey.UpArrow)
|
||||
{
|
||||
this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) });
|
||||
this.SetState(this.State with { ListSelectionIndex = Math.Max(0, this.State.ListSelectionIndex - 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.DownArrow)
|
||||
{
|
||||
this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) });
|
||||
this.SetState(this.State with { ListSelectionIndex = Math.Min(maxIndex, this.State.ListSelectionIndex + 1) });
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Enter)
|
||||
{
|
||||
string result = isOnCustomTextOption
|
||||
? this.State!.ListInputText
|
||||
: this.Props.Items[this.State!.SelectedIndex];
|
||||
? this.State.ListSelectionCustomInputText
|
||||
: this.State.ListSelectionOptions[this.State.ListSelectionIndex];
|
||||
|
||||
this.SetState(this.State with { ListInputText = "", SelectedIndex = 0 });
|
||||
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(result, BottomPanelMode.ListSelection));
|
||||
this.SetState(this.State with { ListSelectionCustomInputText = "", ListSelectionIndex = 0 });
|
||||
this.DispatchListSelectionSubmission(result);
|
||||
}
|
||||
else if (isOnCustomTextOption)
|
||||
{
|
||||
if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (this.State!.ListInputText.Length > 0)
|
||||
if (this.State.ListSelectionCustomInputText.Length > 0)
|
||||
{
|
||||
this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] });
|
||||
this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText[..^1] });
|
||||
}
|
||||
}
|
||||
else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar))
|
||||
{
|
||||
this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar });
|
||||
this.SetState(this.State with { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText + 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;
|
||||
@@ -306,7 +223,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
}
|
||||
|
||||
this.SetState(this.State with { InputText = "" });
|
||||
this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.Streaming));
|
||||
_ = this.Runner.OnStreamingInputAsync(text);
|
||||
}
|
||||
else if (e.KeyInfo.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
@@ -321,6 +238,90 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
}
|
||||
}
|
||||
|
||||
private void DispatchTextInputSubmission(string text)
|
||||
{
|
||||
if (this.State!.PendingQuestions.Count > 0)
|
||||
{
|
||||
_ = this.HandleFollowUpAnswerAsync(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = this.Runner.OnUserInputAsync(text);
|
||||
}
|
||||
}
|
||||
|
||||
private void DispatchListSelectionSubmission(string text)
|
||||
{
|
||||
// List selection is only used to answer FollowUpQuestions.
|
||||
_ = this.HandleFollowUpAnswerAsync(text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a user answer to the head of the pending follow-up question queue:
|
||||
/// awaits the question's continuation (which is responsible for echoing both the
|
||||
/// question and answer to the scroll area as it sees fit), appends any returned
|
||||
/// chat message to the response accumulator, advances the queue, and — when the
|
||||
/// queue empties — drains the accumulator and resumes the runner.
|
||||
/// </summary>
|
||||
private async Task HandleFollowUpAnswerAsync(string text)
|
||||
{
|
||||
IReadOnlyList<ChatMessage>? messagesToSend = null;
|
||||
|
||||
await this._followUpGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
HarnessConsoleUXStateDriver ux = this._uxDriver;
|
||||
IReadOnlyList<FollowUpQuestion> queue = this.State!.PendingQuestions;
|
||||
if (queue.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FollowUpQuestion head = queue[0];
|
||||
|
||||
ChatMessage? response;
|
||||
try
|
||||
{
|
||||
response = await head.Continuation(text, ux).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"❌ Follow-up handler error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false);
|
||||
response = null;
|
||||
}
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
ux.AddFollowUpResponse(response);
|
||||
}
|
||||
|
||||
ux.AdvanceFollowUpQuestion();
|
||||
|
||||
if (this.State!.PendingQuestions.Count == 0)
|
||||
{
|
||||
messagesToSend = ux.TakeFollowUpResponses();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._followUpGate.Release();
|
||||
}
|
||||
|
||||
// Resume the agent outside the gate — StartAgentTurnAsync runs the full agent
|
||||
// loop which may queue new follow-up questions (re-entering this method).
|
||||
if (messagesToSend is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.Runner.StartAgentTurnAsync([.. messagesToSend]).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await this._uxDriver.WriteInfoLineAsync($"❌ Agent error: {ex.GetType().Name}: {ex.Message}", ConsoleColor.Red).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e)
|
||||
{
|
||||
this._resizedSinceLastRender = true;
|
||||
@@ -332,35 +333,40 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void RenderCore(HarnessAppComponentProps props, HarnessAppComponentState state)
|
||||
public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentState state)
|
||||
{
|
||||
if (this._deactivated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the text panel height for the last scroll item
|
||||
IReadOnlyList<object> lastItems = props.ScrollItems.Count > 0
|
||||
? [props.ScrollItems[^1]]
|
||||
IReadOnlyList<string> lastItems = state.ScrollAreaContentItems.Count > 0
|
||||
? [state.ScrollAreaContentItems[^1]]
|
||||
: [];
|
||||
int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem);
|
||||
int textPanelHeight = TextPanel.CalculateHeight(lastItems);
|
||||
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);
|
||||
int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems);
|
||||
|
||||
// Build the bottom panel child based on mode
|
||||
ConsoleReactiveComponent bottomChild;
|
||||
int bottomChildHeight;
|
||||
|
||||
if (props.Mode == BottomPanelMode.ListSelection)
|
||||
if (state.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,
|
||||
Title = state.ListSelectionTitle,
|
||||
Items = state.ListSelectionOptions,
|
||||
SelectedIndex = state.ListSelectionIndex,
|
||||
HighlightColor = state.ListHighlightColor,
|
||||
CustomTextPlaceholder = state.ListSelectionCustomTextPlaceholder,
|
||||
CustomText = state.ListSelectionCustomInputText,
|
||||
};
|
||||
|
||||
bottomChildHeight = ListSelection.CalculateHeight(listProps);
|
||||
@@ -368,25 +374,25 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
this._listSelection.Props = listProps;
|
||||
bottomChild = this._listSelection;
|
||||
}
|
||||
else if (props.Mode == BottomPanelMode.Streaming)
|
||||
else if (state.Mode == BottomPanelMode.Streaming)
|
||||
{
|
||||
TextInputProps textInputProps;
|
||||
if (props.InputEnabled)
|
||||
if (state.InputEnabled)
|
||||
{
|
||||
textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = props.Prompt,
|
||||
Prompt = state.Prompt,
|
||||
Text = state.InputText,
|
||||
Placeholder = props.Placeholder,
|
||||
Placeholder = state.Placeholder,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = props.Prompt,
|
||||
Prompt = state.Prompt,
|
||||
Text = "",
|
||||
Placeholder = props.StreamingPrompt,
|
||||
Placeholder = state.StreamingPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -400,9 +406,9 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
{
|
||||
var textInputProps = new TextInputProps
|
||||
{
|
||||
Prompt = props.Prompt,
|
||||
Prompt = state.Prompt,
|
||||
Text = state.InputText,
|
||||
Placeholder = props.Placeholder,
|
||||
Placeholder = state.Placeholder,
|
||||
};
|
||||
|
||||
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
|
||||
@@ -415,46 +421,52 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
var ruleProps = new TopBottomRuleProps
|
||||
{
|
||||
Width = state.ConsoleWidth,
|
||||
Color = props.ModeColor,
|
||||
Color = state.ModeColor,
|
||||
Children = [bottomChild],
|
||||
};
|
||||
|
||||
// Calculate the agent status height
|
||||
var agentStatusProps = new AgentStatusProps
|
||||
{
|
||||
ShowSpinner = props.ShowSpinner,
|
||||
UsageText = props.UsageText,
|
||||
ShowSpinner = state.ShowSpinner,
|
||||
UsageText = state.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,
|
||||
Mode = state.ModeText,
|
||||
ModeColor = state.ModeColor,
|
||||
HelpText = state.HelpText,
|
||||
};
|
||||
int modeAndHelpHeight = AgentModeAndHelp.CalculateHeight(modeAndHelpProps);
|
||||
|
||||
// Hide agent status and mode/help during follow-up questions (ListSelection mode)
|
||||
// as they clutter the UI and aren't relevant.
|
||||
bool showStatusAndHelp = state.Mode != BottomPanelMode.ListSelection;
|
||||
int agentStatusHeight = showStatusAndHelp ? AgentStatus.CalculateHeight(agentStatusProps) : 0;
|
||||
int modeAndHelpHeight = showStatusAndHelp ? AgentModeAndHelp.CalculateHeight(modeAndHelpProps) : 0;
|
||||
|
||||
int ruleHeight = TopBottomRule.CalculateHeight(ruleProps);
|
||||
int scrollBottom = Math.Max(1, state.ConsoleHeight - ruleHeight - textPanelHeight - agentStatusHeight - queuedPanelHeight - modeAndHelpHeight);
|
||||
int nonScrollHeight = ruleHeight + textPanelHeight + agentStatusHeight + queuedPanelHeight + modeAndHelpHeight + 1; // +1 for bottom padding
|
||||
int scrollBottom = Math.Max(1, state.ConsoleHeight - nonScrollHeight);
|
||||
|
||||
// If scroll region changed or a clear is needed, reset everything
|
||||
if (this._resizedSinceLastRender || (this.ScrollRegionBottom != 0 && scrollBottom != this.ScrollRegionBottom))
|
||||
if (this._resizedSinceLastRender || (this._scrollRegionBottom != 0 && scrollBottom != this._scrollRegionBottom))
|
||||
{
|
||||
// Reset scroll region to full screen before erasing so the erase covers all rows —
|
||||
// some terminals only erase within the active DECSTBM region.
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
|
||||
this._textScrollPanel.Reset();
|
||||
this._resizedSinceLastRender = false;
|
||||
}
|
||||
|
||||
this.ScrollRegionBottom = scrollBottom;
|
||||
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()
|
||||
IReadOnlyList<string> scrollItems = state.ScrollAreaContentItems.Count > 1
|
||||
? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList()
|
||||
: [];
|
||||
|
||||
this._textScrollPanel.X = 1;
|
||||
@@ -486,18 +498,21 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
this._queuedPanel.Height = queuedPanelHeight;
|
||||
this._queuedPanel.Props = new TextPanelProps
|
||||
{
|
||||
Items = props.QueuedItems,
|
||||
Items = state.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();
|
||||
if (showStatusAndHelp)
|
||||
{
|
||||
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;
|
||||
@@ -506,24 +521,27 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
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();
|
||||
if (showStatusAndHelp)
|
||||
{
|
||||
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);
|
||||
this.PositionCursor(state);
|
||||
}
|
||||
|
||||
private void PositionCursor(HarnessAppComponentProps props, HarnessAppComponentState state)
|
||||
private void PositionCursor(HarnessAppComponentState state)
|
||||
{
|
||||
if (props.Mode == BottomPanelMode.TextInput
|
||||
|| (props.Mode == BottomPanelMode.Streaming && props.InputEnabled))
|
||||
if (state.Mode == BottomPanelMode.TextInput
|
||||
|| (state.Mode == BottomPanelMode.Streaming && state.InputEnabled))
|
||||
{
|
||||
int promptLength = props.Prompt.Length;
|
||||
int promptLength = state.Prompt.Length;
|
||||
int textWidth = state.ConsoleWidth - promptLength;
|
||||
int textLength = state.InputText.Length;
|
||||
|
||||
@@ -540,13 +558,13 @@ public class HarnessAppComponent : ConsoleReactiveComponent<HarnessAppComponentP
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1));
|
||||
}
|
||||
}
|
||||
else if (props.Mode == BottomPanelMode.ListSelection
|
||||
&& props.ListCustomTextPlaceholder != null
|
||||
&& state.SelectedIndex == props.Items.Count)
|
||||
else if (state.Mode == BottomPanelMode.ListSelection
|
||||
&& state.ListSelectionCustomTextPlaceholder != null
|
||||
&& state.ListSelectionIndex == state.ListSelectionOptions.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;
|
||||
int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0;
|
||||
int customOptionY = this._rule.Y + 1 + titleLines + state.ListSelectionOptions.Count;
|
||||
int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1;
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveFramework;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
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>
|
||||
/// Internal state for <see cref="HarnessAppComponent"/>. All UI fields that may
|
||||
/// change after construction live here; they are mutated exclusively via
|
||||
/// <see cref="ConsoleReactiveComponent{TProps,TState}.SetState"/> by the
|
||||
/// owning <see cref="HarnessConsoleUXStateDriver"/>.
|
||||
/// </summary>
|
||||
public record HarnessAppComponentState : ConsoleReactiveState
|
||||
{
|
||||
// --- Console dimensions ---
|
||||
|
||||
/// <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; }
|
||||
|
||||
// --- Bottom panel mode ---
|
||||
|
||||
/// <summary>Gets the bottom panel mode.</summary>
|
||||
public BottomPanelMode Mode { get; init; } = BottomPanelMode.TextInput;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the queue of follow-up questions waiting for user answers. The head
|
||||
/// (<c>[0]</c>) is the question currently being displayed; subsequent items
|
||||
/// are dispatched in order as each is answered. While this queue is non-empty,
|
||||
/// the next user submission is treated as the answer to the head question
|
||||
/// instead of going to the agent runner's normal input handler.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FollowUpQuestion> PendingQuestions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accumulated follow-up response messages collected during the
|
||||
/// current agent turn — both direct <see cref="FollowUpMessage"/>s emitted
|
||||
/// by observers and continuation results from answered questions. Consumed
|
||||
/// by the runner via <see cref="IUXStateDriver.TakeFollowUpResponses"/>
|
||||
/// before the next agent invocation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessage> AccumulatedFollowUpResponses { get; init; } = [];
|
||||
|
||||
// --- Text input (active in TextInput / Streaming modes) ---
|
||||
|
||||
/// <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 current input text being typed.</summary>
|
||||
public string InputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets a value indicating whether input is enabled during streaming.</summary>
|
||||
public bool InputEnabled { get; init; }
|
||||
|
||||
/// <summary>Gets the prompt to show during streaming when input is disabled.</summary>
|
||||
public string StreamingPrompt { get; init; } = "(agent is running...)";
|
||||
|
||||
// --- List selection (active in ListSelection mode) ---
|
||||
|
||||
/// <summary>Gets the title text displayed above the list selection (for interactive prompts).</summary>
|
||||
public string? ListSelectionTitle { get; init; }
|
||||
|
||||
/// <summary>Gets the list selection options.</summary>
|
||||
public IReadOnlyList<string> ListSelectionOptions { get; init; } = [];
|
||||
|
||||
/// <summary>Gets the highlighted option index in list selection mode.</summary>
|
||||
public int ListSelectionIndex { get; init; }
|
||||
|
||||
/// <summary>Gets the placeholder text for the custom text input option in the list.</summary>
|
||||
public string? ListSelectionCustomTextPlaceholder { get; init; }
|
||||
|
||||
/// <summary>Gets the current text being typed into the list's custom text option.</summary>
|
||||
public string ListSelectionCustomInputText { get; init; } = "";
|
||||
|
||||
/// <summary>Gets the highlight color for the active list item.</summary>
|
||||
public ConsoleColor ListHighlightColor { get; init; } = ConsoleColor.Cyan;
|
||||
|
||||
// --- Scroll / output area ---
|
||||
|
||||
/// <summary>Gets the items rendered in the scroll-area. Each item is a pre-rendered
|
||||
/// console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> ScrollAreaContentItems { get; init; } = [];
|
||||
|
||||
/// <summary>Gets the queued input items to display above the rule. Each item is a
|
||||
/// pre-rendered console string (may include ANSI escape sequences and newlines).</summary>
|
||||
public IReadOnlyList<string> QueuedItems { get; init; } = [];
|
||||
|
||||
// --- Agent mode + status display ---
|
||||
|
||||
/// <summary>Gets the foreground color for the rule borders and mode label.</summary>
|
||||
public ConsoleColor? ModeColor { get; init; }
|
||||
|
||||
/// <summary>Gets the current mode name displayed below the bottom rule (e.g. "plan").</summary>
|
||||
public string? ModeText { get; init; }
|
||||
|
||||
/// <summary>Gets the help text displayed below the bottom rule (available commands).</summary>
|
||||
public string? HelpText { get; init; }
|
||||
|
||||
/// <summary>Gets a value indicating whether the agent status spinner is visible.</summary>
|
||||
public bool ShowSpinner { get; init; }
|
||||
|
||||
/// <summary>Gets the formatted token usage text to display in the status bar.</summary>
|
||||
public string? UsageText { get; init; }
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.Shared.Console.Commands;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
@@ -15,244 +13,58 @@ public static class HarnessConsole
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs an interactive console session with the specified agent.
|
||||
/// Supports streaming output, tool call display, spinner animation,
|
||||
/// optional planning UX with structured output, and the <c>/todos</c> command.
|
||||
/// Constructs the reactive UI component and the <see cref="HarnessAgentRunner"/>,
|
||||
/// wires them together, and awaits the component's <see cref="HarnessAppComponent.ShutdownTask"/>
|
||||
/// (which completes when the user types <c>/exit</c>).
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent to interact with.</param>
|
||||
/// <param name="title">The title displayed in the console header.</param>
|
||||
/// <param name="userPrompt">A short prompt to the user, displayed below the title.</param>
|
||||
/// <param name="userPrompt">A short prompt to the user, displayed as a placeholder in the input area.</param>
|
||||
/// <param name="options">Optional configuration options for the console session.</param>
|
||||
public static async Task RunAgentAsync(AIAgent agent, string title, string userPrompt, HarnessConsoleOptions? options = null)
|
||||
public static async Task RunAgentAsync(AIAgent agent, string userPrompt, HarnessConsoleOptions? options = null)
|
||||
{
|
||||
options ??= new();
|
||||
|
||||
if (options.EnablePlanningUx
|
||||
&& (string.IsNullOrWhiteSpace(options.PlanningModeName) || string.IsNullOrWhiteSpace(options.ExecutionModeName)))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"When EnablePlanningUx is true, both PlanningModeName and ExecutionModeName must be configured.",
|
||||
nameof(options));
|
||||
}
|
||||
// Null means use defaults; an explicit (possibly empty) list means use exactly what was provided.
|
||||
var observers = options.Observers
|
||||
?? HarnessConsoleOptions.BuildDefaultObservers();
|
||||
var commandHandlers = options.CommandHandlers
|
||||
?? HarnessConsoleOptions.BuildDefaultCommandHandlers(agent, options.ModeColors);
|
||||
|
||||
var todoProvider = agent.GetService<TodoProvider>();
|
||||
var modeProvider = agent.GetService<AgentModeProvider>();
|
||||
var messageInjector = agent.GetService<MessageInjectingChatClient>();
|
||||
|
||||
var commandHandlers = new List<CommandHandler>
|
||||
{
|
||||
new TodoCommandHandler(todoProvider),
|
||||
new ModeCommandHandler(modeProvider, options.ModeColors),
|
||||
};
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
using var ux = new HarnessUXContainer(
|
||||
using var component = new HarnessAppComponent(
|
||||
placeholder: userPrompt,
|
||||
initialMode: modeProvider?.GetMode(session),
|
||||
inputEnabled: messageInjector is not null,
|
||||
runnerFactory: ux => new HarnessAgentRunner(
|
||||
agent: agent,
|
||||
session: session,
|
||||
modeProvider: modeProvider,
|
||||
messageInjector: messageInjector,
|
||||
commandHandlers: commandHandlers,
|
||||
observers: observers,
|
||||
ux: ux),
|
||||
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) =>
|
||||
// Trigger the initial render of the component now that state is seeded.
|
||||
component.Render();
|
||||
|
||||
try
|
||||
{
|
||||
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)")!;
|
||||
|
||||
ux.Initialize(title, commandHelp!, messageInjector is not null);
|
||||
|
||||
string userInput = await ux.WaitForInputAsync();
|
||||
|
||||
while (!string.IsNullOrWhiteSpace(userInput) && !userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
await component.ShutdownTask.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
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, ux).ConfigureAwait(false))
|
||||
{
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!handled)
|
||||
{
|
||||
await RunAgentTurnAsync(agent, session, modeProvider, messageInjector, options, ux, userInput);
|
||||
}
|
||||
|
||||
ux.CurrentMode = modeProvider?.GetMode(session);
|
||||
userInput = await ux.WaitForInputAsync();
|
||||
component.Deactivate();
|
||||
}
|
||||
|
||||
ux.Deactivate();
|
||||
System.Console.ResetColor();
|
||||
System.Console.Write(AnsiEscapes.ResetScrollRegion);
|
||||
System.Console.Write(AnsiEscapes.EraseEntireScreen);
|
||||
System.Console.Write(AnsiEscapes.MoveCursor(1, 1));
|
||||
System.Console.WriteLine("Goodbye!");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one or more agent invocations for a single user turn, using the current
|
||||
/// observers. Re-invokes automatically for tool approvals and mode-driven follow-ups
|
||||
/// (e.g., planning clarification loops).
|
||||
/// </summary>
|
||||
private static async Task RunAgentTurnAsync(
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
AgentModeProvider? modeProvider,
|
||||
MessageInjectingChatClient? messageInjector,
|
||||
HarnessConsoleOptions options,
|
||||
HarnessUXContainer ux,
|
||||
string userInput)
|
||||
{
|
||||
IList<ChatMessage>? nextMessages = [new ChatMessage(ChatRole.User, userInput)];
|
||||
IReadOnlyList<ChatMessage> lastPendingMessages = messageInjector?.GetPendingMessages(session) ?? [];
|
||||
|
||||
while (nextMessages is not null)
|
||||
{
|
||||
var observers = CreateObservers(options, modeProvider, session);
|
||||
|
||||
var runOptions = new AgentRunOptions();
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
observer.ConfigureRunOptions(runOptions);
|
||||
}
|
||||
|
||||
ux.CurrentMode = modeProvider?.GetMode(session);
|
||||
ux.BeginStreaming();
|
||||
ux.BeginStreamingOutput();
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var update in agent.RunStreamingAsync(nextMessages, session, runOptions))
|
||||
{
|
||||
// Update mode color if the mode changed during streaming.
|
||||
if (modeProvider is not null)
|
||||
{
|
||||
string currentMode = modeProvider.GetMode(session);
|
||||
if (currentMode != ux.CurrentMode)
|
||||
{
|
||||
ux.CurrentMode = currentMode;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
await observer.OnContentAsync(ux, content);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
foreach (var observer in observers)
|
||||
{
|
||||
await observer.OnTextAsync(ux, update.Text);
|
||||
}
|
||||
}
|
||||
|
||||
SyncQueuedMessageDisplay(messageInjector, session, ux, ref lastPendingMessages);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"❌ Stream error: {ex.GetType().Name}:\n{ex}", ConsoleColor.Red);
|
||||
}
|
||||
|
||||
// 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(ux, agent, session, options);
|
||||
if (messages is { Count: > 0 })
|
||||
{
|
||||
combinedMessages.AddRange(messages);
|
||||
hasObserverMessages = true;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
{
|
||||
new ToolCallDisplayObserver(),
|
||||
new ToolApprovalObserver(),
|
||||
new ErrorDisplayObserver(),
|
||||
new ReasoningDisplayObserver(),
|
||||
new UsageDisplayObserver(options.MaxContextWindowTokens, options.MaxOutputTokens),
|
||||
};
|
||||
|
||||
if (options.EnablePlanningUx
|
||||
&& modeProvider is not null
|
||||
&& string.Equals(modeProvider.GetMode(session), options.PlanningModeName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
observers.Add(new PlanningOutputObserver(modeProvider));
|
||||
}
|
||||
else
|
||||
{
|
||||
observers.Add(new TextOutputObserver());
|
||||
}
|
||||
|
||||
return observers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using Harness.Shared.Console.Commands;
|
||||
using Harness.Shared.Console.Observers;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
@@ -8,45 +14,120 @@ namespace Harness.Shared.Console;
|
||||
public class HarnessConsoleOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the optional maximum context window size in tokens.
|
||||
/// When set, token usage is displayed as a percentage of the budget.
|
||||
/// Gets or sets the list of console observers that participate in the agent response
|
||||
/// streaming lifecycle. Use the factory methods on this class to create common observer sets.
|
||||
/// When <see langword="null"/> (the default), a default set of observers is used.
|
||||
/// Set to an empty list to disable all observers.
|
||||
/// </summary>
|
||||
public int? MaxContextWindowTokens { get; set; }
|
||||
public IReadOnlyList<ConsoleObserver>? Observers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional maximum output tokens.
|
||||
/// Used with <see cref="MaxContextWindowTokens"/> to show input/output budget breakdown.
|
||||
/// Gets or sets the list of command handlers to check before sending user input to the agent.
|
||||
/// Use <see cref="BuildDefaultCommandHandlers"/> to create the default set.
|
||||
/// When <see langword="null"/> (the default), a default set of handlers is used.
|
||||
/// Set to an empty list to disable all command handlers.
|
||||
/// </summary>
|
||||
public int? MaxOutputTokens { get; set; }
|
||||
public IReadOnlyList<CommandHandler>? CommandHandlers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the planning UX is enabled.
|
||||
/// When <see langword="true"/> and the agent is in the mode specified by <see cref="PlanningModeName"/>,
|
||||
/// the console uses structured output to present clarification questions and approval requests
|
||||
/// instead of streaming free-form text.
|
||||
/// The default mode-to-color mapping used when no custom <see cref="ModeColors"/> are provided.
|
||||
/// </summary>
|
||||
/// <value>Defaults to <see langword="false"/>.</value>
|
||||
public bool EnablePlanningUx { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the agent mode that activates the planning UX.
|
||||
/// Must be set when <see cref="EnablePlanningUx"/> is <see langword="true"/>.
|
||||
/// </summary>
|
||||
public string? PlanningModeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the agent mode to switch to when the user approves a plan.
|
||||
/// Must be set when <see cref="EnablePlanningUx"/> is <see langword="true"/>.
|
||||
/// </summary>
|
||||
public string? ExecutionModeName { get; set; }
|
||||
public static readonly IReadOnlyDictionary<string, ConsoleColor> DefaultModeColors = new ReadOnlyDictionary<string, ConsoleColor>(
|
||||
new Dictionary<string, ConsoleColor>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["plan"] = ConsoleColor.Cyan,
|
||||
["execute"] = ConsoleColor.Green,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a mapping of agent mode names to console colors.
|
||||
/// When a mode is not found in this dictionary, the default color (<see cref="ConsoleColor.Gray"/>) is used.
|
||||
/// </summary>
|
||||
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(StringComparer.OrdinalIgnoreCase)
|
||||
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default set of observers without planning support.
|
||||
/// Includes tool call display, tool approval, error display, reasoning display,
|
||||
/// usage display, and text output.
|
||||
/// </summary>
|
||||
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
|
||||
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
|
||||
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
|
||||
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
|
||||
/// <returns>A list of observers for a standard (non-planning) console session.</returns>
|
||||
public static List<ConsoleObserver> BuildDefaultObservers(
|
||||
int? maxContextWindowTokens = null,
|
||||
int? maxOutputTokens = null,
|
||||
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
|
||||
{
|
||||
["plan"] = ConsoleColor.Cyan,
|
||||
["execute"] = ConsoleColor.Green,
|
||||
};
|
||||
return
|
||||
[
|
||||
new ToolCallDisplayObserver(toolFormatters),
|
||||
new ToolApprovalObserver(toolFormatters),
|
||||
new ErrorDisplayObserver(),
|
||||
new ReasoningDisplayObserver(),
|
||||
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
|
||||
new TextOutputObserver(),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default set of observers with planning support.
|
||||
/// Includes a <see cref="PlanningOutputObserver"/> instead of <see cref="TextOutputObserver"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent, used to resolve <see cref="AgentModeProvider"/>.</param>
|
||||
/// <param name="planModeName">The mode name that represents the planning mode.</param>
|
||||
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
|
||||
/// <param name="modeColors">Optional mode-to-color mapping for display.
|
||||
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
|
||||
/// <param name="maxContextWindowTokens">Optional maximum context window size in tokens for usage display.</param>
|
||||
/// <param name="maxOutputTokens">Optional maximum output tokens for usage display.</param>
|
||||
/// <param name="toolFormatters">Optional tool call formatters. When <see langword="null"/>,
|
||||
/// each observer uses the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/>.</param>
|
||||
/// <returns>A list of observers for a planning-enabled console session.</returns>
|
||||
public static List<ConsoleObserver> BuildObserversWithPlanning(
|
||||
AIAgent agent,
|
||||
string planModeName,
|
||||
string executionModeName,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null,
|
||||
int? maxContextWindowTokens = null,
|
||||
int? maxOutputTokens = null,
|
||||
IReadOnlyList<ToolCallFormatter>? toolFormatters = null)
|
||||
{
|
||||
var modeProvider = agent.GetService<AgentModeProvider>()
|
||||
?? throw new InvalidOperationException("Planning requires an AgentModeProvider service on the agent.");
|
||||
|
||||
return
|
||||
[
|
||||
new ToolCallDisplayObserver(toolFormatters),
|
||||
new ToolApprovalObserver(toolFormatters),
|
||||
new ErrorDisplayObserver(),
|
||||
new ReasoningDisplayObserver(),
|
||||
new UsageDisplayObserver(maxContextWindowTokens, maxOutputTokens),
|
||||
new PlanningOutputObserver(modeProvider, planModeName, executionModeName, modeColors ?? DefaultModeColors),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default set of command handlers.
|
||||
/// Includes exit, todo, and mode command handlers.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent, used to resolve <see cref="TodoProvider"/> and <see cref="AgentModeProvider"/>.</param>
|
||||
/// <param name="modeColors">Optional mode-to-color mapping for the mode command display.
|
||||
/// Defaults to <see cref="DefaultModeColors"/> when <see langword="null"/>.</param>
|
||||
/// <returns>A list of command handlers for a standard console session.</returns>
|
||||
public static List<CommandHandler> BuildDefaultCommandHandlers(
|
||||
AIAgent agent,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
var todoProvider = agent.GetService<TodoProvider>();
|
||||
var modeProvider = agent.GetService<AgentModeProvider>();
|
||||
|
||||
return
|
||||
[
|
||||
new ExitCommandHandler(),
|
||||
new TodoCommandHandler(todoProvider),
|
||||
new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IUXStateDriver"/> implementation. Owned by
|
||||
/// <see cref="HarnessAppComponent"/>; mutates the component's state via a
|
||||
/// <c>SetState</c>-style callback. Each public operation updates state and lets
|
||||
/// the component's render-skip optimization handle the actual draw.
|
||||
/// </summary>
|
||||
internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
|
||||
{
|
||||
private readonly Func<HarnessAppComponentState> _getState;
|
||||
private readonly Action<HarnessAppComponentState> _setState;
|
||||
private readonly Action _requestShutdown;
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
private readonly List<string> _outputItems = [];
|
||||
private readonly object _stateLock = new();
|
||||
|
||||
private OutputEntryType? _lastEntryType;
|
||||
private bool _hasReceivedAnyText;
|
||||
private OutputEntry? _currentStreamingEntry;
|
||||
private int _currentStreamingEntryIndex = -1;
|
||||
private string? _currentMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HarnessConsoleUXStateDriver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="getState">Returns the component's current state.</param>
|
||||
/// <param name="setState">Replaces the component's state and triggers a re-render.</param>
|
||||
/// <param name="requestShutdown">Callback invoked when a command handler requests application shutdown.</param>
|
||||
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
|
||||
public HarnessConsoleUXStateDriver(
|
||||
Func<HarnessAppComponentState> getState,
|
||||
Action<HarnessAppComponentState> setState,
|
||||
Action requestShutdown,
|
||||
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._getState = getState;
|
||||
this._setState = setState;
|
||||
this._requestShutdown = requestShutdown;
|
||||
this._modeColors = modeColors;
|
||||
this._currentMode = getState().ModeText;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? CurrentMode
|
||||
{
|
||||
get => this._currentMode;
|
||||
set
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
this._currentMode = value;
|
||||
return s with
|
||||
{
|
||||
ModeColor = ModeColors.Get(value, this._modeColors),
|
||||
ModeText = value,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void BeginStreaming() =>
|
||||
this.UpdateState(s => s with
|
||||
{
|
||||
Mode = BottomPanelMode.Streaming,
|
||||
ShowSpinner = true,
|
||||
});
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void StopSpinner() =>
|
||||
this.UpdateState(s => s with { ShowSpinner = false });
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void EndStreaming() =>
|
||||
this.UpdateState(s => s with
|
||||
{
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
ShowSpinner = false,
|
||||
});
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void BeginStreamingOutput()
|
||||
{
|
||||
lock (this._stateLock)
|
||||
{
|
||||
this._hasReceivedAnyText = false;
|
||||
this._currentStreamingEntry = null;
|
||||
this._currentStreamingEntryIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetUsageText(string usageText) =>
|
||||
this.UpdateState(s => s with { UsageText = usageText });
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SetQueuedMessages(IReadOnlyList<ChatMessage> pending)
|
||||
{
|
||||
var newQueued = new List<string>(pending.Count);
|
||||
foreach (var msg in pending)
|
||||
{
|
||||
string text = msg.Text ?? string.Empty;
|
||||
newQueued.Add(RenderEntry($" 💬 {text}\n", ConsoleColor.DarkGray));
|
||||
}
|
||||
|
||||
this.UpdateState(s => s with { QueuedItems = newQueued });
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions)
|
||||
{
|
||||
if (questions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
bool wasEmpty = s.PendingQuestions.Count == 0;
|
||||
|
||||
var combined = new List<FollowUpQuestion>(s.PendingQuestions.Count + questions.Count);
|
||||
combined.AddRange(s.PendingQuestions);
|
||||
combined.AddRange(questions);
|
||||
|
||||
HarnessAppComponentState next = s with { PendingQuestions = combined };
|
||||
|
||||
if (wasEmpty)
|
||||
{
|
||||
next = this.ConfigureForHeadQuestion(next, combined[0]);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddFollowUpResponse(ChatMessage response)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
var combined = new List<ChatMessage>(s.AccumulatedFollowUpResponses.Count + 1);
|
||||
combined.AddRange(s.AccumulatedFollowUpResponses);
|
||||
combined.Add(response);
|
||||
return s with { AccumulatedFollowUpResponses = combined };
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AdvanceFollowUpQuestion()
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
if (s.PendingQuestions.Count == 0)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
var remaining = s.PendingQuestions.Skip(1).ToList();
|
||||
HarnessAppComponentState next = s with { PendingQuestions = remaining };
|
||||
|
||||
if (remaining.Count > 0)
|
||||
{
|
||||
return this.ConfigureForHeadQuestion(next, remaining[0]);
|
||||
}
|
||||
|
||||
return next with
|
||||
{
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
ListSelectionOptions = [],
|
||||
ListSelectionTitle = null,
|
||||
ListSelectionCustomTextPlaceholder = null,
|
||||
ListSelectionIndex = 0,
|
||||
ListSelectionCustomInputText = "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<ChatMessage> TakeFollowUpResponses()
|
||||
{
|
||||
return this.UpdateState(s =>
|
||||
{
|
||||
IReadOnlyList<ChatMessage> responses = s.AccumulatedFollowUpResponses;
|
||||
if (responses.Count == 0)
|
||||
{
|
||||
return (s, responses);
|
||||
}
|
||||
|
||||
return (s with { AccumulatedFollowUpResponses = [] }, responses);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the bottom-panel display fields on the supplied state for the
|
||||
/// given head question. For text questions, also writes the prompt as an
|
||||
/// info line above the input row as a side effect.
|
||||
/// </summary>
|
||||
private HarnessAppComponentState ConfigureForHeadQuestion(HarnessAppComponentState state, FollowUpQuestion question)
|
||||
{
|
||||
if (question is ChoiceFollowUpQuestion choice)
|
||||
{
|
||||
return state with
|
||||
{
|
||||
Mode = BottomPanelMode.ListSelection,
|
||||
ListSelectionOptions = choice.Choices.ToList(),
|
||||
ListSelectionTitle = choice.Prompt,
|
||||
ListSelectionCustomTextPlaceholder = choice.AllowCustomText ? "✏️ Type a custom response..." : null,
|
||||
ListSelectionIndex = 0,
|
||||
ListSelectionCustomInputText = "",
|
||||
};
|
||||
}
|
||||
|
||||
// Text question — prompt is rendered as an info line above the input row.
|
||||
// We append entries and capture the scroll snapshot inline so the caller's
|
||||
// single _setState picks up both the new output and the UI mode change.
|
||||
ConsoleColor ruleColor = ModeColors.Get(this._currentMode, this._modeColors);
|
||||
List<string> scrollSnapshot = this.AppendOutputEntriesAndSnapshot(
|
||||
new OutputEntry(OutputEntryType.InfoLine, "\n", ruleColor),
|
||||
new OutputEntry(OutputEntryType.InfoLine, $" {question.Prompt}", ruleColor));
|
||||
|
||||
return state with
|
||||
{
|
||||
Mode = BottomPanelMode.TextInput,
|
||||
ListSelectionOptions = [],
|
||||
ListSelectionTitle = null,
|
||||
ListSelectionCustomTextPlaceholder = null,
|
||||
ListSelectionIndex = 0,
|
||||
ListSelectionCustomInputText = "",
|
||||
ScrollAreaContentItems = scrollSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void WriteUserInputEcho(string text)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||
OutputEntryType.UserInput,
|
||||
$"\nYou: {text}\n\n",
|
||||
ConsoleColor.Green));
|
||||
return s with { ScrollAreaContentItems = snapshot };
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task WriteInfoAsync(string text, ConsoleColor? color = null) =>
|
||||
this.WriteInfoCoreAsync(text, color, newLine: false);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task WriteInfoLineAsync(string text, ConsoleColor? color = null) =>
|
||||
this.WriteInfoCoreAsync(text, color, newLine: true);
|
||||
|
||||
private Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
// Add a blank line separator when transitioning from streaming text or user input.
|
||||
string prefix = this._lastEntryType is OutputEntryType.StreamingText or OutputEntryType.StreamFooter
|
||||
? "\n "
|
||||
: " ";
|
||||
|
||||
string fullText = newLine ? prefix + text + "\n\n" : prefix + text;
|
||||
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||
OutputEntryType.InfoLine,
|
||||
fullText,
|
||||
color ?? ModeColors.Get(this._currentMode, this._modeColors)));
|
||||
return s with { ScrollAreaContentItems = snapshot };
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task WriteTextAsync(string text, ConsoleColor? color = null)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
this._lastEntryType = OutputEntryType.StreamingText;
|
||||
this._hasReceivedAnyText = true;
|
||||
|
||||
ConsoleColor effectiveColor = color ?? ModeColors.Get(this._currentMode, this._modeColors);
|
||||
|
||||
if (this._currentStreamingEntry is not null
|
||||
&& this._currentStreamingEntryIndex == this._outputItems.Count - 1)
|
||||
{
|
||||
// The streaming entry is still the last item — safe to replace in place.
|
||||
this._currentStreamingEntry = this._currentStreamingEntry with
|
||||
{
|
||||
Text = this._currentStreamingEntry.Text + text,
|
||||
};
|
||||
this._outputItems[^1] = RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Either the first text delta or other entries (tool calls, info lines)
|
||||
// were appended after the previous streaming entry — start a fresh one.
|
||||
const string Prefix = "\n";
|
||||
this._currentStreamingEntry = new OutputEntry(OutputEntryType.StreamingText, Prefix + text, effectiveColor);
|
||||
this._outputItems.Add(RenderEntry(this._currentStreamingEntry.Text, this._currentStreamingEntry.Color));
|
||||
this._currentStreamingEntryIndex = this._outputItems.Count - 1;
|
||||
}
|
||||
|
||||
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task EndStreamingOutputAsync()
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
if (this._hasReceivedAnyText)
|
||||
{
|
||||
this._outputItems.Add(RenderEntry("\n", null));
|
||||
this._currentStreamingEntry = null;
|
||||
this._lastEntryType = OutputEntryType.StreamFooter;
|
||||
return s with { ScrollAreaContentItems = new List<string>(this._outputItems) };
|
||||
}
|
||||
|
||||
return s;
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task WriteNoTextWarningAsync(bool hasFollowUpActions)
|
||||
{
|
||||
if (!this._hasReceivedAnyText && !hasFollowUpActions)
|
||||
{
|
||||
this.UpdateState(s =>
|
||||
{
|
||||
List<string> snapshot = this.AppendOutputEntriesAndSnapshot(new OutputEntry(
|
||||
OutputEntryType.StreamFooter,
|
||||
" (no text response from agent)\n",
|
||||
ConsoleColor.DarkYellow));
|
||||
return s with { ScrollAreaContentItems = snapshot };
|
||||
});
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the supplied text with ANSI foreground color escape sequences (or returns
|
||||
/// the text unchanged when no color is specified). Output is appended to
|
||||
/// <see cref="_outputItems"/> and consumed verbatim by <see cref="TextScrollPanel"/>
|
||||
/// and <see cref="TextPanel"/>.
|
||||
/// </summary>
|
||||
private static string RenderEntry(string text, ConsoleColor? color) =>
|
||||
color.HasValue
|
||||
? $"{AnsiEscapes.SetForegroundColor(color.Value)}{text}{AnsiEscapes.ResetAttributes}"
|
||||
: text;
|
||||
|
||||
private void UpdateState(Func<HarnessAppComponentState, HarnessAppComponentState> update)
|
||||
{
|
||||
lock (this._stateLock)
|
||||
{
|
||||
this._setState(update(this._getState()));
|
||||
}
|
||||
}
|
||||
|
||||
private T UpdateState<T>(Func<HarnessAppComponentState, (HarnessAppComponentState State, T Result)> update)
|
||||
{
|
||||
lock (this._stateLock)
|
||||
{
|
||||
var (newState, result) = update(this._getState());
|
||||
this._setState(newState);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one or more output entries to the output list, updates
|
||||
/// <see cref="_lastEntryType"/> to the last entry's type, and returns a
|
||||
/// snapshot of <see cref="_outputItems"/>. Must be called inside a locked
|
||||
/// context (e.g. within an <see cref="UpdateState"/> callback).
|
||||
/// </summary>
|
||||
private List<string> AppendOutputEntriesAndSnapshot(params OutputEntry[] entries)
|
||||
{
|
||||
this.AppendOutputEntriesCore(entries);
|
||||
return new List<string>(this._outputItems);
|
||||
}
|
||||
|
||||
private void AppendOutputEntriesCore(OutputEntry[] entries)
|
||||
{
|
||||
foreach (OutputEntry entry in entries)
|
||||
{
|
||||
this._outputItems.Add(RenderEntry(entry.Text, entry.Color));
|
||||
}
|
||||
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
this._lastEntryType = entries[^1].Type;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RequestShutdown() => this._requestShutdown();
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the harness UI state. All callers (observers, command handlers,
|
||||
/// the agent runner) interact with the UI exclusively through this interface, which
|
||||
/// internally translates each operation into a <c>SetState</c> call on the underlying
|
||||
/// reactive component.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface is intentionally narrow: it does not expose blocking input methods.
|
||||
/// The agent runner orchestrates input flow via <see cref="FollowUpQuestion"/>
|
||||
/// objects returned from observers.
|
||||
/// </remarks>
|
||||
public interface IUXStateDriver
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current agent mode (e.g. "plan", "execute"). Setting also
|
||||
/// refreshes the rule colour and bottom-panel prompt to match the new mode.
|
||||
/// </summary>
|
||||
string? CurrentMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Echoes a submitted user input as a regular user-input entry in the output area.
|
||||
/// </summary>
|
||||
void WriteUserInputEcho(string text);
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output as an output entry, without a trailing newline.
|
||||
/// </summary>
|
||||
Task WriteInfoAsync(string text, ConsoleColor? color = null);
|
||||
|
||||
/// <summary>
|
||||
/// Writes informational output as an output entry, followed by a newline.
|
||||
/// </summary>
|
||||
Task WriteInfoLineAsync(string text, ConsoleColor? color = null);
|
||||
|
||||
/// <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>
|
||||
Task WriteTextAsync(string text, ConsoleColor? color = null);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a blank-line separator to visually close the streaming output section.
|
||||
/// </summary>
|
||||
Task EndStreamingOutputAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Shows a "(no text response from agent)" warning if no text was received
|
||||
/// and no observer produced follow-up actions.
|
||||
/// </summary>
|
||||
Task WriteNoTextWarningAsync(bool hasFollowUpActions);
|
||||
|
||||
/// <summary>
|
||||
/// Switches the bottom panel to streaming mode and starts the spinner.
|
||||
/// </summary>
|
||||
void BeginStreaming();
|
||||
|
||||
/// <summary>
|
||||
/// Stops the spinner without leaving streaming mode.
|
||||
/// </summary>
|
||||
void StopSpinner();
|
||||
|
||||
/// <summary>
|
||||
/// Switches the bottom panel back to text-input mode and stops the spinner.
|
||||
/// </summary>
|
||||
void EndStreaming();
|
||||
|
||||
/// <summary>
|
||||
/// Resets per-turn streaming bookkeeping in preparation for a new agent turn.
|
||||
/// </summary>
|
||||
void BeginStreamingOutput();
|
||||
|
||||
/// <summary>
|
||||
/// Sets the formatted usage text shown on the agent status bar.
|
||||
/// </summary>
|
||||
void SetUsageText(string usageText);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the queued-message display with one entry per pending message.
|
||||
/// </summary>
|
||||
void SetQueuedMessages(IReadOnlyList<ChatMessage> pending);
|
||||
|
||||
/// <summary>
|
||||
/// Appends the supplied questions to the pending follow-up question queue in
|
||||
/// component state. If the queue was empty, the bottom-panel display is
|
||||
/// reconfigured to present the new head question.
|
||||
/// </summary>
|
||||
void QueueFollowUpQuestions(IReadOnlyList<FollowUpQuestion> questions);
|
||||
|
||||
/// <summary>
|
||||
/// Appends a message to the accumulated follow-up response list in component state.
|
||||
/// Called by the runner for direct <see cref="FollowUpMessage"/> outputs and by
|
||||
/// the component when a question's continuation produces a response.
|
||||
/// </summary>
|
||||
void AddFollowUpResponse(ChatMessage response);
|
||||
|
||||
/// <summary>
|
||||
/// Pops the head of the pending follow-up question queue. Reconfigures the
|
||||
/// bottom-panel display for the new head, or restores the default text-input
|
||||
/// mode if the queue is now empty.
|
||||
/// </summary>
|
||||
void AdvanceFollowUpQuestion();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current accumulated follow-up responses and clears them in state.
|
||||
/// Called by the runner immediately before invoking the next agent turn.
|
||||
/// </summary>
|
||||
IReadOnlyList<ChatMessage> TakeFollowUpResponses();
|
||||
|
||||
/// <summary>
|
||||
/// Signals that the application should shut down. Completes the shutdown task
|
||||
/// on the owning component.
|
||||
/// </summary>
|
||||
void RequestShutdown();
|
||||
}
|
||||
+22
-17
@@ -18,36 +18,41 @@ public abstract class ConsoleObserver
|
||||
/// Override to set options such as <see cref="AgentRunOptions.ResponseFormat"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The run options to configure.</param>
|
||||
public virtual void ConfigureRunOptions(AgentRunOptions options)
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called for each <see cref="AIContent"/> item in the response stream.
|
||||
/// </summary>
|
||||
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="content">The content item from the stream.</param>
|
||||
public virtual Task OnContentAsync(HarnessUXContainer ux, AIContent content) => Task.CompletedTask;
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
public virtual Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called for each text update in the response stream.
|
||||
/// </summary>
|
||||
/// <param name="ux">The harness UX container, used for rendering output and interacting with the user.</param>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="text">The text from the update.</param>
|
||||
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="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(
|
||||
HarnessUXContainer ux,
|
||||
public virtual Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Called after the response stream completes. Returns a heterogeneous list of
|
||||
/// follow-up actions (questions to ask the user, and/or messages to add directly to
|
||||
/// the next agent invocation), or <see langword="null"/> if no follow-up is needed.
|
||||
/// </summary>
|
||||
/// <param name="ux">The UX state driver, used for rendering output.</param>
|
||||
/// <param name="agent">The agent being interacted with.</param>
|
||||
/// <param name="session">The current agent session.</param>
|
||||
/// <returns>Follow-up actions to process after the stream completes, or <see langword="null"/>.</returns>
|
||||
public virtual Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||
IUXStateDriver ux,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options) => Task.FromResult<IList<ChatMessage>?>(null);
|
||||
AgentSession session) => Task.FromResult<IList<FollowUpAction>?>(null);
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
@@ -7,10 +8,10 @@ namespace Harness.Shared.Console.Observers;
|
||||
/// <summary>
|
||||
/// Displays error content (❌) from the response stream.
|
||||
/// </summary>
|
||||
internal sealed class ErrorDisplayObserver : ConsoleObserver
|
||||
public sealed class ErrorDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is ErrorContent errorContent)
|
||||
{
|
||||
|
||||
+100
-55
@@ -2,51 +2,77 @@
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Planning observer that configures structured output, collects streamed text,
|
||||
/// and deserializes it as a <see cref="PlanningResponse"/>. Renders clarification
|
||||
/// questions and approval prompts, and manages mode switching when the user approves a plan.
|
||||
/// Planning observer that is mode-aware: in planning mode it configures structured
|
||||
/// JSON output, collects streamed text, and deserializes it as a <see cref="PlanningResponse"/>;
|
||||
/// in execution mode it passes text straight through to <see cref="IUXStateDriver.WriteTextAsync"/>
|
||||
/// for live streaming display.
|
||||
/// </summary>
|
||||
internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
public sealed class PlanningOutputObserver : ConsoleObserver
|
||||
{
|
||||
private readonly StringBuilder _textCollector = new();
|
||||
private readonly AgentModeProvider _modeProvider;
|
||||
private readonly string _planModeName;
|
||||
private readonly string _executionModeName;
|
||||
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PlanningOutputObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="modeProvider">The mode provider for switching modes on approval.</param>
|
||||
public PlanningOutputObserver(AgentModeProvider modeProvider)
|
||||
/// <param name="planModeName">The mode name that represents the planning mode.</param>
|
||||
/// <param name="executionModeName">The mode name to switch to when the user approves a plan.</param>
|
||||
/// <param name="modeColors">Optional mode-to-color mapping for display.</param>
|
||||
public PlanningOutputObserver(AgentModeProvider modeProvider, string planModeName, string executionModeName, IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
|
||||
{
|
||||
this._modeProvider = modeProvider;
|
||||
this._planModeName = planModeName;
|
||||
this._executionModeName = executionModeName;
|
||||
this._modeColors = modeColors;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void ConfigureRunOptions(AgentRunOptions options)
|
||||
public override void ConfigureRunOptions(AgentRunOptions options, AIAgent agent, AgentSession session)
|
||||
{
|
||||
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
|
||||
if (this.IsPlanningMode(this._modeProvider.GetMode(session)))
|
||||
{
|
||||
options.ResponseFormat = ChatResponseFormat.ForJsonSchema<PlanningResponse>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task OnTextAsync(HarnessUXContainer ux, string text)
|
||||
public override Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
|
||||
{
|
||||
// Collect text silently instead of displaying it.
|
||||
this._textCollector.Append(text);
|
||||
return Task.CompletedTask;
|
||||
if (this.IsPlanningMode(ux.CurrentMode))
|
||||
{
|
||||
// Planning mode: collect text silently for JSON parsing after the stream.
|
||||
this._textCollector.Append(text);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Execution mode: stream text directly to the console.
|
||||
return ux.WriteTextAsync(text);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
||||
HarnessUXContainer ux,
|
||||
public override async Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||
IUXStateDriver ux,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options)
|
||||
AgentSession session)
|
||||
{
|
||||
if (!this.IsPlanningMode(ux.CurrentMode))
|
||||
{
|
||||
// Execution mode: text was already streamed live; nothing to parse.
|
||||
this._textCollector.Clear();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read collected text from our stream observation.
|
||||
string collectedText = this._textCollector.ToString();
|
||||
this._textCollector.Clear();
|
||||
@@ -75,10 +101,9 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render based on response type.
|
||||
if (planningResponse.Type == PlanningResponseType.Clarification)
|
||||
{
|
||||
return AsUserMessages(await this.RenderClarificationsAndCollectResponsesAsync(ux, planningResponse));
|
||||
return BuildClarificationActions(planningResponse);
|
||||
}
|
||||
|
||||
if (planningResponse.Type == PlanningResponseType.Approval)
|
||||
@@ -90,67 +115,87 @@ internal sealed class PlanningOutputObserver : ConsoleObserver
|
||||
return null;
|
||||
}
|
||||
|
||||
string response = await this.RenderApprovalAndCollectResponseAsync(ux, question, options);
|
||||
if (response == "Approved")
|
||||
{
|
||||
this._modeProvider.SetMode(session, options.ExecutionModeName!);
|
||||
|
||||
await ux.WriteInfoLineAsync($"✅ Switched to {options.ExecutionModeName} mode.",
|
||||
ModeColors.Get(options.ExecutionModeName, options.ModeColors));
|
||||
}
|
||||
|
||||
return AsUserMessages(response);
|
||||
return new List<FollowUpAction> { this.BuildApprovalAction(question, session) };
|
||||
}
|
||||
|
||||
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(HarnessUXContainer ux, PlanningResponse response)
|
||||
private static List<FollowUpAction> BuildClarificationActions(PlanningResponse response)
|
||||
{
|
||||
var answers = new List<string>();
|
||||
var actions = new List<FollowUpAction>(response.Questions.Count);
|
||||
|
||||
foreach (var question in response.Questions)
|
||||
{
|
||||
string? answer;
|
||||
string prompt = question.Message;
|
||||
|
||||
async Task<ChatMessage?> Continuation(string answer, IUXStateDriver ux)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(answer))
|
||||
{
|
||||
string noAnswer = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.DarkGray)}(no answer){AnsiEscapes.ResetAttributes}";
|
||||
await ux.WriteInfoLineAsync(noAnswer, ConsoleColor.Gray).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{answer}{AnsiEscapes.ResetAttributes}";
|
||||
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
|
||||
|
||||
return new ChatMessage(ChatRole.User, $"Q: {prompt}\nA: {answer}");
|
||||
}
|
||||
|
||||
if (question.Choices is { Count: > 0 })
|
||||
{
|
||||
answer = await ux.ReadSelectionAsync(
|
||||
question.Message,
|
||||
question.Choices);
|
||||
actions.Add(new ChoiceFollowUpQuestion(
|
||||
Prompt: prompt,
|
||||
Choices: question.Choices,
|
||||
AllowCustomText: true,
|
||||
Continuation: Continuation));
|
||||
}
|
||||
else
|
||||
{
|
||||
answer = (await ux.ReadLineAsync(question.Message))?.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(answer))
|
||||
{
|
||||
answers.Add($"Q: {question.Message}\nA: {answer}");
|
||||
actions.Add(new TextFollowUpQuestion(
|
||||
Prompt: prompt,
|
||||
Continuation: Continuation));
|
||||
}
|
||||
}
|
||||
|
||||
return answers.Count > 0 ? string.Join("\n\n", answers) : null;
|
||||
return actions;
|
||||
}
|
||||
|
||||
private async Task<string> RenderApprovalAndCollectResponseAsync(HarnessUXContainer ux, PlanningQuestion question, HarnessConsoleOptions options)
|
||||
private ChoiceFollowUpQuestion BuildApprovalAction(PlanningQuestion question, AgentSession session)
|
||||
{
|
||||
var choices = new List<string>
|
||||
{
|
||||
"Approve and switch to execute mode",
|
||||
};
|
||||
const string ApproveOption = "Approve and switch to execute mode";
|
||||
var choices = new List<string> { ApproveOption };
|
||||
|
||||
string selection = await ux.ReadSelectionAsync(question.Message, choices);
|
||||
return new ChoiceFollowUpQuestion(
|
||||
Prompt: question.Message,
|
||||
Choices: choices,
|
||||
AllowCustomText: true,
|
||||
Continuation: async (selection, ux) =>
|
||||
{
|
||||
string formatted = $"🔹 {question.Message}\n └─ {AnsiEscapes.SetForegroundColor(ConsoleColor.Green)}{selection}{AnsiEscapes.ResetAttributes}";
|
||||
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
|
||||
|
||||
if (selection == choices[0])
|
||||
{
|
||||
return "Approved";
|
||||
}
|
||||
if (selection == ApproveOption)
|
||||
{
|
||||
this._modeProvider.SetMode(session, this._executionModeName);
|
||||
await ux.WriteInfoLineAsync(
|
||||
$"✅ Switched to {this._executionModeName} mode.",
|
||||
ModeColors.Get(this._executionModeName, this._modeColors)).ConfigureAwait(false);
|
||||
return new ChatMessage(ChatRole.User, "Approved");
|
||||
}
|
||||
|
||||
// Custom freeform input — treat as suggested changes.
|
||||
return selection;
|
||||
// Custom freeform input — treat as suggested changes.
|
||||
return new ChatMessage(ChatRole.User, selection);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the current mode matches the configured plan mode name.
|
||||
/// A <see langword="null"/> mode (no mode provider) is also treated as planning mode.
|
||||
/// </summary>
|
||||
private bool IsPlanningMode(string? currentMode) =>
|
||||
currentMode is null || string.Equals(currentMode, this._planModeName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
@@ -7,10 +8,10 @@ namespace Harness.Shared.Console.Observers;
|
||||
/// <summary>
|
||||
/// Displays reasoning content in dark magenta from the response stream.
|
||||
/// </summary>
|
||||
internal sealed class ReasoningDisplayObserver : ConsoleObserver
|
||||
public sealed class ReasoningDisplayObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is TextReasoningContent reasoning && !string.IsNullOrEmpty(reasoning.Text))
|
||||
{
|
||||
|
||||
+4
-2
@@ -1,15 +1,17 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Streams agent text output directly to the console.
|
||||
/// Used in normal (non-planning) mode.
|
||||
/// </summary>
|
||||
internal sealed class TextOutputObserver : ConsoleObserver
|
||||
public sealed class TextOutputObserver : ConsoleObserver
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnTextAsync(HarnessUXContainer ux, string text)
|
||||
public override async Task OnTextAsync(IUXStateDriver ux, string text, AIAgent agent, AgentSession session)
|
||||
{
|
||||
await ux.WriteTextAsync(text);
|
||||
}
|
||||
|
||||
+67
-48
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.ConsoleReactiveComponents;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -7,86 +9,103 @@ namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Collects <see cref="ToolApprovalRequestContent"/> items during the response stream,
|
||||
/// displays approval-needed notifications inline, and prompts the user for approval
|
||||
/// decisions after the stream completes.
|
||||
/// displays approval-needed notifications inline, and after the stream completes returns
|
||||
/// one <see cref="ChoiceFollowUpQuestion"/> per pending approval request. Each question's
|
||||
/// continuation produces a separate <see cref="ChatMessage"/> carrying the approval
|
||||
/// response content.
|
||||
/// </summary>
|
||||
internal sealed class ToolApprovalObserver : ConsoleObserver
|
||||
public sealed class ToolApprovalObserver : ConsoleObserver
|
||||
{
|
||||
private readonly List<ToolApprovalRequestContent> _approvalRequests = [];
|
||||
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolApprovalObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
|
||||
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
|
||||
public ToolApprovalObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
|
||||
{
|
||||
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is ToolApprovalRequestContent approvalRequest)
|
||||
{
|
||||
this._approvalRequests.Add(approvalRequest);
|
||||
string toolName = approvalRequest.ToolCall is FunctionCallContent fc
|
||||
? ToolCallFormatter.Format(fc)
|
||||
? ToolCallFormatter.Format(this._formatters, fc)
|
||||
: approvalRequest.ToolCall?.ToString() ?? "unknown";
|
||||
await ux.WriteInfoLineAsync($"⚠️ Approval needed: {toolName}", ConsoleColor.Yellow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<ChatMessage>?> OnStreamCompleteAsync(
|
||||
HarnessUXContainer ux,
|
||||
public override Task<IList<FollowUpAction>?> OnStreamCompleteAsync(
|
||||
IUXStateDriver ux,
|
||||
AIAgent agent,
|
||||
AgentSession session,
|
||||
HarnessConsoleOptions options)
|
||||
AgentSession session)
|
||||
{
|
||||
if (this._approvalRequests.Count == 0)
|
||||
{
|
||||
return null;
|
||||
return Task.FromResult<IList<FollowUpAction>?>(null);
|
||||
}
|
||||
|
||||
var actions = new List<FollowUpAction>(this._approvalRequests.Count);
|
||||
foreach (var request in this._approvalRequests)
|
||||
{
|
||||
actions.Add(this.BuildApprovalQuestion(request));
|
||||
}
|
||||
|
||||
var messages = await PromptForApprovalsAsync(ux, this._approvalRequests);
|
||||
this._approvalRequests.Clear();
|
||||
return messages;
|
||||
return Task.FromResult<IList<FollowUpAction>?>(actions);
|
||||
}
|
||||
|
||||
private static async Task<List<ChatMessage>?> PromptForApprovalsAsync(HarnessUXContainer ux, List<ToolApprovalRequestContent> approvalRequests)
|
||||
private ChoiceFollowUpQuestion BuildApprovalQuestion(ToolApprovalRequestContent request)
|
||||
{
|
||||
if (approvalRequests.Count == 0)
|
||||
string toolName = request.ToolCall is FunctionCallContent fc
|
||||
? ToolCallFormatter.Format(this._formatters, fc)
|
||||
: request.ToolCall?.ToString() ?? "unknown";
|
||||
|
||||
var choices = new List<string>
|
||||
{
|
||||
return null;
|
||||
}
|
||||
"Approve this call",
|
||||
"Always approve this tool (any arguments)",
|
||||
"Always approve this tool with these arguments",
|
||||
"Deny",
|
||||
};
|
||||
|
||||
var responses = new List<AIContent>();
|
||||
foreach (var request in approvalRequests)
|
||||
{
|
||||
string toolName = request.ToolCall is FunctionCallContent fc
|
||||
? ToolCallFormatter.Format(fc)
|
||||
: request.ToolCall?.ToString() ?? "unknown";
|
||||
string prompt = $"🔐 Tool approval: {toolName}";
|
||||
|
||||
var choices = new List<string>
|
||||
return new ChoiceFollowUpQuestion(
|
||||
Prompt: prompt,
|
||||
Choices: choices,
|
||||
AllowCustomText: false,
|
||||
Continuation: async (selection, ux) =>
|
||||
{
|
||||
"Approve this call",
|
||||
"Always approve this tool (any arguments)",
|
||||
"Always approve this tool with these arguments",
|
||||
"Deny",
|
||||
};
|
||||
AIContent response = selection switch
|
||||
{
|
||||
"Always approve this tool (any arguments)" => request.CreateAlwaysApproveToolResponse("User chose to always approve this tool"),
|
||||
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
|
||||
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
|
||||
_ => request.CreateResponse(approved: true, reason: "User approved"),
|
||||
};
|
||||
|
||||
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"),
|
||||
"Always approve this tool with these arguments" => request.CreateAlwaysApproveToolWithArgumentsResponse("User chose to always approve this tool with these arguments"),
|
||||
"Deny" => request.CreateResponse(approved: false, reason: "User denied"),
|
||||
_ => request.CreateResponse(approved: true, reason: "User approved"),
|
||||
};
|
||||
string action = selection switch
|
||||
{
|
||||
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
|
||||
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
|
||||
"Deny" => "❌ Denied",
|
||||
_ => "✅ Approved",
|
||||
};
|
||||
|
||||
string action = selection switch
|
||||
{
|
||||
"Always approve this tool (any arguments)" => "✅ Always approved (any args)",
|
||||
"Always approve this tool with these arguments" => "✅ Always approved (these args)",
|
||||
"Deny" => "❌ Denied",
|
||||
_ => "✅ Approved",
|
||||
};
|
||||
await ux.WriteInfoLineAsync($" {action}", ConsoleColor.DarkGray);
|
||||
ConsoleColor answerColor = selection == "Deny" ? ConsoleColor.Red : ConsoleColor.Green;
|
||||
string formatted = $"🔹 {prompt}\n └─ {AnsiEscapes.SetForegroundColor(answerColor)}{action}{AnsiEscapes.ResetAttributes}";
|
||||
await ux.WriteInfoLineAsync(formatted, ConsoleColor.Gray).ConfigureAwait(false);
|
||||
|
||||
responses.Add(response);
|
||||
}
|
||||
|
||||
return [new ChatMessage(ChatRole.User, responses)];
|
||||
return new ChatMessage(ChatRole.User, [response]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -1,5 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
@@ -8,14 +10,26 @@ namespace Harness.Shared.Console.Observers;
|
||||
/// Displays tool call notifications (🔧) for <see cref="FunctionCallContent"/>
|
||||
/// and <see cref="ToolCallContent"/> items in the response stream.
|
||||
/// </summary>
|
||||
internal sealed class ToolCallDisplayObserver : ConsoleObserver
|
||||
public sealed class ToolCallDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private readonly IReadOnlyList<ToolCallFormatter> _formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ToolCallDisplayObserver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="formatters">Optional list of tool formatters. When <see langword="null"/>,
|
||||
/// the default formatters from <see cref="ToolCallFormatter.BuildDefaultToolFormatters"/> are used.</param>
|
||||
public ToolCallDisplayObserver(IReadOnlyList<ToolCallFormatter>? formatters = null)
|
||||
{
|
||||
this._formatters = formatters ?? ToolCallFormatter.BuildDefaultToolFormatters();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
public override async Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is FunctionCallContent functionCall)
|
||||
{
|
||||
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(functionCall)}...", ConsoleColor.DarkYellow);
|
||||
await ux.WriteInfoLineAsync($"🔧 Calling tool: {ToolCallFormatter.Format(this._formatters, functionCall)}...", ConsoleColor.DarkYellow);
|
||||
}
|
||||
else if (content is ToolCallContent toolCall)
|
||||
{
|
||||
|
||||
-288
@@ -1,288 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <see cref="FunctionCallContent"/> instances into human-readable strings
|
||||
/// for console display.
|
||||
/// </summary>
|
||||
public static class ToolCallFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a formatted string for the given tool call, with human-readable
|
||||
/// details for known tools (todos, mode, sub-agents, web tools).
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content to format.</param>
|
||||
/// <returns>A formatted string describing the tool call.</returns>
|
||||
public static string Format(FunctionCallContent call)
|
||||
{
|
||||
string? detail = call.Name switch
|
||||
{
|
||||
// Todo tools
|
||||
"TodoList_Add" => FormatAddTodos(call),
|
||||
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
|
||||
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
|
||||
"TodoList_GetRemaining" => null,
|
||||
"TodoList_GetAll" => null,
|
||||
|
||||
// Mode tools
|
||||
"AgentMode_Set" => FormatStringArg(call, "mode"),
|
||||
"AgentMode_Get" => null,
|
||||
|
||||
// Sub-agent tools
|
||||
"SubAgents_StartTask" => FormatStartSubTask(call),
|
||||
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
|
||||
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
|
||||
"SubAgents_GetAllTasks" => null,
|
||||
"SubAgents_ContinueTask" => FormatContinueTask(call),
|
||||
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
|
||||
|
||||
// File memory tools
|
||||
"FileMemory_SaveFile" => FormatSaveFile(call),
|
||||
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_ListFiles" => null,
|
||||
"FileMemory_SearchFiles" => FormatSearchFiles(call),
|
||||
|
||||
// External tools
|
||||
"web_search" => FormatStringArg(call, "query"),
|
||||
"DownloadUri" => FormatStringArg(call, "uri"),
|
||||
|
||||
_ => FormatFallback(call),
|
||||
};
|
||||
|
||||
return detail is not null ? $"{call.Name} {detail}" : call.Name;
|
||||
}
|
||||
|
||||
private static string? FormatAddTodos(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var titles = new List<string>();
|
||||
|
||||
if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in jsonArray.EnumerateArray())
|
||||
{
|
||||
string? title = item.TryGetProperty("title", out JsonElement titleElement)
|
||||
? titleElement.GetString()
|
||||
: null;
|
||||
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
titles.Add(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (titles.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})");
|
||||
foreach (string title in titles)
|
||||
{
|
||||
sb.Append($"\n • {title}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
|
||||
{
|
||||
List<int>? ids = GetIntList(call, paramName);
|
||||
if (ids is null || ids.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"({verb} #{string.Join(", #", ids)})";
|
||||
}
|
||||
|
||||
private static string? FormatSingleId(FunctionCallContent call, string paramName)
|
||||
{
|
||||
int? id = GetInt(call, paramName);
|
||||
return id.HasValue ? $"(task #{id.Value})" : null;
|
||||
}
|
||||
|
||||
private static string? FormatStartSubTask(FunctionCallContent call)
|
||||
{
|
||||
string? agentName = GetString(call, "agentName");
|
||||
string? description = GetString(call, "description");
|
||||
|
||||
if (agentName is null && description is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder("(");
|
||||
if (agentName is not null)
|
||||
{
|
||||
sb.Append($"agent: {agentName}");
|
||||
}
|
||||
|
||||
if (description is not null)
|
||||
{
|
||||
if (agentName is not null)
|
||||
{
|
||||
sb.Append(", ");
|
||||
}
|
||||
|
||||
sb.Append($"\"{Truncate(description, 60)}\"");
|
||||
}
|
||||
|
||||
sb.Append(')');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatContinueTask(FunctionCallContent call)
|
||||
{
|
||||
int? taskId = GetInt(call, "taskId");
|
||||
string? text = GetString(call, "text");
|
||||
|
||||
if (!taskId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return text is not null
|
||||
? $"(task #{taskId.Value}, \"{Truncate(text, 50)}\")"
|
||||
: $"(task #{taskId.Value})";
|
||||
}
|
||||
|
||||
private static string? FormatSaveFile(FunctionCallContent call)
|
||||
{
|
||||
string? fileName = GetString(call, "fileName");
|
||||
string? description = GetString(call, "description");
|
||||
|
||||
if (fileName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(description)
|
||||
? $"({fileName})"
|
||||
: $"({fileName}, with description)";
|
||||
}
|
||||
|
||||
private static string? FormatSearchFiles(FunctionCallContent call)
|
||||
{
|
||||
string? pattern = GetString(call, "regexPattern");
|
||||
string? filePattern = GetString(call, "filePattern");
|
||||
|
||||
if (pattern is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(filePattern)
|
||||
? $"(/{pattern}/)"
|
||||
: $"(/{pattern}/ in {filePattern})";
|
||||
}
|
||||
|
||||
private static string? FormatStringArg(FunctionCallContent call, string paramName)
|
||||
{
|
||||
string? value = GetString(call, paramName);
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
|
||||
private static string? FormatFallback(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments is null || call.Arguments.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var kvp in call.Arguments)
|
||||
{
|
||||
string? stringValue = kvp.Value switch
|
||||
{
|
||||
JsonElement je => je.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => je.GetString(),
|
||||
JsonValueKind.Number => je.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => null,
|
||||
},
|
||||
not null => kvp.Value.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (stringValue is not null)
|
||||
{
|
||||
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
|
||||
}
|
||||
|
||||
private static string? GetString(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
|
||||
string s => s,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
private static int? GetInt(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
|
||||
int i => i,
|
||||
_ => int.TryParse(value.ToString(), out int parsed) ? parsed : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<int>? GetIntList(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new List<int>();
|
||||
|
||||
if (value is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in je.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
result.Add(item.GetInt32());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count > 0 ? result : null;
|
||||
}
|
||||
|
||||
private static string Truncate(string text, int maxLength)
|
||||
{
|
||||
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.Observers;
|
||||
@@ -7,7 +8,7 @@ namespace Harness.Shared.Console.Observers;
|
||||
/// <summary>
|
||||
/// Displays token usage statistics (📊) from the response stream.
|
||||
/// </summary>
|
||||
internal sealed class UsageDisplayObserver : ConsoleObserver
|
||||
public sealed class UsageDisplayObserver : ConsoleObserver
|
||||
{
|
||||
private readonly int? _maxContextWindowTokens;
|
||||
private readonly int? _maxOutputTokens;
|
||||
@@ -24,7 +25,7 @@ internal sealed class UsageDisplayObserver : ConsoleObserver
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task OnContentAsync(HarnessUXContainer ux, AIContent content)
|
||||
public override Task OnContentAsync(IUXStateDriver ux, AIContent content, AIAgent agent, AgentSession session)
|
||||
{
|
||||
if (content is UsageContent usage)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Harness.Shared.Console;
|
||||
/// <summary>
|
||||
/// Represents the type of an output entry in the console conversation.
|
||||
/// </summary>
|
||||
public enum OutputEntryType
|
||||
internal enum OutputEntryType
|
||||
{
|
||||
/// <summary>User input echo (e.g. "You: hello").</summary>
|
||||
UserInput,
|
||||
@@ -25,9 +25,10 @@ public enum OutputEntryType
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single output entry in the console conversation history.
|
||||
/// These entries are rendered by the <see cref="HarnessAppComponent"/> via its render delegate.
|
||||
/// Used internally by <see cref="HarnessConsoleUXStateDriver"/> to track
|
||||
/// the in-progress streaming entry and last-entry type for spacing decisions.
|
||||
/// </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);
|
||||
internal sealed record OutputEntry(OutputEntryType Type, string Text, ConsoleColor? Color = null);
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Catch-all formatter that handles any tool not matched by a more specific formatter.
|
||||
/// Displays a generic summary of the tool's arguments. This formatter should always be
|
||||
/// placed last in the formatter list.
|
||||
/// </summary>
|
||||
public sealed class FallbackToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments is null || call.Arguments.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var kvp in call.Arguments)
|
||||
{
|
||||
string? stringValue = kvp.Value switch
|
||||
{
|
||||
JsonElement je => je.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => je.GetString(),
|
||||
JsonValueKind.Number => je.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => null,
|
||||
},
|
||||
not null => kvp.Value.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (stringValue is not null)
|
||||
{
|
||||
parts.Add($"{kvp.Key}: {Truncate(stringValue, 40)}");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? $"({string.Join(", ", parts)})" : null;
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>FileMemory_*</c> tool calls, showing file names and search patterns
|
||||
/// with tree-view corners for save operations.
|
||||
/// </summary>
|
||||
public sealed class FileMemoryToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("FileMemory_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"FileMemory_SaveFile" => FormatSaveFile(call),
|
||||
"FileMemory_ReadFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_DeleteFile" => FormatStringArg(call, "fileName"),
|
||||
"FileMemory_SearchFiles" => FormatSearchFiles(call),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatSaveFile(FunctionCallContent call)
|
||||
{
|
||||
string? fileName = GetStringArgumentValue(call, "fileName");
|
||||
string? description = GetStringArgumentValue(call, "description");
|
||||
|
||||
if (fileName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(description)
|
||||
? $"\n └─ {fileName}"
|
||||
: $"\n └─ {fileName} (with description)";
|
||||
}
|
||||
|
||||
private static string? FormatSearchFiles(FunctionCallContent call)
|
||||
{
|
||||
string? pattern = GetStringArgumentValue(call, "regexPattern");
|
||||
string? filePattern = GetStringArgumentValue(call, "filePattern");
|
||||
|
||||
if (pattern is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(filePattern)
|
||||
? $"(/{pattern}/)"
|
||||
: $"(/{pattern}/ in {filePattern})";
|
||||
}
|
||||
|
||||
private static string? FormatStringArg(FunctionCallContent call, string paramName)
|
||||
{
|
||||
string? value = GetStringArgumentValue(call, paramName);
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>AgentMode_*</c> tool calls, showing the target mode for Set operations.
|
||||
/// </summary>
|
||||
public sealed class ModeToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("AgentMode_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"AgentMode_Set" => FormatStringArg(call, "mode"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatStringArg(FunctionCallContent call, string paramName)
|
||||
{
|
||||
string? value = GetStringArgumentValue(call, paramName);
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>SubAgents_*</c> tool calls with human-readable details
|
||||
/// for task start, continue, wait, and result retrieval operations.
|
||||
/// </summary>
|
||||
public sealed class SubAgentToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("SubAgents_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"SubAgents_StartTask" => FormatStartSubTask(call),
|
||||
"SubAgents_WaitForFirstCompletion" => FormatIdList(call, "taskIds", "Wait for"),
|
||||
"SubAgents_GetTaskResults" => FormatSingleId(call, "taskId"),
|
||||
"SubAgents_ContinueTask" => FormatContinueTask(call),
|
||||
"SubAgents_ClearCompletedTask" => FormatSingleId(call, "taskId"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatStartSubTask(FunctionCallContent call)
|
||||
{
|
||||
string? agentName = GetStringArgumentValue(call, "agentName");
|
||||
string? description = GetStringArgumentValue(call, "description");
|
||||
|
||||
if (agentName is null && description is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
if (agentName is not null && description is not null)
|
||||
{
|
||||
sb.Append($"\n ├─ Agent: {agentName}");
|
||||
sb.Append($"\n └─ \"{Truncate(description, 80)}\"");
|
||||
}
|
||||
else if (agentName is not null)
|
||||
{
|
||||
sb.Append($"\n └─ Agent: {agentName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"\n └─ \"{Truncate(description!, 80)}\"");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
|
||||
{
|
||||
List<int>? ids = GetIntListArgumentValue(call, paramName);
|
||||
if (ids is null || ids.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
string connector = i < ids.Count - 1 ? "├─" : "└─";
|
||||
sb.Append($"\n {connector} {verb} #{ids[i]}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatSingleId(FunctionCallContent call, string paramName)
|
||||
{
|
||||
int? id = GetIntArgumentValue(call, paramName);
|
||||
return id.HasValue ? $"(task #{id.Value})" : null;
|
||||
}
|
||||
|
||||
private static string? FormatContinueTask(FunctionCallContent call)
|
||||
{
|
||||
int? taskId = GetIntArgumentValue(call, "taskId");
|
||||
string? text = GetStringArgumentValue(call, "text");
|
||||
|
||||
if (!taskId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (text is not null)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"\n ├─ Task #{taskId.Value}");
|
||||
sb.Append($"\n └─ \"{Truncate(text, 80)}\"");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
return $"\n └─ Task #{taskId.Value}";
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>TodoList_*</c> tool calls with tree-view output for added items
|
||||
/// and structured output for complete/remove operations.
|
||||
/// </summary>
|
||||
public sealed class TodoToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"TodoList_Add" => FormatAddTodos(call),
|
||||
"TodoList_Complete" => FormatIdList(call, "ids", "Complete"),
|
||||
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatAddTodos(FunctionCallContent call)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue("todos", out object? todosObj) != true || todosObj is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var titles = new List<string>();
|
||||
|
||||
if (todosObj is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in jsonArray.EnumerateArray())
|
||||
{
|
||||
string? title = item.TryGetProperty("title", out JsonElement titleElement)
|
||||
? titleElement.GetString()
|
||||
: null;
|
||||
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
titles.Add(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (titles.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"({titles.Count} item{(titles.Count == 1 ? "" : "s")})");
|
||||
for (int i = 0; i < titles.Count; i++)
|
||||
{
|
||||
string connector = i < titles.Count - 1 ? "├─" : "└─";
|
||||
sb.Append($"\n {connector} {titles[i]}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatIdList(FunctionCallContent call, string paramName, string verb)
|
||||
{
|
||||
List<int>? ids = GetIntListArgumentValue(call, paramName);
|
||||
if (ids is null || ids.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
string connector = i < ids.Count - 1 ? "├─" : "└─";
|
||||
sb.Append($"\n {connector} {verb} #{ids[i]}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for tool call formatters that produce human-readable display strings
|
||||
/// for <see cref="FunctionCallContent"/> items shown in the console.
|
||||
/// </summary>
|
||||
public abstract class ToolCallFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if this formatter can handle the given function call.
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content to check.</param>
|
||||
/// <returns><see langword="true"/> if this formatter should be used; otherwise <see langword="false"/>.</returns>
|
||||
public abstract bool CanFormat(FunctionCallContent call);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the detail portion of the formatted output for the given tool call,
|
||||
/// or <see langword="null"/> if only the tool name should be displayed.
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content to format.</param>
|
||||
/// <returns>A detail string to append after the tool name, or <see langword="null"/>.</returns>
|
||||
public abstract string? FormatDetail(FunctionCallContent call);
|
||||
|
||||
/// <summary>
|
||||
/// Formats a tool call using the first matching formatter from the provided list.
|
||||
/// Returns <c>"{toolName} {detail}"</c> when a formatter produces detail,
|
||||
/// or just <c>"{toolName}"</c> otherwise.
|
||||
/// </summary>
|
||||
internal static string Format(IReadOnlyList<ToolCallFormatter> formatters, FunctionCallContent call)
|
||||
{
|
||||
foreach (var formatter in formatters)
|
||||
{
|
||||
if (formatter.CanFormat(call))
|
||||
{
|
||||
string? detail = formatter.FormatDetail(call);
|
||||
return detail is not null ? $"{call.Name} {detail}" : call.Name;
|
||||
}
|
||||
}
|
||||
|
||||
return call.Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the default list of tool call formatters. The <see cref="FallbackToolFormatter"/>
|
||||
/// is always last. Users can call this method and combine the result with their own formatters.
|
||||
/// </summary>
|
||||
/// <returns>A list of all built-in tool call formatters.</returns>
|
||||
public static List<ToolCallFormatter> BuildDefaultToolFormatters()
|
||||
{
|
||||
return
|
||||
[
|
||||
new TodoToolFormatter(),
|
||||
new ModeToolFormatter(),
|
||||
new SubAgentToolFormatter(),
|
||||
new FileMemoryToolFormatter(),
|
||||
new WebSearchToolFormatter(),
|
||||
new FallbackToolFormatter(),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a string argument value from a function call.
|
||||
/// </summary>
|
||||
protected static string? GetStringArgumentValue(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(),
|
||||
string s => s,
|
||||
_ => value.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts an integer argument value from a function call.
|
||||
/// </summary>
|
||||
protected static int? GetIntArgumentValue(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
|
||||
int i => i,
|
||||
_ => int.TryParse(value.ToString(), out int parsed) ? parsed : null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a list of integer argument values from a function call.
|
||||
/// </summary>
|
||||
protected static List<int>? GetIntListArgumentValue(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) != true || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new List<int>();
|
||||
|
||||
if (value is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in je.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
result.Add(item.GetInt32());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.Count > 0 ? result : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates a string to the specified maximum length, appending an ellipsis if truncated.
|
||||
/// </summary>
|
||||
protected static string Truncate(string text, int maxLength)
|
||||
{
|
||||
return text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength), "…");
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>web_search</c> tool calls, showing the search query.
|
||||
/// </summary>
|
||||
public sealed class WebSearchToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) =>
|
||||
call.Name is "web_search";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call)
|
||||
{
|
||||
string? value = GetStringArgumentValue(call, "query");
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>DownloadUri</c> tool calls, showing the target URI.
|
||||
/// </summary>
|
||||
public sealed class DownloadUriToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) =>
|
||||
call.Name is "DownloadUri";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call)
|
||||
{
|
||||
string? value = GetStringArgumentValue(call, "uri");
|
||||
return value is not null ? $"({value})" : null;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
//
|
||||
// Special commands:
|
||||
// /todos — Display the current todo list without invoking the agent.
|
||||
// exit — End the session.
|
||||
// /mode — Get or set the current agent mode.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
@@ -16,6 +17,7 @@
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.Identity;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
@@ -158,13 +160,15 @@ AIAgent agent =
|
||||
// Run the interactive console session using the shared HarnessConsole helper.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
title: "Research Assistant",
|
||||
userPrompt: "Enter a research topic to get started.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
MaxContextWindowTokens = MaxContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens,
|
||||
EnablePlanningUx = true,
|
||||
PlanningModeName = "plan",
|
||||
ExecutionModeName = "execute"
|
||||
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
maxContextWindowTokens: MaxContextWindowTokens,
|
||||
maxOutputTokens: MaxOutputTokens,
|
||||
toolFormatters: [new DownloadUriToolFormatter(), .. ToolCallFormatter.BuildDefaultToolFormatters()]),
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// equipped with Foundry's hosted web search tool.
|
||||
//
|
||||
// Special commands:
|
||||
// exit — End the session.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
@@ -103,5 +103,4 @@ AIAgent parentAgent =
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
parentAgent,
|
||||
title: "Stock Price Researcher (SubAgents Demo)",
|
||||
userPrompt: "Enter a list of stock tickers (e.g., BAC, MSFT, BA):");
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// Ask the agent to analyze the data, produce summaries, or create new output files.
|
||||
//
|
||||
// Special commands:
|
||||
// exit — End the session.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
@@ -85,5 +85,4 @@ AIAgent agent =
|
||||
// Run the interactive console session.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
title: "Data Processing Assistant",
|
||||
userPrompt: "Ask me to analyze the data files, produce summaries, or create output files.");
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// <see cref="HarnessAgent"/> assembles the following pipeline from a caller-supplied <see cref="IChatClient"/>:
|
||||
/// <list type="number">
|
||||
/// <item><description><see cref="FunctionInvokingChatClient"/> — automatic function/tool invocation.</description></item>
|
||||
/// <item><description><see cref="MessageInjectingChatClient"/> — allows external code to inject messages into the conversation mid-stream.</description></item>
|
||||
/// <item><description><see cref="PerServiceCallChatHistoryPersistingChatClient"/> — persists chat history after every individual service call within a function-invocation loop.</description></item>
|
||||
/// <item><description><see cref="AIContextProviderChatClient"/> with a <see cref="CompactionProvider"/> — applies context-window compaction before each call so long function-invocation loops do not overflow the context window.</description></item>
|
||||
/// </list>
|
||||
@@ -110,6 +111,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
|
||||
return chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation()
|
||||
.UseMessageInjection()
|
||||
.UsePerServiceCallChatHistoryPersistence()
|
||||
.UseAIContextProviders(compactionProvider)
|
||||
.BuildAIAgent(new ChatClientAgentOptions
|
||||
|
||||
Reference in New Issue
Block a user