using HanaTui.System; using HanaTui.Tui.Components; using Spectre.Console; using Spectre.Console.Rendering; namespace HanaTui.Tui; /// /// Runs an operation while showing a live split-panel: /// Left: System stats (CPU, RAM, Swap, elapsed) /// Right: Streaming operation log /// /// Abort: first Q warns, second Q within 3s sends SIGTERM then SIGKILL. /// Post-completion: 10s countdown, any key cancels and holds; Enter/Esc returns. /// public static class TaskRunnerScreen { private const int CountdownSeconds = 10; private const double AbortWindowSeconds = 3.0; public static async Task RunAsync( string operationTitle, Func, CancellationToken, Task> operation) { AnsiConsole.Clear(); var logPanel = new LogPanel(); var stats = new SystemStats(); var cts = new CancellationTokenSource(); var startTime = DateTime.Now; var operationDone = false; var operationSuccess = false; DateTime? firstQTime = null; // Key listener var keyTask = Task.Run(async () => { while (!operationDone) { if (Console.KeyAvailable) { var key = Console.ReadKey(intercept: true); if (key.Key == ConsoleKey.Q || key.KeyChar is 'q' or 'Q') { if (cts.IsCancellationRequested) break; if (firstQTime is null) { firstQTime = DateTime.Now; logPanel.Add("[WARN] Press Q again within 3 seconds to abort."); } else if ((DateTime.Now - firstQTime.Value).TotalSeconds <= AbortWindowSeconds) { logPanel.Add("[WARN] Aborting operation..."); await cts.CancelAsync(); } else { firstQTime = DateTime.Now; logPanel.Add("[WARN] Press Q again within 3 seconds to abort."); } } } await Task.Delay(50, CancellationToken.None); } }, CancellationToken.None); // Operation task var operationTask = Task.Run(async () => { try { operationSuccess = await operation(line => logPanel.Add(line), cts.Token); } catch (Exception ex) { logPanel.Add($"[ERROR] Unexpected exception: {ex.Message}"); operationSuccess = false; } finally { operationDone = true; } }, CancellationToken.None); // Live render loop await AnsiConsole.Live(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running)) .AutoClear(false) .StartAsync(async ctx => { while (!operationDone) { ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running)); await Task.Delay(800, CancellationToken.None); } var elapsed = DateTime.Now - startTime; RunState finalState; if (cts.IsCancellationRequested) { finalState = RunState.Aborted; logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed)}."); } else if (operationSuccess) { finalState = RunState.Success; logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed)}."); } else { finalState = RunState.Failed; logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed)}."); } ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, finalState)); }); stats.Dispose(); await operationTask; operationDone = true; await keyTask; await PostCompletionWaitAsync(operationTitle, logPanel, stats, startTime, cts.IsCancellationRequested ? RunState.Aborted : operationSuccess ? RunState.Success : RunState.Failed); } // ----------------------------------------------------------------------- private enum RunState { Running, Success, Failed, Aborted } private static async Task PostCompletionWaitAsync( string title, LogPanel logPanel, SystemStats stats, DateTime startTime, RunState state) { var countdownCancelled = false; var returnNow = false; var staying = false; var startCountdown = DateTime.Now; var countdownTask = Task.Run(async () => { for (int i = CountdownSeconds; i > 0 && !countdownCancelled; i--) await Task.Delay(1000, CancellationToken.None); if (!countdownCancelled) returnNow = true; }, CancellationToken.None); await AnsiConsole.Live(BuildLayout(title, logPanel, stats, startTime, state)) .AutoClear(false) .StartAsync(async ctx => { while (!returnNow) { if (Console.KeyAvailable) { var key = Console.ReadKey(intercept: true); countdownCancelled = true; if (key.Key is ConsoleKey.Enter or ConsoleKey.Escape) { returnNow = true; break; } if (!staying) { staying = true; logPanel.Add("[INFO] Countdown cancelled. Press Enter or Esc to return to menu."); } } string footerText; if (!staying) { var secsLeft = Math.Max(0, CountdownSeconds - (int)(DateTime.Now - startCountdown).TotalSeconds); footerText = $"Returning to menu in {secsLeft}s... (any key to stay)"; } else { footerText = "Press Enter or Esc to return to menu."; } ctx.UpdateTarget(BuildLayout(title, logPanel, stats, startTime, state, footerText)); await Task.Delay(200, CancellationToken.None); } }); await countdownTask; } // ----------------------------------------------------------------------- // Layout // ----------------------------------------------------------------------- private static IRenderable BuildLayout( string title, LogPanel logPanel, SystemStats stats, DateTime startTime, RunState state, string? footerText = null) { var elapsed = DateTime.Now - startTime; var snap = stats.CurrentSnapshot; const int statsPanelWidth = 40; var statsRenderable = StatsPanel.Build(snap, elapsed, statsPanelWidth - 4); var logRenderable = logPanel.Build(); var layout = new Layout("root").SplitColumns( new Layout("stats").Size(statsPanelWidth), new Layout("log")); layout["stats"].Update(statsRenderable); layout["log"].Update(logRenderable); var outerGrid = new Grid(); outerGrid.AddColumn(new GridColumn()); // Title row — state label uses Text objects, not markup interpolation var titleGrid = new Grid(); titleGrid.AddColumn(new GridColumn().NoWrap()); titleGrid.AddColumn(new GridColumn().NoWrap()); titleGrid.AddColumn(new GridColumn().NoWrap()); titleGrid.AddColumn(new GridColumn().NoWrap()); titleGrid.AddRow( new Markup("[bold dodgerblue1] Operation:[/]"), new Text(" " + title, new Style(Color.Yellow)), new Markup(" [bold dodgerblue1]Status:[/]"), StateLabel(state) ); outerGrid.AddRow(titleGrid); outerGrid.AddRow(layout); // Footer row — plain Text, no markup parsing of dynamic content var footerGrid = new Grid(); footerGrid.AddColumn(new GridColumn().NoWrap()); footerGrid.AddColumn(new GridColumn().NoWrap()); var footerLeft = footerText is not null ? new Text(" " + footerText, new Style(Color.Grey, decoration: Decoration.Dim)) : (IRenderable)new Text(" Press Q to abort (twice within 3s)", new Style(Color.Grey, decoration: Decoration.Dim)); var footerRight = new Text(" [Q] abort", new Style(Color.Grey, decoration: Decoration.Dim)); footerGrid.AddRow(footerLeft, footerRight); outerGrid.AddRow(footerGrid); return outerGrid; } private static IRenderable StateLabel(RunState state) => state switch { RunState.Running => new Markup(" [yellow]Running...[/]"), RunState.Success => new Markup(" [green]Completed[/]"), RunState.Failed => new Markup(" [red]Failed[/]"), RunState.Aborted => new Markup(" [red]Aborted[/]"), _ => new Text(""), }; private static string FormatElapsed(TimeSpan t) { if (t.TotalHours >= 1) return $"{(int)t.TotalHours}h {t.Minutes:D2}m {t.Seconds:D2}s"; if (t.TotalMinutes >= 1) return $"{t.Minutes}m {t.Seconds:D2}s"; return $"{t.Seconds}s"; } }