diff --git a/hanatui/publish/hanatui b/hanatui/publish/hanatui
index 8a8abd0..35a9bf1 100755
Binary files a/hanatui/publish/hanatui and b/hanatui/publish/hanatui differ
diff --git a/hanatui/src/Tui/Components/LogPanel.cs b/hanatui/src/Tui/Components/LogPanel.cs
index 2569e78..aa25400 100644
--- a/hanatui/src/Tui/Components/LogPanel.cs
+++ b/hanatui/src/Tui/Components/LogPanel.cs
@@ -3,27 +3,23 @@ using Spectre.Console.Rendering;
namespace HanaTui.Tui.Components;
-///
-/// A timestamped log entry.
-///
public sealed class LogEntry
{
- public DateTime Time { get; init; } = DateTime.Now;
- public string Text { get; init; } = "";
+ public DateTime Time { get; init; } = DateTime.Now;
+ public string Text { get; init; } = "";
public LogLevel Level { get; init; } = LogLevel.Info;
}
public enum LogLevel { Info, Sql, Done, Warn, Error }
///
-/// Maintains a bounded list of log entries and renders them as a
-/// Spectre.Console IRenderable panel, showing the most recent N lines.
-/// Thread-safe.
+/// Thread-safe bounded log. Build() returns an IRenderable panel.
+/// Dynamic content is rendered via Text() objects — never interpolated into markup strings.
///
public sealed class LogPanel
{
- private const int MaxEntries = 500;
- private const int VisibleLines = 20; // shown inside the panel
+ private const int MaxEntries = 500;
+ private const int VisibleLines = 20;
private readonly List _entries = new(MaxEntries);
private readonly object _lock = new();
@@ -31,19 +27,13 @@ public sealed class LogPanel
public void Add(string text)
{
var level = DetectLevel(text);
- var entry = new LogEntry { Time = DateTime.Now, Text = text, Level = level };
-
lock (_lock)
{
- if (_entries.Count >= MaxEntries)
- _entries.RemoveAt(0);
- _entries.Add(entry);
+ if (_entries.Count >= MaxEntries) _entries.RemoveAt(0);
+ _entries.Add(new LogEntry { Time = DateTime.Now, Text = text, Level = level });
}
}
- ///
- /// Returns a renderable panel showing the last N log lines.
- ///
public IRenderable Build()
{
List snapshot;
@@ -56,30 +46,37 @@ public sealed class LogPanel
var rows = new Grid();
rows.AddColumn(new GridColumn().NoWrap());
- // Pad to VisibleLines so the panel doesn't resize each tick
- var padCount = VisibleLines - snapshot.Count;
- for (int i = 0; i < padCount; i++)
+ // Pad so the panel height stays stable
+ for (int i = 0; i < VisibleLines - snapshot.Count; i++)
rows.AddRow(new Text(""));
foreach (var entry in snapshot)
{
- var timeStr = entry.Time.ToString("HH:mm:ss");
- var (tagLabel, color) = entry.Level switch
+ var (tagLabel, tagColor, textColor) = entry.Level switch
{
- LogLevel.Sql => ("SQL ", "blue"),
- LogLevel.Done => ("DONE", "green"),
- LogLevel.Warn => ("WARN", "yellow"),
- LogLevel.Error => ("ERR ", "red"),
- _ => ("INFO", "grey"),
+ LogLevel.Sql => ("SQL ", Color.Blue, Color.White),
+ LogLevel.Done => ("DONE", Color.Green, Color.White),
+ LogLevel.Warn => ("WARN", Color.Yellow, Color.White),
+ LogLevel.Error => ("ERR ", Color.Red, Color.White),
+ _ => ("INFO", Color.Grey, Color.Silver),
};
- // Strip the leading [TAG] prefix from the raw text if present,
- // since we re-render it with color. Then escape the remainder.
- var rawText = StripKnownPrefix(entry.Text);
- var safeText = Markup.Escape(rawText);
+ // Strip the [TAG] prefix the service already prepends — we re-render it with color
+ var bodyText = StripKnownPrefix(entry.Text);
- rows.AddRow(new Markup(
- $"[dim]{timeStr}[/] [{color}][[{tagLabel}]][/] {safeText}"));
+ // Compose the row from Text objects — zero markup parsing of dynamic content
+ var lineGrid = new Grid();
+ lineGrid.AddColumn(new GridColumn().NoWrap().Width(10)); // time
+ lineGrid.AddColumn(new GridColumn().NoWrap().Width(8)); // tag
+ lineGrid.AddColumn(new GridColumn().NoWrap()); // body
+
+ lineGrid.AddRow(
+ new Text(entry.Time.ToString("HH:mm:ss"), new Style(Color.Grey, decoration: Decoration.Dim)),
+ new Text($"[{tagLabel}]", new Style(tagColor)),
+ new Text(" " + bodyText, new Style(textColor))
+ );
+
+ rows.AddRow(lineGrid);
}
return new Panel(rows)
@@ -90,16 +87,15 @@ public sealed class LogPanel
};
}
+ // -------------------------------------------------------------------------
+
private static LogLevel DetectLevel(string text)
{
- if (text.Contains("[SQL ]") || text.Contains("[SQL]"))
- return LogLevel.Sql;
- if (text.Contains("[DONE]"))
- return LogLevel.Done;
- if (text.Contains("[WARN]"))
- return LogLevel.Warn;
- if (text.Contains("[ERROR]") || text.Contains("[ERR]") || text.Contains("[ERR ]"))
- return LogLevel.Error;
+ if (text.Contains("[SQL ]") || text.Contains("[SQL]")) return LogLevel.Sql;
+ if (text.Contains("[DONE]")) return LogLevel.Done;
+ if (text.Contains("[WARN]")) return LogLevel.Warn;
+ if (text.Contains("[ERROR]") || text.Contains("[ERR ]") || text.Contains("[ERR]"))
+ return LogLevel.Error;
return LogLevel.Info;
}
@@ -108,11 +104,9 @@ public sealed class LogPanel
private static string StripKnownPrefix(string text)
{
- foreach (var prefix in KnownPrefixes)
- {
- if (text.StartsWith(prefix, StringComparison.Ordinal))
- return text[prefix.Length..];
- }
+ foreach (var p in KnownPrefixes)
+ if (text.StartsWith(p, StringComparison.Ordinal))
+ return text[p.Length..];
return text;
}
}
diff --git a/hanatui/src/Tui/Components/StatsPanel.cs b/hanatui/src/Tui/Components/StatsPanel.cs
index 7e2255a..31e0408 100644
--- a/hanatui/src/Tui/Components/StatsPanel.cs
+++ b/hanatui/src/Tui/Components/StatsPanel.cs
@@ -6,21 +6,18 @@ namespace HanaTui.Tui.Components;
///
/// Renders the system stats panel (CPU bars, RAM bar, Swap bar, elapsed time).
-/// Returns a Spectre.Console IRenderable so it can be embedded in a Live layout.
+/// Uses only hardcoded markup tags — no dynamic data ever enters a markup string.
///
public static class StatsPanel
{
- ///
- /// Build the stats panel renderable from the current snapshot.
- ///
public static IRenderable Build(SystemSnapshot snap, TimeSpan elapsed, int panelWidth)
{
- var barWidth = Math.Max(10, panelWidth - 16); // leave room for label + %
+ var barWidth = Math.Max(10, panelWidth - 16);
var grid = new Grid();
grid.AddColumn(new GridColumn().NoWrap());
- // --- CPU Section ---
+ // --- CPU ---
grid.AddRow(new Markup("[bold yellow]CPU[/]"));
if (snap.CpuPercents.Length == 0)
@@ -34,23 +31,21 @@ public static class StatsPanel
var label = snap.CpuLabels.Length > i ? snap.CpuLabels[i] : $"cpu{i}";
var displayLabel = label == "cpu" ? "Total" : label.Replace("cpu", "Core");
var pct = snap.CpuPercents[i];
- var bar = BuildBar(pct, barWidth, CpuColor(pct));
- grid.AddRow(new Markup($" [dim]{displayLabel,-6}[/] {bar} [bold]{pct,5:F1}%[/]"));
+ // Build as Columns: label | bar | pct — no interpolation of dynamic values into markup
+ grid.AddRow(BuildBarRow(displayLabel, pct, barWidth));
}
}
grid.AddRow(new Text(""));
- // --- Memory Section ---
+ // --- Memory ---
grid.AddRow(new Markup("[bold cyan]MEMORY[/]"));
if (snap.MemTotalKb > 0)
{
var memPct = snap.MemUsedPercent;
- var memBar = BuildBar(memPct, barWidth, MemColor(memPct));
- grid.AddRow(new Markup(
- $" [dim]Used [/] {memBar} [bold]{memPct,5:F1}%[/]"));
- grid.AddRow(new Markup(
- $" [dim]{SystemSnapshot.FormatGb(snap.MemUsedKb)} / {SystemSnapshot.FormatGb(snap.MemTotalKb)}[/]"));
+ grid.AddRow(BuildBarRow("Used", memPct, barWidth));
+ grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.MemUsedKb)} / {SystemSnapshot.FormatGb(snap.MemTotalKb)}",
+ new Style(Color.Grey)));
}
else
{
@@ -59,16 +54,14 @@ public static class StatsPanel
grid.AddRow(new Text(""));
- // --- Swap Section ---
+ // --- Swap ---
grid.AddRow(new Markup("[bold cyan]SWAP[/]"));
if (snap.SwapTotalKb > 0)
{
var swapPct = snap.SwapUsedPercent;
- var swapBar = BuildBar(swapPct, barWidth, MemColor(swapPct));
- grid.AddRow(new Markup(
- $" [dim]Used [/] {swapBar} [bold]{swapPct,5:F1}%[/]"));
- grid.AddRow(new Markup(
- $" [dim]{SystemSnapshot.FormatGb(snap.SwapUsedKb)} / {SystemSnapshot.FormatGb(snap.SwapTotalKb)}[/]"));
+ grid.AddRow(BuildBarRow("Used", swapPct, barWidth));
+ grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.SwapUsedKb)} / {SystemSnapshot.FormatGb(snap.SwapTotalKb)}",
+ new Style(Color.Grey)));
}
else
{
@@ -78,17 +71,21 @@ public static class StatsPanel
grid.AddRow(new Text(""));
// --- Elapsed ---
- grid.AddRow(new Markup(
- $"[bold]Elapsed:[/] [yellow]{FormatElapsed(elapsed)}[/]"));
+ // Two separate renderables composed — no interpolation of elapsed into markup
+ var elapsedGrid = new Grid();
+ elapsedGrid.AddColumn(new GridColumn().NoWrap());
+ elapsedGrid.AddColumn(new GridColumn().NoWrap());
+ elapsedGrid.AddRow(
+ new Markup("[bold]Elapsed:[/]"),
+ new Text(" " + FormatElapsed(elapsed), new Style(Color.Yellow)));
+ grid.AddRow(elapsedGrid);
- var panel = new Panel(grid)
+ return new Panel(grid)
{
Header = new PanelHeader("[bold] SYSTEM STATS [/]"),
Border = BoxBorder.Rounded,
Padding = new Padding(1, 0),
};
-
- return panel;
}
// -------------------------------------------------------------------------
@@ -96,36 +93,46 @@ public static class StatsPanel
// -------------------------------------------------------------------------
///
- /// Renders a horizontal block bar of the given width.
- /// e.g. [████████░░░░]
- /// Result is a markup string safe for embedding inside a larger Markup call.
+ /// Builds a single bar row as a Grid with three columns:
+ /// label (dim) | bar (block chars, plain Text with color) | pct (bold)
+ /// Nothing here goes through markup parsing with dynamic content.
///
- private static string BuildBar(double percent, int width, string color)
+ private static IRenderable BuildBarRow(string label, double pct, int barWidth)
{
- var filled = (int)Math.Round(percent / 100.0 * width);
- filled = Math.Clamp(filled, 0, width);
- var empty = width - filled;
+ var filled = (int)Math.Round(pct / 100.0 * barWidth);
+ filled = Math.Clamp(filled, 0, barWidth);
+ var empty = barWidth - filled;
- // \u2588 = █ \u2591 = ░
- // [[ and ]] are Spectre escape sequences for literal [ and ]
+ var filledColor = CpuColor(pct);
var filledStr = new string('\u2588', filled);
var emptyStr = new string('\u2591', empty);
- return $"[[[{color}]{filledStr}[/][dim]{emptyStr}[/]]]";
+
+ var row = new Grid();
+ row.AddColumn(new GridColumn().NoWrap().Width(9)); // label
+ row.AddColumn(new GridColumn().NoWrap()); // bar chars
+ row.AddColumn(new GridColumn().NoWrap().Width(7)); // pct
+
+ row.AddRow(
+ new Text($" {label,-5}", new Style(Color.Grey, decoration: Decoration.Dim)),
+ // Bar: colored filled + dim empty, wrapped in plain Text objects side by side
+ new Columns(
+ new Text("[", new Style(Color.Grey)),
+ new Text(filledStr, new Style(filledColor)),
+ new Text(emptyStr, new Style(Color.Grey, decoration: Decoration.Dim)),
+ new Text("]", new Style(Color.Grey))
+ ),
+ new Text($" {pct,5:F1}%", new Style(Color.White, decoration: Decoration.Bold))
+ );
+
+ return row;
}
- private static string CpuColor(double pct) => pct switch
+ private static Color CpuColor(double pct) => pct switch
{
- > 90 => "red",
- > 70 => "yellow",
- > 40 => "green",
- _ => "blue",
- };
-
- private static string MemColor(double pct) => pct switch
- {
- > 90 => "red",
- > 70 => "yellow",
- _ => "cyan",
+ > 90 => Color.Red,
+ > 70 => Color.Yellow,
+ > 40 => Color.Green,
+ _ => Color.Blue,
};
private static string FormatElapsed(TimeSpan t)
diff --git a/hanatui/src/Tui/TaskRunnerScreen.cs b/hanatui/src/Tui/TaskRunnerScreen.cs
index 56c2944..a787dbb 100644
--- a/hanatui/src/Tui/TaskRunnerScreen.cs
+++ b/hanatui/src/Tui/TaskRunnerScreen.cs
@@ -10,19 +10,13 @@ namespace HanaTui.Tui;
/// Left: System stats (CPU, RAM, Swap, elapsed)
/// Right: Streaming operation log
///
-/// Abort behavior:
-/// First Q -> warning shown in log
-/// Second Q (within 3s) -> sends cancellation (SIGTERM -> wait 5s -> SIGKILL)
-///
-/// Post-completion:
-/// Stats freeze, log stays.
-/// "Returning in 10s... [any key to stay]" countdown.
-/// If key pressed: stays until Enter/Esc.
+/// 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;
+ private const int CountdownSeconds = 10;
+ private const double AbortWindowSeconds = 3.0;
public static async Task RunAsync(
string operationTitle,
@@ -30,20 +24,16 @@ public static class TaskRunnerScreen
{
AnsiConsole.Clear();
- var logPanel = new LogPanel();
- var stats = new SystemStats();
- var cts = new CancellationTokenSource();
+ var logPanel = new LogPanel();
+ var stats = new SystemStats();
+ var cts = new CancellationTokenSource();
var startTime = DateTime.Now;
- var operationDone = false;
+ var operationDone = false;
var operationSuccess = false;
+ DateTime? firstQTime = null;
- // Abort state machine
- DateTime? firstQPressTime = null;
-
- // -----------------------------------------------------------------------
- // Key listener task — runs concurrently with the Live render loop
- // -----------------------------------------------------------------------
+ // Key listener
var keyTask = Task.Run(async () =>
{
while (!operationDone)
@@ -51,25 +41,24 @@ public static class TaskRunnerScreen
if (Console.KeyAvailable)
{
var key = Console.ReadKey(intercept: true);
- if (key.Key == ConsoleKey.Q || key.KeyChar == 'q' || key.KeyChar == 'Q')
+ if (key.Key == ConsoleKey.Q || key.KeyChar is 'q' or 'Q')
{
- if (cts.IsCancellationRequested) break; // already cancelled
+ if (cts.IsCancellationRequested) break;
- if (firstQPressTime is null)
+ if (firstQTime is null)
{
- firstQPressTime = DateTime.Now;
- logPanel.Add("[WARN] Press Q again within 3 seconds to abort the operation.");
+ firstQTime = DateTime.Now;
+ logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
}
- else if ((DateTime.Now - firstQPressTime.Value).TotalSeconds <= AbortWindowSeconds)
+ else if ((DateTime.Now - firstQTime.Value).TotalSeconds <= AbortWindowSeconds)
{
logPanel.Add("[WARN] Aborting operation...");
await cts.CancelAsync();
}
else
{
- // Window expired, treat as first press again
- firstQPressTime = DateTime.Now;
- logPanel.Add("[WARN] Press Q again within 3 seconds to abort the operation.");
+ firstQTime = DateTime.Now;
+ logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
}
}
}
@@ -77,16 +66,12 @@ public static class TaskRunnerScreen
}
}, CancellationToken.None);
- // -----------------------------------------------------------------------
- // Main operation task
- // -----------------------------------------------------------------------
+ // Operation task
var operationTask = Task.Run(async () =>
{
try
{
- operationSuccess = await operation(
- line => logPanel.Add(line),
- cts.Token);
+ operationSuccess = await operation(line => logPanel.Add(line), cts.Token);
}
catch (Exception ex)
{
@@ -99,93 +84,77 @@ public static class TaskRunnerScreen
}
}, CancellationToken.None);
- // -----------------------------------------------------------------------
// Live render loop
- // -----------------------------------------------------------------------
- await AnsiConsole.Live(BuildLayout(logPanel, stats, startTime, "[yellow]Running...[/]"))
+ await AnsiConsole.Live(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running))
.AutoClear(false)
.StartAsync(async ctx =>
{
while (!operationDone)
{
- var elapsed = DateTime.Now - startTime;
- ctx.UpdateTarget(
- BuildLayout(logPanel, stats, startTime, "[yellow]Running...[/]"));
+ ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running));
await Task.Delay(800, CancellationToken.None);
}
- // Final render with result
- var elapsed2 = DateTime.Now - startTime;
- var statusMsg = cts.IsCancellationRequested
- ? "[red]Aborted[/]"
- : operationSuccess
- ? "[green]Completed successfully[/]"
- : "[red]Failed[/]";
-
+ var elapsed = DateTime.Now - startTime;
+ RunState finalState;
if (cts.IsCancellationRequested)
- logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed2)}.");
+ {
+ finalState = RunState.Aborted;
+ logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed)}.");
+ }
else if (operationSuccess)
- logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed2)}.");
+ {
+ finalState = RunState.Success;
+ logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed)}.");
+ }
else
- logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed2)}.");
+ {
+ finalState = RunState.Failed;
+ logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed)}.");
+ }
- ctx.UpdateTarget(BuildLayout(logPanel, stats, startTime, statusMsg));
+ ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, finalState));
});
- // Clean up
stats.Dispose();
- await operationTask; // ensure it's fully done
+ await operationTask;
operationDone = true;
await keyTask;
- // -----------------------------------------------------------------------
- // Post-completion: countdown or hold
- // -----------------------------------------------------------------------
- await PostCompletionWaitAsync(logPanel, stats, startTime, operationSuccess, cts.IsCancellationRequested);
+ await PostCompletionWaitAsync(operationTitle, logPanel, stats, startTime,
+ cts.IsCancellationRequested ? RunState.Aborted :
+ operationSuccess ? RunState.Success : RunState.Failed);
}
// -----------------------------------------------------------------------
- // Post-completion hold screen
- // -----------------------------------------------------------------------
+
+ private enum RunState { Running, Success, Failed, Aborted }
private static async Task PostCompletionWaitAsync(
- LogPanel logPanel,
- SystemStats stats,
- DateTime startTime,
- bool success,
- bool aborted)
+ string title, LogPanel logPanel, SystemStats stats, DateTime startTime, RunState state)
{
- var statusMsg = aborted ? "[red]Aborted[/]" :
- success ? "[green]Completed[/]" : "[red]Failed[/]";
-
var countdownCancelled = false;
var returnNow = false;
+ var staying = false;
+ var startCountdown = DateTime.Now;
- // Start 10-second countdown in background
var countdownTask = Task.Run(async () =>
{
for (int i = CountdownSeconds; i > 0 && !countdownCancelled; i--)
- {
await Task.Delay(1000, CancellationToken.None);
- }
- if (!countdownCancelled)
- returnNow = true;
+ if (!countdownCancelled) returnNow = true;
}, CancellationToken.None);
- await AnsiConsole.Live(BuildLayout(logPanel, stats, startTime, statusMsg))
+ await AnsiConsole.Live(BuildLayout(title, logPanel, stats, startTime, state))
.AutoClear(false)
.StartAsync(async ctx =>
{
- var secondsLeft = CountdownSeconds;
- var staying = false;
-
while (!returnNow)
{
if (Console.KeyAvailable)
{
var key = Console.ReadKey(intercept: true);
countdownCancelled = true;
- staying = true;
if (key.Key is ConsoleKey.Enter or ConsoleKey.Escape)
{
@@ -193,27 +162,26 @@ public static class TaskRunnerScreen
break;
}
- // Any other key: cancel countdown, show "hold" message
- logPanel.Add("[INFO] Countdown cancelled. Press [Enter] or [Esc] to return to menu.");
+ if (!staying)
+ {
+ staying = true;
+ logPanel.Add("[INFO] Countdown cancelled. Press Enter or Esc to return to menu.");
+ }
}
- // Update footer countdown
- var elapsed = DateTime.Now - startTime;
- string footer;
+ string footerText;
if (!staying)
{
- secondsLeft = CountdownSeconds -
- (int)(DateTime.Now - startTime).TotalSeconds; // approximate
- // recompute properly
- footer = $"[dim]Returning to menu in {Math.Max(0, secondsLeft)}s... " +
- "[any key to stay][/]";
+ var secsLeft = Math.Max(0, CountdownSeconds -
+ (int)(DateTime.Now - startCountdown).TotalSeconds);
+ footerText = $"Returning to menu in {secsLeft}s... (any key to stay)";
}
else
{
- footer = "[dim]Press [Enter] or [Esc] to return to menu.[/]";
+ footerText = "Press Enter or Esc to return to menu.";
}
- ctx.UpdateTarget(BuildLayoutWithFooter(logPanel, stats, startTime, statusMsg, footer));
+ ctx.UpdateTarget(BuildLayout(title, logPanel, stats, startTime, state, footerText));
await Task.Delay(200, CancellationToken.None);
}
});
@@ -222,74 +190,78 @@ public static class TaskRunnerScreen
}
// -----------------------------------------------------------------------
- // Layout builders
+ // Layout
// -----------------------------------------------------------------------
private static IRenderable BuildLayout(
+ string title,
LogPanel logPanel,
SystemStats stats,
DateTime startTime,
- string statusMsg)
- {
- return BuildLayoutWithFooter(logPanel, stats, startTime, statusMsg, footer: null);
- }
-
- private static IRenderable BuildLayoutWithFooter(
- LogPanel logPanel,
- SystemStats stats,
- DateTime startTime,
- string statusMsg,
- string? footer)
+ RunState state,
+ string? footerText = null)
{
var elapsed = DateTime.Now - startTime;
- var snap = stats.CurrentSnapshot;
+ var snap = stats.CurrentSnapshot;
- // Stats panel (left) — fixed width ~36 chars
- var statsPanelWidth = 36;
+ const int statsPanelWidth = 40;
var statsRenderable = StatsPanel.Build(snap, elapsed, statsPanelWidth - 4);
+ var logRenderable = logPanel.Build();
- // Log panel (right) — fills remaining space
- var logRenderable = logPanel.Build();
-
- var layout = new Layout("root")
- .SplitColumns(
- new Layout("stats").Size(statsPanelWidth),
- new Layout("log"));
-
+ var layout = new Layout("root").SplitColumns(
+ new Layout("stats").Size(statsPanelWidth),
+ new Layout("log"));
layout["stats"].Update(statsRenderable);
layout["log"].Update(logRenderable);
- // Wrap in a grid so we can add a footer row
var outerGrid = new Grid();
outerGrid.AddColumn(new GridColumn());
- // Title row
- outerGrid.AddRow(new Markup(
- $"[bold dodgerblue1] Running:[/] [yellow]{Markup.Escape(ExtractTitle(statusMsg))}[/] " +
- $"Status: {statusMsg}"));
-
+ // 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);
- if (footer is not null)
- outerGrid.AddRow(new Markup($"\n {footer} [dim][[Q]] Abort[/]"));
- else
- outerGrid.AddRow(new Markup("\n [dim][[Q]] Press once to warn, twice to abort[/]"));
+ // 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 string ExtractTitle(string statusMsg)
+ private static IRenderable StateLabel(RunState state) => state switch
{
- // statusMsg is markup like "[yellow]Running...[/]" — we just return a static title
- return "HANA Operation";
- }
+ 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";
+ 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";
}
}