From b342005c6511c31a12ac138eeb8f2ce7258db7e2 Mon Sep 17 00:00:00 2001 From: Jared Parsons Date: Tue, 23 Jun 2026 22:07:53 -0700 Subject: [PATCH] Fix health report rendering: tables, scrolling, emoji, wrapping - Render markdown tables as formatted text with column alignment instead of raw markdown pipe syntax - Add word-wrapping in table cells so content is never truncated - Preserve short columns (<=12 chars) at natural width; only shrink wide columns proportionally when table exceeds available width - Add scroll loops to health state and run detail pages - Set UTF-8 output encoding for emoji characters - Add [M]arkdown toggle to view raw markdown for debugging render issues - Fix WrapMarkupLine failing on escaped brackets (e.g. [[#83775]]) by re-escaping literal bracket chars when rebuilding wrapped output - Pass ContentWidth to ToMarkupLines so tables are constrained at render time rather than relying on panel wrapping which breaks table structure - Disable truncation in health pages (use wrapping instead) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tiger.Tests/MarkdownRendererTests.cs | 265 +++++++++++++++++++++++ src/Tiger.Tests/PanelRendererTests.cs | 123 +++++++++++ src/Tiger/Commands/AnalysisBrowser.cs | 2 +- src/Tiger/Commands/HealthCommand.cs | 131 +++++++---- src/Tiger/Commands/PanelRenderer.cs | 18 +- src/Tiger/MarkdownRenderer.cs | 254 +++++++++++++++++++++- src/Tiger/Program.cs | 4 + 7 files changed, 753 insertions(+), 44 deletions(-) diff --git a/src/Tiger.Tests/MarkdownRendererTests.cs b/src/Tiger.Tests/MarkdownRendererTests.cs index 385dcbf..88b17f4 100644 --- a/src/Tiger.Tests/MarkdownRendererTests.cs +++ b/src/Tiger.Tests/MarkdownRendererTests.cs @@ -1,3 +1,4 @@ +using Spectre.Console; using Xunit; namespace Tiger.Tests; @@ -528,4 +529,268 @@ [bold]Recommended Actions[/] expected.ReplaceLineEndings("\n").Trim(), actual.ReplaceLineEndings("\n").Trim()); } + + [Fact] + public void ToMarkupLines_RendersTableAsText() + { + var md = """ + | Problem | Severity | Trend | + |---------|----------|-------| + | Helix crashes | Medium | Chronic | + | Test timeout | High | New | + """; + + var lines = MarkdownRenderer.ToMarkupLines(md); + var actual = string.Join("\n", lines); + + var expected = """ + [bold]Problem [/] [dim]│[/] [bold]Severity[/] [dim]│[/] [bold]Trend [/] + [dim]──────────────┼──────────┼────────[/] + Helix crashes [dim]│[/] Medium [dim]│[/] Chronic + Test timeout [dim]│[/] High [dim]│[/] New + """; + Assert.Equal( + expected.ReplaceLineEndings("\n").Trim(), + actual.ReplaceLineEndings("\n").Trim()); + } + + [Fact] + public void RenderTableAsText_FormatsColumnsCorrectly() + { + var headers = new[] { "Name", "Status" }; + var rows = new List + { + new[] { "Alpha", "OK" }, + new[] { "Beta release", "Failed" }, + }; + + var lines = MarkdownRenderer.RenderTableAsText(headers, rows); + var actual = string.Join("\n", lines); + + var expected = """ + [bold]Name [/] [dim]│[/] [bold]Status[/] + [dim]─────────────┼───────[/] + Alpha [dim]│[/] OK + Beta release [dim]│[/] Failed + """; + Assert.Equal( + expected.ReplaceLineEndings("\n").Trim(), + actual.ReplaceLineEndings("\n").Trim()); + } + + [Fact] + public void ToMarkupLines_ConstrainsTableToMaxWidth() + { + // Real-world table that's much wider than a typical terminal + var md = """ + | Problem | Since | Severity | Trend | + |---------|-------|----------|-------| + | Test_Windows_CoreClr_Debug_Single_Machine 120-min timeout | Jun 20 | High | Persistent — 5 hits in 3 days | + | Razor AddImport code action tests (12 tests) | Jun 22 | Medium | Active — tracked by #83775 | + """; + + var constrained = MarkdownRenderer.ToMarkupLines(md, 80); + var actual = string.Join("\n", constrained); + + var expected = """ + [bold]Problem [/] [dim]│[/] [bold]Since [/] [dim]│[/] [bold]Severity[/] [dim]│[/] [bold]Trend [/] + [dim]─────────────────────────────────────┼────────┼──────────┼────────────────────[/] + Test_Windows_CoreClr_Debug_Single_Ma [dim]│[/] Jun 20 [dim]│[/] High [dim]│[/] Persistent — 5 + chine 120-min timeout [dim]│[/] [dim]│[/] [dim]│[/] hits in 3 days + Razor AddImport code action tests [dim]│[/] Jun 22 [dim]│[/] Medium [dim]│[/] Active — tracked + (12 tests) [dim]│[/] [dim]│[/] [dim]│[/] by #83775 + """; + Assert.Equal(expected.ReplaceLineEndings("\n"), actual); + } + + [Fact] + public void RenderTableAsText_WordWrapsWhenConstrained() + { + var headers = new[] { "Name", "Description" }; + var rows = new List + { + new[] { "Alpha", "A very long description that should be truncated to fit the width" }, + }; + + // maxWidth = 40: available for columns = 40 - 2 (indent) - 3 (separator) = 35 + // "Name" (natural=5) is ≤12 so stays at 5; "Description" gets remainder = 30 + var lines = MarkdownRenderer.RenderTableAsText(headers, rows, 40); + var actual = string.Join("\n", lines); + + var expected = """ + [bold]Name [/] [dim]│[/] [bold]Description [/] + [dim]──────┼───────────────────────────────[/] + Alpha [dim]│[/] A very long description that + [dim]│[/] should be truncated to fit + [dim]│[/] the width + """; + Assert.Equal(expected.ReplaceLineEndings("\n"), actual); + } + + [Fact] + public void Emoji_PreservedInBoldText() + { + // Emoji characters like 🟡 must pass through to ANSI output unchanged. + // The MarkupToAnsi helper renders via Spectre to capture real ANSI codes. + var md = "**Overall Health: 🟡 YELLOW**"; + var lines = MarkdownRenderer.ToMarkupLines(md); + + var actual = string.Join("\n", lines); + // Bold inline: " [bold]Overall Health: 🟡 YELLOW[/]" + Assert.Equal(" [bold]Overall Health: 🟡 YELLOW[/]", actual); + + // Verify the emoji survives ANSI rendering + var ansi = RenderMarkupToAnsi(actual.Trim()); + Assert.Equal("\x1b[1mOverall Health: 🟡 YELLOW\x1b[0m", ansi); + } + + [Fact] + public void Emoji_PreservedInPlainText() + { + var md = "Status: 🟢 GREEN — all passing"; + var lines = MarkdownRenderer.ToMarkupLines(md); + + var actual = string.Join("\n", lines); + Assert.Equal(" Status: 🟢 GREEN — all passing", actual); + + var ansi = RenderMarkupToAnsi(actual.Trim()); + Assert.Equal("Status: 🟢 GREEN — all passing", ansi); + } + + [Fact] + public void Emoji_PreservedInTableCells() + { + var md = """ + | Status | Meaning | + |--------|---------| + | 🟢 | Passing | + | 🟡 | Degraded | + | 🔴 | Failing | + """; + + var lines = MarkdownRenderer.ToMarkupLines(md); + var actual = string.Join("\n", lines); + + // Emoji in cells are preserved after inline formatting + var expected = """ + [bold]Status[/] [dim]│[/] [bold]Meaning [/] + [dim]───────┼─────────[/] + 🟢 [dim]│[/] Passing + 🟡 [dim]│[/] Degraded + 🔴 [dim]│[/] Failing + """; + Assert.Equal( + expected.ReplaceLineEndings("\n").Trim(), + actual.ReplaceLineEndings("\n").Trim()); + } + + [Fact] + public void Emoji_PreservedInBulletPoints() + { + var md = "- 🟡 Medium severity issue"; + var lines = MarkdownRenderer.ToMarkupLines(md); + + var actual = string.Join("\n", lines); + Assert.Equal(" [blue]•[/] 🟡 Medium severity issue", actual); + + var ansi = RenderMarkupToAnsi(actual.Trim()); + Assert.Equal("\x1b[38;5;12m•\x1b[0m 🟡 Medium severity issue", ansi); + } + + /// + /// Renders a Spectre markup string to raw ANSI escape sequences. + /// + private static string RenderMarkupToAnsi(string markupText) + { + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + }); + return console.ToAnsi(new Markup(markupText)); + } + + [Fact] + public void FormatInlineMarkup_LinksWithQueryStrings_ProduceValidMarkup() + { + // Real-world line from health reports with links containing query strings + var line = "5 succeeded / 10 failed. Latest 2 builds ([1477359](https://dev.azure.com/dnceng-public/public/_build/results?buildId=1477359), [1477299](https://dev.azure.com/dnceng-public/public/_build/results?buildId=1477299)) both **succeeded**."; + + var result = MarkdownRenderer.FormatInlineMarkup(line); + + // Must not throw when rendered by Spectre + var markup = new Markup($" {result}"); + Assert.NotNull(markup); + } + + [Fact] + public void ToMarkupLines_RealHealthReport_AllLinesAreValidMarkup() + { + // Content from a real health report — exercises links in table cells, + // backtick-quoted identifiers, bold text, and emoji + var md = """ + > Generated: 2026-06-23 08:48 | Window: 3 days + + **Overall Health: 🟡 YELLOW** + + **CI pass rate (main):** 5 succeeded / 10 failed since Jun 20. Latest 2 builds ([1477359](https://dev.azure.com/dnceng-public/public/_build/results?buildId=1477359), [1477299](https://dev.azure.com/dnceng-public/public/_build/results?buildId=1477299)) both **succeeded** (Jun 23 ~8 AM PT). + + ### Active Problems + + | Problem | Since | Severity | Trend | + |---------|-------|----------|-------| + | `Test_Windows_CoreClr_Debug_Single_Machine` 120-min timeout | Jun 19 | 🔴 High | **Persistent** — 4 hits across 4 days on different NetCore-Public agents; systemic queue capacity issue | + | `Test_Linux_Debug` Helix work item crashes | Jun 22 | 🔴 High | **Active** — 5 CI builds affected; test crashes (exit code 1) in multiple work items | + | Razor AddImport code action tests (12 tests) | Jun 22 | 🟡 Medium | **Active** — tracked by [#83775](https://github.com/dotnet/roslyn/issues/83775), only on Linux legs | + | "Helix completed without specifying a job id" | Jun 23 | 🟡 Low | **New** — single PR occurrence, monitoring | + + ### Known Issue Correlations + - **#83775** actively matching Razor AddImport failures (2 CI + 1 PR build) + - **#83972** (Pool leak) — matched 1 occurrence in CI (workitem_0 in build 1476004) + - No other known issues triggered + + ### Recommended Actions + 1. **🔴 File Known Build Error for timeout**: Pattern `ran longer than the maximum time of 120 minutes` with `BuildRetry: true` + 2. **🔴 Investigate Linux Helix work item crashes**: 5 CI builds failed due to work item crashes on Linux + 3. **🟡 Razor AddImport**: Tracked by #83775 — no additional action needed + """; + + // Test at multiple widths to catch wrapping issues + foreach (var width in new[] { 80, 100, 116, 150, 200 }) + { + var lines = MarkdownRenderer.ToMarkupLines(md, width); + + for (var i = 0; i < lines.Count; i++) + { + try + { + _ = new Markup(lines[i]); + } + catch (Exception ex) + { + Assert.Fail($"Width={width}, Line {i} produced invalid markup: {ex.Message}\nLine: {lines[i]}"); + } + } + + // Also test wrapping at various panel widths + foreach (var wrapWidth in new[] { 70, 100, 116, 150 }) + { + foreach (var line in lines) + { + var wrapped = Tiger.Commands.PanelRenderer.WrapMarkupLine(line, wrapWidth); + for (var j = 0; j < wrapped.Count; j++) + { + try + { + _ = new Markup(wrapped[j]); + } + catch (Exception ex) + { + Assert.Fail($"Width={width}, WrapWidth={wrapWidth}: invalid markup: {ex.Message}\nOriginal: {line}\nWrapped[{j}]: {wrapped[j]}"); + } + } + } + } + } + } } diff --git a/src/Tiger.Tests/PanelRendererTests.cs b/src/Tiger.Tests/PanelRendererTests.cs index a912977..d40f259 100644 --- a/src/Tiger.Tests/PanelRendererTests.cs +++ b/src/Tiger.Tests/PanelRendererTests.cs @@ -604,6 +604,129 @@ public void RenderPanelLine_WrappedDiagnosisPreservesBorders() Assert.Equal(MarkupToAnsi(expected.ReplaceLineEndings("\n").Trim()), actual); } + [Fact] + public void RenderDetailPanel_RendersTableWithinPanel_WhenTruncationDisabled() + { + // Width 60 gives content width of 56 (60 - 4 for borders/padding). + // Table fits naturally: "Problem" col=13, "Since" col=6, "Severity" col=8. + // Visible row = 2 + 13 + 3 + 6 + 3 + 8 = 35, fits in 56. + var console = new TestConsole().EmitAnsiSequences().Width(60).Height(12); + var renderer = new PanelRenderer(console); + renderer.TruncationEnabled = false; + + var md = """ + | Problem | Since | Severity | + |---------|-------|----------| + | Helix timeout | Jun 20 | High | + | Test crash | Jun 22 | Medium | + """; + + var contentLines = MarkdownRenderer.ToMarkupLines(md, renderer.ContentWidth); + + renderer.RenderDetailPanel( + ["Health"], + null, + contentLines, + "[blue]Esc[/] Back"); + + var actual = StripChrome(console.Output).ReplaceLineEndings("\n").Trim(); + var lines = actual.Split('\n'); + + // Every rendered line should fit within the panel width + foreach (var line in lines) + { + var stripped = StripAnsiEscapes(line); + Assert.True(stripped.Length <= 60, $"Line exceeds panel width (was {stripped.Length}): {stripped}"); + } + + // Table data is intact — no truncation ellipsis in the output + Assert.Equal(12, lines.Length); + Assert.True(!actual.Contains("..."), "Table should not be truncated when it fits"); + } + + [Fact] + public void RenderDetailPanel_ConstrainsTableToFit_WhenNarrowerThanContent() + { + // Width 50 gives content width of 46 (50 - 4). + // With maxWidth passed, table cells are truncated to fit panel width. + var console = new TestConsole().EmitAnsiSequences().Width(50).Height(12); + var renderer = new PanelRenderer(console); + renderer.TruncationEnabled = false; + + var md = """ + | Name | Description | + |------|-------------| + | Beta | This description exceeds the panel width boundary | + """; + + var contentLines = MarkdownRenderer.ToMarkupLines(md, renderer.ContentWidth); + + renderer.RenderDetailPanel( + ["Test"], + null, + contentLines, + "[blue]Esc[/] Back"); + + var actual = StripChrome(console.Output).ReplaceLineEndings("\n").Trim(); + var lines = actual.Split('\n'); + + // Every rendered line must fit within the 50-char panel + foreach (var line in lines) + { + var stripped = StripAnsiEscapes(line); + Assert.True(stripped.Length <= 50, $"Line exceeds panel width (was {stripped.Length}): {stripped}"); + } + } + + [Fact] + public void RenderDetailPanel_TruncatesTableRow_WhenTruncationEnabled() + { + // Width 50, truncation ON. Without maxWidth, the table produces long lines + // that get truncated by the panel renderer with "..." + var console = new TestConsole().EmitAnsiSequences().Width(50).Height(12); + var renderer = new PanelRenderer(console); + renderer.TruncationEnabled = true; + + var md = """ + | Name | Description | + |------|-------------| + | Beta | A much longer description that will exceed the panel width easily | + """; + + // No maxWidth — table renders naturally wide, then truncation kicks in + var contentLines = MarkdownRenderer.ToMarkupLines(md); + + renderer.RenderDetailPanel( + ["Test"], + null, + contentLines, + "[blue]Esc[/] Back"); + + var actual = StripChrome(console.Output).ReplaceLineEndings("\n").Trim(); + var lines = actual.Split('\n'); + + // With truncation, the data row is too wide and gets "..." appended + // Find the content line that has the data row (contains "Beta") + var dataLine = lines.First(l => l.Contains("Beta")); + var dataPlain = StripAnsiEscapes(dataLine); + // The line ends with "..." followed by padding and border + Assert.True(dataPlain.Contains("..."), $"Expected truncation '...' in: {dataPlain}"); + // The full visible content is: ║ ... ║ + // Verify the exact truncation point + Assert.Equal("║ Beta │ A much longer description that wil... ║", dataPlain); + // The tail of the description is cut off + Assert.True(!actual.Contains("panel width easily"), "Truncated text should not appear"); + } + + /// + /// Strips ANSI escape sequences from a string, leaving only visible text. + /// + private static string StripAnsiEscapes(string text) => + AnsiEscapeRegex().Replace(text, ""); + + [GeneratedRegex(@"\x1b\[[0-9;]*[A-Za-z]|\x1b\]8;[^\x1b]*\x1b\\")] + private static partial Regex AnsiEscapeRegex(); + // ── Tokenizer ─────────────────────────────────────────────────── [Fact] diff --git a/src/Tiger/Commands/AnalysisBrowser.cs b/src/Tiger/Commands/AnalysisBrowser.cs index 117377e..b34de9f 100644 --- a/src/Tiger/Commands/AnalysisBrowser.cs +++ b/src/Tiger/Commands/AnalysisBrowser.cs @@ -216,7 +216,7 @@ private void ShowFullLog(BuildAnalysisInfo analysis) content = content[..10000] + "\n\n... (truncated — see full file on disk)"; } - detailLines = MarkdownRenderer.ToMarkupLines(content); + detailLines = MarkdownRenderer.ToMarkupLines(content, _ui.ContentWidth); } _ui.RenderDetailPanel( ["Analysis", $"#{analysis.BuildId}", "Log"], diff --git a/src/Tiger/Commands/HealthCommand.cs b/src/Tiger/Commands/HealthCommand.cs index 7ae332f..2b0da39 100644 --- a/src/Tiger/Commands/HealthCommand.cs +++ b/src/Tiger/Commands/HealthCommand.cs @@ -82,13 +82,22 @@ private void ShowCombosPage(HealthAgentService agent) /// private void ShowStatePage(HealthAgentService agent, string repository, string definition) { + _ui.TruncationEnabled = false; + var showRawMarkdown = false; while (true) { var state = agent.GetCurrentState(repository, definition); var detailLines = new List(); if (state is not null) { - detailLines = MarkdownRenderer.ToMarkupLines(state); + if (showRawMarkdown) + { + detailLines = state.Split('\n').Select(l => Markup.Escape(l)).ToList(); + } + else + { + detailLines = MarkdownRenderer.ToMarkupLines(state, _ui.ContentWidth); + } } else { @@ -104,31 +113,44 @@ private void ShowStatePage(HealthAgentService agent, string repository, string d new("Re-run", ConsoleKey.R, -2), new("Gist", ConsoleKey.G, -3), new("View runs", ConsoleKey.V, -4), + new(showRawMarkdown ? "Rendered" : "Markdown", ConsoleKey.M, -5), })); - var key = Console.ReadKey(true); - switch (key.Key) + while (true) { - case ConsoleKey.R: - AnsiConsole.MarkupLine("[dim]Running health analysis...[/]"); - try - { - agent.RequestEvaluationAsync(repository, definition).GetAwaiter().GetResult(); - AnsiConsole.MarkupLine("[green]Health analysis complete.[/]"); - } - catch (Exception ex) - { - AnsiConsole.MarkupLine($"[red]Analysis failed: {Markup.Escape(ex.Message)}[/]"); - } - break; - case ConsoleKey.G: - CreateGist(repository, definition, state); - break; - case ConsoleKey.V: - ShowRunsPage(agent, repository, definition); - break; - case ConsoleKey.Escape: - return; + var key = Console.ReadKey(true); + if (_ui.HandleDetailScroll(key)) + { + continue; + } + + switch (key.Key) + { + case ConsoleKey.R: + AnsiConsole.MarkupLine("[dim]Running health analysis...[/]"); + try + { + agent.RequestEvaluationAsync(repository, definition).GetAwaiter().GetResult(); + AnsiConsole.MarkupLine("[green]Health analysis complete.[/]"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Analysis failed: {Markup.Escape(ex.Message)}[/]"); + } + break; + case ConsoleKey.G: + CreateGist(repository, definition, state); + break; + case ConsoleKey.V: + ShowRunsPage(agent, repository, definition); + break; + case ConsoleKey.M: + showRawMarkdown = !showRawMarkdown; + break; + case ConsoleKey.Escape: + return; + } + break; } } } @@ -225,23 +247,56 @@ private void ShowRunsPage(HealthAgentService agent, string repository, string de private void ShowRunDetail(HealthRunInfo run) { - List detailLines; - if (File.Exists(run.LogPath)) - { - var content = File.ReadAllText(run.LogPath); - detailLines = MarkdownRenderer.ToMarkupLines(content); - } - else + _ui.TruncationEnabled = false; + var showRawMarkdown = false; + while (true) { - detailLines = ["[red]Log file not found.[/]"]; - } - _ui.RenderDetailPanel( - ["Health", "Run", Markup.Escape(run.Timestamp.Replace("_", " "))], - null, - detailLines, - "[blue]Esc[/] Back"); + List detailLines; + if (File.Exists(run.LogPath)) + { + var content = File.ReadAllText(run.LogPath); + if (showRawMarkdown) + { + detailLines = content.Split('\n').Select(l => Markup.Escape(l)).ToList(); + } + else + { + detailLines = MarkdownRenderer.ToMarkupLines(content, _ui.ContentWidth); + } + } + else + { + detailLines = ["[red]Log file not found.[/]"]; + } + _ui.RenderDetailPanel( + ["Health", "Run", Markup.Escape(run.Timestamp.Replace("_", " "))], + null, + detailLines, + PanelRenderer.BuildCommandBarString(new List + { + new(showRawMarkdown ? "Rendered" : "Markdown", ConsoleKey.M, -5), + })); - Console.ReadKey(true); + while (true) + { + var key = Console.ReadKey(true); + if (_ui.HandleDetailScroll(key)) + { + continue; + } + switch (key.Key) + { + case ConsoleKey.M: + showRawMarkdown = !showRawMarkdown; + break; + case ConsoleKey.Escape: + return; + default: + continue; + } + break; + } + } } } diff --git a/src/Tiger/Commands/PanelRenderer.cs b/src/Tiger/Commands/PanelRenderer.cs index 346a037..b658ddc 100644 --- a/src/Tiger/Commands/PanelRenderer.cs +++ b/src/Tiger/Commands/PanelRenderer.cs @@ -337,7 +337,7 @@ internal static List WrapMarkupLine(string markupContent, int maxWidth) var chunk = text[pos..]; if (chunk.Length <= remaining) { - currentLine.Append(chunk); + currentLine.Append(EscapeBrackets(chunk)); visibleLen += chunk.Length; pos = text.Length; } @@ -362,7 +362,7 @@ internal static List WrapMarkupLine(string markupContent, int maxWidth) } } - currentLine.Append(chunk[..breakPos]); + currentLine.Append(EscapeBrackets(chunk[..breakPos])); visibleLen += breakPos; pos += breakPos; @@ -441,6 +441,20 @@ private static int FindWordBreak(string text, int maxLen) return lastSpace > 0 ? lastSpace : 0; } + /// + /// Re-escapes literal bracket characters in visible text so the output is valid + /// Spectre markup. TokenizeMarkup decodes [[ → [ and ]] → ] for width measurement, + /// but the output string must use [[ and ]] for Spectre to treat them as literals. + /// + private static string EscapeBrackets(string text) + { + if (!text.Contains('[') && !text.Contains(']')) + { + return text; + } + return text.Replace("[", "[[").Replace("]", "]]"); + } + /// /// Tokenizes a Spectre markup string into tags (e.g., [bold], [/]) and /// visible text segments. diff --git a/src/Tiger/MarkdownRenderer.cs b/src/Tiger/MarkdownRenderer.cs index 7d187d6..2c5b0de 100644 --- a/src/Tiger/MarkdownRenderer.cs +++ b/src/Tiger/MarkdownRenderer.cs @@ -186,14 +186,23 @@ internal static (string[] Headers, List Rows, int NextIndex) ParseTabl /// Converts a single line of markdown to Spectre.Console markup lines. /// For multi-line input, use . /// - public static List ToMarkupLines(string markdown) + public static List ToMarkupLines(string markdown) => ToMarkupLines(markdown, null); + + /// + /// Converts multi-line markdown to Spectre.Console markup lines. + /// When is specified, tables are constrained to fit + /// within that width by shrinking columns proportionally. + /// + public static List ToMarkupLines(string markdown, int? maxWidth) { var result = new List(); + var lines = markdown.Split('\n'); + var i = 0; var inCodeBlock = false; - foreach (var line in markdown.Split('\n')) + while (i < lines.Length) { - var trimmed = line.TrimEnd('\r'); + var trimmed = lines[i].TrimEnd('\r'); // Code block fences if (trimmed.StartsWith("```")) @@ -207,12 +216,23 @@ public static List ToMarkupLines(string markdown) { result.Add("[dim]└────────────────────────────────────────[/]"); } + i++; continue; } if (inCodeBlock) { result.Add($"[dim]│[/] [grey]{Markup.Escape(trimmed)}[/]"); + i++; + continue; + } + + // Tables: detect header + separator pattern and render inline + if (IsTableRow(trimmed) && i + 1 < lines.Length && IsTableSeparator(lines[i + 1].TrimEnd('\r'))) + { + var (headers, rows, nextIndex) = ParseTable(lines, i); + result.AddRange(RenderTableAsText(headers, rows, maxWidth)); + i = nextIndex; continue; } @@ -220,6 +240,7 @@ public static List ToMarkupLines(string markdown) if (trimmed is "---" or "***" or "___") { result.Add("[dim]────────────────────────────────────────[/]"); + i++; continue; } @@ -227,16 +248,19 @@ public static List ToMarkupLines(string markdown) if (trimmed.StartsWith("### ")) { result.Add($"[bold]{FormatInlineMarkup(trimmed[4..])}[/]"); + i++; continue; } if (trimmed.StartsWith("## ")) { result.Add($"[bold underline]{FormatInlineMarkup(trimmed[3..])}[/]"); + i++; continue; } if (trimmed.StartsWith("# ")) { result.Add($"[bold blue underline]{FormatInlineMarkup(trimmed[2..])}[/]"); + i++; continue; } @@ -245,6 +269,7 @@ public static List ToMarkupLines(string markdown) { var content = FormatInlineMarkup(trimmed[6..]); result.Add($" [dim]•[/] {content}"); + i++; continue; } @@ -253,6 +278,7 @@ public static List ToMarkupLines(string markdown) { var content = FormatInlineMarkup(trimmed[4..]); result.Add($" [dim]•[/] {content}"); + i++; continue; } @@ -261,6 +287,7 @@ public static List ToMarkupLines(string markdown) { var content = FormatInlineMarkup(trimmed[2..]); result.Add($" [blue]•[/] {content}"); + i++; continue; } @@ -273,6 +300,7 @@ public static List ToMarkupLines(string markdown) var number = trimmed[..dotIndex]; var content = FormatInlineMarkup(trimmed[(dotIndex + 2)..]); result.Add($" [blue]{number}.[/] {content}"); + i++; continue; } } @@ -282,6 +310,7 @@ public static List ToMarkupLines(string markdown) { var content = FormatInlineMarkup(trimmed[2..]); result.Add($" [dim]│[/] [italic]{content}[/]"); + i++; continue; } @@ -289,16 +318,235 @@ public static List ToMarkupLines(string markdown) if (string.IsNullOrWhiteSpace(trimmed)) { result.Add(""); + i++; continue; } // Regular text with inline formatting result.Add($" {FormatInlineMarkup(trimmed)}"); + i++; + } + + return result; + } + + /// + /// Renders a parsed table as formatted text lines suitable for panel display. + /// Uses column-aligned layout with dim separators. When + /// is specified, columns are shrunk proportionally so the table fits, and cells + /// that exceed their column width are truncated with "...". + /// + internal static List RenderTableAsText(string[] headers, List rows, int? maxWidth = null) + { + var result = new List(); + var colCount = headers.Length; + + // Calculate natural column widths based on raw text + var colWidths = new int[colCount]; + for (var c = 0; c < colCount; c++) + { + colWidths[c] = headers[c].Length; + } + foreach (var row in rows) + { + for (var c = 0; c < colCount; c++) + { + if (c < row.Length && row[c].Length > colWidths[c]) + { + colWidths[c] = row[c].Length; + } + } + } + + // Constrain columns to fit within maxWidth if specified. + // Total visible width = 2 (indent) + sum(colWidths) + 3*(colCount-1) (separators " │ ") + if (maxWidth is not null) + { + var separatorWidth = 3 * (colCount - 1); + var indent = 2; + var availableForColumns = maxWidth.Value - indent - separatorWidth; + + if (colWidths.Sum() > availableForColumns && availableForColumns > 0) + { + // Preserve short columns at their natural width; only shrink wide columns. + // A column is "short" if its natural width <= ShortColumnThreshold. + const int ShortColumnThreshold = 12; + var newWidths = new int[colCount]; + var fixedTotal = 0; + var shrinkableTotal = 0; + + for (var c = 0; c < colCount; c++) + { + if (colWidths[c] <= ShortColumnThreshold) + { + newWidths[c] = colWidths[c]; // keep as-is + fixedTotal += colWidths[c]; + } + else + { + newWidths[c] = -1; // mark for shrinking + shrinkableTotal += colWidths[c]; + } + } + + var remainingForShrinkable = availableForColumns - fixedTotal; + + if (remainingForShrinkable > 0 && shrinkableTotal > 0) + { + // Distribute remaining space proportionally among wide columns + var allocated = 0; + var lastShrinkable = -1; + for (var c = 0; c < colCount; c++) + { + if (newWidths[c] == -1) + { + lastShrinkable = c; + } + } + + for (var c = 0; c < colCount; c++) + { + if (newWidths[c] != -1) + { + continue; + } + + if (c == lastShrinkable) + { + newWidths[c] = remainingForShrinkable - allocated; + } + else + { + newWidths[c] = (int)((double)colWidths[c] / shrinkableTotal * remainingForShrinkable); + } + + newWidths[c] = Math.Max(4, newWidths[c]); + allocated += newWidths[c]; + } + } + else + { + // Even fixed columns exceed budget — fall back to proportional for all + var totalNatural = colWidths.Sum(); + var allocated = 0; + for (var c = 0; c < colCount; c++) + { + if (c == colCount - 1) + { + newWidths[c] = availableForColumns - allocated; + } + else + { + newWidths[c] = (int)((double)colWidths[c] / totalNatural * availableForColumns); + } + newWidths[c] = Math.Max(4, newWidths[c]); + allocated += newWidths[c]; + } + } + + colWidths = newWidths; + } + } + + // Header row — truncate headers that exceed column width + var headerParts = new string[colCount]; + for (var c = 0; c < colCount; c++) + { + var h = headers[c]; + if (h.Length > colWidths[c]) + { + h = colWidths[c] > 3 ? h[..(colWidths[c] - 3)] + "..." : h[..colWidths[c]]; + } + headerParts[c] = $"[bold]{FormatInlineMarkup(h.PadRight(colWidths[c]))}[/]"; + } + result.Add($" {string.Join(" [dim]│[/] ", headerParts)}"); + + // Separator + var sepParts = new string[colCount]; + for (var c = 0; c < colCount; c++) + { + sepParts[c] = new string('─', colWidths[c]); + } + result.Add($" [dim]{string.Join("─┼─", sepParts)}[/]"); + + // Data rows — word-wrap cells that exceed their column width + foreach (var row in rows) + { + // Word-wrap each cell into multiple lines + var wrappedCells = new List(); + for (var c = 0; c < colCount; c++) + { + var cell = c < row.Length ? row[c] : ""; + wrappedCells.Add(WordWrapCell(cell, colWidths[c])); + } + + // Determine max number of visual lines across all cells in this row + var lineCount = wrappedCells.Max(w => w.Length); + + for (var line = 0; line < lineCount; line++) + { + var cellParts = new string[colCount]; + for (var c = 0; c < colCount; c++) + { + var cellLine = line < wrappedCells[c].Length + ? wrappedCells[c][line] + : ""; + cellParts[c] = FormatInlineMarkup(cellLine.PadRight(colWidths[c])); + } + result.Add($" {string.Join(" [dim]│[/] ", cellParts)}"); + } } return result; } + /// + /// Word-wraps a cell value into lines that fit within the given width. + /// Breaks at word boundaries when possible, hard-breaks mid-word if a + /// single word exceeds the width. + /// + internal static string[] WordWrapCell(string text, int width) + { + if (width <= 0) + { + return [text]; + } + + if (text.Length <= width) + { + return [text]; + } + + var lines = new List(); + var remaining = text.AsSpan(); + + while (remaining.Length > 0) + { + if (remaining.Length <= width) + { + lines.Add(remaining.ToString()); + break; + } + + // Find the last space within the width limit for a word break + var breakAt = remaining[..width].LastIndexOf(' '); + if (breakAt <= 0) + { + // No space found — hard break at width + breakAt = width; + lines.Add(remaining[..breakAt].ToString()); + remaining = remaining[breakAt..]; + } + else + { + lines.Add(remaining[..breakAt].ToString()); + remaining = remaining[(breakAt + 1)..]; // skip the space + } + } + + return lines.ToArray(); + } + /// /// Handles inline markdown formatting: [links](url), **bold**, *italic*, `code`. /// diff --git a/src/Tiger/Program.cs b/src/Tiger/Program.cs index fc82025..1909821 100644 --- a/src/Tiger/Program.cs +++ b/src/Tiger/Program.cs @@ -1,8 +1,12 @@ using Spectre.Console; using Spectre.Console.Cli; +using System.Text; using Tiger; using Tiger.Commands; +// Enable UTF-8 output for emoji and special characters +Console.OutputEncoding = Encoding.UTF8; + // Warn about missing external tools CheckRequiredTools();