// Copyright (c) Microsoft. All rights reserved. using Spectre.Console; namespace Harness.Shared.Console; /// /// Centralizes all console output and spinner management for the harness console. /// Observers write through this class so the spinner is automatically paused before output. /// public sealed class ConsoleWriter : IDisposable { private readonly Spinner _spinner = new(); private readonly IReadOnlyDictionary? _modeColors; private bool _lastWasText; private bool _hasReceivedAnyText; /// /// Initializes a new instance of the class. /// /// Optional mapping of mode names to console colors. public ConsoleWriter(IReadOnlyDictionary? modeColors = null) { this._modeColors = modeColors; } /// /// Gets or sets the current agent mode (e.g., "plan", "execute"). /// Used to determine the console color for mode-prefixed output. /// public string? CurrentMode { get; set; } /// /// Writes the agent response header (e.g., "[plan] Agent: ") and starts the spinner. /// public void WriteResponseHeader() { if (this.CurrentMode is not null) { System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors); System.Console.Write($"\n[{this.CurrentMode}] Agent: "); } else { System.Console.Write("\nAgent: "); } this._lastWasText = true; this._hasReceivedAnyText = false; this._spinner.Start(); } /// /// Writes informational output with automatic prefix spacing, without a trailing newline. /// Use when continuation content will be appended on the same line. /// /// The informational text to write (without leading newline/indent — added automatically). /// Optional console color for the text. public async Task WriteInfoAsync(string text, ConsoleColor? color = null) { await this.WriteInfoCoreAsync(text, color, newLine: false); } /// /// Writes informational output with automatic prefix spacing, followed by a newline. /// /// The informational text to write (without leading newline/indent — added automatically). /// Optional console color for the text. public async Task WriteInfoLineAsync(string text, ConsoleColor? color = null) { await this.WriteInfoCoreAsync(text, color, newLine: true); } private async Task WriteInfoCoreAsync(string text, ConsoleColor? color, bool newLine) { await this._spinner.StopAsync(); string prefix = this._lastWasText ? "\n\n " : " "; this._lastWasText = false; System.Console.ForegroundColor = color ?? GetModeColor(this.CurrentMode, this._modeColors); if (newLine) { System.Console.WriteLine(prefix + text); } else { System.Console.Write(prefix + text); } System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors); this._spinner.Start(); } /// /// Writes text output from the agent, managing line break state. /// Ensures a newline is written before the first text output. /// /// The text to write. /// Optional console color override for this text. public async Task WriteTextAsync(string text, ConsoleColor? color = null) { await this._spinner.StopAsync(); if (!this._lastWasText) { System.Console.Write("\n"); this._lastWasText = true; } this._hasReceivedAnyText = true; if (color.HasValue) { System.Console.ForegroundColor = color.Value; } System.Console.Write(text); if (color.HasValue) { System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors); } this._spinner.Start(); } /// /// Reads a line of input from the console, pausing the spinner while waiting for input. /// Optionally displays a prompt before reading. The prompt is rendered between /// two horizontal rules for visual clarity. /// /// Optional prompt text to display before reading input. /// Optional console color for the prompt text. /// The line read from the console, or null if no input is available. public async Task ReadLineAsync(string? prompt = null, ConsoleColor? promptColor = null) { await this._spinner.StopAsync(); if (prompt is not null) { System.Console.WriteLine(); AnsiConsole.Write(this.CreateModeRule()); if (promptColor.HasValue) { System.Console.ForegroundColor = promptColor.Value; } System.Console.Write($" {prompt}"); if (promptColor.HasValue) { System.Console.ForegroundColor = GetModeColor(this.CurrentMode, this._modeColors); } } string? input = System.Console.ReadLine(); if (prompt is not null) { AnsiConsole.Write(this.CreateModeRule()); } this._lastWasText = false; return input; } /// /// Presents a selection prompt with the given choices, plus an option to type a custom response. /// Uses Spectre.Console for interactive arrow-key selection. /// /// The title/question displayed above the selection list. /// The list of choices to present. /// The selected choice text, or the custom-typed response. public async Task ReadSelectionAsync(string title, IList choices) { await this._spinner.StopAsync(); AnsiConsole.Write(this.CreateModeRule()); const string FreeformOption = "✏️ Type a custom response..."; var allChoices = choices.Concat([FreeformOption]).ToList(); var prompt = new SelectionPrompt() .Title($" [bold]{Markup.Escape(title)}[/]") .PageSize(10) .AddChoices(allChoices); string selection = AnsiConsole.Prompt(prompt); if (selection == FreeformOption) { var textPrompt = new TextPrompt(" [grey]Response:[/]"); selection = AnsiConsole.Prompt(textPrompt); } AnsiConsole.MarkupLine($" [dim]→ {Markup.Escape(selection)}[/]"); AnsiConsole.Write(this.CreateModeRule()); this._lastWasText = false; return selection; } /// /// Writes the stream-complete footer (handles "no text response" fallback, resets color). /// public async Task WriteStreamFooterAsync(bool hasFollowUpMessages) { await this._spinner.StopAsync(); if (!this._hasReceivedAnyText && !hasFollowUpMessages) { System.Console.ForegroundColor = ConsoleColor.DarkYellow; System.Console.Write("\n (no text response from agent)"); } System.Console.ResetColor(); System.Console.WriteLine(); } /// public void Dispose() { this._spinner.Dispose(); } /// /// Gets the console color associated with a mode name, using the provided color map. /// internal static ConsoleColor GetModeColor(string? mode, IReadOnlyDictionary? modeColors = null) { if (mode is null) { return ConsoleColor.Gray; } if (modeColors is not null && modeColors.TryGetValue(mode, out var color)) { return color; } return ConsoleColor.Gray; } /// /// Creates a styled with the current mode color. /// internal Rule CreateModeRule() { var spectreColor = ToSpectreColor(GetModeColor(this.CurrentMode, this._modeColors)); return new Rule().RuleStyle(new Style(spectreColor)); } internal static Color ToSpectreColor(ConsoleColor consoleColor) => consoleColor switch { ConsoleColor.Black => Color.Black, ConsoleColor.DarkBlue => Color.Blue, ConsoleColor.DarkGreen => Color.Green, ConsoleColor.DarkCyan => Color.Teal, ConsoleColor.DarkRed => Color.Red, ConsoleColor.DarkMagenta => Color.Purple, ConsoleColor.DarkYellow => Color.Olive, ConsoleColor.Gray => Color.Silver, ConsoleColor.DarkGray => Color.Grey, ConsoleColor.Blue => Color.Blue1, ConsoleColor.Green => Color.Green1, ConsoleColor.Cyan => Color.Aqua, ConsoleColor.Red => Color.Red1, ConsoleColor.Magenta => Color.Fuchsia, ConsoleColor.Yellow => Color.Yellow, ConsoleColor.White => Color.White, _ => Color.Silver, }; }