// 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,
};
}