// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils.Markdown;
using Aspire.Shared;
using Spectre.Console;
namespace Aspire.Cli.Utils;
/// <summary>
/// Lightweight, spec-aligned console logger for aligned colored task output without
/// rewriting the entire existing publishing pipeline. Integrates by mapping publish
/// step/task events to Start/Progress/Success/Warning/Failure calls.
/// </summary>
internal sealed class ConsoleActivityLogger
{
private readonly IAnsiConsole _console;
private readonly bool _enableColor;
private readonly ICliHostEnvironment _hostEnvironment;
private readonly bool _isDebugOrTraceLoggingEnabled;
private readonly object _lock = new();
private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
private readonly Dictionary<string, string> _stepColors = new();
private readonly Dictionary<string, ActivityState> _stepStates = new(); // Track final state per step for summary
private readonly Dictionary<string, string> _displayNames = new(); // Optional friendly display names for step keys
private List<StepDurationRecord>? _durationRecords; // Optional per-step duration breakdown
private readonly string[] _availableColors = ["blue", "cyan", "yellow", "magenta", "purple", "orange3"];
private int _colorIndex;
private int _successCount;
private int _warningCount;
private int _failureCount;
private volatile bool _spinning;
private Task? _spinnerTask;
private readonly char[] _spinnerChars = ['|', '/', '-', '\\'];
private int _spinnerIndex;
private string? _finalStatusHeader;
private bool _pipelineSucceeded;
private IReadOnlyList<BackchannelPipelineSummaryItem>? _pipelineSummary;
private TimeSpan? _summaryElapsedOverride;
// No raw ANSI escape codes; rely on Spectre.Console markup tokens.
private const string SuccessSymbol = "✓";
private const string FailureSymbol = "✗";
// The warning symbol is intentionally not ⚠ because that character can be displayed as an emoji in some terminals, causing rendering issues.
private const string WarningSymbol = "△";
private const string InProgressSymbol = "→";
private const string InfoSymbol = "i";
private const int SummaryTimelineWidth = 28;
private const int SummaryTimelineTicks = 4;
public ConsoleActivityLogger(IAnsiConsole console, ICliHostEnvironment hostEnvironment, bool isDebugOrTraceLoggingEnabled = false, bool? forceColor = null)
{
_console = console;
_hostEnvironment = hostEnvironment;
_enableColor = forceColor ?? _hostEnvironment.SupportsAnsi;
_isDebugOrTraceLoggingEnabled = isDebugOrTraceLoggingEnabled;
// Disable spinner in non-interactive environments
if (!_hostEnvironment.SupportsInteractiveOutput)
{
_spinning = false;
}
}
public enum ActivityState
{
InProgress,
Success,
Warning,
Failure,
Info
}
public void StartTask(string taskKey, string? startingMessage = null)
{
lock (_lock)
{
// Initialize step state as InProgress if first time seen
if (!_stepStates.ContainsKey(taskKey))
{
_stepStates[taskKey] = ActivityState.InProgress;
}
}
WriteLine(taskKey, InProgressSymbol, startingMessage ?? ConsoleActivityLoggerStrings.ActivityStarting, ActivityState.InProgress);
}
public void StartTask(string taskKey, string displayName, string? startingMessage = null)
{
lock (_lock)
{
if (!_stepStates.ContainsKey(taskKey))
{
_stepStates[taskKey] = ActivityState.InProgress;
}
_displayNames[taskKey] = displayName;
}
WriteLine(taskKey, InProgressSymbol, startingMessage ?? string.Format(CultureInfo.CurrentCulture, ConsoleActivityLoggerStrings.ActivityStartingWithName, displayName), ActivityState.InProgress);
}
public void StartSpinner()
{
// Skip spinner in non-interactive environments
if (!_hostEnvironment.SupportsInteractiveOutput || _spinning)
{
return;
}
_spinning = true;
_spinnerTask = Task.Run(async () =>
{
lock (_lock)
{
SafeSetCursorVisible(visible: false);
}
try
{
while (_spinning)
{
var spinChar = _spinnerChars[_spinnerIndex % _spinnerChars.Length];
// Take the same lock used by WriteLine so the spinner write and the
// corresponding MoveLeft are atomic relative to other console output.
// Without this, a concurrent WriteLine could land between the Write
// and the MoveLeft, leaving the cursor at column 0; Spectre's legacy
// cursor backend (used whenever ANSI is disabled) then evaluates
// `Console.CursorLeft -= 1`, which throws
// "value must be greater than or equal to zero and less than the
// console's buffer size in that dimension. (Parameter 'left')".
lock (_lock)
{
_console.Write(spinChar.ToString());
SafeMoveCursorLeft();
}
_spinnerIndex++;
await Task.Delay(120).ConfigureAwait(false);
}
}
finally
{
lock (_lock)
{
// Clear spinner character
_console.Write(" ");
SafeMoveCursorLeft();
SafeSetCursorVisible(visible: true);
}
}
});
}
private void SafeMoveCursorLeft()
{
try
{
_console.Cursor.MoveLeft();
}
catch (ArgumentOutOfRangeException)
{
// Spectre's legacy cursor backend does not clamp negative coordinates, so
// moving left from column 0 (e.g. when the just-written spinner char wrapped
// to a new line, or the cursor was already at column 0) throws. Swallow it;
// the only consequence is a stray spinner glyph, never a crash.
}
catch (IOException)
{
// Cursor manipulation can fail on redirected/non-tty outputs. Spinner output
// is purely cosmetic, so there is nothing meaningful to do here.
}
}
private void SafeSetCursorVisible(bool visible)
{
try
{
if (visible)
{
_console.Cursor.Show();
}
else
{
_console.Cursor.Hide();
}
}
catch (IOException)
{
// Spectre's legacy cursor backend implements Show/Hide as
// `System.Console.CursorVisible = X`, which throws IOException when stdout
// is redirected or attached to a non-tty. The spinner is purely cosmetic,
// so a missing show/hide is not worth crashing the command (especially in
// the `finally` block, where an exception would also fault the spinner task
// and surface from StopSpinnerAsync).
}
catch (PlatformNotSupportedException)
{
// Some platforms (notably non-Windows for the setter) do not support
// Console.CursorVisible. Same rationale as above: cosmetic, ignore.
}
}
public async Task StopSpinnerAsync()
{
_spinning = false;
if (_spinnerTask is not null)
{
await _spinnerTask.ConfigureAwait(false);
_spinnerTask = null;
}
}
public void Progress(string taskKey, string message)
{
WriteLine(taskKey, InProgressSymbol, message, ActivityState.InProgress);
}
public void Success(string taskKey, string message, double? seconds = null)
{
lock (_lock)
{
_successCount++;
_stepStates[taskKey] = ActivityState.Success;
}
WriteCompletion(taskKey, SuccessSymbol, message, ActivityState.Success, seconds);
}
public void Warning(string taskKey, string message, double? seconds = null)
{
lock (_lock)
{
_warningCount++;
_stepStates[taskKey] = ActivityState.Warning;
}
WriteCompletion(taskKey, WarningSymbol, message, ActivityState.Warning, seconds);
}
public void Failure(string taskKey, string message, double? seconds = null)
{
lock (_lock)
{
_failureCount++;
_stepStates[taskKey] = ActivityState.Failure;
}
WriteCompletion(taskKey, FailureSymbol, message, ActivityState.Failure, seconds);
}
public void Info(string taskKey, string message, bool dim = false)
{
WriteLine(taskKey, InfoSymbol, message, ActivityState.Info, dim);
}
public void Continuation(string message)
{
lock (_lock)
{
// Continuation lines: indent with two spaces relative to the symbol column for readability
const string continuationPrefix = " ";
foreach (var line in SplitLinesPreserve(message))
{
_console.Write(continuationPrefix);
_console.WriteLine(line);
}
}
}
public void WriteSummary()
{
lock (_lock)
{
var totalDuration = _summaryElapsedOverride ?? _stopwatch.Elapsed;
var totalSeconds = totalDuration.TotalSeconds;
var line = new string('-', 60);
_console.MarkupLine(line);
var totalSteps = _stepStates.Count;
// Derive per-step outcome counts from _stepStates (not task-level counters) for accurate X/Y display.
var succeededSteps = _stepStates.Values.Count(v => v == ActivityState.Success);
var warningSteps = _stepStates.Values.Count(v => v == ActivityState.Warning);
var failedSteps = _stepStates.Values.Count(v => v == ActivityState.Failure);
var summaryParts = new List<string>();
var succeededSegment = totalSteps > 0
? string.Format(CultureInfo.CurrentCulture, ConsoleActivityLoggerStrings.SummaryStepsSucceededWithTotal, succeededSteps, totalSteps)
: string.Format(CultureInfo.CurrentCulture, ConsoleActivityLoggerStrings.SummaryStepsSucceeded, succeededSteps);
if (_enableColor)
{
summaryParts.Add($"[green]{ConsoleHelpers.FormatEmojiPrefix(KnownEmojis.CheckMarkButton, _console, suppressColor: true)}{succeededSegment}[/]");
if (warningSteps > 0)
{
var warningText = warningSteps == 1
? string.Format(CultureInfo.CurrentCulture, ConsoleActivityLoggerStrings.SummaryWarningsSingular, warningSteps)
: string.Format(CultureInfo.CurrentCulture, ConsoleActivityLoggerStrings.SummaryWarningsPlural, warningSteps);
summaryParts.Add($"[yellow]{ConsoleHelpers.FormatEmojiPrefix(KnownEmojis.Warning, _console, suppressColor: true)}{warningText}[/]");
}
if (failedSteps > 0)
{
summaryParts.Add($"[red]{ConsoleHelpers.FormatEmojiPrefix(KnownEmojis.CrossMark, _console, suppressColor: true)}{string.Format(CultureInfo.CurrentCulture, ConsoleActivityLoggerStrings.SummaryFailed, failedSteps)}[/]");
}
}
else
{
summaryParts.Add($"{SuccessSymbol} {succeededSegment}");
if (warningSteps > 0)
{
var warningText = warningSteps == 1
? string.Format(CultureInfo.CurrentCulture, ConsoleActivityLoggerStrings.SummaryWarningsSingular, warningSteps)
: string.Format(CultureInfo.CurrentCulture, ConsoleActivityLoggerStrings.SummaryWarningsPlural, warningSteps);
summaryParts.Add($"{WarningSymbol} {warningText}");
}
if (failedSteps > 0)
{
summaryParts.Add($"{FailureSymbol} {string.Format(CultureInfo.CurrentCulture, ConsoleActivityLoggerStrings.SummaryFailed, failedSteps)}");
}
}
summaryParts.Add(string.Format(CultureInfo.CurrentCulture, ConsoleActivityLoggerStrings.SummaryTotalTime, DurationFormatter.FormatDuration(TimeSpan.FromSeconds(totalSeconds), CultureInfo.InvariantCulture, DecimalDurationDisplay.Fixed)));
_console.MarkupLine(string.Join(" • ", summaryParts));
if (_durationRecords is { Count: > 0 })
{
WriteStepDurationsSummary(_durationRecords);
}
// If a caller provided a final status line via SetFinalResult, print it now
if (!string.IsNullOrEmpty(_finalStatusHeader))
{
_console.MarkupLine(_finalStatusHeader!);
// Display pipeline summary if available (for successful deployments)
// Store in local variable to avoid potential threading issues
var pipelineSummary = _pipelineSummary;
if (_pipelineSucceeded && pipelineSummary is { Count: > 0 })
{
_console.WriteLine();
foreach (var item in pipelineSummary)
{
var formattedLine = FormatPipelineSummaryItem(item);
_console.MarkupLine(formattedLine);
}
}
// If pipeline failed and not already in debug/trace mode, show help message about using --log-level debug
if (!_pipelineSucceeded && !_isDebugOrTraceLoggingEnabled)
{
var helpMessage = _enableColor
? $"[dim]{ConsoleActivityLoggerStrings.SummaryLogLevelHelp}[/]"
: ConsoleActivityLoggerStrings.SummaryLogLevelHelp;
_console.MarkupLine(helpMessage);
}
}
_console.MarkupLine(line);
_console.WriteLine(); // Ensure final newline after deployment summary
}
}
/// <summary>
/// Formats a pipeline summary item for display.
/// Values with Markdown enabled are converted to Spectre markup; plain-text values are escaped.
/// </summary>
private string FormatPipelineSummaryItem(BackchannelPipelineSummaryItem item)
{
if (_enableColor)
{
var escapedKey = item.Key.EscapeMarkup();
var convertedValue = item.EnableMarkdown
? MarkdownToSpectreConverter.ConvertToSpectre(item.Value)
: item.Value.EscapeMarkup();
convertedValue = HighlightMessage(convertedValue);
return $" [blue]{escapedKey}[/]: {convertedValue}";
}
else
{
var plainKey = item.Key.EscapeMarkup();
var plainValue = item.EnableMarkdown
? MarkdownLinkConverter.ConvertLinksToPlainText(item.Value).EscapeMarkup()
: item.Value.EscapeMarkup();
return $" {plainKey}: {plainValue}";
}
}
/// <summary>
/// Sets the final pipeline result lines to be displayed in the summary (e.g., PIPELINE FAILED ...).
/// Optional usage so existing callers remain compatible.
/// </summary>
/// <param name="succeeded">Whether the pipeline succeeded.</param>
/// <param name="pipelineSummary">Optional pipeline summary as key-value pairs to display after the result. The list preserves insertion order.</param>
public void SetFinalResult(bool succeeded, IReadOnlyList<BackchannelPipelineSummaryItem>? pipelineSummary = null)
{
_pipelineSucceeded = succeeded;
_pipelineSummary = pipelineSummary;
// Always show only a single final header line with symbol; no per-step duplication.
if (succeeded)
{
_finalStatusHeader = _enableColor
? $"[green]{ConsoleHelpers.FormatEmojiPrefix(KnownEmojis.CheckMarkButton, _console, suppressColor: true)}{ConsoleActivityLoggerStrings.PipelineSucceeded}[/]"
: $"{SuccessSymbol} {ConsoleActivityLoggerStrings.PipelineSucceeded}";
}
else
{
_finalStatusHeader = _enableColor
? $"[red]{ConsoleHelpers.FormatEmojiPrefix(KnownEmojis.CrossMark, _console, suppressColor: true)}{ConsoleActivityLoggerStrings.PipelineFailed}[/]"
: $"{FailureSymbol} {ConsoleActivityLoggerStrings.PipelineFailed}";
}
}
/// <summary>
/// Provides per-step duration data for inclusion in the summary.
/// </summary>
public void SetStepDurations(IEnumerable<StepDurationRecord> records)
{
_durationRecords = records.ToList();
}
internal void SeedSummaryState(IEnumerable<StepDurationRecord> records)
{
var recordList = records.ToList();
lock (_lock)
{
_stepStates.Clear();
_displayNames.Clear();
foreach (var record in recordList)
{
_stepStates[record.Key] = record.State;
_displayNames[record.Key] = record.DisplayName;
}
_summaryElapsedOverride = recordList.Count > 0
? recordList.Max(r => r.EndOffset > TimeSpan.Zero ? r.EndOffset : r.Duration)
: TimeSpan.Zero;
}
}
public readonly record struct StepDurationRecord(
string Key,
string DisplayName,
ActivityState State,
TimeSpan Duration,
string? FailureReason,
string? ParentKey = null,
int Level = 0,
int Sequence = 0,
TimeSpan StartOffset = default,
TimeSpan EndOffset = default);
private void WriteStepDurationsSummary(IReadOnlyList<StepDurationRecord> records)
{
var orderedRecords = OrderStepDurationsHierarchically(records);
if (orderedRecords.Count == 0)
{
return;
}
var summaryTitle = SharedCommandStrings.PipelineStepsSummaryTitle;
var timelineLabel = SharedCommandStrings.PipelineStepTimelineLabel;
var totalTimeline = orderedRecords.Max(r => r.EndOffset > TimeSpan.Zero ? r.EndOffset : r.Duration);
var durationWidth = Math.Max(10, orderedRecords.Max(r => FormatSummaryDuration(r.Duration, totalTimeline).Length));
var nameWidth = Math.Max(timelineLabel.Length, orderedRecords.Max(r => StringUtils.RemoveMarkup(GetIndentedDisplayName(r)).Length));
var renderTimeline = ShouldRenderTimeline(durationWidth, nameWidth, totalTimeline);
var timelinePrefix = $" {new string(' ', durationWidth)} {new string(' ', nameWidth)} ";
var timelineLabelPrefix = $" {new string(' ', durationWidth)} {timelineLabel.PadRight(nameWidth)} ";
_console.WriteLine();
_console.MarkupLine(summaryTitle);
if (renderTimeline)
{
_console.MarkupLine($"{timelineLabelPrefix}[dim]{BuildTimelineLabels(totalTimeline, SummaryTimelineWidth).EscapeMarkup()}[/]");
_console.MarkupLine($"{timelinePrefix}[dim]{BuildTimelineScale(SummaryTimelineWidth).EscapeMarkup()}[/]");
}
foreach (var rec in orderedRecords)
{
var durStr = FormatSummaryDuration(rec.Duration, totalTimeline).PadLeft(durationWidth);
var stateSymbol = rec.State switch
{
ActivityState.Success => SuccessSymbol,
ActivityState.Warning => WarningSymbol,
ActivityState.Failure => FailureSymbol,
ActivityState.Info => InfoSymbol,
_ => InProgressSymbol
};
var symbol = _enableColor ? $"[{GetStateColor(rec.State)}]{stateSymbol}[/]" : stateSymbol;
var displayName = GetIndentedDisplayName(rec);
var plainDisplayName = StringUtils.RemoveMarkup(displayName);
// Pad based on visible (plain-text) width, then re-append the markup name so tags render correctly.
var padding = renderTimeline ? Math.Max(0, nameWidth - plainDisplayName.Length) : 0;
var name = displayName + new string(' ', padding);
// FailureReason is already Spectre-safe (pre-processed through ConvertTextWithMarkdownFlag which escapes or converts markdown).
var reason = rec.State == ActivityState.Failure && !string.IsNullOrEmpty(rec.FailureReason)
? (_enableColor ? $" [red]— {HighlightMessage(rec.FailureReason!)}[/]" : $" — {rec.FailureReason!}")
: string.Empty;
var lineSb = new StringBuilder();
lineSb.Append(" ")
.Append(durStr).Append(" ")
.Append(symbol).Append(' ')
.Append("[dim]").Append(name).Append("[/]");
if (renderTimeline)
{
var timelineBar = ColorizeSummaryBar(BuildTimelineBar(rec, totalTimeline, SummaryTimelineWidth), rec.State);
lineSb.Append(" ").Append(timelineBar);
}
lineSb.Append(reason);
_console.MarkupLine(lineSb.ToString());
}
_console.WriteLine();
}
private static List<StepDurationRecord> OrderStepDurationsHierarchically(IReadOnlyList<StepDurationRecord> records)
{
var orderedRecords = records
.OrderBy(r => r.Sequence)
.ThenBy(r => r.DisplayName, StringComparers.CommandName)
.ToList();
var recordsByKey = orderedRecords.ToDictionary(r => r.Key, StringComparers.CommandName);
var childrenByParent = new Dictionary<string, List<StepDurationRecord>>(StringComparers.CommandName);
foreach (var record in orderedRecords)
{
if (record.ParentKey is { Length: > 0 } parentKey &&
!string.Equals(parentKey, record.Key, StringComparisons.CommandName) &&
recordsByKey.ContainsKey(parentKey))
{
if (!childrenByParent.TryGetValue(parentKey, out var children))
{
children = [];
childrenByParent[parentKey] = children;
}
children.Add(record);
}
}
foreach (var children in childrenByParent.Values)
{
children.Sort(static (left, right) =>
{
var sequenceComparison = left.Sequence.CompareTo(right.Sequence);
return sequenceComparison != 0
? sequenceComparison
: StringComparers.CommandName.Compare(left.DisplayName, right.DisplayName);
});
}
var result = new List<StepDurationRecord>(orderedRecords.Count);
var visited = new HashSet<string>(StringComparers.CommandName);
foreach (var root in orderedRecords.Where(r => r.ParentKey is null || !recordsByKey.ContainsKey(r.ParentKey)))
{
VisitRecord(root, childrenByParent, visited, result);
}
foreach (var record in orderedRecords)
{
if (visited.Add(record.Key))
{
result.Add(record);
}
}
return result;
}
private static void VisitRecord(
StepDurationRecord record,
IReadOnlyDictionary<string, List<StepDurationRecord>> childrenByParent,
ISet<string> visited,
ICollection<StepDurationRecord> result)
{
if (!visited.Add(record.Key))
{
return;
}
result.Add(record);
if (!childrenByParent.TryGetValue(record.Key, out var children))
{
return;
}
foreach (var child in children)
{
VisitRecord(child, childrenByParent, visited, result);
}
}
private static string GetIndentedDisplayName(StepDurationRecord record)
{
var level = Math.Max(record.Level, 0);
return level == 0
? record.DisplayName
: $"{new string(' ', level * 2)}{record.DisplayName}";
}
private static string BuildTimelineScale(int width)
{
if (width <= 0)
{
return "││";
}
var chars = Enumerable.Repeat('─', width).ToArray();
for (var tick = 1; tick < SummaryTimelineTicks; tick++)
{
var position = (int)Math.Round((double)tick * (width - 1) / SummaryTimelineTicks);
if (position >= 0 && position < chars.Length)
{
chars[position] = '┬';
}
}
return $"│{new string(chars)}│";
}
private static string BuildTimelineLabels(TimeSpan totalTimeline, int width)
{
// Match the zero label to the unit family used by the end label so short timelines don't mix `0s`
// with millisecond- or microsecond-based durations.
var startText = BuildTimelineStartLabel(totalTimeline);
var endText = DurationFormatter.FormatDuration(totalTimeline, CultureInfo.InvariantCulture, DecimalDurationDisplay.Fixed);
var labelWidth = Math.Max(width + 2, startText.Length + 1 + endText.Length);
var spacing = Math.Max(1, labelWidth - startText.Length - endText.Length);
return $"{startText}{new string(' ', spacing)}{endText}";
}
private static string BuildTimelineStartLabel(TimeSpan totalTimeline)
{
var unit = totalTimeline > TimeSpan.Zero ? DurationFormatter.GetUnit(totalTimeline) : "ms";
return $"0{unit}";
}
private static string FormatSummaryDuration(TimeSpan duration, TimeSpan totalTimeline)
{
return duration == TimeSpan.Zero
? BuildTimelineStartLabel(totalTimeline)
: DurationFormatter.FormatDuration(duration, CultureInfo.InvariantCulture, DecimalDurationDisplay.Fixed);
}
private bool ShouldRenderTimeline(int durationWidth, int nameWidth, TimeSpan totalTimeline)
{
var consoleWidth = _console.Profile.Width;
if (consoleWidth <= 0 || consoleWidth == int.MaxValue)
{
return true;
}
// If the shared padded name column plus the chart would overflow the console, prefer keeping
// the hierarchical step names readable and omit the chart for the whole summary.
var timelineWidth = Math.Max(
BuildTimelineLabels(totalTimeline, SummaryTimelineWidth).Length,
BuildTimelineScale(SummaryTimelineWidth).Length);
return 2 + durationWidth + 2 + 1 + 1 + nameWidth + 2 + timelineWidth <= consoleWidth;
}
private static string BuildTimelineBar(StepDurationRecord record, TimeSpan totalTimeline, int width)
{
if (width <= 0)
{
return "││";
}
var chars = Enumerable.Repeat(' ', width).ToArray();
var start = record.StartOffset;
var end = record.EndOffset > start ? record.EndOffset : start + record.Duration;
double startPosition;
double endPosition;
if (totalTimeline <= TimeSpan.Zero)
{
startPosition = 0;
endPosition = 0;
}
else
{
startPosition = start.TotalMilliseconds / totalTimeline.TotalMilliseconds * (width - 1);
endPosition = end.TotalMilliseconds / totalTimeline.TotalMilliseconds * (width - 1);
}
// When a span is smaller than a single character cell it would disappear if we only rendered bar caps.
// Show a point marker instead so very short durations remain visible in the summary.
if (endPosition - startPosition < 1)
{
var pointIndex = Math.Clamp((int)Math.Round((startPosition + endPosition) / 2, MidpointRounding.AwayFromZero), 0, width - 1);
chars[pointIndex] = '╴';
}
else
{
var startIndex = Math.Clamp((int)Math.Floor(startPosition), 0, width - 1);
var endIndex = Math.Clamp((int)Math.Ceiling(endPosition), startIndex, width - 1);
chars[startIndex] = '╶';
chars[endIndex] = '╴';
for (var i = startIndex + 1; i < endIndex; i++)
{
chars[i] = '─';
}
}
return $"│{new string(chars)}│";
}
private string ColorizeSummaryBar(string bar, ActivityState state)
{
var escapedBar = bar.EscapeMarkup();
if (!_enableColor)
{
return escapedBar;
}
return $"[{GetStateColor(state)}]{escapedBar}[/]";
}
private void WriteCompletion(string taskKey, string symbol, string message, ActivityState state, double? seconds)
{
var text = seconds.HasValue ? $"{message} ({DurationFormatter.FormatDuration(TimeSpan.FromSeconds(seconds.Value), CultureInfo.InvariantCulture, DecimalDurationDisplay.Fixed)})" : message;
WriteLine(taskKey, symbol, text, state);
}
private void WriteLine(string taskKey, string symbol, string message, ActivityState state, bool dim = false)
{
lock (_lock)
{
var time = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
var stepColor = GetOrAssignStepColor(taskKey);
var displayKey = _displayNames.TryGetValue(taskKey, out var dn) ? dn : taskKey;
var coloredSymbol = _enableColor ? ColorizeSymbol(symbol, state) : symbol;
foreach (var line in SplitLinesPreserve(message))
{
// Format: dim timestamp, colored step tag, symbol, message with Spectre markup
var highlightedLine = HighlightMessage(line);
// Apply dim formatting per-line so that [dim]...[/] tags don't span across
// split lines or conflict with Spectre markup already present in the message.
if (dim && _enableColor)
{
highlightedLine = $"[dim]{highlightedLine}[/]";
}
var markup = new StringBuilder();
markup.Append("[dim]").Append(time).Append("[/] ");
markup.Append('[').Append(stepColor).Append(']').Append('(').Append(displayKey).Append(")[/] ");
if (_enableColor)
{
if (state == ActivityState.Failure)
{
// Make the entire failure segment (symbol + message) red, not just the symbol
markup.Append("[red]").Append(symbol).Append(' ').Append(highlightedLine).Append("[/]");
}
else if (state == ActivityState.Warning)
{
// Optionally color whole warning message (improves scanability)
markup.Append("[yellow]").Append(symbol).Append(' ').Append(highlightedLine).Append("[/]");
}
else
{
markup.Append(coloredSymbol).Append(' ').Append(highlightedLine);
}
}
else
{
markup.Append(symbol).Append(' ').Append(highlightedLine);
}
var markupString = markup.ToString();
try
{
_console.MarkupLine(markupString);
}
catch (InvalidOperationException ex)
{
throw new InvalidOperationException(
$"Spectre markup rendering failed for line: \"{markupString}\". Original message: \"{line}\"", ex);
}
}
}
}
private string GetOrAssignStepColor(string taskKey)
{
if (!_stepColors.TryGetValue(taskKey, out var color))
{
color = _availableColors[_colorIndex % _availableColors.Length];
_stepColors[taskKey] = color;
_colorIndex++;
}
return color;
}
private static IEnumerable<string> SplitLinesPreserve(string message)
{
if (message.IndexOf('\n') < 0)
{
yield return message;
yield break;
}
var lines = message.Replace("\r\n", "\n").Split('\n');
foreach (var l in lines)
{
yield return l;
}
}
private static string GetStateColor(ActivityState state) => state switch
{
ActivityState.Success => "green",
ActivityState.Warning => "yellow",
ActivityState.Failure => "red",
ActivityState.Info => "blue",
_ => "cyan"
};
private static string ColorizeSymbol(string symbol, ActivityState state) =>
$"[{GetStateColor(state)}]{symbol}[/]";
// Messages are already converted from Markdown to Spectre markup in PipelineCommandBase.
// When interactive output is not supported, we need to convert Spectre link markup
// back to plain text since clickable links won't work. Show the URL for accessibility.
private string HighlightMessage(string message)
{
if (!_hostEnvironment.SupportsInteractiveOutput)
{
// Convert Spectre link markup [cyan][link=url]text[/][/] to show URL
// Pattern matches: [cyan][link=URL]TEXT[/][/] and replaces with URL
return Regex.Replace(
message,
@"\[cyan\]\[link=([^\]]+)\]([^\[]+)\[/\]\[/\]",
"$1");
}
return message;
}
// Note: DetectColorSupport is no longer needed as we use _hostEnvironment.SupportsAnsi directly
}