// Copyright (c) Microsoft. All rights reserved. using Harness.ConsoleReactiveComponents; using Microsoft.Extensions.AI; namespace Harness.Shared.Console; /// /// Event arguments raised when the user submits text while the bottom panel is in /// streaming mode (i.e. an agent turn is in progress). /// public sealed class StreamingInputReceivedEventArgs : EventArgs { /// /// Initializes a new instance of the class. /// /// The submitted text. public StreamingInputReceivedEventArgs(string text) { this.Text = text; } /// /// Gets the submitted text. /// public string Text { get; } } /// /// Façade over the harness UI: owns the , manages /// its props, dispatches input submissions, and provides the high-level read/write /// operations used by observers, command handlers, and the harness loop. /// /// /// All callers interact with the UI exclusively through this class. The underlying /// and its props are an implementation detail and /// must not be exposed. /// public sealed class HarnessUXContainer : IDisposable { /// /// The prompt displayed in the bottom-panel input area. /// private const string UserPrompt = "> "; private readonly IReadOnlyDictionary? _modeColors; private readonly List _outputItems = []; private readonly HarnessAppComponent _appComponent; private readonly object _outputLock = new(); private TaskCompletionSource? _pendingInputTcs; private OutputEntryType? _lastEntryType; private bool _hasReceivedAnyText; private OutputEntry? _currentStreamingEntry; private string? _currentMode; /// /// Initializes a new instance of the class. /// /// Placeholder text shown when the input is empty. /// The current agent mode, used to colour the rule and prompt. /// Whether the bottom-panel input accepts keystrokes during streaming. /// Optional mapping of mode names to console colors. public HarnessUXContainer( string placeholder, string? initialMode, bool inputEnabled, IReadOnlyDictionary? 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; } /// /// 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. /// public event EventHandler? StreamingInputReceived; /// /// 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. /// 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(); } } /// /// Performs the initial screen clear, sets the help text in the mode-and-help bar, /// and adds the title to the output area. /// /// The title displayed in the console header. /// The command help strings displayed in the mode-and-help bar. /// Whether streaming-time message injection is enabled. public void Initialize(string title, IEnumerable 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")); } /// /// Restores the cursor and exits the alternate screen, ending the interactive UI. /// public void Deactivate() => this._appComponent.Deactivate(); /// /// Switches the bottom panel to streaming mode and starts the spinner. /// public void BeginStreaming() { this._appComponent.Props = this._appComponent.Props! with { Mode = BottomPanelMode.Streaming, ShowSpinner = true, }; this._appComponent.Render(); } /// /// Stops the spinner without leaving streaming mode. Use between the end of the /// stream and any observer-driven prompts (e.g. tool approvals). /// public void StopSpinner() { this._appComponent.Props = this._appComponent.Props! with { ShowSpinner = false }; this._appComponent.Render(); } /// /// Switches the bottom panel back to text-input mode and stops the spinner. /// public void EndStreaming() { this._appComponent.Props = this._appComponent.Props! with { Mode = BottomPanelMode.TextInput, ShowSpinner = false, }; this._appComponent.Render(); } /// /// Resets per-turn streaming bookkeeping in preparation for a new agent turn. /// public void BeginStreamingOutput() { this._hasReceivedAnyText = false; this._currentStreamingEntry = null; } /// /// Sets the formatted usage text shown on the agent status bar. /// public void SetUsageText(string usageText) { this._appComponent.Props = this._appComponent.Props! with { UsageText = usageText }; this._appComponent.Render(); } /// /// Clears the usage text from the agent status bar. /// public void ClearUsageText() { this._appComponent.Props = this._appComponent.Props! with { UsageText = null }; this._appComponent.Render(); } /// /// Replaces the queued-message display with one entry per pending message. /// public void ShowQueuedMessages(IReadOnlyList pending) { var newQueued = new List(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(); } /// /// Echoes a submitted user input as a regular user-input entry in the output area, /// using the current mode-aware prompt prefix. /// /// The user-entered text. public void WriteUserInputEcho(string text) { this.AppendOutputEntries(new OutputEntry( OutputEntryType.UserInput, $"\nYou: {text}\n", ConsoleColor.Green)); } /// /// Writes informational output as an output entry, without a trailing newline. /// public Task WriteInfoAsync(string text, ConsoleColor? color = null) => this.WriteInfoCoreAsync(text, color, newLine: false); /// /// Writes informational output as an output entry, followed by a newline. /// 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; } /// /// Writes streaming text output from the agent. Successive calls accumulate into a /// single streaming entry that is re-rendered by the text panel. /// 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(this._outputItems), }; } this._appComponent.Render(); return Task.CompletedTask; } /// /// Writes a blank-line separator to visually close the streaming output section. /// Call before observer completions so their output is visually separated. /// 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(this._outputItems), }; } this._appComponent.Render(); return Task.CompletedTask; } /// /// Shows a "(no text response from agent)" warning if no text was received /// and no observer produced follow-up messages. Call after observer completions. /// /// Whether any observer produced follow-up messages. 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; } /// /// Reads a line of input from the user. If is supplied /// it is rendered as an info line above the input row before reading. /// public async Task 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; } /// /// 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. /// public async Task ReadSelectionAsync(string title, IList 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; } /// /// Awaits the next non-streaming user input submission. /// public Task WaitForInputAsync() { this._pendingInputTcs = new TaskCompletionSource(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); } } /// public void Dispose() { this._appComponent.InputSubmitted -= this.OnInputSubmitted; this._appComponent.Deactivate(); this._appComponent.Dispose(); } /// /// Renders an to a string with ANSI color codes. /// Used as the render delegate for the . /// 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; } /// /// Appends one or more output entries to the output list under lock, /// updates to the last entry's type, and renders. /// 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(this._outputItems), }; } this._appComponent.Render(); } }