From 53756b17a51e24f58e74a9413926542be03f8d35 Mon Sep 17 00:00:00 2001 From: viamu Date: Sat, 28 Feb 2026 21:07:35 -0300 Subject: [PATCH 1/3] Replace chaotic parallel logs with live-updating table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel execution (parallel_foreach and parallel steps) now renders a clean in-place table via Spectre.Console Live instead of interleaved thinking/tool_use lines. Each item shows status icon, elapsed timer, label, and current activity — updating in real-time every second. Co-Authored-By: Claude Opus 4.6 --- .../Steps/ParallelForeachStep.cs | 154 +++++++-------- CodeGenesis.Engine/Steps/ParallelStep.cs | 150 ++++++++------- CodeGenesis.Engine/UI/ParallelLiveTable.cs | 175 ++++++++++++++++++ CodeGenesis.Engine/UI/PipelineRenderer.cs | 99 +++++----- 4 files changed, 383 insertions(+), 195 deletions(-) create mode 100644 CodeGenesis.Engine/UI/ParallelLiveTable.cs diff --git a/CodeGenesis.Engine/Steps/ParallelForeachStep.cs b/CodeGenesis.Engine/Steps/ParallelForeachStep.cs index 048c9fb..f1dc3e6 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,108 @@ public async Task ExecuteAsync(PipelineContext context, Cancellation }; } + var concurrencyInfo = config.MaxConcurrency.HasValue + ? $"max {config.MaxConcurrency}" + : "unlimited"; + var header = $"parallel_foreach [{ConsoleTheme.MutedTag}]{config.ItemVar}[/] " + + $"[{ConsoleTheme.SubtleTag}]{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, header, 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 +193,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..9cc1a21 100644 --- a/CodeGenesis.Engine/Steps/ParallelStep.cs +++ b/CodeGenesis.Engine/Steps/ParallelStep.cs @@ -23,94 +23,104 @@ 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 header = $"parallel [{ConsoleTheme.MutedTag}]{branches.Count} branch(es)[/] " + + $"[{ConsoleTheme.SubtleTag}]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, header, 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 +173,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..c5c7db8 --- /dev/null +++ b/CodeGenesis.Engine/UI/ParallelLiveTable.cs @@ -0,0 +1,175 @@ +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 = 45) + { + _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.Simple) + .BorderStyle(new Style(ConsoleTheme.Subtle)) + .AddColumn(new TableColumn("Status").Width(12).NoWrap()) + .AddColumn(new TableColumn("Item").Width(maxLabelWidth + 2)) + .AddColumn(new TableColumn("Activity")); + + 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..."; + item.StartedAt = Stopwatch.GetTimestamp(); + } + } + + public void UpdateActivity(int index, string message) + { + lock (_lock) + { + ref var item = ref _items[index]; + item.Activity = Truncate(message, 70); + } + } + + 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(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 == ItemStatus.Waiting + ? $"[{statusColor}] {icon}[/]" + : $"[{statusColor}]{icon}[/] [{ConsoleTheme.MutedTag}]{FormatDuration(item.Elapsed)}[/]"; + + 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.MutedTag, + ItemStatus.Failed => ConsoleTheme.ErrorTag, + _ => ConsoleTheme.SubtleTag + }; + + var activityText = item.Status == ItemStatus.Waiting + ? $"[{ConsoleTheme.MutedTag}]waiting[/]" + : $"[{activityColor}]{(item.Activity ?? "").EscapeMarkup()}[/]"; + + _table.AddRow( + new Markup(statusText), + new Markup($"[{labelColor}]{item.Label.EscapeMarkup()}[/]"), + new Markup(activityText)); + } + } + + private static string FormatMetrics(int tokens, double cost) + { + var parts = new List(); + if (tokens > 0) parts.Add($"{tokens:N0} tokens"); + if (cost > 0) parts.Add($"${cost:F4}"); + return parts.Count > 0 ? string.Join(" ", parts) : "done"; + } + + 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..c3198aa 100644 --- a/CodeGenesis.Engine/UI/PipelineRenderer.cs +++ b/CodeGenesis.Engine/UI/PipelineRenderer.cs @@ -286,45 +286,65 @@ 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. + /// + public async Task RunParallelWithLiveTable( + IReadOnlyList labels, + string header, + 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}]{header.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 +357,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 ────────────────────────────────────────────────────── /// From 8a7503eff7799337c5a23af332767fb7cd586f77 Mon Sep 17 00:00:00 2001 From: viamu Date: Sat, 28 Feb 2026 21:09:59 -0300 Subject: [PATCH 2/3] Exclude parallel steps from spinner to avoid concurrent display error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spectre.Console does not allow concurrent interactive displays. Since parallel steps now use Live table rendering, they must be excluded from the Status spinner wrapper — same as ForeachStep and ApprovalStep. Co-Authored-By: Claude Opus 4.6 --- CodeGenesis.Engine/Pipeline/PipelineExecutor.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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( From e4075d130680d464849c8afb93c37043d94bd002 Mon Sep 17 00:00:00 2001 From: viamu Date: Sat, 28 Feb 2026 21:15:40 -0300 Subject: [PATCH 3/3] Improve parallel live table layout and fix markup escaping - Use Rounded border with hidden headers for cleaner table look - Fix raw markup tags showing in header by separating stepType/detail - Fix CS1525 error: use `,6` alignment instead of invalid `,>6` - Use HideHeaders() instead of ShowHeaders(false) - Add NoWrap to columns and right-aligned duration formatting - Include elapsed time in completion metrics Co-Authored-By: Claude Opus 4.6 --- .../Steps/ParallelForeachStep.cs | 5 ++- CodeGenesis.Engine/Steps/ParallelStep.cs | 5 ++- CodeGenesis.Engine/UI/ParallelLiveTable.cs | 35 +++++++++++-------- CodeGenesis.Engine/UI/PipelineRenderer.cs | 10 ++++-- 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/CodeGenesis.Engine/Steps/ParallelForeachStep.cs b/CodeGenesis.Engine/Steps/ParallelForeachStep.cs index f1dc3e6..8f2605b 100644 --- a/CodeGenesis.Engine/Steps/ParallelForeachStep.cs +++ b/CodeGenesis.Engine/Steps/ParallelForeachStep.cs @@ -47,8 +47,7 @@ public async Task ExecuteAsync(PipelineContext context, Cancellation var concurrencyInfo = config.MaxConcurrency.HasValue ? $"max {config.MaxConcurrency}" : "unlimited"; - var header = $"parallel_foreach [{ConsoleTheme.MutedTag}]{config.ItemVar}[/] " + - $"[{ConsoleTheme.SubtleTag}]{items.Count} item(s) concurrency: {concurrencyInfo}[/]"; + var detail = $"{config.ItemVar} {items.Count} item(s) concurrency: {concurrencyInfo}"; var maxConcurrency = config.MaxConcurrency ?? int.MaxValue; using var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); @@ -56,7 +55,7 @@ public async Task ExecuteAsync(PipelineContext context, Cancellation var iterationResults = new (bool Success, PipelineContext Context, string Item, int Index)[items.Count]; - await renderer.RunParallelWithLiveTable(items, header, async liveTable => + await renderer.RunParallelWithLiveTable(items, "parallel_foreach", detail, async liveTable => { var tasks = new Task[items.Count]; diff --git a/CodeGenesis.Engine/Steps/ParallelStep.cs b/CodeGenesis.Engine/Steps/ParallelStep.cs index 9cc1a21..a8d3f3a 100644 --- a/CodeGenesis.Engine/Steps/ParallelStep.cs +++ b/CodeGenesis.Engine/Steps/ParallelStep.cs @@ -26,8 +26,7 @@ public async Task ExecuteAsync(PipelineContext context, Cancellation var concurrencyInfo = config.MaxConcurrency.HasValue ? $"max {config.MaxConcurrency}" : "unlimited"; - var header = $"parallel [{ConsoleTheme.MutedTag}]{branches.Count} branch(es)[/] " + - $"[{ConsoleTheme.SubtleTag}]concurrency: {concurrencyInfo}[/]"; + var detail = $"{branches.Count} branch(es) concurrency: {concurrencyInfo}"; var branchLabels = branches.Select(b => b.Branch.Name).ToList(); @@ -37,7 +36,7 @@ public async Task ExecuteAsync(PipelineContext context, Cancellation var branchResults = new (bool Success, PipelineContext Context, string BranchName)[branches.Count]; - await renderer.RunParallelWithLiveTable(branchLabels, header, async liveTable => + await renderer.RunParallelWithLiveTable(branchLabels, "parallel", detail, async liveTable => { var tasks = new Task[branches.Count]; diff --git a/CodeGenesis.Engine/UI/ParallelLiveTable.cs b/CodeGenesis.Engine/UI/ParallelLiveTable.cs index c5c7db8..d6ae9bb 100644 --- a/CodeGenesis.Engine/UI/ParallelLiveTable.cs +++ b/CodeGenesis.Engine/UI/ParallelLiveTable.cs @@ -15,7 +15,7 @@ public sealed class ParallelLiveTable private readonly ItemState[] _items; private readonly Table _table; - public ParallelLiveTable(IReadOnlyList labels, int maxLabelWidth = 45) + public ParallelLiveTable(IReadOnlyList labels, int maxLabelWidth = 40) { _items = new ItemState[labels.Count]; for (var i = 0; i < labels.Count; i++) @@ -28,11 +28,12 @@ public ParallelLiveTable(IReadOnlyList labels, int maxLabelWidth = 45) } _table = new Table() - .Border(TableBorder.Simple) + .Border(TableBorder.Rounded) .BorderStyle(new Style(ConsoleTheme.Subtle)) - .AddColumn(new TableColumn("Status").Width(12).NoWrap()) - .AddColumn(new TableColumn("Item").Width(maxLabelWidth + 2)) - .AddColumn(new TableColumn("Activity")); + .HideHeaders() + .AddColumn(new TableColumn(string.Empty).Width(10).NoWrap()) + .AddColumn(new TableColumn(string.Empty).NoWrap()) + .AddColumn(new TableColumn(string.Empty).NoWrap()); RebuildRows(); } @@ -45,7 +46,7 @@ public void MarkStarted(int index) { ref var item = ref _items[index]; item.Status = ItemStatus.Running; - item.Activity = "starting..."; + item.Activity = "starting\u2026"; item.StartedAt = Stopwatch.GetTimestamp(); } } @@ -55,7 +56,7 @@ public void UpdateActivity(int index, string message) lock (_lock) { ref var item = ref _items[index]; - item.Activity = Truncate(message, 70); + item.Activity = Truncate(message, 60); } } @@ -68,7 +69,7 @@ public void MarkComplete(int index, bool success, TimeSpan elapsed, int tokens, item.Elapsed = elapsed; item.Tokens = tokens; item.Cost = cost; - item.Activity = FormatMetrics(tokens, cost); + item.Activity = FormatMetrics(elapsed, tokens, cost); } } @@ -102,9 +103,12 @@ private void RebuildRows() _ => ("?", ConsoleTheme.MutedTag) }; - var statusText = item.Status == ItemStatus.Waiting - ? $"[{statusColor}] {icon}[/]" - : $"[{statusColor}]{icon}[/] [{ConsoleTheme.MutedTag}]{FormatDuration(item.Elapsed)}[/]"; + 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 { @@ -116,13 +120,13 @@ private void RebuildRows() var activityColor = item.Status switch { - ItemStatus.Success => ConsoleTheme.MutedTag, + ItemStatus.Success => ConsoleTheme.SubtleTag, ItemStatus.Failed => ConsoleTheme.ErrorTag, _ => ConsoleTheme.SubtleTag }; var activityText = item.Status == ItemStatus.Waiting - ? $"[{ConsoleTheme.MutedTag}]waiting[/]" + ? $"[{ConsoleTheme.MutedTag} italic]waiting[/]" : $"[{activityColor}]{(item.Activity ?? "").EscapeMarkup()}[/]"; _table.AddRow( @@ -132,12 +136,13 @@ private void RebuildRows() } } - private static string FormatMetrics(int tokens, double cost) + 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 parts.Count > 0 ? string.Join(" ", parts) : "done"; + return string.Join(" ", parts); } private static string FormatDuration(TimeSpan ts) => ts.TotalSeconds switch diff --git a/CodeGenesis.Engine/UI/PipelineRenderer.cs b/CodeGenesis.Engine/UI/PipelineRenderer.cs index c3198aa..9ec196d 100644 --- a/CodeGenesis.Engine/UI/PipelineRenderer.cs +++ b/CodeGenesis.Engine/UI/PipelineRenderer.cs @@ -292,14 +292,20 @@ public void RenderForeachIterationComplete(string itemValue, int index, int tota /// 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 header, + string stepType, + string detail, Func work) { AnsiConsole.MarkupLine( $"{Indent}[{ConsoleTheme.PrimaryTag}]\u26a1[/] " + - $"[{ConsoleTheme.SecondaryTag}]{header.EscapeMarkup()}[/]"); + $"[{ConsoleTheme.SecondaryTag}]{stepType.EscapeMarkup()}[/] " + + $"[{ConsoleTheme.MutedTag}]{detail.EscapeMarkup()}[/]"); AnsiConsole.WriteLine(); var liveTable = new ParallelLiveTable(labels);