// Copyright (c) Microsoft. All rights reserved. using Harness.ConsoleReactiveComponents; using Harness.ConsoleReactiveFramework; using Harness.Shared.Console.Components; using Microsoft.Extensions.AI; namespace Harness.Shared.Console; /// /// The main application component for the Harness console. Manages the scroll region /// and bottom panel (text input, list selection, or streaming indicator). Owns the /// and routes user input events to the /// registered . /// public class HarnessAppComponent : ConsoleReactiveComponent, IDisposable { private readonly TopBottomRule _rule = new(); private readonly ListSelection _listSelection = new(); private readonly TextInput _textInput = new(); private readonly TextScrollPanel _textScrollPanel = new(); private readonly TextPanel _queuedPanel = new(); private readonly AgentStatus _agentStatus = new(); private readonly AgentModeAndHelp _modeAndHelp = new(); private readonly HarnessConsoleUXStateDriver _uxDriver; private readonly TaskCompletionSource _shutdownTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly SemaphoreSlim _followUpGate = new(1, 1); private int _scrollRegionBottom; private bool _resizedSinceLastRender = true; private bool _deactivated; private BottomPanelMode _lastRenderedBottomPanelMode; /// /// 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. /// Factory invoked with the component's /// to construct the that owns the agent loop. /// Optional mapping of mode names to console colors. public HarnessAppComponent( string placeholder, string? initialMode, bool inputEnabled, Func runnerFactory, IReadOnlyDictionary? modeColors = null) { 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), replaceSession: s => this.Runner!.ReplaceSessionAsync(s), 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; } /// /// Gets the agent runner that owns the agent loop. Constructed by the factory /// passed to the component's constructor. /// public HarnessAgentRunner Runner { get; } /// /// Completes when a command handler requests application shutdown (e.g. the user types /exit). /// Awaited by . /// public Task ShutdownTask => this._shutdownTcs.Task; /// /// Deactivates the component, resetting the scroll region and unsubscribing from events. /// This method is idempotent and safe to call multiple times. /// public void Deactivate() { if (this._deactivated) { return; } this._deactivated = true; this._agentStatus.Dispose(); KeyEventListener.Instance.KeyPressed -= this.OnKeyPressed; ConsoleResizeListener.Instance.ConsoleResized -= this.OnConsoleResized; } /// public void Dispose() { this.Dispose(true); GC.SuppressFinalize(this); } /// /// Releases managed resources. /// /// true to release managed resources. protected virtual void Dispose(bool disposing) { if (disposing) { this.Deactivate(); this._followUpGate.Dispose(); this.Runner.Dispose(); } } private void OnKeyPressed(object? sender, KeyPressEventArgs e) { BottomPanelMode mode = this.State!.Mode; if (mode == BottomPanelMode.TextInput) { this.HandleTextInputKey(e); } else if (mode == BottomPanelMode.ListSelection) { this.HandleListSelectionKey(e); } else if (mode == BottomPanelMode.Streaming && this.State.InputEnabled) { this.HandleStreamingInputKey(e); } } private void HandleTextInputKey(KeyPressEventArgs e) { if (e.KeyInfo.Key == ConsoleKey.Enter) { string text = this.State!.InputText; if (string.IsNullOrWhiteSpace(text)) { return; } this.SetState(this.State with { InputText = "" }); this.DispatchTextInputSubmission(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.State!.ListSelectionOptions.Count - 1; if (this.State.ListSelectionCustomTextPlaceholder != null) { maxIndex = this.State.ListSelectionOptions.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 { ListSelectionIndex = Math.Max(0, this.State.ListSelectionIndex - 1) }); } else if (e.KeyInfo.Key == ConsoleKey.DownArrow) { 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.ListSelectionCustomInputText : this.State.ListSelectionOptions[this.State.ListSelectionIndex]; this.SetState(this.State with { ListSelectionCustomInputText = "", ListSelectionIndex = 0 }); this.DispatchListSelectionSubmission(result); } else if (isOnCustomTextOption) { if (e.KeyInfo.Key == ConsoleKey.Backspace) { if (this.State.ListSelectionCustomInputText.Length > 0) { 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 { ListSelectionCustomInputText = this.State.ListSelectionCustomInputText + e.KeyInfo.KeyChar }); } } } private void HandleStreamingInputKey(KeyPressEventArgs e) { if (e.KeyInfo.Key == ConsoleKey.Enter) { string text = this.State!.InputText; if (string.IsNullOrWhiteSpace(text)) { return; } this.SetState(this.State with { InputText = "" }); _ = this.Runner.OnStreamingInputAsync(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 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); } /// /// 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. /// private async Task HandleFollowUpAnswerAsync(string text) { IReadOnlyList? messagesToSend = null; await this._followUpGate.WaitAsync().ConfigureAwait(false); try { HarnessConsoleUXStateDriver ux = this._uxDriver; IReadOnlyList 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; this.SetState(this.State! with { ConsoleWidth = e.NewWidth, ConsoleHeight = e.NewHeight, }); } /// public override void RenderCore(ConsoleReactiveProps props, HarnessAppComponentState state) { if (this._deactivated) { return; } // Calculate queued items panel height int queuedPanelHeight = TextPanel.CalculateHeight(state.QueuedItems, state.ConsoleWidth); // Build the bottom panel child based on mode ConsoleReactiveComponent bottomChild; int bottomChildHeight; if (state.Mode == BottomPanelMode.ListSelection) { var listProps = new ListSelectionProps { Title = state.ListSelectionTitle, Items = state.ListSelectionOptions, SelectedIndex = state.ListSelectionIndex, HighlightColor = state.ListHighlightColor, CustomTextPlaceholder = state.ListSelectionCustomTextPlaceholder, CustomText = state.ListSelectionCustomInputText, }; bottomChildHeight = ListSelection.CalculateHeight(listProps); listProps = listProps with { Height = bottomChildHeight }; this._listSelection.Props = listProps; bottomChild = this._listSelection; } else if (state.Mode == BottomPanelMode.Streaming) { TextInputProps textInputProps; if (state.InputEnabled) { textInputProps = new TextInputProps { Prompt = state.Prompt, Text = state.InputText, Placeholder = state.Placeholder, }; } else { textInputProps = new TextInputProps { Prompt = state.Prompt, Text = "", Placeholder = state.StreamingPrompt, }; } bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth); textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight }; this._textInput.Props = textInputProps; bottomChild = this._textInput; } else { var textInputProps = new TextInputProps { Prompt = state.Prompt, Text = state.InputText, Placeholder = state.Placeholder, }; bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth); textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight }; this._textInput.Props = textInputProps; bottomChild = this._textInput; } // When the bottom panel mode changes, the new child must repaint even if its // props haven't changed — the screen area was overwritten by the previous child. if (state.Mode != this._lastRenderedBottomPanelMode) { bottomChild.Invalidate(); this._lastRenderedBottomPanelMode = state.Mode; } var ruleProps = new TopBottomRuleProps { Width = state.ConsoleWidth, Color = state.ModeColor, Children = [bottomChild], }; var agentStatusProps = new AgentStatusProps { ShowSpinner = state.ShowSpinner, UsageText = state.UsageText, }; var modeAndHelpProps = new AgentModeAndHelpProps { Mode = state.ModeText, ModeColor = state.ModeColor, HelpText = state.HelpText, }; // 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 nonScrollHeight = ruleHeight + 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)) { // 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); // Invalidate all children so they re-render even if props haven't changed this._rule.Invalidate(); this._textScrollPanel.Invalidate(); this._queuedPanel.Invalidate(); this._agentStatus.Invalidate(); this._modeAndHelp.Invalidate(); this._textInput.Invalidate(); this._listSelection.Invalidate(); this._resizedSinceLastRender = false; } this._scrollRegionBottom = scrollBottom; System.Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom)); // Render text scroll panel in the scroll area this._textScrollPanel.Props = new TextScrollPanelProps { X = 1, Y = 1, Width = state.ConsoleWidth, Height = scrollBottom, Items = state.ScrollAreaContentItems, }; this._textScrollPanel.Render(); // Render queued input items between scroll area and agent status int queuedPanelY = scrollBottom + 1; this._queuedPanel.Props = new TextPanelProps { X = 1, Y = queuedPanelY, Width = state.ConsoleWidth, Height = queuedPanelHeight, Items = state.QueuedItems, }; this._queuedPanel.Render(); // Render the agent status line between queued items and rule int agentStatusY = queuedPanelY + queuedPanelHeight; if (showStatusAndHelp) { this._agentStatus.Props = agentStatusProps with { X = 1, Y = agentStatusY, Width = state.ConsoleWidth, Height = agentStatusHeight, }; this._agentStatus.Render(); } // Render the bottom rule + child below the agent status this._rule.Props = ruleProps with { X = 1, Y = agentStatusY + agentStatusHeight, }; this._rule.Render(); // Render the mode-and-help line below the bottom rule if (showStatusAndHelp) { int modeAndHelpY = agentStatusY + agentStatusHeight + ruleHeight; this._modeAndHelp.Props = modeAndHelpProps with { X = 1, Y = modeAndHelpY, Width = state.ConsoleWidth, Height = modeAndHelpHeight, }; this._modeAndHelp.Render(); } // Clear the bottom padding line System.Console.Write(AnsiEscapes.MoveAndEraseLine(state.ConsoleHeight)); // Position cursor for natural typing appearance this.PositionCursor(state); } private void PositionCursor(HarnessAppComponentState state) { if (state.Mode == BottomPanelMode.TextInput || (state.Mode == BottomPanelMode.Streaming && state.InputEnabled)) { int promptLength = state.Prompt.Length; int textWidth = state.ConsoleWidth - promptLength; int textLength = state.InputText.Length; int textInputY = (this._rule.Props?.Y ?? 0) + 1; if (textWidth <= 0 || textLength == 0) { System.Console.Write(AnsiEscapes.MoveCursor(textInputY, promptLength + 1)); } else { int cursorRow = textLength < textWidth ? 0 : 1 + ((textLength - textWidth) / textWidth); int cursorCol = textLength < textWidth ? textLength : (textLength - textWidth) % textWidth; System.Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1)); } } else if (state.Mode == BottomPanelMode.ListSelection && state.ListSelectionCustomTextPlaceholder != null && state.ListSelectionIndex == state.ListSelectionOptions.Count) { int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0; int customOptionY = (this._rule.Props?.Y ?? 0) + 1 + titleLines + state.ListSelectionOptions.Count; int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1; System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol)); } } }