diff --git a/CodeGenesis.Engine/Pipeline/PipelineExecutor.cs b/CodeGenesis.Engine/Pipeline/PipelineExecutor.cs index 7147564..b4e410f 100644 --- a/CodeGenesis.Engine/Pipeline/PipelineExecutor.cs +++ b/CodeGenesis.Engine/Pipeline/PipelineExecutor.cs @@ -27,12 +27,12 @@ public async Task RunAsync( StepResult result; try { - // Foreach renders its own sequential progress interleaved with sub-steps; - // wrapping it in a spinner would swallow that output. - // Parallel/ParallelForeach suppress sub-step rendering, so the spinner - // runs at the bottom while branch/item completions appear above it. - // ApprovalStep requires interactive Console input — no spinner. - if (step is ForeachStep or ApprovalStep) + // Foreach renders its own sequential progress interleaved with sub-steps. + // Parallel/ParallelForeach use their own Live table display. + // ApprovalStep requires interactive Console input. + // None of these can be wrapped in a spinner (Spectre.Console + // does not allow concurrent interactive displays). + if (step is ForeachStep or ParallelStep or ParallelForeachStep or ApprovalStep) result = await step.ExecuteAsync(context, ct); else result = await renderer.RunWithSpinner( diff --git a/CodeGenesis.Engine/Steps/ParallelForeachStep.cs b/CodeGenesis.Engine/Steps/ParallelForeachStep.cs index 048c9fb..8f2605b 100644 --- a/CodeGenesis.Engine/Steps/ParallelForeachStep.cs +++ b/CodeGenesis.Engine/Steps/ParallelForeachStep.cs @@ -33,8 +33,6 @@ public async Task ExecuteAsync(PipelineContext context, Cancellation var collectionRaw = resolveTemplate(config.Collection, allVars); var items = CollectionParser.Parse(collectionRaw); - renderer.RenderParallelForeachStart(config.ItemVar, items.Count, config.MaxConcurrency); - if (items.Count == 0) { sw.Stop(); @@ -46,104 +44,107 @@ public async Task ExecuteAsync(PipelineContext context, Cancellation }; } + var concurrencyInfo = config.MaxConcurrency.HasValue + ? $"max {config.MaxConcurrency}" + : "unlimited"; + var detail = $"{config.ItemVar} {items.Count} item(s) concurrency: {concurrencyInfo}"; + var maxConcurrency = config.MaxConcurrency ?? int.MaxValue; using var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var iterationResults = new (bool Success, PipelineContext Context, string Item, int Index)[items.Count]; - var tasks = new Task[items.Count]; - for (var i = 0; i < items.Count; i++) + await renderer.RunParallelWithLiveTable(items, "parallel_foreach", detail, async liveTable => { - var index = i; - var item = items[i]; + var tasks = new Task[items.Count]; - tasks[i] = Task.Run(async () => + for (var i = 0; i < items.Count; i++) { - await semaphore.WaitAsync(linkedCts.Token); - try - { - // Suppress all sub-step rendering; only item-level messages show - renderer.PushScope(); - renderer.SuppressRendering(); + var index = i; + var item = items[i]; - // Skip null or empty items - if (string.IsNullOrWhiteSpace(item)) + tasks[i] = Task.Run(async () => + { + await semaphore.WaitAsync(linkedCts.Token); + try { - renderer.ResumeRendering(); - renderer.RenderParallelForeachItemStart("(empty)", index, items.Count); + // Suppress all sub-step rendering inside parallel + renderer.PushScope(); renderer.SuppressRendering(); - var emptyCtx = new PipelineContext + + // Skip null or empty items + if (string.IsNullOrWhiteSpace(item)) + { + var emptyCtx = new PipelineContext + { + TaskDescription = context.TaskDescription, + WorkingDirectory = context.WorkingDirectory + }; + liveTable.MarkStarted(index); + liveTable.MarkComplete(index, true, TimeSpan.Zero, 0, 0); + iterationResults[index] = (true, emptyCtx, item, index); + return; + } + + liveTable.MarkStarted(index); + + // Create isolated context for this iteration + var iterationContext = new PipelineContext { TaskDescription = context.TaskDescription, - WorkingDirectory = context.WorkingDirectory + WorkingDirectory = context.WorkingDirectory, + StatusUpdate = msg => liveTable.UpdateActivity(index, msg) }; - iterationResults[index] = (true, emptyCtx, item, index); - return; - } - // Temporarily resume to render our own item-level message - renderer.ResumeRendering(); - renderer.RenderParallelForeachItemStart(item, index, items.Count); - renderer.SuppressRendering(); + // Copy parent step outputs so sub-steps can read them + foreach (var (key, value) in context.StepOutputs) + iterationContext.StepOutputs[key] = value; - // Create isolated context for this iteration - var iterationContext = new PipelineContext - { - TaskDescription = context.TaskDescription, - WorkingDirectory = context.WorkingDirectory, - StatusUpdate = msg => renderer.RenderThinking(item, msg) - }; - - // Copy parent step outputs so sub-steps can read them - foreach (var (key, value) in context.StepOutputs) - iterationContext.StepOutputs[key] = value; - - // Build loop variables for this iteration - var iterVars = new Dictionary(allVars) - { - ["loop.item"] = item, - ["loop.index"] = index.ToString(), - [config.ItemVar] = item - }; + // Build loop variables for this iteration + var iterVars = new Dictionary(allVars) + { + ["loop.item"] = item, + ["loop.index"] = index.ToString(), + [config.ItemVar] = item + }; - // Clone sub-steps for this iteration so parallel threads don't share mutable state - var clonedSubSteps = CloneSubSteps(subSteps, iterVars); + // Clone sub-steps for this iteration so parallel threads don't share mutable state + var clonedSubSteps = CloneSubSteps(subSteps, iterVars); - var iterSw = Stopwatch.StartNew(); + var iterSw = Stopwatch.StartNew(); - var success = await executor.RunAsync(clonedSubSteps, iterationContext, linkedCts.Token, - onBeforeStep: step => ResolveBeforeStep(step, iterVars, iterationContext)); + var success = await executor.RunAsync(clonedSubSteps, iterationContext, linkedCts.Token, + onBeforeStep: step => ResolveBeforeStep(step, iterVars, iterationContext)); - iterSw.Stop(); + iterSw.Stop(); - iterationResults[index] = (success, iterationContext, item, index); + iterationResults[index] = (success, iterationContext, item, index); - if (!success && config.FailFast) - await linkedCts.CancelAsync(); + if (!success && config.FailFast) + await linkedCts.CancelAsync(); - // Resume rendering for our own completion message - renderer.ResumeRendering(); - var iterTokens = iterationContext.TotalInputTokens + iterationContext.TotalOutputTokens; - renderer.RenderParallelForeachItemComplete(item, index, items.Count, success, iterSw.Elapsed, iterTokens, iterationContext.TotalCostUsd); - } - finally - { - renderer.ResumeRendering(); - renderer.PopScope(); - semaphore.Release(); - } - }, linkedCts.Token); - } + var iterTokens = iterationContext.TotalInputTokens + iterationContext.TotalOutputTokens; + liveTable.MarkComplete(index, success, iterSw.Elapsed, iterTokens, iterationContext.TotalCostUsd); + } + finally + { + renderer.ResumeRendering(); + renderer.PopScope(); + semaphore.Release(); + } + }, linkedCts.Token); + } - try - { - await Task.WhenAll(tasks); - } - catch (OperationCanceledException) when (config.FailFast) - { - // Expected when fail_fast cancels siblings - } + try + { + await Task.WhenAll(tasks); + } + catch (OperationCanceledException) when (config.FailFast) + { + // Expected when fail_fast cancels siblings + } + }); sw.Stop(); @@ -191,7 +192,7 @@ public async Task ExecuteAsync(PipelineContext context, Cancellation var succeeded = iterationResults.Count(r => r.Success); var failed = items.Count - succeeded; - renderer.RenderParallelForeachEnd(items.Count, succeeded, failed); + renderer.RenderParallelSummary(items.Count, succeeded, failed); return new StepResult { diff --git a/CodeGenesis.Engine/Steps/ParallelStep.cs b/CodeGenesis.Engine/Steps/ParallelStep.cs index e3c71bd..a8d3f3a 100644 --- a/CodeGenesis.Engine/Steps/ParallelStep.cs +++ b/CodeGenesis.Engine/Steps/ParallelStep.cs @@ -23,94 +23,103 @@ public async Task ExecuteAsync(PipelineContext context, Cancellation { var sw = Stopwatch.StartNew(); - renderer.RenderParallelStart(branches.Count, config.MaxConcurrency); + var concurrencyInfo = config.MaxConcurrency.HasValue + ? $"max {config.MaxConcurrency}" + : "unlimited"; + var detail = $"{branches.Count} branch(es) concurrency: {concurrencyInfo}"; + + var branchLabels = branches.Select(b => b.Branch.Name).ToList(); var maxConcurrency = config.MaxConcurrency ?? int.MaxValue; using var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var branchResults = new (bool Success, PipelineContext Context, string BranchName)[branches.Count]; - var tasks = new Task[branches.Count]; - for (var i = 0; i < branches.Count; i++) + await renderer.RunParallelWithLiveTable(branchLabels, "parallel", detail, async liveTable => { - var index = i; - var (branch, branchSteps) = branches[i]; + var tasks = new Task[branches.Count]; - tasks[i] = Task.Run(async () => + for (var i = 0; i < branches.Count; i++) { - await semaphore.WaitAsync(linkedCts.Token); - try - { - // Suppress sub-step rendering; only branch-level messages show - renderer.PushScope(); - renderer.SuppressRendering(); + var index = i; + var (branch, branchSteps) = branches[i]; - // Create isolated context for this branch - var branchContext = new PipelineContext + tasks[i] = Task.Run(async () => + { + await semaphore.WaitAsync(linkedCts.Token); + try { - TaskDescription = branch.Name, - WorkingDirectory = context.WorkingDirectory, - StatusUpdate = msg => renderer.RenderThinking(branch.Name, msg) - }; + // Suppress sub-step rendering inside parallel + renderer.PushScope(); + renderer.SuppressRendering(); - // Copy parent step outputs for reads - foreach (var (key, value) in context.StepOutputs) - branchContext.StepOutputs[key] = value; + liveTable.MarkStarted(index); - // Resolve sub-step templates with current variables - var branchVars = new Dictionary(variables); - foreach (var (key, value) in context.StepOutputs) - branchVars[$"steps.{key}"] = value; - - var branchSw = Stopwatch.StartNew(); - - var success = await executor.RunAsync(branchSteps, branchContext, linkedCts.Token, - onBeforeStep: step => + // Create isolated context for this branch + var branchContext = new PipelineContext { - if (step is DynamicStep dynamicStep) - { - var allVars = new Dictionary(branchVars); - foreach (var (key, value) in branchContext.StepOutputs) - allVars[$"steps.{key}"] = value; + TaskDescription = branch.Name, + WorkingDirectory = context.WorkingDirectory, + StatusUpdate = msg => liveTable.UpdateActivity(index, msg) + }; - dynamicStep.UpdateResolvedPrompt( - resolveTemplate(dynamicStep.OriginalPromptTemplate, allVars)); + // Copy parent step outputs for reads + foreach (var (key, value) in context.StepOutputs) + branchContext.StepOutputs[key] = value; - if (dynamicStep.OriginalSystemPromptTemplate is not null) - dynamicStep.UpdateResolvedSystemPrompt( - resolveTemplate(dynamicStep.OriginalSystemPromptTemplate, allVars)); - } - }); + // Resolve sub-step templates with current variables + var branchVars = new Dictionary(variables); + foreach (var (key, value) in context.StepOutputs) + branchVars[$"steps.{key}"] = value; - branchSw.Stop(); - branchResults[index] = (success, branchContext, branch.Name); + var branchSw = Stopwatch.StartNew(); - if (!success && config.FailFast) - await linkedCts.CancelAsync(); - - // Resume rendering for our own branch completion message - renderer.ResumeRendering(); - var branchTokens = branchContext.TotalInputTokens + branchContext.TotalOutputTokens; - renderer.RenderParallelBranchComplete(branch.Name, success, branchSw.Elapsed, branchTokens, branchContext.TotalCostUsd); - } - finally - { - renderer.ResumeRendering(); - renderer.PopScope(); - semaphore.Release(); - } - }, linkedCts.Token); - } + var success = await executor.RunAsync(branchSteps, branchContext, linkedCts.Token, + onBeforeStep: step => + { + if (step is DynamicStep dynamicStep) + { + var allVars = new Dictionary(branchVars); + foreach (var (key, value) in branchContext.StepOutputs) + allVars[$"steps.{key}"] = value; + + dynamicStep.UpdateResolvedPrompt( + resolveTemplate(dynamicStep.OriginalPromptTemplate, allVars)); + + if (dynamicStep.OriginalSystemPromptTemplate is not null) + dynamicStep.UpdateResolvedSystemPrompt( + resolveTemplate(dynamicStep.OriginalSystemPromptTemplate, allVars)); + } + }); + + branchSw.Stop(); + branchResults[index] = (success, branchContext, branch.Name); + + if (!success && config.FailFast) + await linkedCts.CancelAsync(); + + var branchTokens = branchContext.TotalInputTokens + branchContext.TotalOutputTokens; + liveTable.MarkComplete(index, success, branchSw.Elapsed, branchTokens, branchContext.TotalCostUsd); + } + finally + { + renderer.ResumeRendering(); + renderer.PopScope(); + semaphore.Release(); + } + }, linkedCts.Token); + } - try - { - await Task.WhenAll(tasks); - } - catch (OperationCanceledException) when (config.FailFast) - { - // Expected when fail_fast cancels siblings - } + try + { + await Task.WhenAll(tasks); + } + catch (OperationCanceledException) when (config.FailFast) + { + // Expected when fail_fast cancels siblings + } + }); sw.Stop(); @@ -163,6 +172,10 @@ public async Task ExecuteAsync(PipelineContext context, Cancellation } } + var succeeded = branchResults.Count(r => r.Success); + var failed = branches.Count - succeeded; + renderer.RenderParallelSummary(branches.Count, succeeded, failed); + return new StepResult { Outcome = allSuccess ? StepOutcome.Success : StepOutcome.Failed, diff --git a/CodeGenesis.Engine/UI/ParallelLiveTable.cs b/CodeGenesis.Engine/UI/ParallelLiveTable.cs new file mode 100644 index 0000000..d6ae9bb --- /dev/null +++ b/CodeGenesis.Engine/UI/ParallelLiveTable.cs @@ -0,0 +1,180 @@ +using System.Diagnostics; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace CodeGenesis.Engine.UI; + +/// +/// Thread-safe live table that displays parallel execution progress. +/// Each item is a row with status icon, elapsed time, item label, and current activity. +/// Updates in-place using Spectre.Console's Live rendering. +/// +public sealed class ParallelLiveTable +{ + private readonly object _lock = new(); + private readonly ItemState[] _items; + private readonly Table _table; + + public ParallelLiveTable(IReadOnlyList labels, int maxLabelWidth = 40) + { + _items = new ItemState[labels.Count]; + for (var i = 0; i < labels.Count; i++) + { + _items[i] = new ItemState + { + Label = Truncate(labels[i], maxLabelWidth), + Status = ItemStatus.Waiting + }; + } + + _table = new Table() + .Border(TableBorder.Rounded) + .BorderStyle(new Style(ConsoleTheme.Subtle)) + .HideHeaders() + .AddColumn(new TableColumn(string.Empty).Width(10).NoWrap()) + .AddColumn(new TableColumn(string.Empty).NoWrap()) + .AddColumn(new TableColumn(string.Empty).NoWrap()); + + RebuildRows(); + } + + public IRenderable Renderable => _table; + + public void MarkStarted(int index) + { + lock (_lock) + { + ref var item = ref _items[index]; + item.Status = ItemStatus.Running; + item.Activity = "starting\u2026"; + item.StartedAt = Stopwatch.GetTimestamp(); + } + } + + public void UpdateActivity(int index, string message) + { + lock (_lock) + { + ref var item = ref _items[index]; + item.Activity = Truncate(message, 60); + } + } + + public void MarkComplete(int index, bool success, TimeSpan elapsed, int tokens, double cost) + { + lock (_lock) + { + ref var item = ref _items[index]; + item.Status = success ? ItemStatus.Success : ItemStatus.Failed; + item.Elapsed = elapsed; + item.Tokens = tokens; + item.Cost = cost; + item.Activity = FormatMetrics(elapsed, tokens, cost); + } + } + + /// + /// Rebuilds all table rows from current state. Call inside Live refresh. + /// + public void Refresh() + { + lock (_lock) + { + RebuildRows(); + } + } + + private void RebuildRows() + { + _table.Rows.Clear(); + + foreach (ref var item in _items.AsSpan()) + { + // Update elapsed for running items + if (item.Status == ItemStatus.Running && item.StartedAt > 0) + item.Elapsed = Stopwatch.GetElapsedTime(item.StartedAt); + + var (icon, statusColor) = item.Status switch + { + ItemStatus.Waiting => ("\u25cc", ConsoleTheme.MutedTag), + ItemStatus.Running => ("\u25cb", ConsoleTheme.SecondaryTag), + ItemStatus.Success => (ConsoleTheme.Check, ConsoleTheme.SuccessTag), + ItemStatus.Failed => (ConsoleTheme.Cross, ConsoleTheme.ErrorTag), + _ => ("?", ConsoleTheme.MutedTag) + }; + + var statusText = item.Status switch + { + ItemStatus.Waiting => $" [{statusColor}]{icon}[/]", + ItemStatus.Running => $"[{statusColor}]{icon}[/] [{ConsoleTheme.SubtleTag}]{FormatDuration(item.Elapsed),6}[/]", + _ => $"[{statusColor}]{icon}[/] [{ConsoleTheme.MutedTag}]{FormatDuration(item.Elapsed),6}[/]" + }; + + var labelColor = item.Status switch + { + ItemStatus.Success => ConsoleTheme.SuccessTag, + ItemStatus.Failed => ConsoleTheme.ErrorTag, + ItemStatus.Running => "bold", + _ => ConsoleTheme.MutedTag + }; + + var activityColor = item.Status switch + { + ItemStatus.Success => ConsoleTheme.SubtleTag, + ItemStatus.Failed => ConsoleTheme.ErrorTag, + _ => ConsoleTheme.SubtleTag + }; + + var activityText = item.Status == ItemStatus.Waiting + ? $"[{ConsoleTheme.MutedTag} italic]waiting[/]" + : $"[{activityColor}]{(item.Activity ?? "").EscapeMarkup()}[/]"; + + _table.AddRow( + new Markup(statusText), + new Markup($"[{labelColor}]{item.Label.EscapeMarkup()}[/]"), + new Markup(activityText)); + } + } + + private static string FormatMetrics(TimeSpan elapsed, int tokens, double cost) + { + var parts = new List(); + parts.Add(FormatDuration(elapsed)); + if (tokens > 0) parts.Add($"{tokens:N0} tokens"); + if (cost > 0) parts.Add($"${cost:F4}"); + return string.Join(" ", parts); + } + + private static string FormatDuration(TimeSpan ts) => ts.TotalSeconds switch + { + < 1 => $"{ts.TotalMilliseconds:F0}ms", + < 60 => $"{ts.TotalSeconds:F0}s", + < 3600 => $"{ts.Minutes}m {ts.Seconds:D2}s", + _ => $"{ts.Hours}h {ts.Minutes:D2}m" + }; + + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value)) return value; + return value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength), "\u2026"); + } + + private struct ItemState + { + public string Label; + public ItemStatus Status; + public string? Activity; + public long StartedAt; + public TimeSpan Elapsed; + public int Tokens; + public double Cost; + } + + private enum ItemStatus + { + Waiting, + Running, + Success, + Failed + } +} diff --git a/CodeGenesis.Engine/UI/PipelineRenderer.cs b/CodeGenesis.Engine/UI/PipelineRenderer.cs index b07c53d..9ec196d 100644 --- a/CodeGenesis.Engine/UI/PipelineRenderer.cs +++ b/CodeGenesis.Engine/UI/PipelineRenderer.cs @@ -286,45 +286,71 @@ public void RenderForeachIterationComplete(string itemValue, int index, int tota AnsiConsole.WriteLine(); } - // ── Parallel Foreach ────────────────────────────────────────────── + // ── Parallel Live Table ───────────────────────────────────────── - public void RenderParallelForeachStart(string itemVar, int itemCount, int? maxConcurrency) + /// + /// Runs parallel work with a live-updating table that shows per-item status. + /// Replaces the chaotic interleaved console output with a clean in-place table. + /// + /// Display labels for each parallel item. + /// Step type name (e.g. "parallel_foreach", "parallel"). + /// Detail text (e.g. "area_path 7 item(s) concurrency: max 3"). + /// Async delegate that runs the parallel work using the live table. + public async Task RunParallelWithLiveTable( + IReadOnlyList labels, + string stepType, + string detail, + Func work) { - var concurrencyInfo = maxConcurrency.HasValue - ? $"max {maxConcurrency}" - : "unlimited"; AnsiConsole.MarkupLine( $"{Indent}[{ConsoleTheme.PrimaryTag}]\u26a1[/] " + - $"[{ConsoleTheme.SecondaryTag}]parallel_foreach[/] " + - $"[{ConsoleTheme.MutedTag}]{itemVar.EscapeMarkup()}[/] " + - $"[{ConsoleTheme.SubtleTag}]{itemCount} item(s) concurrency: {concurrencyInfo}[/]"); + $"[{ConsoleTheme.SecondaryTag}]{stepType.EscapeMarkup()}[/] " + + $"[{ConsoleTheme.MutedTag}]{detail.EscapeMarkup()}[/]"); AnsiConsole.WriteLine(); - } - public void RenderParallelForeachItemStart(string itemValue, int index, int total) - { - AnsiConsole.MarkupLine( - $"{Indent} [{ConsoleTheme.PrimaryTag}]\u25cb[/] " + - $"[{ConsoleTheme.SecondaryTag}][[{index + 1}/{total}]][/] " + - $"[bold]{Truncate(itemValue, 50).EscapeMarkup()}[/]"); - } + var liveTable = new ParallelLiveTable(labels); - public void RenderParallelForeachItemComplete(string itemValue, int index, int total, bool success, TimeSpan elapsed, int tokens, double cost) - { - var (icon, colorTag) = success - ? (ConsoleTheme.Check, ConsoleTheme.SuccessTag) - : (ConsoleTheme.Cross, ConsoleTheme.ErrorTag); - var metrics = FormatMetrics(elapsed, tokens, cost); + await AnsiConsole.Live(liveTable.Renderable) + .AutoClear(false) + .Overflow(VerticalOverflow.Ellipsis) + .StartAsync(async ctx => + { + ctx.Refresh(); - AnsiConsole.MarkupLine( - $"{Indent} [{colorTag}]{icon}[/] " + - $"[{ConsoleTheme.MutedTag}][[{index + 1}/{total}]][/] " + - $"{Truncate(itemValue, 50).EscapeMarkup()} {metrics}"); + // Periodic refresh to update elapsed timers for running items + using var refreshCts = new CancellationTokenSource(); + var refreshTask = Task.Run(async () => + { + while (!refreshCts.Token.IsCancellationRequested) + { + try + { + await Task.Delay(1000, refreshCts.Token); + liveTable.Refresh(); + ctx.Refresh(); + } + catch (OperationCanceledException) + { + break; + } + } + }, refreshCts.Token); + + await work(liveTable); + + await refreshCts.CancelAsync(); + try { await refreshTask; } catch (OperationCanceledException) { } + + // Final refresh to show completed state + liveTable.Refresh(); + ctx.Refresh(); + }); + + AnsiConsole.WriteLine(); } - public void RenderParallelForeachEnd(int total, int succeeded, int failed) + public void RenderParallelSummary(int total, int succeeded, int failed) { - AnsiConsole.WriteLine(); if (failed == 0) { AnsiConsole.MarkupLine( @@ -337,29 +363,6 @@ public void RenderParallelForeachEnd(int total, int succeeded, int failed) } } - // ── Parallel ────────────────────────────────────────────────────── - - public void RenderParallelStart(int branchCount, int? maxConcurrency) - { - var concurrencyInfo = maxConcurrency.HasValue - ? $"max {maxConcurrency}" - : "unlimited"; - AnsiConsole.MarkupLine( - $"{Indent}[{ConsoleTheme.SecondaryTag}]parallel[/] " + - $"[{ConsoleTheme.MutedTag}]{branchCount} branch(es)[/] " + - $"[{ConsoleTheme.SubtleTag}]concurrency: {concurrencyInfo}[/]"); - } - - public void RenderParallelBranchComplete(string branchName, bool success, TimeSpan elapsed, int tokens, double cost) - { - var (icon, colorTag) = success - ? (ConsoleTheme.Check, ConsoleTheme.SuccessTag) - : (ConsoleTheme.Cross, ConsoleTheme.ErrorTag); - var metrics = FormatMetrics(elapsed, tokens, cost); - AnsiConsole.MarkupLine( - $"{Indent} [{colorTag}]{icon}[/] {branchName.EscapeMarkup()} {metrics}"); - } - // ── Approval ────────────────────────────────────────────────────── ///