// Copyright (c) Microsoft. All rights reserved. using Harness.ConsoleReactiveComponents; using Harness.ConsoleReactiveFramework; using Harness.Shared.Console.Components; namespace Harness.Shared.Console; /// /// Determines which component is shown in the bottom panel. /// public enum BottomPanelMode { /// Show the text input component for user input. TextInput, /// Show the list selection component for interactive prompts. ListSelection, /// Show a disabled input indicator during agent streaming. Streaming, } /// /// Event arguments for the event. /// public sealed class InputSubmittedEventArgs : EventArgs { /// /// Initializes a new instance of the class. /// /// The submitted text. /// The bottom panel mode in which the input was submitted. public InputSubmittedEventArgs(string text, BottomPanelMode mode) { this.Text = text; this.Mode = mode; } /// Gets the submitted text. public string Text { get; } /// Gets the bottom panel mode in which the input was submitted. public BottomPanelMode Mode { get; } } /// /// Props for . /// public record HarnessAppComponentProps : ConsoleReactiveProps { /// Gets or sets the list selection choices (for ListSelection mode). public IReadOnlyList Items { get; set; } = Array.Empty(); /// Gets or sets the scroll items (output entries) to render in the scroll panel. public IReadOnlyList ScrollItems { get; set; } = []; /// Gets or sets the bottom panel mode. public BottomPanelMode Mode { get; set; } = BottomPanelMode.TextInput; /// Gets or sets the prompt string for text input mode. public string Prompt { get; set; } = "You: "; /// Gets or sets the placeholder text shown when the input is empty. public string Placeholder { get; set; } = ""; /// Gets or sets the highlight color for the active list item. public ConsoleColor ListHighlightColor { get; set; } = ConsoleColor.Cyan; /// Gets or sets the placeholder text for the custom text input option in the list. public string? ListCustomTextPlaceholder { get; set; } /// Gets or sets the foreground color for the rule borders and mode label. public ConsoleColor? ModeColor { get; set; } /// Gets or sets the current mode name displayed below the bottom rule (e.g. "plan"). public string? ModeText { get; set; } /// Gets or sets the help text displayed below the bottom rule (available commands). public string? HelpText { get; set; } /// Gets or sets the title text displayed above the list selection (for interactive prompts). public string? ListTitle { get; set; } /// Gets or sets a value indicating whether input is enabled during streaming. public bool InputEnabled { get; set; } /// Gets or sets the prompt to show during streaming when input is disabled. public string StreamingPrompt { get; set; } = "(agent is running...)"; /// Gets or sets a value indicating whether the agent status spinner is visible. public bool ShowSpinner { get; set; } /// Gets or sets the formatted token usage text to display in the status bar. public string? UsageText { get; set; } /// Gets or sets the queued input items to display above the rule. public IReadOnlyList QueuedItems { get; set; } = []; } /// /// Internal state for . /// public record HarnessAppComponentState : ConsoleReactiveState { /// Gets the selected index in list selection mode. public int SelectedIndex { get; init; } /// Gets the current input text being typed. public string InputText { get; init; } = ""; /// Gets the current text being typed into the list's custom text option. public string ListInputText { get; init; } = ""; /// Gets the current console width in columns. public int ConsoleWidth { get; init; } /// Gets the current console height in rows. public int ConsoleHeight { get; init; } } /// /// 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 event when the user submits text in any mode. /// public class HarnessAppComponent : ConsoleReactiveComponent, IDisposable { private readonly TopBottomRule _rule = new(); private readonly ListSelection _listSelection = new(); private readonly TextInput _textInput = new(); private readonly TextScrollPanel _textScrollPanel; private readonly TextPanel _textPanel; private readonly TextPanel _queuedPanel; private readonly AgentStatus _agentStatus = new(); private readonly AgentModeAndHelp _modeAndHelp = new(); private readonly Func _renderItem; private bool _resizedSinceLastRender; private bool _deactivated; /// /// Initializes a new instance of the class. /// /// A delegate that renders a single output entry and returns the text to display. public HarnessAppComponent(Func renderScrollItem) { this._renderItem = renderScrollItem; this._textScrollPanel = new TextScrollPanel(renderScrollItem); this._textPanel = new TextPanel(renderScrollItem); this._queuedPanel = new TextPanel(renderScrollItem); this.State = new HarnessAppComponentState { ConsoleWidth = System.Console.WindowWidth, ConsoleHeight = System.Console.WindowHeight, }; KeyEventListener.Instance.KeyPressed += this.OnKeyPressed; ConsoleResizeListener.Instance.ConsoleResized += this.OnConsoleResized; } /// /// Gets the 1-based row number of the last row in the output scroll region. /// public int ScrollRegionBottom { get; private set; } /// /// Occurs when the user submits input via Enter, in any mode (text input, list selection, /// or streaming injection). Consumers inspect /// to decide how to handle the submission. /// public event EventHandler? InputSubmitted; /// /// 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; System.Console.Write(AnsiEscapes.ResetScrollRegion); System.Console.Write(AnsiEscapes.MoveCursor(System.Console.WindowHeight, 1)); System.Console.WriteLine(); } /// 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(); } } private void OnKeyPressed(object? sender, KeyPressEventArgs e) { if (this.Props!.Mode == BottomPanelMode.TextInput) { this.HandleTextInputKey(e); } else if (this.Props.Mode == BottomPanelMode.ListSelection) { this.HandleListSelectionKey(e); } else if (this.Props.Mode == BottomPanelMode.Streaming && this.Props.InputEnabled) { this.HandleStreamingInputKey(e); } } private void HandleTextInputKey(KeyPressEventArgs e) { if (e.KeyInfo.Key == ConsoleKey.Enter) { string text = this.State!.InputText; if (string.IsNullOrWhiteSpace(text)) { return; } this.SetState(this.State with { InputText = "" }); this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.TextInput)); } else if (e.KeyInfo.Key == ConsoleKey.Backspace) { if (this.State!.InputText.Length > 0) { this.SetState(this.State with { InputText = this.State.InputText[..^1] }); } } else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar)) { this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar }); } } private void HandleListSelectionKey(KeyPressEventArgs e) { int maxIndex = this.Props!.Items.Count - 1; if (this.Props.ListCustomTextPlaceholder != null) { maxIndex = this.Props.Items.Count; } bool isOnCustomTextOption = this.Props.ListCustomTextPlaceholder != null && this.State!.SelectedIndex == this.Props.Items.Count; if (e.KeyInfo.Key == ConsoleKey.UpArrow) { this.SetState(this.State! with { SelectedIndex = Math.Max(0, this.State.SelectedIndex - 1) }); } else if (e.KeyInfo.Key == ConsoleKey.DownArrow) { this.SetState(this.State! with { SelectedIndex = Math.Min(maxIndex, this.State.SelectedIndex + 1) }); } else if (e.KeyInfo.Key == ConsoleKey.Enter) { string result = isOnCustomTextOption ? this.State!.ListInputText : this.Props.Items[this.State!.SelectedIndex]; this.SetState(this.State with { ListInputText = "", SelectedIndex = 0 }); this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(result, BottomPanelMode.ListSelection)); } else if (isOnCustomTextOption) { if (e.KeyInfo.Key == ConsoleKey.Backspace) { if (this.State!.ListInputText.Length > 0) { this.SetState(this.State with { ListInputText = this.State.ListInputText[..^1] }); } } else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar)) { this.SetState(this.State! with { ListInputText = this.State.ListInputText + e.KeyInfo.KeyChar }); } } } private void HandleStreamingInputKey(KeyPressEventArgs e) { // During streaming with input enabled, capture text for message injection if (e.KeyInfo.Key == ConsoleKey.Enter) { string text = this.State!.InputText; if (string.IsNullOrWhiteSpace(text)) { return; } this.SetState(this.State with { InputText = "" }); this.InputSubmitted?.Invoke(this, new InputSubmittedEventArgs(text, BottomPanelMode.Streaming)); } else if (e.KeyInfo.Key == ConsoleKey.Backspace) { if (this.State!.InputText.Length > 0) { this.SetState(this.State with { InputText = this.State.InputText[..^1] }); } } else if (e.KeyInfo.KeyChar != '\0' && !char.IsControl(e.KeyInfo.KeyChar)) { this.SetState(this.State! with { InputText = this.State.InputText + e.KeyInfo.KeyChar }); } } private void OnConsoleResized(object? sender, ConsoleResizeEventArgs e) { this._resizedSinceLastRender = true; this.SetState(this.State! with { ConsoleWidth = e.NewWidth, ConsoleHeight = e.NewHeight, }); } /// public override void RenderCore(HarnessAppComponentProps props, HarnessAppComponentState state) { // Determine the text panel height for the last scroll item IReadOnlyList lastItems = props.ScrollItems.Count > 0 ? [props.ScrollItems[^1]] : []; int textPanelHeight = TextPanel.CalculateHeight(lastItems, this._renderItem); if (textPanelHeight > 0) { textPanelHeight++; // Extra line for spacing between text panel and rule } // Calculate queued items panel height int queuedPanelHeight = TextPanel.CalculateHeight(props.QueuedItems, this._renderItem); // Build the bottom panel child based on mode ConsoleReactiveComponent bottomChild; int bottomChildHeight; if (props.Mode == BottomPanelMode.ListSelection) { var listProps = new ListSelectionProps { Title = props.ListTitle, Items = props.Items, SelectedIndex = state.SelectedIndex, HighlightColor = props.ListHighlightColor, CustomTextPlaceholder = props.ListCustomTextPlaceholder, CustomText = state.ListInputText, }; bottomChildHeight = ListSelection.CalculateHeight(listProps); this._listSelection.Height = bottomChildHeight; this._listSelection.Props = listProps; bottomChild = this._listSelection; } else if (props.Mode == BottomPanelMode.Streaming) { TextInputProps textInputProps; if (props.InputEnabled) { textInputProps = new TextInputProps { Prompt = props.Prompt, Text = state.InputText, Placeholder = props.Placeholder, }; } else { textInputProps = new TextInputProps { Prompt = props.Prompt, Text = "", Placeholder = props.StreamingPrompt, }; } bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth); this._textInput.Width = state.ConsoleWidth; this._textInput.Height = bottomChildHeight; this._textInput.Props = textInputProps; bottomChild = this._textInput; } else { var textInputProps = new TextInputProps { Prompt = props.Prompt, Text = state.InputText, Placeholder = props.Placeholder, }; bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth); this._textInput.Width = state.ConsoleWidth; this._textInput.Height = bottomChildHeight; this._textInput.Props = textInputProps; bottomChild = this._textInput; } var ruleProps = new TopBottomRuleProps { Width = state.ConsoleWidth, Color = props.ModeColor, Children = [bottomChild], }; // Calculate the agent status height var agentStatusProps = new AgentStatusProps { ShowSpinner = props.ShowSpinner, UsageText = props.UsageText, }; int agentStatusHeight = AgentStatus.CalculateHeight(agentStatusProps); // Calculate the mode-and-help height var modeAndHelpProps = new AgentModeAndHelpProps { Mode = props.ModeText, ModeColor = props.ModeColor, HelpText = props.HelpText, }; int modeAndHelpHeight = AgentModeAndHelp.CalculateHeight(modeAndHelpProps); int ruleHeight = TopBottomRule.CalculateHeight(ruleProps); int scrollBottom = Math.Max(1, state.ConsoleHeight - ruleHeight - textPanelHeight - agentStatusHeight - queuedPanelHeight - modeAndHelpHeight); // If scroll region changed or a clear is needed, reset everything if (this._resizedSinceLastRender || (this.ScrollRegionBottom != 0 && scrollBottom != this.ScrollRegionBottom)) { System.Console.Write(AnsiEscapes.EraseEntireScreen); System.Console.Write(AnsiEscapes.EraseScrollbackBuffer); this._textScrollPanel.Reset(); this._resizedSinceLastRender = false; } this.ScrollRegionBottom = scrollBottom; System.Console.Write(AnsiEscapes.SetScrollRegion(scrollBottom)); // Render text scroll panel in the scroll area (all items except the last) IReadOnlyList scrollItems = props.ScrollItems.Count > 1 ? props.ScrollItems.Take(props.ScrollItems.Count - 1).ToList() : []; this._textScrollPanel.X = 1; this._textScrollPanel.Y = 1; this._textScrollPanel.Width = state.ConsoleWidth; this._textScrollPanel.Height = scrollBottom; this._textScrollPanel.Props = new TextScrollPanelProps { Items = scrollItems, }; this._textScrollPanel.Render(); // Render the text panel for the last (dynamic) item just below the scroll region this._textPanel.X = 1; this._textPanel.Y = scrollBottom + 1; this._textPanel.Width = state.ConsoleWidth; this._textPanel.Height = textPanelHeight; this._textPanel.Props = new TextPanelProps { Items = lastItems, }; this._textPanel.Render(); // Render queued input items between text panel and agent status int queuedPanelY = scrollBottom + textPanelHeight + 1; this._queuedPanel.X = 1; this._queuedPanel.Y = queuedPanelY; this._queuedPanel.Width = state.ConsoleWidth; this._queuedPanel.Height = queuedPanelHeight; this._queuedPanel.Props = new TextPanelProps { Items = props.QueuedItems, }; this._queuedPanel.Render(); // Render the agent status line between queued items and rule int agentStatusY = queuedPanelY + queuedPanelHeight; this._agentStatus.X = 1; this._agentStatus.Y = agentStatusY; this._agentStatus.Width = state.ConsoleWidth; this._agentStatus.Height = agentStatusHeight; this._agentStatus.Props = agentStatusProps; this._agentStatus.Render(); // Render the bottom rule + child below the agent status this._rule.X = 1; this._rule.Y = agentStatusY + agentStatusHeight; this._rule.Props = ruleProps; this._rule.Render(); // Render the mode-and-help line below the bottom rule int modeAndHelpY = this._rule.Y + ruleHeight; this._modeAndHelp.X = 1; this._modeAndHelp.Y = modeAndHelpY; this._modeAndHelp.Width = state.ConsoleWidth; this._modeAndHelp.Height = modeAndHelpHeight; this._modeAndHelp.Props = modeAndHelpProps; this._modeAndHelp.Render(); // Position cursor for natural typing appearance this.PositionCursor(props, state); } private void PositionCursor(HarnessAppComponentProps props, HarnessAppComponentState state) { if (props.Mode == BottomPanelMode.TextInput || (props.Mode == BottomPanelMode.Streaming && props.InputEnabled)) { int promptLength = props.Prompt.Length; int textWidth = state.ConsoleWidth - promptLength; int textLength = state.InputText.Length; int textInputY = this._rule.Y + 1; if (textWidth <= 0 || textLength == 0) { System.Console.Write(AnsiEscapes.MoveCursor(textInputY, promptLength + 1)); } else { int cursorRow = textLength < textWidth ? 0 : 1 + ((textLength - textWidth) / textWidth); int cursorCol = textLength < textWidth ? textLength : (textLength - textWidth) % textWidth; System.Console.Write(AnsiEscapes.MoveCursor(textInputY + cursorRow, promptLength + cursorCol + 1)); } } else if (props.Mode == BottomPanelMode.ListSelection && props.ListCustomTextPlaceholder != null && state.SelectedIndex == props.Items.Count) { int titleLines = props.ListTitle?.Split('\n').Length ?? 0; int customOptionY = this._rule.Y + 1 + titleLines + props.Items.Count; int cursorCol = 2 + state.ListInputText.Length + 1; System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol)); } } }