From 4a6d50fce7f6e8c6d280c98121c6c24ee6f70515 Mon Sep 17 00:00:00 2001 From: Jared Parsons Date: Tue, 9 Jun 2026 13:28:05 -0700 Subject: [PATCH 01/10] UI redesign: panel layout with focusable command bar - Replace all screens with consistent PanelLayout framework (header/content/command bar) - Add Tab-focusable command bar with [X] bracket-style hotkey display - Main menu shows Figlet ASCII tiger logo + command bar only (no list) - Flicker-free list navigation using in-place cursor updates - Dim gray borders, blue hotkeys for visual hierarchy - Convert all browsers to CommandBarItem-based command bars - Remove legacy string-hotkey SelectInPanel overload - Fix SQLite test isolation (ClearPool in Dispose) - Update AGENTS.md with full UI conventions documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 48 +- src/Tiger/Commands/AgentBrowser.cs | 122 +++--- src/Tiger/Commands/AnalysisBrowser.cs | 20 +- src/Tiger/Commands/BuildBrowser.cs | 442 ++++++++++--------- src/Tiger/Commands/DashboardCommand.cs | 172 +++----- src/Tiger/Commands/HealthCommand.cs | 157 +++---- src/Tiger/Commands/PanelLayout.cs | 582 +++++++++++++++++++++++++ src/Tiger/Commands/TestBrowser.cs | 62 +-- src/Tiger/TigerDatabase.cs | 5 +- 9 files changed, 1087 insertions(+), 523 deletions(-) create mode 100644 src/Tiger/Commands/PanelLayout.cs diff --git a/AGENTS.md b/AGENTS.md index 7b0d91c..232ce46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,15 +61,41 @@ pass before presenting changes for review. ## UI Conventions -- **Menus** use `BrowserUI.SelectWithEscape` with hotkey support (the `extraKeys` parameter). Menu items embed the hotkey letter in blue within the label text: `[blue]E[/]dit filter`. This gives both arrow-key scrolling and single-keypress shortcuts. -- Inline hotkey bars (e.g. `[blue]T[/]ests [blue]J[/]obs [blue]Esc[/] Back`) follow the same format. -- **All menu locations** (update all when changing the format convention): - - `DashboardCommand.cs` — main menu, status view hotkey bar - - `BuildBrowser.cs` — build list hotkey bar, empty-list hotkey bar, filter menu, filter help, build detail hotkey bar, test failure hotkey bar, timeline issues hotkey bar - - `TestBrowser.cs` — test list hotkey bar, empty-list hotkey bar, filter menu, test detail hotkey bar - - `AnalysisBrowser.cs` — analysis detail menu, full log menu - - `AgentBrowser.cs` — agent list hotkey bar, agent detail menu - - `HealthCommand.cs` — state page menu, run detail menu - - `ConfigEditor.cs` — config menu -- **Escape** always means "go back" or "cancel" in any interactive context. +### Panel Layout + +All screens use `PanelLayout` for a consistent "command and control" look: +- **Header**: `TIGER ▸ Section ▸ Subsection` breadcrumb trail +- **Content area**: List selection or detail view +- **Command bar**: Bottom bar with hotkey commands, focusable via Tab + +### Command Bar (`CommandBarItem`) + +The command bar is the standard way to expose actions on any screen. It uses `List` where each item has a label, hotkey, and return value. + +- **Hotkey display**: Bracket style — `[B]uilds [T]ests [H]ealth` — with the bracketed letter rendered in blue. +- **Focus model**: Tab toggles focus between the list and the command bar. When the bar is focused, ←→ moves the highlight and Enter executes. Hotkey letters work regardless of focus. +- **Focused item**: Shown as `[bold white on blue] Label [/]` (inverted highlight). +- **Main menu**: Uses `PanelLayout.ShowMainMenu(commands)` — displays Figlet ASCII art + TIGER branding, navigation only via command bar. +- **List screens**: Use `PanelLayout.SelectInPanel(..., commands)` — list + Tab-focusable command bar. +- **Detail screens**: Use `PanelLayout.RenderDetailPanel(...)` with a static hotkey string footer (detail views handle their own key loops). + +### Hotkey Conventions + +- Hotkeys use the `[X]` bracket format in labels (e.g., `[E]dit filter`, `[R]efresh`) +- The bracket letter is highlighted in blue via Spectre markup: `[blue][[X]][/]` +- Escape always means "go back" or "cancel" +- Tab always switches focus to the command bar (on list screens) + +### All menu locations (update all when changing the format convention): + - `DashboardCommand.cs` — main menu (ShowMainMenu) + - `BuildBrowser.cs` — build list command bar, empty-list detail, filter menu, filter help, build detail, test failures, timeline issues + - `TestBrowser.cs` — test list command bar, empty-list detail, filter menu, test detail + - `AnalysisBrowser.cs` — analysis list command bar, analysis detail menu, full log menu + - `AgentBrowser.cs` — agent list command bar, agent detail menu + - `HealthCommand.cs` — health list command bar, runs list command bar, state page menu, run detail + - `ConfigEditor.cs` — config menu (still uses `BrowserUI.SelectWithEscape`) + +### Code Style + - **if/try/catch** bodies and braces must be on separate lines — never on the same line as the keyword. +- **Escape** always means "go back" or "cancel" in any interactive context. diff --git a/src/Tiger/Commands/AgentBrowser.cs b/src/Tiger/Commands/AgentBrowser.cs index 7bf5f3c..d130add 100644 --- a/src/Tiger/Commands/AgentBrowser.cs +++ b/src/Tiger/Commands/AgentBrowser.cs @@ -21,23 +21,25 @@ public void Browse() { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Agent Tasks[/]"); - AnsiConsole.WriteLine(); - var tasks = LoadTasks(); if (tasks is null) { - AnsiConsole.MarkupLine("[red]Failed to load agent tasks from gh CLI.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + PanelLayout.RenderDetailPanel( + ["Agents"], + null, + () => PanelLayout.RenderPanelLine("[red]Failed to load agent tasks from gh CLI.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } if (tasks.Count == 0) { - AnsiConsole.MarkupLine("[dim]No agent tasks found.[/]"); - AnsiConsole.MarkupLine(" [blue]R[/]efresh [blue]Esc[/] Back"); + PanelLayout.RenderDetailPanel( + ["Agents"], + null, + () => PanelLayout.RenderPanelLine("[dim]No agent tasks found.[/]"), + "[blue]R[/]efresh [blue]Esc[/] Back"); while (true) { @@ -74,15 +76,16 @@ public void Browse() items.Add($"{stateIcon} {Markup.Escape(name)}{prInfo}{tigerMark} [dim]{Markup.Escape(repo)}[/]"); } - var extraKeys = new Dictionary + var commands = new List { - [ConsoleKey.R] = -2, + new("Refresh", ConsoleKey.R, -2), }; - AnsiConsole.MarkupLine(" [blue]R[/]efresh [yellow]★[/] = submitted from Tiger"); - AnsiConsole.WriteLine(); - - var selected = BrowserUI.SelectWithEscape("", items, useMarkup: true, extraKeys: extraKeys); + var selected = PanelLayout.SelectInPanel( + ["Agents"], + $"[dim]{tasks.Count} task(s)[/] [yellow]★[/] = submitted from Tiger", + items, + commands); if (selected == -1) { @@ -101,47 +104,8 @@ private void ShowTaskDetail(AgentTaskInfo task, HashSet trackedIds) { while (true) { - AnsiConsole.Clear(); - var isTracked = task.Id is not null && trackedIds.Contains(task.Id); - var headerTable = new Table().Border(TableBorder.Rounded); - headerTable.AddColumn(new TableColumn("").NoWrap()); - headerTable.AddColumn(new TableColumn("")); - headerTable.HideHeaders(); - - headerTable.AddRow("[bold]Name[/]", Markup.Escape(task.Name ?? "unnamed")); - headerTable.AddRow("[bold]State[/]", FormatState(task.State)); - headerTable.AddRow("[bold]Repository[/]", Markup.Escape(task.Repository ?? "unknown")); - if (task.Id is not null) - { - headerTable.AddRow("[bold]Session[/]", Markup.Escape(task.Id)); - } - if (task.CreatedAt is not null) - { - headerTable.AddRow("[bold]Created[/]", BrowserUI.FormatTime(task.CreatedAt)); - } - if (task.UpdatedAt is not null) - { - headerTable.AddRow("[bold]Updated[/]", BrowserUI.FormatTime(task.UpdatedAt)); - } - if (task.PullRequestNumber is not null && task.PullRequestUrl is not null) - { - headerTable.AddRow("[bold]Pull Request[/]", - $"{BrowserUI.FormatLink(task.PullRequestUrl, $"PR #{task.PullRequestNumber}")} ({Markup.Escape(task.PullRequestState ?? "unknown")})"); - } - else if (task.PullRequestNumber is not null) - { - headerTable.AddRow("[bold]Pull Request[/]", $"#{task.PullRequestNumber}"); - } - if (isTracked) - { - headerTable.AddRow("[bold]Source[/]", "[yellow]Submitted from Tiger[/]"); - } - - AnsiConsole.Write(headerTable); - AnsiConsole.WriteLine(); - var menuItems = new List(); var actions = new List(); var extraKeys = new Dictionary(); @@ -161,13 +125,59 @@ private void ShowTaskDetail(AgentTaskInfo task, HashSet trackedIds) extraKeys[ConsoleKey.R] = menuItems.Count - 1; actions.Add("refresh"); - var choice = BrowserUI.SelectWithEscape("", menuItems, useMarkup: true, extraKeys: extraKeys); - if (choice < 0) + // Use RenderDetailPanel for the header info, then SelectInPanel for menu + PanelLayout.RenderDetailPanel( + ["Agents", Markup.Escape(task.Name ?? "unnamed")], + $"{FormatState(task.State)} {Markup.Escape(task.Repository ?? "unknown")}", + () => + { + PanelLayout.RenderField("Name", Markup.Escape(task.Name ?? "unnamed")); + PanelLayout.RenderField("State", FormatState(task.State)); + PanelLayout.RenderField("Repository", Markup.Escape(task.Repository ?? "unknown")); + if (task.Id is not null) + { + PanelLayout.RenderField("Session", Markup.Escape(task.Id)); + } + if (task.CreatedAt is not null) + { + PanelLayout.RenderField("Created", BrowserUI.FormatTime(task.CreatedAt)); + } + if (task.UpdatedAt is not null) + { + PanelLayout.RenderField("Updated", BrowserUI.FormatTime(task.UpdatedAt)); + } + if (task.PullRequestNumber is not null && task.PullRequestUrl is not null) + { + PanelLayout.RenderField("Pull Request", + $"{BrowserUI.FormatLink(task.PullRequestUrl, $"PR #{task.PullRequestNumber}")} ({Markup.Escape(task.PullRequestState ?? "unknown")})"); + } + else if (task.PullRequestNumber is not null) + { + PanelLayout.RenderField("Pull Request", $"#{task.PullRequestNumber}"); + } + if (isTracked) + { + PanelLayout.RenderField("Source", "[yellow]Submitted from Tiger[/]"); + } + }, + "[blue]O[/]pen PR [blue]V[/]iew logs [blue]R[/]efresh [blue]Esc[/] Back"); + + var key = Console.ReadKey(true); + if (key.Key == ConsoleKey.Escape) { return; } - switch (actions[choice]) + // Map key to action + string? action = key.Key switch + { + ConsoleKey.O when task.PullRequestUrl is not null => "open_pr", + ConsoleKey.V => "logs", + ConsoleKey.R => "refresh", + _ => null, + }; + + switch (action) { case "open_pr": var openProcess = new System.Diagnostics.Process(); diff --git a/src/Tiger/Commands/AnalysisBrowser.cs b/src/Tiger/Commands/AnalysisBrowser.cs index b883979..96742a3 100644 --- a/src/Tiger/Commands/AnalysisBrowser.cs +++ b/src/Tiger/Commands/AnalysisBrowser.cs @@ -25,15 +25,14 @@ public void Browse() { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold]Build Failure Analysis[/]"); - AnsiConsole.WriteLine(); - var analyses = _db.GetRecentAnalyses(50); if (analyses.Count == 0) { - AnsiConsole.MarkupLine("[dim]No analyses yet. Failed builds will be analyzed automatically.[/]"); - AnsiConsole.MarkupLine("[dim]Press Escape to go back.[/]"); + PanelLayout.RenderDetailPanel( + ["Analysis"], + null, + () => PanelLayout.RenderPanelLine("[dim]No analyses yet. Failed builds will be analyzed automatically.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } @@ -52,7 +51,6 @@ public void Browse() var category = a.Category is not null ? $"[dim]({Markup.Escape(a.Category)})[/]" : ""; var label = $"{statusIcon} {Markup.Escape(a.DefinitionName)} #{a.BuildId} {category}"; - // Add a brief one-line summary if (a.DiagnosisSummary is not null) { var firstLine = a.DiagnosisSummary.Split('\n')[0].Trim(); @@ -66,7 +64,13 @@ public void Browse() return label; }).ToList(); - var selected = BrowserUI.SelectWithEscape("", items, useMarkup: true); + var commands = new List(); + + var selected = PanelLayout.SelectInPanel( + ["Analysis"], + $"[dim]{analyses.Count} analysis result(s)[/]", + items, + commands); if (selected < 0) { return; diff --git a/src/Tiger/Commands/BuildBrowser.cs b/src/Tiger/Commands/BuildBrowser.cs index 48c1ba3..8c7ebab 100644 --- a/src/Tiger/Commands/BuildBrowser.cs +++ b/src/Tiger/Commands/BuildBrowser.cs @@ -53,7 +53,6 @@ private void RunLoop() { while (_position >= 0) { - AnsiConsole.Clear(); var action = Render(_history[_position]); switch (action) @@ -108,26 +107,26 @@ private NavAction RenderBuildList() { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Builds[/]"); - if (_filter.IsActive) - { - AnsiConsole.MarkupLine($"Filter: {Markup.Escape(_filter.ToString())}"); - } - else - { - AnsiConsole.MarkupLine("[dim]Filter: (none)[/]"); - } - AnsiConsole.WriteLine(); - var builds = QueryBuilds(); + var filterText = _filter.IsActive + ? $"Filter: {Markup.Escape(_filter.ToString())}" + : "[dim]Filter: (none)[/]"; + var context = builds.Count > 0 + ? $"{filterText} [dim]({builds.Count} builds)[/]" + : filterText; + if (builds.Count == 0) { - AnsiConsole.MarkupLine(_filter.IsActive + var emptyMsg = _filter.IsActive ? "[yellow]No builds match the current filter.[/]" - : "[yellow]No builds ingested yet.[/]"); - AnsiConsole.MarkupLine(" [blue]E[/]dit filter [blue]F[/]ilter menu [blue]Esc[/] Back"); + : "[yellow]No builds ingested yet.[/]"; + + PanelLayout.RenderDetailPanel( + ["Builds"], + context, + () => PanelLayout.RenderPanelLine(emptyMsg), + "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]Esc[/] Back"); var emptyKey = Console.ReadKey(true); if (emptyKey.Key == ConsoleKey.E) @@ -154,9 +153,6 @@ private NavAction RenderBuildList() return NavAction.Back.Instance; } - AnsiConsole.MarkupLine($"[dim]{builds.Count} builds[/]"); - AnsiConsole.WriteLine(); - var choices = builds.Select(b => { var resultIcon = b.Result switch @@ -176,20 +172,23 @@ private NavAction RenderBuildList() _lastBuilds = builds; - var hotkeys = _filter.IsActive - ? "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]C[/]lear [blue]H[/]elp" - : "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]H[/]elp"; - - var selected = BrowserUI.SelectWithEscape("Select a build:", choices, - extraKeys: new Dictionary { - { ConsoleKey.E, -5 }, - { ConsoleKey.F, -2 }, - { ConsoleKey.H, -3 }, - { ConsoleKey.C, -4 }, - }, - useMarkup: true, - startIndex: _selectedBuildIndex, - hotkeys: hotkeys); + var commands = new List + { + new("Edit filter", ConsoleKey.E, -5), + new("Filter menu", ConsoleKey.F, -2), + new("Help", ConsoleKey.H, -3), + }; + if (_filter.IsActive) + { + commands.Add(new("Clear", ConsoleKey.C, -4)); + } + + var selected = PanelLayout.SelectInPanel( + ["Builds"], + context, + choices, + commands, + startIndex: _selectedBuildIndex); if (selected == -5) // E pressed { @@ -213,7 +212,9 @@ private NavAction RenderBuildList() continue; } if (selected < 0) + { return NavAction.Back.Instance; + } _selectedBuildIndex = selected; var b2 = builds[selected]; @@ -454,8 +455,6 @@ private static void ShowFilterHelp() private NavAction RenderBuildDetail(BuildDetailPage page) { - Console.SetCursorPosition(0, 0); - // Header info from DB var buildInfo = _db.WithCommand(cmd => { @@ -488,7 +487,11 @@ FROM builds if (!buildInfo.Found) { - AnsiConsole.MarkupLine("[red]Build not found.[/]"); + PanelLayout.RenderDetailPanel( + ["Builds", $"#{page.BuildId}"], + null, + () => PanelLayout.RenderPanelLine("[red]Build not found.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; } @@ -503,66 +506,15 @@ FROM builds var url = $"https://dev.azure.com/{Uri.EscapeDataString(page.Org)}/{Uri.EscapeDataString(page.Project)}/_build/results?buildId={page.BuildId}"; - // Build header - var headerTable = new Table().Border(TableBorder.Rounded).Expand(); - headerTable.AddColumn(new TableColumn("").NoWrap()); - headerTable.AddColumn(new TableColumn("")); - headerTable.HideHeaders(); - - headerTable.AddRow("[bold]Build[/]", $"#{page.BuildId} — {defName} {buildNumber}"); - headerTable.AddRow("[bold]Result[/]", BrowserUI.FormatResult(result)); - if (prNumber is not null && repoName is not null) - { - var prUrl = $"https://github.com/{repoName}/pull/{prNumber}"; - - // Try to get cached PR info - var prInfo = _db.WithCommand(cmd => - { - cmd.CommandText = "SELECT title, author FROM pull_requests WHERE repository = @repo AND pr_number = @pr"; - cmd.Parameters.AddWithValue("@repo", repoName); - cmd.Parameters.AddWithValue("@pr", prNumber); - using var reader = cmd.ExecuteReader(); - if (reader.Read() && !reader.IsDBNull(0)) - { - return (Found: true, Title: reader.GetString(0), Author: reader.IsDBNull(1) ? string.Empty : reader.GetString(1)); - } - - return (Found: false, Title: string.Empty, Author: string.Empty); - }); - - if (prInfo.Found) - { - // Truncate title to fit: "PR Info" col + "#123 author " leaves room for title - var prefix = $"#{prNumber} {prInfo.Author} "; - var maxTitleLen = Math.Max(10, Console.WindowWidth - prefix.Length - 20); - var truncatedTitle = prInfo.Title.Length > maxTitleLen ? prInfo.Title[..maxTitleLen] + "..." : prInfo.Title; - headerTable.AddRow("[bold]PR Info[/]", $"#{prNumber} [blue]{Markup.Escape(prInfo.Author)}[/] {Markup.Escape(truncatedTitle)}"); - } - else - { - headerTable.AddRow("[bold]PR Info[/]", $"#{prNumber}"); - } - headerTable.AddRow("[bold]PR Url[/]", BrowserUI.FormatLink(prUrl, $"PR #{prNumber}")); - } - else if (prNumber is not null) - { - headerTable.AddRow("[bold]PR Info[/]", $"#{prNumber}"); - } - else - { - headerTable.AddRow("[bold]Branch[/]", branch); - } - if (finishTime is not null) - headerTable.AddRow("[bold]Finished[/]", BrowserUI.FormatTime(finishTime)); - headerTable.AddRow("[bold]URL[/]", BrowserUI.FormatLink(url, url)); - - // Ingestion status line: Timeline, Tests, Helix with status icons + // Ingestion status var taskStatuses = GetIngestionTaskStatuses(page.Org, page.Project, page.BuildId); var taskStatusMap = taskStatuses.ToDictionary(t => t.TaskType, t => t); string TaskIcon(string taskType) { if (!taskStatusMap.TryGetValue(taskType, out var t)) + { return "[yellow]...[/]"; + } return t.Status switch { "complete" => "[green]✓[/]", @@ -572,149 +524,205 @@ string TaskIcon(string taskType) _ => "[yellow]...[/]", }; } - headerTable.AddRow("[bold]Data[/]", - $"Timeline: {TaskIcon("timeline")} Tests: {TaskIcon("tests")} Helix: {TaskIcon("helix")}"); - - AnsiConsole.Write(headerTable); - AnsiConsole.WriteLine(); var timelineStatus = taskStatusMap.GetValueOrDefault("timeline").Status; var testsStatus = taskStatusMap.GetValueOrDefault("tests").Status; - // Failed jobs section (from DB timeline issues, when timeline is ingested) - if (timelineStatus == "complete") - { - var failedJobNames = _db.WithCommand(cmd => - { - cmd.CommandText = """ - SELECT DISTINCT parent_name - FROM build_timeline_issues - WHERE organization = @org AND build_id = @buildId - AND parent_name IS NOT NULL AND issue_type = 'error' - ORDER BY parent_name - """; - cmd.Parameters.AddWithValue("@org", page.Org); - cmd.Parameters.AddWithValue("@proj", page.Project); - cmd.Parameters.AddWithValue("@buildId", page.BuildId); - - var failedJobNames = new List(); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) - { - failedJobNames.Add(reader.GetString(0)); - } - return failedJobNames; - }); + var canForward = _position < _history.Count - 1; + var buildIndex = _lastBuilds.FindIndex(b => b.BuildId == page.BuildId && b.Org == page.Org && b.Project == page.Project); + var canNext = buildIndex >= 0 && buildIndex < _lastBuilds.Count - 1; + var canPrev = buildIndex > 0; - AnsiConsole.MarkupLine("[bold underline]Failed Jobs[/]"); - if (failedJobNames.Count > 0) - { - foreach (var jobName in failedJobNames.Take(15)) - AnsiConsole.MarkupLine($" [red]X[/] {Markup.Escape(jobName)}"); - } - else - { - AnsiConsole.MarkupLine(" [green]No failed jobs[/]"); - } - AnsiConsole.WriteLine(); - } + var hotkeys = "[blue]T[/]ests [blue]J[/]obs [blue]H[/]elix [blue]A[/]nalysis [blue]Esc[/] Back" + + (canForward ? " [blue]F[/]orward" : "") + + (canNext ? " [blue]N[/]ext" : "") + + (canPrev ? " [blue]P[/]rev" : ""); - // Failed tests section - AnsiConsole.MarkupLine("[bold underline]Failed Tests[/]"); - if (testsStatus != "complete") - { - AnsiConsole.MarkupLine(" [yellow]Tests not available yet[/]"); - } - else - { - var failedTests = _db.WithCommand(cmd => + PanelLayout.RenderDetailPanel( + ["Builds", $"#{page.BuildId} {defName}"], + $"{BrowserUI.FormatResult(result)} {BrowserUI.FormatTime(finishTime)}", + () => { - cmd.CommandText = """ - SELECT r.run_name, tr.test_case_title, tr.error_message - FROM test_results tr - JOIN test_runs r ON tr.organization = r.organization AND tr.run_id = r.run_id - WHERE r.organization = @org AND r.project = @proj AND r.build_id = @buildId - AND tr.outcome = 'Failed' - ORDER BY r.run_name, tr.test_case_title - LIMIT 50 - """; - cmd.Parameters.AddWithValue("@org", page.Org); - cmd.Parameters.AddWithValue("@proj", page.Project); - cmd.Parameters.AddWithValue("@buildId", page.BuildId); - - var failedTests = new List<(string RunName, string Title, string Error)>(); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) + // Build info fields + PanelLayout.RenderField("Build", $"#{page.BuildId} — {defName} {buildNumber}"); + PanelLayout.RenderField("Result", BrowserUI.FormatResult(result)); + if (prNumber is not null && repoName is not null) + { + var prUrl = $"https://github.com/{repoName}/pull/{prNumber}"; + var prInfo = _db.WithCommand(cmd => + { + cmd.CommandText = "SELECT title, author FROM pull_requests WHERE repository = @repo AND pr_number = @pr"; + cmd.Parameters.AddWithValue("@repo", repoName); + cmd.Parameters.AddWithValue("@pr", prNumber); + using var reader = cmd.ExecuteReader(); + if (reader.Read() && !reader.IsDBNull(0)) + { + return (Found: true, Title: reader.GetString(0), Author: reader.IsDBNull(1) ? string.Empty : reader.GetString(1)); + } + return (Found: false, Title: string.Empty, Author: string.Empty); + }); + + if (prInfo.Found) + { + var prefix = $"#{prNumber} {prInfo.Author} "; + var maxTitleLen = Math.Max(10, PanelLayout.ContentWidth - prefix.Length - 20); + var truncatedTitle = prInfo.Title.Length > maxTitleLen ? prInfo.Title[..maxTitleLen] + "..." : prInfo.Title; + PanelLayout.RenderField("PR", $"#{prNumber} [blue]{Markup.Escape(prInfo.Author)}[/] {Markup.Escape(truncatedTitle)}"); + } + else + { + PanelLayout.RenderField("PR", $"#{prNumber}"); + } + PanelLayout.RenderField("PR URL", BrowserUI.FormatLink(prUrl, $"PR #{prNumber}")); + } + else if (prNumber is not null) + { + PanelLayout.RenderField("PR", $"#{prNumber}"); + } + else { - var runName = reader.GetString(0); - var title = reader.GetString(1); - var error = reader.IsDBNull(2) ? string.Empty : reader.GetString(2); - failedTests.Add((runName, title, error)); + PanelLayout.RenderField("Branch", branch); } - return failedTests; - }); + if (finishTime is not null) + { + PanelLayout.RenderField("Finished", BrowserUI.FormatTime(finishTime)); + } + PanelLayout.RenderField("URL", BrowserUI.FormatLink(url, url)); + PanelLayout.RenderField("Data", $"Timeline: {TaskIcon("timeline")} Tests: {TaskIcon("tests")} Helix: {TaskIcon("helix")}"); + PanelLayout.RenderEmptyLine(); - if (failedTests.Count == 0) - { - AnsiConsole.MarkupLine(" [green]All tests passed[/]"); - } - else - { - foreach (var group in failedTests.GroupBy(t => t.RunName)) + // Failed jobs section + if (timelineStatus == "complete") { - AnsiConsole.MarkupLine($" [bold yellow]{Markup.Escape(group.Key)}[/]"); - var shown = 0; - var total = group.Count(); - foreach (var test in group.Take(5)) + var failedJobNames = _db.WithCommand(cmd => { - var title = test.Title.Length > 68 ? test.Title[..65] + "..." : test.Title; - var error = test.Error; - if (error.Length > 60) error = error[..57] + "..."; - error = error.ReplaceLineEndings(" "); - AnsiConsole.MarkupLine($" [red]X[/] {Markup.Escape(title)}"); - if (!string.IsNullOrWhiteSpace(error)) - AnsiConsole.MarkupLine($" [dim]{Markup.Escape(error)}[/]"); - shown++; + cmd.CommandText = """ + SELECT DISTINCT parent_name + FROM build_timeline_issues + WHERE organization = @org AND build_id = @buildId + AND parent_name IS NOT NULL AND issue_type = 'error' + ORDER BY parent_name + """; + cmd.Parameters.AddWithValue("@org", page.Org); + cmd.Parameters.AddWithValue("@proj", page.Project); + cmd.Parameters.AddWithValue("@buildId", page.BuildId); + + var names = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + names.Add(reader.GetString(0)); + } + return names; + }); + + PanelLayout.RenderSectionTitle("Failed Jobs"); + if (failedJobNames.Count > 0) + { + foreach (var jobName in failedJobNames.Take(15)) + { + PanelLayout.RenderPanelLine($" [red]X[/] {Markup.Escape(jobName)}"); + } } - if (total > shown) - AnsiConsole.MarkupLine($" [dim]... {total - shown} more failure(s), press T to see all[/]"); + else + { + PanelLayout.RenderPanelLine(" [green]No failed jobs[/]"); + } + PanelLayout.RenderEmptyLine(); } - } - } - // Helix work items count - var helixCount = _db.WithCommand(cmd => - { - cmd.CommandText = """ - SELECT COUNT(DISTINCT hw.job_name || '/' || hw.work_item_name) - FROM test_results tr - JOIN test_runs trn ON tr.organization = trn.organization - AND tr.project = trn.project AND tr.run_id = trn.run_id - JOIN helix_work_items hw ON tr.helix_job_name = hw.job_name - AND tr.helix_work_item_name = hw.work_item_name - WHERE trn.organization = @org AND trn.project = @proj AND trn.build_id = @buildId - AND tr.outcome = 'Failed' - """; - cmd.Parameters.AddWithValue("@org", page.Org); - cmd.Parameters.AddWithValue("@proj", page.Project); - cmd.Parameters.AddWithValue("@buildId", page.BuildId); - return Convert.ToInt32(cmd.ExecuteScalar()); - }); - if (helixCount > 0) - { - AnsiConsole.MarkupLine($" [bold]Helix Work Items:[/] {helixCount}"); - } + // Failed tests section + PanelLayout.RenderSectionTitle("Failed Tests"); + if (testsStatus != "complete") + { + PanelLayout.RenderPanelLine(" [yellow]Tests not available yet[/]"); + } + else + { + var failedTests = _db.WithCommand(cmd => + { + cmd.CommandText = """ + SELECT r.run_name, tr.test_case_title, tr.error_message + FROM test_results tr + JOIN test_runs r ON tr.organization = r.organization AND tr.run_id = r.run_id + WHERE r.organization = @org AND r.project = @proj AND r.build_id = @buildId + AND tr.outcome = 'Failed' + ORDER BY r.run_name, tr.test_case_title + LIMIT 50 + """; + cmd.Parameters.AddWithValue("@org", page.Org); + cmd.Parameters.AddWithValue("@proj", page.Project); + cmd.Parameters.AddWithValue("@buildId", page.BuildId); + + var tests = new List<(string RunName, string Title, string Error)>(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + tests.Add((reader.GetString(0), reader.GetString(1), + reader.IsDBNull(2) ? string.Empty : reader.GetString(2))); + } + return tests; + }); + + if (failedTests.Count == 0) + { + PanelLayout.RenderPanelLine(" [green]All tests passed[/]"); + } + else + { + foreach (var group in failedTests.GroupBy(t => t.RunName)) + { + PanelLayout.RenderPanelLine($" [bold yellow]{Markup.Escape(group.Key)}[/]"); + var shown = 0; + var total = group.Count(); + foreach (var test in group.Take(5)) + { + var title = test.Title.Length > 68 ? test.Title[..65] + "..." : test.Title; + var error = test.Error; + if (error.Length > 60) + { + error = error[..57] + "..."; + } + error = error.ReplaceLineEndings(" "); + PanelLayout.RenderPanelLine($" [red]X[/] {Markup.Escape(title)}"); + if (!string.IsNullOrWhiteSpace(error)) + { + PanelLayout.RenderPanelLine($" [dim]{Markup.Escape(error)}[/]"); + } + shown++; + } + if (total > shown) + { + PanelLayout.RenderPanelLine($" [dim]... {total - shown} more failure(s), press T to see all[/]"); + } + } + } + } - AnsiConsole.WriteLine(); - var canForward = _position < _history.Count - 1; - var buildIndex = _lastBuilds.FindIndex(b => b.BuildId == page.BuildId && b.Org == page.Org && b.Project == page.Project); - var canNext = buildIndex >= 0 && buildIndex < _lastBuilds.Count - 1; - var canPrev = buildIndex > 0; - AnsiConsole.MarkupLine("[bold]Navigation:[/]"); - AnsiConsole.MarkupLine(" [blue]T[/]ests [blue]J[/]obs [blue]H[/]elix [blue]A[/]nalysis [blue]B[/]ack" + - (canForward ? " [blue]F[/]orward" : "") + - (canNext ? " [blue]N[/]ext" : "") + - (canPrev ? " [blue]P[/]rev" : "")); + // Helix work items count + var helixCount = _db.WithCommand(cmd => + { + cmd.CommandText = """ + SELECT COUNT(DISTINCT hw.job_name || '/' || hw.work_item_name) + FROM test_results tr + JOIN test_runs trn ON tr.organization = trn.organization + AND tr.project = trn.project AND tr.run_id = trn.run_id + JOIN helix_work_items hw ON tr.helix_job_name = hw.job_name + AND tr.helix_work_item_name = hw.work_item_name + WHERE trn.organization = @org AND trn.project = @proj AND trn.build_id = @buildId + AND tr.outcome = 'Failed' + """; + cmd.Parameters.AddWithValue("@org", page.Org); + cmd.Parameters.AddWithValue("@proj", page.Project); + cmd.Parameters.AddWithValue("@buildId", page.BuildId); + return Convert.ToInt32(cmd.ExecuteScalar()); + }); + if (helixCount > 0) + { + PanelLayout.RenderPanelLine($" [bold]Helix Work Items:[/] {helixCount}"); + } + }, + hotkeys); return ReadNavKey(page); } diff --git a/src/Tiger/Commands/DashboardCommand.cs b/src/Tiger/Commands/DashboardCommand.cs index dd3d2f5..66a585d 100644 --- a/src/Tiger/Commands/DashboardCommand.cs +++ b/src/Tiger/Commands/DashboardCommand.cs @@ -9,14 +9,14 @@ namespace Tiger.Commands; /// public sealed class DashboardCommand : AsyncCommand { - private const string MenuStatus = "Status"; - private const string MenuBuilds = "Builds"; - private const string MenuTests = "Tests"; - private const string MenuHealth = "Health"; - private const string MenuAnalysis = "Analysis"; - private const string MenuAgents = "Agents"; - private const string MenuConfig = "Configuration"; - private const string MenuQuit = "Quit"; + private const int MenuBuilds = 0; + private const int MenuTests = 1; + private const int MenuHealth = 2; + private const int MenuAnalysis = 3; + private const int MenuAgents = 4; + private const int MenuConfig = 5; + private const int MenuStatus = 6; + private const int MenuQuit = 7; protected override async Task ExecuteAsync(CommandContext context, CancellationToken ct) { @@ -70,13 +70,6 @@ protected override async Task ExecuteAsync(CommandContext context, Cancella return 0; } - private static void RenderBanner() - { - AnsiConsole.Write(new FigletText("tiger").Color(Color.Orange1)); - AnsiConsole.MarkupLine("[dim]CI/CD Infrastructure Management[/]"); - AnsiConsole.WriteLine(); - } - private static async Task RunMenuLoopAsync( TigerContext tigerContext, TigerDatabase db, AzdoClientFactory clientFactory, @@ -84,81 +77,21 @@ private static async Task RunMenuLoopAsync( BuildAnalysisService analysisAgent, ServiceLog serviceLog, CancellationToken ct) { - var menuLabels = new[] + var commands = new List { - $"[blue]B[/]uilds", - $"[blue]T[/]ests", - $"[blue]H[/]ealth", - $"[blue]A[/]nalysis", - $"A[blue]g[/]ents", - $"[blue]C[/]onfiguration", - $"[blue]S[/]tatus", - $"[blue]Q[/]uit", + new("Builds", ConsoleKey.B, MenuBuilds), + new("Tests", ConsoleKey.T, MenuTests), + new("Health", ConsoleKey.H, MenuHealth), + new("Analysis", ConsoleKey.A, MenuAnalysis), + new("Agents", ConsoleKey.G, MenuAgents), + new("Config", ConsoleKey.C, MenuConfig), + new("Status", ConsoleKey.S, MenuStatus), + new("Quit", ConsoleKey.Q, MenuQuit), }; - var selected = 0; while (!ct.IsCancellationRequested) { - AnsiConsole.Clear(); - RenderBanner(); - - AnsiConsole.MarkupLine("[bold]What would you like to do?[/]"); - for (var i = 0; i < menuLabels.Length; i++) - { - if (i == selected) - { - AnsiConsole.MarkupLine($" [blue]>[/] {menuLabels[i]}"); - } - else - { - AnsiConsole.MarkupLine($" {menuLabels[i]}"); - } - } - AnsiConsole.MarkupLine(" [blue]↑↓[/] Navigate [blue]Enter[/] Select"); - - var key = Console.ReadKey(true); - - // Hotkeys - var hotkey = char.ToUpperInvariant(key.KeyChar) switch - { - 'B' => 0, 'T' => 1, 'H' => 2, 'A' => 3, 'G' => 4, 'C' => 5, 'S' => 6, 'Q' => 7, - _ => -1, - }; - if (hotkey >= 0) - { - selected = hotkey; - } - else - { - switch (key.Key) - { - case ConsoleKey.UpArrow: - selected = (selected - 1 + menuLabels.Length) % menuLabels.Length; - continue; - case ConsoleKey.DownArrow: - selected = (selected + 1) % menuLabels.Length; - continue; - case ConsoleKey.Enter: - break; - case ConsoleKey.Escape: - return; - default: - continue; - } - } - - var choice = selected switch - { - 0 => MenuBuilds, - 1 => MenuTests, - 2 => MenuHealth, - 3 => MenuAnalysis, - 4 => MenuAgents, - 5 => MenuConfig, - 6 => MenuStatus, - 7 => MenuQuit, - _ => MenuQuit, - }; + var choice = PanelLayout.ShowMainMenu(commands); switch (choice) { @@ -196,6 +129,7 @@ private static async Task RunMenuLoopAsync( } break; case MenuQuit: + case -1: return; } } @@ -209,23 +143,20 @@ private static async Task RunMenuLoopAsync( private static async Task ShowLiveStatusAsync(ServiceLog serviceLog, CancellationToken ct) { var errorsOnly = false; - var scrollOffset = 0; // 0 = live tail (showing latest), >0 = scrolled back N entries - var maxVisible = Math.Max(Console.WindowHeight - 5, 10); + var scrollOffset = 0; + var maxVisible = Math.Max(Console.WindowHeight - 10, 10); void Render() { - AnsiConsole.Clear(); - var filterLabel = errorsOnly ? " [yellow](errors only)[/]" : ""; - var scrollLabel = scrollOffset > 0 ? $" [dim](scrolled back {scrollOffset})[/]" : " [dim](live)[/]"; - AnsiConsole.MarkupLine($"[bold underline]Service Log[/]{filterLabel}{scrollLabel}"); - AnsiConsole.WriteLine(); + var filterLabel = errorsOnly ? "[yellow](errors only)[/]" : ""; + var scrollLabel = scrollOffset > 0 ? $"[dim](scrolled back {scrollOffset})[/]" : "[dim](live)[/]"; + var context = $"{filterLabel} {scrollLabel}".Trim(); var all = serviceLog.GetRecent(500); var filtered = errorsOnly ? all.Where(e => e.Level is ServiceLogLevel.Error or ServiceLogLevel.Warning).ToList() : all; - // Apply scroll offset from the end var end = filtered.Count - scrollOffset; if (end < 0) { @@ -234,9 +165,34 @@ void Render() var start = Math.Max(0, end - maxVisible); var visible = filtered.Skip(start).Take(end - start).ToList(); - RenderLogEntries(visible); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine(" [blue]E[/]rrors toggle [blue]↑/↓[/] Scroll [blue]End[/] Latest [blue]Esc[/] Back"); + PanelLayout.RenderDetailPanel( + ["Status", "Service Log"], + context, + () => + { + if (visible.Count == 0) + { + PanelLayout.RenderPanelLine("[dim]No log entries yet...[/]"); + } + else + { + foreach (var entry in visible) + { + var time = entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"); + var levelColor = entry.Level switch + { + ServiceLogLevel.Success => "green", + ServiceLogLevel.Warning => "yellow", + ServiceLogLevel.Error => "red", + _ => "blue", + }; + var service = Markup.Escape(entry.Service); + var message = Markup.Escape(entry.Message); + PanelLayout.RenderPanelLine($"[dim]{time}[/] [{levelColor}]{service}[/] {message}"); + } + } + }, + "[blue]E[/]rrors toggle [blue]↑/↓[/] Scroll [blue]End[/] Latest [blue]Esc[/] Back"); } Render(); @@ -306,28 +262,4 @@ void OnEntry() serviceLog.EntryAdded -= OnEntry; } } - - private static void RenderLogEntries(List entries) - { - if (entries.Count == 0) - { - AnsiConsole.MarkupLine("[dim]No log entries yet...[/]"); - return; - } - - foreach (var entry in entries) - { - var time = entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"); - var levelColor = entry.Level switch - { - ServiceLogLevel.Success => "green", - ServiceLogLevel.Warning => "yellow", - ServiceLogLevel.Error => "red", - _ => "blue", - }; - var service = Markup.Escape(entry.Service); - var message = Markup.Escape(entry.Message); - AnsiConsole.MarkupLine($"[dim]{time}[/] [{levelColor}]{service}[/] {message}"); - } - } } diff --git a/src/Tiger/Commands/HealthCommand.cs b/src/Tiger/Commands/HealthCommand.cs index 40d9057..919eeff 100644 --- a/src/Tiger/Commands/HealthCommand.cs +++ b/src/Tiger/Commands/HealthCommand.cs @@ -40,20 +40,18 @@ private static void ShowCombosPage(HealthAgentService agent) { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Tiger Health Reports[/]"); - AnsiConsole.WriteLine(); - var runs = agent.GetRecentRuns(); if (runs.Count == 0) { - AnsiConsole.MarkupLine("[yellow]No health reports available yet. The agent runs every 15 minutes.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to exit...[/]"); + PanelLayout.RenderDetailPanel( + ["Health"], + null, + () => PanelLayout.RenderPanelLine("[yellow]No health reports available yet. The agent runs every 15 minutes.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } - // Get distinct combos var combos = runs .Select(r => (r.Repository, r.Definition)) .Distinct() @@ -61,9 +59,15 @@ private static void ShowCombosPage(HealthAgentService agent) var items = combos.Select(c => $"{c.Repository} / {c.Definition}").ToList(); - var selected = BrowserUI.SelectWithEscape("Select a pipeline:", items); + var selected = PanelLayout.SelectInPanel( + ["Health"], + $"[dim]{combos.Count} pipeline(s)[/]", + items, + new List()); if (selected < 0) + { return; + } var (repo, def) = combos[selected]; ShowStatePage(agent, repo, def); @@ -78,41 +82,37 @@ private static void ShowStatePage(HealthAgentService agent, string repository, s { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine($"[bold underline]{Markup.Escape(repository)} / {Markup.Escape(definition)}[/]"); - AnsiConsole.WriteLine(); - var state = agent.GetCurrentState(repository, definition); - if (state is not null) - { - MarkdownRenderer.Render(state); - } - else - { - AnsiConsole.MarkupLine("[dim]No state summary available yet.[/]"); - } - - AnsiConsole.WriteLine(); - var menuItems = new List - { - $"[blue]R[/]e-run health analysis", - $"[blue]G[/]ist (create public)", - $"[blue]V[/]iew agent runs", - }; - - var extraKeys = new Dictionary - { - [ConsoleKey.R] = 0, - [ConsoleKey.G] = 1, - [ConsoleKey.V] = 2, - }; - - var menuChoice = BrowserUI.SelectWithEscape("", menuItems, useMarkup: true, extraKeys: extraKeys); + PanelLayout.RenderDetailPanel( + ["Health", $"{Markup.Escape(repository)} / {Markup.Escape(definition)}"], + null, + () => + { + if (state is not null) + { + // Render markdown content line-by-line inside panel + var lines = state.ReplaceLineEndings("\n").Split('\n'); + foreach (var line in lines.Take(30)) + { + PanelLayout.RenderPanelLine(Markup.Escape(line)); + } + if (lines.Length > 30) + { + PanelLayout.RenderPanelLine($"[dim]... ({lines.Length - 30} more lines)[/]"); + } + } + else + { + PanelLayout.RenderPanelLine("[dim]No state summary available yet.[/]"); + } + }, + "[blue]R[/]e-run [blue]G[/]ist [blue]V[/]iew runs [blue]Esc[/] Back"); - switch (menuChoice) + var key = Console.ReadKey(true); + switch (key.Key) { - case 0: + case ConsoleKey.R: AnsiConsole.MarkupLine("[dim]Running health analysis...[/]"); try { @@ -124,13 +124,13 @@ private static void ShowStatePage(HealthAgentService agent, string repository, s AnsiConsole.MarkupLine($"[red]Analysis failed: {Markup.Escape(ex.Message)}[/]"); } break; - case 1: + case ConsoleKey.G: CreateGist(repository, definition, state); break; - case 2: + case ConsoleKey.V: ShowRunsPage(agent, repository, definition); break; - default: + case ConsoleKey.Escape: return; } } @@ -198,60 +198,61 @@ private static void ShowRunsPage(HealthAgentService agent, string repository, st { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine($"[bold underline]Agent Runs — {Markup.Escape(repository)} / {Markup.Escape(definition)}[/]"); - AnsiConsole.WriteLine(); - var runs = agent.GetRecentRuns(repository, definition); if (runs.Count == 0) { - AnsiConsole.MarkupLine("[dim]No runs found.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + PanelLayout.RenderDetailPanel( + ["Health", $"{Markup.Escape(repository)}", "Runs"], + null, + () => PanelLayout.RenderPanelLine("[dim]No runs found.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } var items = runs.Select(r => r.Timestamp.Replace("_", " ")).ToList(); - var selected = BrowserUI.SelectWithEscape("Select a run:", items); + var selected = PanelLayout.SelectInPanel( + ["Health", $"{Markup.Escape(repository)}", "Runs"], + $"[dim]{runs.Count} run(s)[/]", + items, + new List()); if (selected < 0) + { return; + } ShowRunDetail(runs[selected]); } } - /// - /// Fourth level: full log of a single agent run. - /// private static void ShowRunDetail(HealthRunInfo run) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine($"[bold underline]Health Report — {Markup.Escape(run.Timestamp.Replace("_", " "))}[/]"); - AnsiConsole.WriteLine(); - - if (File.Exists(run.LogPath)) - { - var content = File.ReadAllText(run.LogPath); - MarkdownRenderer.Render(content); - } - else - { - AnsiConsole.MarkupLine("[red]Log file not found.[/]"); - } - - AnsiConsole.WriteLine(); - - var menuItems = new List - { - $"[blue]B[/]ack", - }; - - var extraKeys = new Dictionary - { - [ConsoleKey.B] = 0, - }; + PanelLayout.RenderDetailPanel( + ["Health", "Run", Markup.Escape(run.Timestamp.Replace("_", " "))], + null, + () => + { + if (File.Exists(run.LogPath)) + { + var content = File.ReadAllText(run.LogPath); + var lines = content.ReplaceLineEndings("\n").Split('\n'); + foreach (var line in lines.Take(40)) + { + PanelLayout.RenderPanelLine(Markup.Escape(line)); + } + if (lines.Length > 40) + { + PanelLayout.RenderPanelLine($"[dim]... ({lines.Length - 40} more lines)[/]"); + } + } + else + { + PanelLayout.RenderPanelLine("[red]Log file not found.[/]"); + } + }, + "[blue]Esc[/] Back"); - BrowserUI.SelectWithEscape("", menuItems, useMarkup: true, extraKeys: extraKeys); + Console.ReadKey(true); } } diff --git a/src/Tiger/Commands/PanelLayout.cs b/src/Tiger/Commands/PanelLayout.cs new file mode 100644 index 0000000..2091b90 --- /dev/null +++ b/src/Tiger/Commands/PanelLayout.cs @@ -0,0 +1,582 @@ +using Spectre.Console; + +namespace Tiger.Commands; + +/// +/// Represents a single command in the command bar. +/// +public record CommandBarItem(string Label, ConsoleKey Hotkey, int ReturnValue); + +/// +/// Renders a consistent "command and control" panel layout for all screens. +/// +/// Structure: +/// ╔═══════════════════════════════════════════════════╗ +/// ║ TIGER ▸ Section ▸ Subsection ║ +/// ║ Context line (filter, counts, etc.) ║ +/// ╠═══════════════════════════════════════════════════╣ +/// ║ Content area (list or detail) ║ +/// ╠═══════════════════════════════════════════════════╣ +/// ║ Command bar (focusable via Tab) ║ +/// ╚═══════════════════════════════════════════════════╝ +/// +/// Border color is dim gray; hotkeys are blue for contrast. +/// Tab moves focus between content and command bar. +/// In the command bar, left/right navigates, Enter executes. +/// +public static class PanelLayout +{ + private const char TopLeft = '╔'; + private const char TopRight = '╗'; + private const char BottomLeft = '╚'; + private const char BottomRight = '╝'; + private const char Horizontal = '═'; + private const char Vertical = '║'; + private const char MiddleLeft = '╠'; + private const char MiddleRight = '╣'; + private const char Separator = '▸'; + + private const string BorderStyle = "dim"; + + /// + /// Gets the usable content width inside the panel borders (total width - 4 for borders and padding). + /// + public static int ContentWidth => Math.Max(40, Console.WindowWidth - 4); + + /// + /// Renders a single line inside the panel with vertical borders. + /// + public static void RenderPanelLine(string markupContent) + { + AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/] "); + AnsiConsole.Markup(markupContent); + + var plainLen = Markup.Remove(markupContent).Length; + var padding = Math.Max(0, Console.WindowWidth - 4 - plainLen); + Console.Write(new string(' ', padding)); + AnsiConsole.MarkupLine($" [{BorderStyle}]{Vertical}[/]"); + } + + /// + /// Renders an empty line inside the panel borders. + /// + public static void RenderEmptyLine() + { + var width = Console.WindowWidth - 2; + AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/]"); + Console.Write(new string(' ', width)); + AnsiConsole.MarkupLine($"[{BorderStyle}]{Vertical}[/]"); + } + + /// + /// Renders a section title inside the panel content area. + /// + public static void RenderSectionTitle(string title) + { + RenderPanelLine($"[bold underline]{title}[/]"); + } + + /// + /// Renders a labeled value pair inside the panel. + /// + public static void RenderField(string label, string value) + { + RenderPanelLine($"[bold]{label}:[/] {value}"); + } + + /// + /// Renders the Tiger ASCII art logo using Spectre.Console FigletText, + /// plus the TIGER text branding. + /// + public static void RenderLogo() + { + // Render figlet to a string buffer and extract lines + var writer = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(writer), + ColorSystem = ColorSystemSupport.NoColors, + }); + console.Write(new FigletText("tiger")); + var figletLines = writer.ToString().Split('\n', StringSplitOptions.None); + + foreach (var line in figletLines) + { + var trimmed = line.TrimEnd('\r', '\n'); + if (trimmed.Length > 0) + { + RenderPanelLine($"[bold orange1]{Markup.Escape(trimmed)}[/]"); + } + } + + RenderEmptyLine(); + RenderPanelLine("[bold orange1]TIGER[/] — CI/CD Infrastructure Management"); + RenderEmptyLine(); + } + + // ── Command Bar ───────────────────────────────────────────────── + + /// + /// Formats a label with its hotkey letter highlighted in [X] bracket style. + /// + private static string FormatHotkeyLabel(CommandBarItem item) + { + var label = item.Label; + var hotkeyChar = item.Hotkey switch + { + >= ConsoleKey.A and <= ConsoleKey.Z => (char)('A' + (item.Hotkey - ConsoleKey.A)), + _ => '\0' + }; + + if (hotkeyChar == '\0') + { + return label; + } + + // Find the hotkey character in the label (case-insensitive) + for (var i = 0; i < label.Length; i++) + { + if (char.ToUpperInvariant(label[i]) == hotkeyChar) + { + return $"{label[..i]}[blue][[{label[i]}]][/]{label[(i + 1)..]}"; + } + } + + return label; + } + + private static string BuildCommandBarMarkup(List commands, int focusedIndex, bool barFocused) + { + if (commands.Count == 0) + { + return "[dim]↑↓ Navigate Enter Select Esc Back[/]"; + } + + var parts = new List(); + for (var i = 0; i < commands.Count; i++) + { + if (barFocused && i == focusedIndex) + { + parts.Add($"[bold white on blue] {commands[i].Label} [/]"); + } + else + { + parts.Add(FormatHotkeyLabel(commands[i])); + } + } + var barContent = string.Join(" ", parts); + var tabHint = barFocused + ? " [dim]←→ Move Enter Execute Tab Content[/]" + : " [dim]Tab Commands[/]"; + return $"{barContent}{tabHint}"; + } + + private static void RenderCommandBarAt(int row, List commands, int focusedIndex, bool barFocused) + { + Console.SetCursorPosition(0, row); + RenderPanelLine(BuildCommandBarMarkup(commands, focusedIndex, barFocused)); + } + + // ── Flicker-free list line ────────────────────────────────────── + + private static void RenderListLineAt(int row, string markupContent, bool isSelected) + { + Console.SetCursorPosition(0, row); + var prefix = isSelected ? "[blue]▸[/] " : " "; + var line = $"{prefix}{markupContent}"; + AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/] "); + AnsiConsole.Markup(line); + var plainLen = Markup.Remove(line).Length; + var padding = Math.Max(0, Console.WindowWidth - 4 - plainLen); + Console.Write(new string(' ', padding)); + AnsiConsole.Markup($" [{BorderStyle}]{Vertical}[/]"); + } + + // ── Main Menu (logo + focusable command bar, no list) ─────────── + + /// + /// Renders the main dashboard: logo in the content area, navigation only via the command bar. + /// Returns the selected command's ReturnValue, or -1 on Escape. + /// + public static int ShowMainMenu(List commands) + { + var barIndex = 0; + + while (true) + { + AnsiConsole.Clear(); + Console.CursorVisible = false; + var width = Console.WindowWidth - 2; + + AnsiConsole.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + RenderPanelLine("[bold orange1]TIGER[/]"); + AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + + RenderLogo(); + + AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + var barRow = Console.CursorTop; + RenderPanelLine(BuildCommandBarMarkup(commands, barIndex, true)); + AnsiConsole.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + + while (true) + { + var key = Console.ReadKey(true); + + switch (key.Key) + { + case ConsoleKey.LeftArrow: + barIndex = (barIndex - 1 + commands.Count) % commands.Count; + RenderCommandBarAt(barRow, commands, barIndex, true); + continue; + case ConsoleKey.RightArrow: + barIndex = (barIndex + 1) % commands.Count; + RenderCommandBarAt(barRow, commands, barIndex, true); + continue; + case ConsoleKey.Enter: + Console.CursorVisible = true; + return commands[barIndex].ReturnValue; + case ConsoleKey.Escape: + Console.CursorVisible = true; + return -1; + default: + // Check hotkeys + for (var i = 0; i < commands.Count; i++) + { + if (key.Key == commands[i].Hotkey) + { + Console.CursorVisible = true; + return commands[i].ReturnValue; + } + } + continue; + } + } + } + } + + // ── Select in Panel (with focusable command bar) ──────────────── + + /// + /// List selection with a focusable command bar. Tab switches focus. + /// Returns list index on Enter (list focused), command ReturnValue on Enter (bar focused), + /// or -1 on Escape. + /// + public static int SelectInPanel(string[] breadcrumbs, string? context, List items, + List commands, int pageSize = 20, + int startIndex = 0, HashSet? skipIndices = null, Action? renderAboveList = null) + { + if (items.Count == 0) + { + return -1; + } + + var selected = Math.Clamp(startIndex, 0, items.Count - 1); + if (skipIndices is not null) + { + while (selected < items.Count && skipIndices.Contains(selected)) + { + selected++; + } + if (selected >= items.Count) + { + selected = Math.Clamp(startIndex, 0, items.Count - 1); + while (selected > 0 && skipIndices.Contains(selected)) + { + selected--; + } + } + } + + var scrollOffset = Math.Max(0, selected - pageSize + 1); + var visibleCount = Math.Min(pageSize, items.Count); + var listStartRow = -1; + var barRow = -1; + var barFocused = false; + var barIndex = 0; + var needsFullRedraw = true; + + while (true) + { + if (needsFullRedraw) + { + AnsiConsole.Clear(); + Console.CursorVisible = false; + var width = Console.WindowWidth - 2; + + AnsiConsole.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + var crumbText = string.Join($" {Separator} ", breadcrumbs); + RenderPanelLine($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); + + if (context is not null) + { + RenderPanelLine(context); + } + + AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + renderAboveList?.Invoke(); + + listStartRow = Console.CursorTop; + + for (var i = 0; i < visibleCount; i++) + { + var idx = scrollOffset + i; + if (idx >= items.Count) + { + RenderEmptyLine(); + continue; + } + + if (!barFocused && idx == selected) + { + RenderPanelLine($"[blue]▸[/] {items[idx]}"); + } + else + { + RenderPanelLine($" {items[idx]}"); + } + } + + if (items.Count > visibleCount) + { + RenderPanelLine($"[dim]({selected + 1}/{items.Count})[/]"); + } + + AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + barRow = Console.CursorTop; + RenderPanelLine(BuildCommandBarMarkup(commands, barIndex, barFocused)); + AnsiConsole.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + + needsFullRedraw = false; + } + + var key = Console.ReadKey(true); + var prevSelected = selected; + var prevScrollOffset = scrollOffset; + + if (key.Key == ConsoleKey.Tab) + { + if (commands.Count == 0) + { + continue; + } + + barFocused = !barFocused; + RenderCommandBarAt(barRow, commands, barIndex, barFocused); + if (listStartRow >= 0 && selected - scrollOffset >= 0 && selected - scrollOffset < visibleCount) + { + RenderListLineAt(listStartRow + (selected - scrollOffset), items[selected], !barFocused); + } + continue; + } + + if (barFocused) + { + switch (key.Key) + { + case ConsoleKey.LeftArrow: + barIndex = (barIndex - 1 + commands.Count) % commands.Count; + RenderCommandBarAt(barRow, commands, barIndex, true); + continue; + case ConsoleKey.RightArrow: + barIndex = (barIndex + 1) % commands.Count; + RenderCommandBarAt(barRow, commands, barIndex, true); + continue; + case ConsoleKey.Enter: + Console.CursorVisible = true; + return commands[barIndex].ReturnValue; + case ConsoleKey.Escape: + Console.CursorVisible = true; + return -1; + default: + for (var i = 0; i < commands.Count; i++) + { + if (key.Key == commands[i].Hotkey) + { + Console.CursorVisible = true; + return commands[i].ReturnValue; + } + } + continue; + } + } + + // List navigation + switch (key.Key) + { + case ConsoleKey.UpArrow: + if (selected > 0) + { + selected--; + while (selected > 0 && skipIndices is not null && skipIndices.Contains(selected)) + { + selected--; + } + if (skipIndices is not null && skipIndices.Contains(selected)) + { + selected++; + } + if (selected < scrollOffset) + { + scrollOffset = selected; + } + } + break; + case ConsoleKey.DownArrow: + if (selected < items.Count - 1) + { + selected++; + while (selected < items.Count - 1 && skipIndices is not null && skipIndices.Contains(selected)) + { + selected++; + } + if (skipIndices is not null && skipIndices.Contains(selected)) + { + selected--; + } + if (selected >= scrollOffset + visibleCount) + { + scrollOffset = selected - visibleCount + 1; + } + } + break; + case ConsoleKey.Enter: + Console.CursorVisible = true; + return selected; + case ConsoleKey.Escape: + Console.CursorVisible = true; + return -1; + default: + for (var i = 0; i < commands.Count; i++) + { + if (key.Key == commands[i].Hotkey) + { + Console.CursorVisible = true; + return commands[i].ReturnValue; + } + } + break; + } + + if (scrollOffset != prevScrollOffset) + { + needsFullRedraw = true; + continue; + } + + if (selected != prevSelected && listStartRow >= 0) + { + var prevRow = listStartRow + (prevSelected - scrollOffset); + var newRow = listStartRow + (selected - scrollOffset); + + if (prevSelected - scrollOffset >= 0 && prevSelected - scrollOffset < visibleCount) + { + RenderListLineAt(prevRow, items[prevSelected], false); + } + if (selected - scrollOffset >= 0 && selected - scrollOffset < visibleCount) + { + RenderListLineAt(newRow, items[selected], true); + } + + if (items.Count > visibleCount) + { + var indicatorRow = listStartRow + visibleCount; + Console.SetCursorPosition(0, indicatorRow); + RenderPanelLine($"[dim]({selected + 1}/{items.Count})[/]"); + } + } + } + } + + + // ── Detail Panel ──────────────────────────────────────────────── + + /// + /// Renders a detail view inside a panel frame. + /// + public static void RenderDetailPanel(string[] breadcrumbs, string? context, Action renderContent, string hotkeys) + { + AnsiConsole.Clear(); + Console.CursorVisible = false; + var width = Console.WindowWidth - 2; + + AnsiConsole.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + var crumbText = string.Join($" {Separator} ", breadcrumbs); + RenderPanelLine($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); + + if (context is not null) + { + RenderPanelLine(context); + } + + AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + renderContent(); + + AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + RenderPanelLine(hotkeys); + AnsiConsole.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + Console.CursorVisible = true; + } + + // ── Text Prompt ───────────────────────────────────────────────── + + /// + /// Prompts for text input inside the panel frame. + /// + public static string? PromptInPanel(string[] breadcrumbs, string prompt, string? currentValue = null) + { + AnsiConsole.Clear(); + Console.CursorVisible = true; + var width = Console.WindowWidth - 2; + + AnsiConsole.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + var crumbText = string.Join($" {Separator} ", breadcrumbs); + RenderPanelLine($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); + + AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + + RenderPanelLine($"[bold]{prompt}[/]"); + if (currentValue is not null) + { + RenderPanelLine($"[dim]Current: {Markup.Escape(currentValue)}[/]"); + } + RenderEmptyLine(); + + AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + RenderPanelLine("[blue]Enter[/] Confirm [blue]Esc[/] Cancel"); + AnsiConsole.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + + var inputRow = Console.CursorTop - 4; + Console.SetCursorPosition(4, inputRow); + AnsiConsole.Markup("[blue]>[/] "); + + var buffer = new System.Text.StringBuilder(); + while (true) + { + var key = Console.ReadKey(true); + if (key.Key == ConsoleKey.Escape) + { + return null; + } + if (key.Key == ConsoleKey.Enter) + { + var result = buffer.ToString().Trim(); + return string.IsNullOrEmpty(result) ? null : result; + } + if (key.Key == ConsoleKey.Backspace) + { + if (buffer.Length > 0) + { + buffer.Remove(buffer.Length - 1, 1); + Console.Write("\b \b"); + } + continue; + } + if (key.KeyChar >= 32) + { + buffer.Append(key.KeyChar); + Console.Write(key.KeyChar); + } + } + } +} diff --git a/src/Tiger/Commands/TestBrowser.cs b/src/Tiger/Commands/TestBrowser.cs index 2f9d692..96832ed 100644 --- a/src/Tiger/Commands/TestBrowser.cs +++ b/src/Tiger/Commands/TestBrowser.cs @@ -27,26 +27,26 @@ public void Browse() { while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Test Failures[/]"); - if (_filter.IsActive) - { - AnsiConsole.MarkupLine($"Filter: {Markup.Escape(_filter.ToString())}"); - } - else - { - AnsiConsole.MarkupLine("[dim]Filter: (none)[/]"); - } - AnsiConsole.WriteLine(); - var tests = QueryTests(); + var filterText = _filter.IsActive + ? $"Filter: {Markup.Escape(_filter.ToString())}" + : "[dim]Filter: (none)[/]"; + var context = tests.Count > 0 + ? $"{filterText} [dim]({tests.Count} failed test(s))[/]" + : filterText; + if (tests.Count == 0) { - AnsiConsole.MarkupLine(_filter.IsActive + var emptyMsg = _filter.IsActive ? "[yellow]No test failures match the current filter.[/]" - : "[yellow]No test failures recorded yet.[/]"); - AnsiConsole.MarkupLine(" [blue]E[/]dit filter [blue]F[/]ilter menu [blue]Esc[/] Back"); + : "[yellow]No test failures recorded yet.[/]"; + + PanelLayout.RenderDetailPanel( + ["Tests"], + context, + () => PanelLayout.RenderPanelLine(emptyMsg), + "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]Esc[/] Back"); var emptyKey = Console.ReadKey(true); if (emptyKey.Key == ConsoleKey.E) { EditFilter(); continue; } @@ -56,28 +56,28 @@ public void Browse() return; } - AnsiConsole.MarkupLine($"[dim]{tests.Count} failed test(s)[/]"); - AnsiConsole.WriteLine(); - var choices = tests.Select(t => { var title = t.TestName.Length > 70 ? t.TestName[..67] + "..." : t.TestName; return $"[red]✗[/] {Markup.Escape(title)} [dim]({t.FailCount} build(s))[/]"; }).ToList(); - var hotkeys = _filter.IsActive - ? "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]C[/]lear [blue]H[/]elp" - : "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]H[/]elp"; - - var selected = BrowserUI.SelectWithEscape("Select a test:", choices, - extraKeys: new Dictionary { - { ConsoleKey.E, -5 }, - { ConsoleKey.F, -2 }, - { ConsoleKey.H, -3 }, - { ConsoleKey.C, -4 }, - }, - useMarkup: true, - hotkeys: hotkeys); + var commands = new List + { + new("Edit filter", ConsoleKey.E, -5), + new("Filter menu", ConsoleKey.F, -2), + new("Help", ConsoleKey.H, -3), + }; + if (_filter.IsActive) + { + commands.Add(new("Clear", ConsoleKey.C, -4)); + } + + var selected = PanelLayout.SelectInPanel( + ["Tests"], + context, + choices, + commands); if (selected == -5) { EditFilter(); continue; } if (selected == -2) { ShowFilterMenu(); continue; } diff --git a/src/Tiger/TigerDatabase.cs b/src/Tiger/TigerDatabase.cs index db1768e..58564b6 100644 --- a/src/Tiger/TigerDatabase.cs +++ b/src/Tiger/TigerDatabase.cs @@ -704,8 +704,9 @@ FROM build_analyses ba public void Dispose() { - // Connection pooling: no shared connection to dispose. - // Microsoft.Data.Sqlite manages the pool internally. + // Clear the connection pool so pooled connections release the file lock. + // This is critical for tests that delete the DB file after use. + SqliteConnection.ClearPool(new SqliteConnection(ConnectionString)); } } From c5e8992c8b7135a2fa86ebe6dbd73cb0c06d8ba7 Mon Sep 17 00:00:00 2001 From: Jared Parsons Date: Tue, 9 Jun 2026 15:32:03 -0700 Subject: [PATCH 02/10] Convert all drill-down views to panel layout - BuildBrowser: test list, test detail, test builds, job list, helix work items - TestBrowser: test detail and test builds now match BuildBrowser's panel style - Filter menu redesigned with description + current filter in content area - Filter help uses RenderDetailPanel - Add BrowserUI.RenderTestDetailInPanel for shared panel-aware test rendering - Add PanelLayout.BuildCommandBarString for static command bar footers - Dynamic page size calculation ensures command bar always stays visible Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tiger/Commands/BrowserUI.cs | 94 +++++- src/Tiger/Commands/BuildBrowser.cs | 447 +++++++++++++++++------------ src/Tiger/Commands/PanelLayout.cs | 33 ++- src/Tiger/Commands/TestBrowser.cs | 44 ++- 4 files changed, 409 insertions(+), 209 deletions(-) diff --git a/src/Tiger/Commands/BrowserUI.cs b/src/Tiger/Commands/BrowserUI.cs index 3233768..28b5c3e 100644 --- a/src/Tiger/Commands/BrowserUI.cs +++ b/src/Tiger/Commands/BrowserUI.cs @@ -315,9 +315,97 @@ public static void RenderTestDetail(TestDetailInfo info) } /// - /// Converts a user pattern to a SQL LIKE pattern or exact match. - /// Default: contains match (ros → %ros%). Trailing ! means exact match. - /// * is a wildcard (dotnet/* → dotnet/%). + /// Renders test detail info using PanelLayout (for use inside RenderDetailPanel content delegates). + /// + public static void RenderTestDetailInPanel(TestDetailInfo info) + { + if (info.IsHelixDeadletter) + { + PanelLayout.RenderPanelLine("[bold red on yellow] ⚠ HELIX DEAD LETTER — Infrastructure failure, not a real test failure [/]"); + PanelLayout.RenderEmptyLine(); + } + + PanelLayout.RenderField("Test Name", Markup.Escape(info.TestName)); + var buildUrl = $"https://dev.azure.com/{Uri.EscapeDataString(info.Org)}/{Uri.EscapeDataString(info.Project)}/_build/results?buildId={info.BuildId}"; + PanelLayout.RenderField("Last Failed Build", FormatLink(buildUrl, $"Build #{info.BuildId}")); + PanelLayout.RenderField("Run", Markup.Escape(info.RunName)); + PanelLayout.RenderField("Failed In", $"{info.BuildCount} build(s)"); + PanelLayout.RenderEmptyLine(); + + PanelLayout.RenderSectionTitle("Error"); + if (!string.IsNullOrWhiteSpace(info.ErrorMessage)) + { + var errorLines = info.ErrorMessage.ReplaceLineEndings("\n").Split('\n'); + foreach (var line in errorLines.Take(5)) + { + PanelLayout.RenderPanelLine($" [red]{Markup.Escape(line)}[/]"); + } + if (errorLines.Length > 5) + { + PanelLayout.RenderPanelLine($" [dim]... ({errorLines.Length - 5} more lines)[/]"); + } + } + else + { + PanelLayout.RenderPanelLine(" [dim]No error message available[/]"); + } + PanelLayout.RenderEmptyLine(); + + PanelLayout.RenderSectionTitle("Stack Trace"); + if (!string.IsNullOrWhiteSpace(info.StackTrace)) + { + var stackLines = info.StackTrace.ReplaceLineEndings("\n").Split('\n'); + foreach (var line in stackLines.Take(10)) + { + PanelLayout.RenderPanelLine($" [dim]{Markup.Escape(line)}[/]"); + } + if (stackLines.Length > 10) + { + PanelLayout.RenderPanelLine($" [dim]... ({stackLines.Length - 10} more lines)[/]"); + } + } + else + { + PanelLayout.RenderPanelLine(" [dim]No stack trace available[/]"); + } + PanelLayout.RenderEmptyLine(); + + PanelLayout.RenderSectionTitle("Helix"); + if (info.HelixJobName is not null) + { + if (info.IsHelixDeadletter) + { + PanelLayout.RenderPanelLine(" [bold red]⚠ DEAD LETTER[/]"); + } + PanelLayout.RenderField("Job", Markup.Escape(info.HelixJobName)); + if (info.HelixWorkItemName is not null) + { + PanelLayout.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); + var consoleUrl = HelixClient.GetConsoleUrl(info.HelixJobName, info.HelixWorkItemName); + PanelLayout.RenderField("Console", FormatLink(consoleUrl, "Console Log")); + + if (info.HelixFiles is { Count: > 0 }) + { + PanelLayout.RenderPanelLine($" [bold]Files ({info.HelixFiles.Count}):[/]"); + foreach (var (name, uri) in info.HelixFiles) + { + if (uri is not null) + { + PanelLayout.RenderPanelLine($" {FormatLink(uri, name)}"); + } + else + { + PanelLayout.RenderPanelLine($" {Markup.Escape(name)}"); + } + } + } + } + } + else + { + PanelLayout.RenderPanelLine(" [dim]No Helix information available[/]"); + } + } /// public static (string Pattern, bool IsExact) ToSqlPattern(string input) { diff --git a/src/Tiger/Commands/BuildBrowser.cs b/src/Tiger/Commands/BuildBrowser.cs index 8c7ebab..09e0787 100644 --- a/src/Tiger/Commands/BuildBrowser.cs +++ b/src/Tiger/Commands/BuildBrowser.cs @@ -305,94 +305,102 @@ LIMIT 50 private void EditFilter() { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Edit Filter[/]"); - AnsiConsole.MarkupLine("[dim]Syntax: repo:VALUE def:VALUE num:VALUE result:VALUE pr:NUMBER[/]"); - AnsiConsole.MarkupLine("[dim]Examples: repo:roslyn result:failed repo:dotnet/* def:*-CI[/]"); - AnsiConsole.MarkupLine("[dim]Append ! for exact match: repo:dotnet/roslyn![/]"); - AnsiConsole.MarkupLine("[dim]Press Esc to cancel[/]"); - AnsiConsole.WriteLine(); - if (_filter.IsActive) - AnsiConsole.MarkupLine($"[dim]Current: {Markup.Escape(_filter.ToString())}[/]"); - AnsiConsole.Markup("[blue]> [/]"); - - var buffer = new System.Text.StringBuilder(); + var currentValue = _filter.IsActive ? _filter.ToString() : null; + var result = PanelLayout.PromptInPanel( + ["Builds", "Edit Filter"], + "Enter filter expression (e.g. repo:roslyn def:ci result:failed)", + currentValue); + + if (result is not null) + { + _filter.ParseExpression(result); + SaveFilter(); + } + } + + private void ShowFilterMenu() + { while (true) { - var key = Console.ReadKey(true); - if (key.Key == ConsoleKey.Escape) - return; // keep existing filter - if (key.Key == ConsoleKey.Enter) - { - AnsiConsole.WriteLine(); - var input = buffer.ToString().Trim(); - if (!string.IsNullOrEmpty(input)) - _filter.ParseExpression(input); - SaveFilter(); - return; - } - if (key.Key == ConsoleKey.Backspace) + var commands = new List { - if (buffer.Length > 0) + new("Repository", ConsoleKey.R, 1), + new("Definition", ConsoleKey.D, 2), + new("Build ID", ConsoleKey.I, 3), + new("Outcome", ConsoleKey.O, 4), + new("Kind", ConsoleKey.K, 5), + new("Branch", ConsoleKey.B, 6), + new("PR number", ConsoleKey.P, 7), + new("Clear", ConsoleKey.C, 8), + }; + + PanelLayout.RenderDetailPanel( + ["Builds", "Filter"], + null, + () => { - buffer.Remove(buffer.Length - 1, 1); - Console.Write("\b \b"); - } - continue; - } - if (key.KeyChar >= 32) + PanelLayout.RenderPanelLine("Filter available builds by repository, definition, kind, branch, etc."); + PanelLayout.RenderEmptyLine(); + + if (_filter.IsActive) + { + PanelLayout.RenderPanelLine($"[bold]Current filter:[/] {Markup.Escape(_filter.ToString())}"); + } + else + { + PanelLayout.RenderPanelLine("[dim]No filter active[/]"); + } + + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderPanelLine("[dim]Syntax: substring match by default, * for wildcards, ! suffix for exact[/]"); + PanelLayout.RenderPanelLine("[dim]Example: repo:roslyn def:*-CI result:failed branch:main[/]"); + }, + PanelLayout.BuildCommandBarString(commands)); + + var key = Console.ReadKey(true); + switch (key.Key) { - buffer.Append(key.KeyChar); - Console.Write(key.KeyChar); + case ConsoleKey.R: + _filter.RepoPattern = PromptFilterField("Repository pattern (e.g. roslyn, dotnet/*)"); + SaveFilter(); + continue; + case ConsoleKey.D: + _filter.DefinitionPattern = PromptFilterField("Definition pattern (e.g. ci, roslyn-CI*)"); + SaveFilter(); + continue; + case ConsoleKey.I: + _filter.IdPattern = PromptFilterField("Build ID pattern (e.g. 1423*, 142333)"); + SaveFilter(); + continue; + case ConsoleKey.O: + _filter.ResultPattern = PromptResultFilter(); + SaveFilter(); + continue; + case ConsoleKey.K: + _filter.KindPattern = BrowserUI.PromptKindFilter(); + SaveFilter(); + continue; + case ConsoleKey.B: + _filter.BranchPattern = PromptFilterField("Branch pattern (e.g. main, release/*)"); + SaveFilter(); + continue; + case ConsoleKey.P: + _filter.PrNumber = PromptPrNumber(); + SaveFilter(); + continue; + case ConsoleKey.C: + _filter.Clear(); + SaveFilter(); + continue; + default: + return; } } } - private void ShowFilterMenu() + private static string? PromptFilterField(string prompt) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Set Filter[/]"); - AnsiConsole.MarkupLine($"[dim]Current: {Markup.Escape(_filter.ToString())}[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine(" [blue]R[/]epository"); - AnsiConsole.MarkupLine(" [blue]D[/]efinition"); - AnsiConsole.MarkupLine(" Build [blue]I[/]D"); - AnsiConsole.MarkupLine(" [blue]O[/]utcome (failed, succeeded, partiallySucceeded)"); - AnsiConsole.MarkupLine(" [blue]K[/]ind (pr, ci)"); - AnsiConsole.MarkupLine(" [blue]B[/]ranch"); - AnsiConsole.MarkupLine(" [blue]P[/]R number"); - AnsiConsole.MarkupLine(" [blue]C[/]lear all filters"); - AnsiConsole.MarkupLine(" [blue]Esc[/] Cancel"); - - var key = Console.ReadKey(true); - switch (key.Key) - { - case ConsoleKey.R: - _filter.RepoPattern = BrowserUI.PromptPattern("Repository pattern (e.g. roslyn, dotnet/*):"); - break; - case ConsoleKey.D: - _filter.DefinitionPattern = BrowserUI.PromptPattern("Definition pattern (e.g. ci, roslyn-CI*):"); - break; - case ConsoleKey.I: - _filter.IdPattern = BrowserUI.PromptPattern("Build ID pattern (e.g. 1423*, 142333):"); - break; - case ConsoleKey.O: - _filter.ResultPattern = PromptResultFilter(); - break; - case ConsoleKey.K: - _filter.KindPattern = BrowserUI.PromptKindFilter(); - break; - case ConsoleKey.B: - _filter.BranchPattern = BrowserUI.PromptPattern("Branch pattern (e.g. main, release/*):"); - break; - case ConsoleKey.P: - _filter.PrNumber = PromptPrNumber(); - break; - case ConsoleKey.C: - _filter.Clear(); - break; - } - SaveFilter(); + return PanelLayout.PromptInPanel(["Builds", "Filter"], prompt); } /// @@ -400,11 +408,18 @@ private void ShowFilterMenu() /// private static string? PromptResultFilter() { - AnsiConsole.WriteLine(); - var choices = new[] { "all", "failed", "succeeded", "partiallySucceeded" }; - var selected = BrowserUI.SelectWithEscape("Select outcome:", choices.ToList(), pageSize: 5); - if (selected < 0) return null; // cancelled - return choices[selected] == "all" ? null : choices[selected]; + var outcomes = new List { "all", "failed", "succeeded", "partiallySucceeded" }; + var commands = new List(); + var selected = PanelLayout.SelectInPanel( + ["Builds", "Filter", "Outcome"], + "[dim]Select build outcome to filter on[/]", + outcomes, + commands); + if (selected < 0) + { + return null; + } + return outcomes[selected] == "all" ? null : outcomes[selected]; } /// @@ -412,8 +427,7 @@ private void ShowFilterMenu() /// private static int? PromptPrNumber() { - AnsiConsole.WriteLine(); - var raw = BrowserUI.PromptPattern("PR number (e.g. 12345):"); + var raw = PanelLayout.PromptInPanel(["Builds", "Filter"], "PR number (e.g. 12345)"); if (raw is null) { return null; @@ -423,31 +437,34 @@ private void ShowFilterMenu() private static void ShowFilterHelp() { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Filter Help[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Quick filter (E):[/]"); - AnsiConsole.MarkupLine(" Type an expression like: [blue]repo:roslyn def:ci[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Matching (default: contains / LIKE):[/]"); - AnsiConsole.MarkupLine(" [dim]ros → matches 'dotnet/roslyn', 'roslyn-CI', etc.[/]"); - AnsiConsole.MarkupLine(" [dim]dotnet/* → matches 'dotnet/roslyn', 'dotnet/runtime'[/]"); - AnsiConsole.MarkupLine(" [dim]14* → matches build numbers starting with '14'[/]"); - AnsiConsole.MarkupLine(" [dim]*-CI → matches definition names ending with '-CI'[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Exact match (append !):[/]"); - AnsiConsole.MarkupLine(" [dim]dotnet/roslyn! → matches exactly 'dotnet/roslyn'[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Filter prefixes:[/]"); - AnsiConsole.MarkupLine(" [blue]repo:[/] Repository name"); - AnsiConsole.MarkupLine(" [blue]def:[/] Definition/pipeline name"); - AnsiConsole.MarkupLine(" [blue]id:[/] Build ID (e.g. 1423*, 142333)"); - AnsiConsole.MarkupLine(" [blue]result:[/] Outcome (failed, succeeded, partiallySucceeded)"); - AnsiConsole.MarkupLine(" [blue]kind:[/] Build kind (pr, ci)"); - AnsiConsole.MarkupLine(" [blue]branch:[/] Source branch (e.g. main, release/*)"); - AnsiConsole.MarkupLine(" [blue]pr:[/] PR number (e.g. 12345)"); AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Multiple filters combine with AND.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to continue...[/]"); + PanelLayout.RenderDetailPanel( + ["Builds", "Filter Help"], + null, + () => + { + PanelLayout.RenderPanelLine("[bold]Quick filter (E):[/]"); + PanelLayout.RenderPanelLine(" Type an expression like: [blue]repo:roslyn def:ci[/]"); + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderPanelLine("[bold]Matching (default: contains / LIKE):[/]"); + PanelLayout.RenderPanelLine(" [dim]ros → matches 'dotnet/roslyn', 'roslyn-CI', etc.[/]"); + PanelLayout.RenderPanelLine(" [dim]dotnet/* → matches 'dotnet/roslyn', 'dotnet/runtime'[/]"); + PanelLayout.RenderPanelLine(" [dim]*-CI → matches definition names ending with '-CI'[/]"); + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderPanelLine("[bold]Exact match (append !):[/]"); + PanelLayout.RenderPanelLine(" [dim]dotnet/roslyn! → matches exactly 'dotnet/roslyn'[/]"); + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderPanelLine("[bold]Filter prefixes:[/]"); + PanelLayout.RenderPanelLine(" [blue]repo:[/] Repository name"); + PanelLayout.RenderPanelLine(" [blue]def:[/] Definition/pipeline name"); + PanelLayout.RenderPanelLine(" [blue]id:[/] Build ID"); + PanelLayout.RenderPanelLine(" [blue]result:[/] Outcome (failed, succeeded, partiallySucceeded)"); + PanelLayout.RenderPanelLine(" [blue]kind:[/] Build kind (pr, ci)"); + PanelLayout.RenderPanelLine(" [blue]branch:[/] Source branch"); + PanelLayout.RenderPanelLine(" [blue]pr:[/] PR number"); + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderPanelLine("[bold]Multiple filters combine with AND.[/]"); + }, + "[blue]Esc[/] Back"); Console.ReadKey(true); } @@ -731,9 +748,6 @@ FROM test_results tr private NavAction RenderTestList(TestListPage page) { - AnsiConsole.MarkupLine($"[bold underline]Failed Tests — Build #{page.BuildId}[/]"); - AnsiConsole.WriteLine(); - var tests = _db.WithCommand(cmd => { var tests = new List<(string RunName, string Title)>(); @@ -759,8 +773,11 @@ FROM test_results tr if (tests.Count == 0) { - AnsiConsole.MarkupLine("[green]No failed tests in this build.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + PanelLayout.RenderDetailPanel( + ["Builds", $"#{page.BuildId}", "Tests"], + null, + () => PanelLayout.RenderPanelLine("[green]No failed tests in this build.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; } @@ -768,9 +785,11 @@ FROM test_results tr // Build grouped display: run name headers are non-selectable, tests are selectable var choices = new List(); var selectableIndices = new List(); // maps choice index → tests list index + var skipIndices = new HashSet(); var grouped = tests.Select((t, i) => (t, i)).GroupBy(x => x.t.RunName); foreach (var group in grouped) { + skipIndices.Add(choices.Count); choices.Add($"[bold yellow]{Markup.Escape(group.Key)}[/]"); selectableIndices.Add(-1); // header, not selectable @@ -783,11 +802,19 @@ FROM test_results tr } var totalFailed = tests.Select(t => t.Title).Distinct().Count(); - var selected = BrowserUI.SelectWithEscape($"{totalFailed} failed test(s) across {grouped.Count()} run(s):", - choices, useMarkup: true, skipIndices: selectableIndices.Select((v, i) => (v, i)).Where(x => x.v == -1).Select(x => x.i).ToHashSet()); + var commands = new List(); + + var selected = PanelLayout.SelectInPanel( + ["Builds", $"#{page.BuildId}", "Tests"], + $"[dim]{totalFailed} failed test(s) across {grouped.Count()} run(s)[/]", + choices, + commands, + skipIndices: skipIndices); if (selected < 0) + { return NavAction.Back.Instance; + } var testTitle = tests[selectableIndices[selected]].Title; return new NavAction.Push( @@ -801,16 +828,27 @@ private NavAction RenderTestDetail(TestDetailPage page) var info = BrowserUI.LoadTestDetail(_db, page.Org, page.Project, page.TestName); if (info is null) { - AnsiConsole.MarkupLine("[yellow]No test failure data found.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + PanelLayout.RenderDetailPanel( + ["Builds", "Tests", "Detail"], + null, + () => PanelLayout.RenderPanelLine("[yellow]No test failure data found.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; } - BrowserUI.RenderTestDetail(info); + var shortTitle = page.TestName.Length > 60 ? page.TestName[..57] + "..." : page.TestName; + var commands = new List + { + new("Builds with failure", ConsoleKey.B, -2), + new("Agent task", ConsoleKey.A, -3), + }; - AnsiConsole.MarkupLine("[bold]Navigation:[/]"); - AnsiConsole.MarkupLine(" [blue]B[/]uilds with this failure [blue]A[/]gent task [blue]Esc[/] Back"); + PanelLayout.RenderDetailPanel( + ["Builds", "Tests", Markup.Escape(shortTitle)], + null, + () => BrowserUI.RenderTestDetailInPanel(info), + PanelLayout.BuildCommandBarString(commands)); while (true) { @@ -833,9 +871,6 @@ private NavAction RenderTestDetail(TestDetailPage page) private NavAction RenderTestBuilds(TestBuildsPage page) { var shortTitle = page.TestName.Length > 60 ? page.TestName[..57] + "..." : page.TestName; - AnsiConsole.MarkupLine($"[bold underline]Builds with failure[/]"); - AnsiConsole.MarkupLine($"[bold]{Markup.Escape(shortTitle)}[/]"); - AnsiConsole.WriteLine(); var builds = _db.WithCommand(cmd => { @@ -875,8 +910,11 @@ LIMIT 30 if (builds.Count == 0) { - AnsiConsole.MarkupLine("[yellow]No builds found with this test failure.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + PanelLayout.RenderDetailPanel( + ["Builds", "Tests", "Builds with failure"], + null, + () => PanelLayout.RenderPanelLine("[yellow]No builds found with this test failure.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; } @@ -895,10 +933,17 @@ LIMIT 30 return $"{resultIcon} {b.BuildId} {Markup.Escape(b.DefinitionName)} {time}{pr}"; }).ToList(); - var selected = BrowserUI.SelectWithEscape("Select a build:", choices, useMarkup: true); + var commands = new List(); + var selected = PanelLayout.SelectInPanel( + ["Builds", "Tests", Markup.Escape(shortTitle), "Builds"], + $"[dim]{builds.Count} build(s) with this failure[/]", + choices, + commands); if (selected < 0) + { return NavAction.Back.Instance; + } var b2 = builds[selected]; return new NavAction.Push(new BuildDetailPage(b2.Org, b2.Project, b2.BuildId)); @@ -945,8 +990,11 @@ FROM build_timeline_issues if (jobIssues.Count == 0) { - AnsiConsole.MarkupLine("[green]No timeline issues recorded for this build.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + PanelLayout.RenderDetailPanel( + ["Builds", $"#{page.BuildId}", "Jobs"], + null, + () => PanelLayout.RenderPanelLine("[green]No timeline issues recorded for this build.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; } @@ -956,55 +1004,75 @@ FROM build_timeline_issues while (true) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine($"[bold underline]Failed Jobs — Build #{page.BuildId}[/]"); - if (errorsOnly) - AnsiConsole.MarkupLine("[dim]Showing errors only[/]"); - AnsiConsole.WriteLine(); - - foreach (var (jobName, issues) in jobIssues) + var commands = new List { - var filtered = errorsOnly - ? issues.Where(i => i.Type == "error").ToList() - : issues; + new(errorsOnly ? "Errors: showing" : "Errors only", ConsoleKey.E, -2), + new(truncate ? "Truncate: off" : "Truncate: on", ConsoleKey.T, -3), + }; - if (filtered.Count == 0) - continue; + PanelLayout.RenderDetailPanel( + ["Builds", $"#{page.BuildId}", "Jobs"], + errorsOnly ? "[dim]Showing errors only[/]" : null, + () => + { + foreach (var (jobName, issues) in jobIssues) + { + var filtered = errorsOnly + ? issues.Where(i => i.Type == "error").ToList() + : issues; - var errorCount = issues.Count(i => i.Type == "error"); - var warnCount = issues.Count(i => i.Type == "warning"); - var summary = new List(); - if (errorCount > 0) summary.Add($"[red]{errorCount} error(s)[/]"); - if (warnCount > 0) summary.Add($"[yellow]{warnCount} warning(s)[/]"); - AnsiConsole.MarkupLine($"[bold]{Markup.Escape(jobName)}[/] {string.Join(" ", summary)}"); + if (filtered.Count == 0) + { + continue; + } - foreach (var (type, message) in filtered.Take(10)) - { - var icon = type == "error" ? "[red]error[/]" : "[yellow]warn[/]"; - var msg = message.ReplaceLineEndings(" "); - if (truncate && msg.Length > 120) - msg = msg[..117] + "..."; - AnsiConsole.MarkupLine($" {icon}: {Markup.Escape(msg)}"); - } + var errorCount = issues.Count(i => i.Type == "error"); + var warnCount = issues.Count(i => i.Type == "warning"); + var summary = new List(); + if (errorCount > 0) + { + summary.Add($"[red]{errorCount} error(s)[/]"); + } + if (warnCount > 0) + { + summary.Add($"[yellow]{warnCount} warning(s)[/]"); + } + PanelLayout.RenderPanelLine($"[bold]{Markup.Escape(jobName)}[/] {string.Join(" ", summary)}"); - if (filtered.Count > 10) - AnsiConsole.MarkupLine($" [dim]... and {filtered.Count - 10} more[/]"); + foreach (var (type, message) in filtered.Take(10)) + { + var icon = type == "error" ? "[red]error[/]" : "[yellow]warn[/]"; + var msg = message.ReplaceLineEndings(" "); + if (truncate && msg.Length > 120) + { + msg = msg[..117] + "..."; + } + PanelLayout.RenderPanelLine($" {icon}: {Markup.Escape(msg)}"); + } - AnsiConsole.WriteLine(); - } + if (filtered.Count > 10) + { + PanelLayout.RenderPanelLine($" [dim]... and {filtered.Count - 10} more[/]"); + } - // Hotkey menu at the bottom - var errorsLabel = errorsOnly ? "[blue]E[/]rrors: showing" : "[blue]E[/]rrors only"; - var truncateLabel = truncate ? "[blue]T[/]runcate: off" : "[blue]T[/]runcate: on"; - AnsiConsole.MarkupLine($" {errorsLabel} {truncateLabel} [blue]Esc[/] Back"); + PanelLayout.RenderEmptyLine(); + } + }, + PanelLayout.BuildCommandBarString(commands)); var key = Console.ReadKey(true); if (key.Key is ConsoleKey.Escape or ConsoleKey.B) + { return NavAction.Back.Instance; + } if (key.Key == ConsoleKey.T) + { truncate = !truncate; + } if (key.Key == ConsoleKey.E) + { errorsOnly = !errorsOnly; + } } } @@ -1057,10 +1125,6 @@ private NavAction ReadNavKey(BuildDetailPage page) private void ShowHelixInfo(BuildDetailPage page) { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine($"[bold underline]Helix Work Items — Build #{page.BuildId}[/]"); - AnsiConsole.WriteLine(); - var helixItems = _db.WithCommand(cmd => { cmd.CommandText = """ @@ -1094,31 +1158,34 @@ LIMIT 30 return helixItems; }); - var hasHelix = false; - foreach (var (job, wi, state, exitCode, consoleUri, isDeadletter) in helixItems) - { - hasHelix = true; - if (isDeadletter) - { - AnsiConsole.MarkupLine($" [bold red]⚠ DEAD LETTER[/] [bold]{Markup.Escape(wi)}[/]"); - } - else + PanelLayout.RenderDetailPanel( + ["Builds", $"#{page.BuildId}", "Helix Work Items"], + $"[dim]{helixItems.Count} work item(s)[/]", + () => { - var stateInfo = state is not null ? $" [{(exitCode == 0 ? "green" : "red")}]{state} (exit {exitCode})[/]" : ""; - AnsiConsole.MarkupLine($" [bold]{Markup.Escape(wi)}[/]{stateInfo}"); - } - - var url = consoleUri ?? HelixClient.GetConsoleUrl(job, wi); - AnsiConsole.MarkupLine($" {BrowserUI.FormatLink(url, "Console Log")}"); - } + if (helixItems.Count == 0) + { + PanelLayout.RenderPanelLine("[yellow]No Helix work items found for failed tests in this build.[/]"); + return; + } - if (!hasHelix) - { - AnsiConsole.MarkupLine("[yellow]No Helix work items found for failed tests in this build.[/]"); - } + foreach (var (job, wi, state, exitCode, consoleUri, isDeadletter) in helixItems) + { + if (isDeadletter) + { + PanelLayout.RenderPanelLine($" [bold red]⚠ DEAD LETTER[/] [bold]{Markup.Escape(wi)}[/]"); + } + else + { + var stateInfo = state is not null ? $" [{(exitCode == 0 ? "green" : "red")}]{state} (exit {exitCode})[/]" : ""; + PanelLayout.RenderPanelLine($" [bold]{Markup.Escape(wi)}[/]{stateInfo}"); + } - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + var url = consoleUri ?? HelixClient.GetConsoleUrl(job, wi); + PanelLayout.RenderPanelLine($" {BrowserUI.FormatLink(url, "Console Log")}"); + } + }, + "[blue]Esc[/] Back"); Console.ReadKey(true); } diff --git a/src/Tiger/Commands/PanelLayout.cs b/src/Tiger/Commands/PanelLayout.cs index 2091b90..433795f 100644 --- a/src/Tiger/Commands/PanelLayout.cs +++ b/src/Tiger/Commands/PanelLayout.cs @@ -171,6 +171,20 @@ private static string BuildCommandBarMarkup(List commands, int f return $"{barContent}{tabHint}"; } + /// + /// Builds a static hotkey string from a list of commands (for use in RenderDetailPanel footers). + /// Renders each command with the [X] bracket-style hotkey highlighting. + /// + public static string BuildCommandBarString(List commands) + { + var parts = new List(); + foreach (var cmd in commands) + { + parts.Add(FormatHotkeyLabel(cmd)); + } + return string.Join(" ", parts) + " [blue]Esc[/] Back"; + } + private static void RenderCommandBarAt(int row, List commands, int focusedIndex, bool barFocused) { Console.SetCursorPosition(0, row); @@ -263,7 +277,7 @@ public static int ShowMainMenu(List commands) /// or -1 on Escape. /// public static int SelectInPanel(string[] breadcrumbs, string? context, List items, - List commands, int pageSize = 20, + List commands, int pageSize = 0, int startIndex = 0, HashSet? skipIndices = null, Action? renderAboveList = null) { if (items.Count == 0) @@ -271,6 +285,23 @@ public static int SelectInPanel(string[] breadcrumbs, string? context, List visible + var maxPageSize = Math.Max(5, Console.WindowHeight - headerRows - footerRows - reservedIndicatorRows); + + if (pageSize <= 0) + { + pageSize = maxPageSize; + } + else + { + pageSize = Math.Min(pageSize, maxPageSize); + } + var selected = Math.Clamp(startIndex, 0, items.Count - 1); if (skipIndices is not null) { diff --git a/src/Tiger/Commands/TestBrowser.cs b/src/Tiger/Commands/TestBrowser.cs index 96832ed..3ba4eac 100644 --- a/src/Tiger/Commands/TestBrowser.cs +++ b/src/Tiger/Commands/TestBrowser.cs @@ -94,27 +94,38 @@ private void ShowTestDetail(TestRow test) { while (true) { - AnsiConsole.Clear(); - var info = BrowserUI.LoadTestDetail(_db, test.Org, test.Project, test.TestName); if (info is null) { - AnsiConsole.MarkupLine("[yellow]No test failure data found.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + PanelLayout.RenderDetailPanel( + ["Tests", "Detail"], + null, + () => PanelLayout.RenderPanelLine("[yellow]No test failure data found.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } - BrowserUI.RenderTestDetail(info); + var shortTitle = test.TestName.Length > 60 ? test.TestName[..57] + "..." : test.TestName; + var commands = new List + { + new("Builds with failure", ConsoleKey.B, -2), + new("Agent task", ConsoleKey.A, -3), + }; - AnsiConsole.MarkupLine("[bold]Navigation:[/]"); - AnsiConsole.MarkupLine(" [blue]B[/]uilds with this failure [blue]A[/]gent task [blue]Esc[/] Back"); + PanelLayout.RenderDetailPanel( + ["Tests", Markup.Escape(shortTitle)], + null, + () => BrowserUI.RenderTestDetailInPanel(info), + PanelLayout.BuildCommandBarString(commands)); while (true) { var key = Console.ReadKey(true); if (key.Key == ConsoleKey.Escape) + { return; + } if (key.Key == ConsoleKey.B) { ShowTestBuilds(test); @@ -131,18 +142,16 @@ private void ShowTestDetail(TestRow test) private void ShowTestBuilds(TestRow test) { - AnsiConsole.Clear(); var shortTitle = test.TestName.Length > 60 ? test.TestName[..57] + "..." : test.TestName; - AnsiConsole.MarkupLine("[bold underline]Builds with failure[/]"); - AnsiConsole.MarkupLine($"[bold]{Markup.Escape(shortTitle)}[/]"); - AnsiConsole.WriteLine(); - var builds = QueryTestBuilds(test); if (builds.Count == 0) { - AnsiConsole.MarkupLine("[yellow]No builds found.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to go back...[/]"); + PanelLayout.RenderDetailPanel( + ["Tests", Markup.Escape(shortTitle), "Builds"], + null, + () => PanelLayout.RenderPanelLine("[yellow]No builds found.[/]"), + "[blue]Esc[/] Back"); Console.ReadKey(true); return; } @@ -151,7 +160,12 @@ private void ShowTestBuilds(TestRow test) BrowserUI.FormatBuildChoice(b.BuildId, b.DefinitionName, b.Result, b.FinishTime, b.PrNumber)).ToList(); - var selected = BrowserUI.SelectWithEscape("Select a build:", choices, useMarkup: true); + var commands = new List(); + var selected = PanelLayout.SelectInPanel( + ["Tests", Markup.Escape(shortTitle), "Builds"], + $"[dim]{builds.Count} build(s) with this failure[/]", + choices, + commands); if (selected >= 0) { From 7ac6c111978dc8a0185a8ed8642f9827a2cd4cad Mon Sep 17 00:00:00 2001 From: Jared Parsons Date: Tue, 9 Jun 2026 16:10:07 -0700 Subject: [PATCH 03/10] Add scrollable detail panels to prevent content overflow RenderDetailPanel now captures content lines and paginates them to fit the terminal height. When content exceeds available space, a scroll indicator appears in the footer and Up/Down/PageUp/PageDown keys scroll the view. Key changes: - RenderPanelLine/RenderEmptyLine buffer lines during capture phase - RenderDetailPanelFrame renders a page of content with padding - HandleDetailScroll(key) lets callers add scroll support with one line - All detail views (build, test, helix) now support scrolling This fixes the Helix section being invisible in test detail views when error messages and stack traces push it below the terminal height. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tiger/Commands/AgentBrowser.cs | 12 +- src/Tiger/Commands/BuildBrowser.cs | 93 +++++++++- src/Tiger/Commands/DashboardCommand.cs | 5 +- src/Tiger/Commands/HealthCommand.cs | 7 +- src/Tiger/Commands/PanelLayout.cs | 158 +++++++++++++++- src/Tiger/Commands/TestBrowser.cs | 246 ++++++++++++++++--------- 6 files changed, 417 insertions(+), 104 deletions(-) diff --git a/src/Tiger/Commands/AgentBrowser.cs b/src/Tiger/Commands/AgentBrowser.cs index d130add..28af278 100644 --- a/src/Tiger/Commands/AgentBrowser.cs +++ b/src/Tiger/Commands/AgentBrowser.cs @@ -39,7 +39,10 @@ public void Browse() ["Agents"], null, () => PanelLayout.RenderPanelLine("[dim]No agent tasks found.[/]"), - "[blue]R[/]efresh [blue]Esc[/] Back"); + PanelLayout.BuildCommandBarString(new List + { + new("Refresh", ConsoleKey.R, -2), + })); while (true) { @@ -160,7 +163,12 @@ private void ShowTaskDetail(AgentTaskInfo task, HashSet trackedIds) PanelLayout.RenderField("Source", "[yellow]Submitted from Tiger[/]"); } }, - "[blue]O[/]pen PR [blue]V[/]iew logs [blue]R[/]efresh [blue]Esc[/] Back"); + PanelLayout.BuildCommandBarString(new List + { + new("Open PR", ConsoleKey.O, -2), + new("View logs", ConsoleKey.V, -3), + new("Refresh", ConsoleKey.R, -4), + })); var key = Console.ReadKey(true); if (key.Key == ConsoleKey.Escape) diff --git a/src/Tiger/Commands/BuildBrowser.cs b/src/Tiger/Commands/BuildBrowser.cs index 09e0787..4a53022 100644 --- a/src/Tiger/Commands/BuildBrowser.cs +++ b/src/Tiger/Commands/BuildBrowser.cs @@ -126,7 +126,11 @@ private NavAction RenderBuildList() ["Builds"], context, () => PanelLayout.RenderPanelLine(emptyMsg), - "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]Esc[/] Back"); + PanelLayout.BuildCommandBarString(new List + { + new("Edit filter", ConsoleKey.E, -5), + new("Filter menu", ConsoleKey.F, -2), + })); var emptyKey = Console.ReadKey(true); if (emptyKey.Key == ConsoleKey.E) @@ -550,10 +554,25 @@ string TaskIcon(string taskType) var canNext = buildIndex >= 0 && buildIndex < _lastBuilds.Count - 1; var canPrev = buildIndex > 0; - var hotkeys = "[blue]T[/]ests [blue]J[/]obs [blue]H[/]elix [blue]A[/]nalysis [blue]Esc[/] Back" + - (canForward ? " [blue]F[/]orward" : "") + - (canNext ? " [blue]N[/]ext" : "") + - (canPrev ? " [blue]P[/]rev" : ""); + var detailCommands = new List + { + new("Tests", ConsoleKey.T, -10), + new("Jobs", ConsoleKey.J, -11), + new("Helix", ConsoleKey.H, -12), + new("Analysis", ConsoleKey.A, -13), + }; + if (canForward) + { + detailCommands.Add(new("Forward", ConsoleKey.F, -14)); + } + if (canNext) + { + detailCommands.Add(new("Next", ConsoleKey.N, -15)); + } + if (canPrev) + { + detailCommands.Add(new("Prev", ConsoleKey.P, -16)); + } PanelLayout.RenderDetailPanel( ["Builds", $"#{page.BuildId} {defName}"], @@ -739,7 +758,7 @@ FROM test_results tr PanelLayout.RenderPanelLine($" [bold]Helix Work Items:[/] {helixCount}"); } }, - hotkeys); + PanelLayout.BuildCommandBarString(detailCommands)); return ReadNavKey(page); } @@ -843,6 +862,10 @@ private NavAction RenderTestDetail(TestDetailPage page) new("Builds with failure", ConsoleKey.B, -2), new("Agent task", ConsoleKey.A, -3), }; + if (info.HelixJobName is not null) + { + commands.Add(new("Helix", ConsoleKey.H, -4)); + } PanelLayout.RenderDetailPanel( ["Builds", "Tests", Markup.Escape(shortTitle)], @@ -853,6 +876,7 @@ private NavAction RenderTestDetail(TestDetailPage page) while (true) { var key = Console.ReadKey(true); + if (PanelLayout.HandleDetailScroll(key)) continue; switch (key.Key) { case ConsoleKey.B: @@ -860,12 +884,61 @@ private NavAction RenderTestDetail(TestDetailPage page) case ConsoleKey.A: BrowserUI.CreateAgentTask(_db, info); return NavAction.Refresh.Instance; + case ConsoleKey.H when info.HelixJobName is not null: + ShowHelixWorkItemDetail(info); + return NavAction.Refresh.Instance; case ConsoleKey.Escape: return NavAction.Back.Instance; } } } + private static void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) + { + var commands = new List(); + PanelLayout.RenderDetailPanel( + ["Tests", "Helix Work Item"], + null, + () => + { + if (info.IsHelixDeadletter) + { + PanelLayout.RenderPanelLine("[bold red on yellow] ⚠ HELIX DEAD LETTER — Infrastructure failure [/]"); + PanelLayout.RenderEmptyLine(); + } + PanelLayout.RenderField("Job", Markup.Escape(info.HelixJobName!)); + if (info.HelixWorkItemName is not null) + { + PanelLayout.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); + var url = HelixClient.GetConsoleUrl(info.HelixJobName!, info.HelixWorkItemName); + PanelLayout.RenderField("Console", BrowserUI.FormatLink(url, "Console Log")); + } + if (info.HelixFiles is { Count: > 0 }) + { + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderSectionTitle($"Files ({info.HelixFiles.Count})"); + foreach (var (name, uri) in info.HelixFiles) + { + if (uri is not null) + { + PanelLayout.RenderPanelLine($" {BrowserUI.FormatLink(uri, name)}"); + } + else + { + PanelLayout.RenderPanelLine($" {Markup.Escape(name)}"); + } + } + } + }, + "[blue]Esc[/] Back"); + while (true) + { + var key = Console.ReadKey(true); + if (PanelLayout.HandleDetailScroll(key)) continue; + if (key.Key == ConsoleKey.Escape) return; + } + } + // ── Test Builds (builds with this failure) ────────────────────── private NavAction RenderTestBuilds(TestBuildsPage page) @@ -1083,6 +1156,7 @@ private NavAction ReadNavKey(BuildDetailPage page) while (true) { var key = Console.ReadKey(true); + if (PanelLayout.HandleDetailScroll(key)) continue; switch (key.Key) { @@ -1186,7 +1260,12 @@ LIMIT 30 } }, "[blue]Esc[/] Back"); - Console.ReadKey(true); + while (true) + { + var key = Console.ReadKey(true); + if (PanelLayout.HandleDetailScroll(key)) continue; + if (key.Key == ConsoleKey.Escape) return; + } } private void ShowAnalysis(BuildDetailPage page) diff --git a/src/Tiger/Commands/DashboardCommand.cs b/src/Tiger/Commands/DashboardCommand.cs index 66a585d..3a3d15e 100644 --- a/src/Tiger/Commands/DashboardCommand.cs +++ b/src/Tiger/Commands/DashboardCommand.cs @@ -192,7 +192,10 @@ void Render() } } }, - "[blue]E[/]rrors toggle [blue]↑/↓[/] Scroll [blue]End[/] Latest [blue]Esc[/] Back"); + PanelLayout.BuildCommandBarString(new List + { + new("Errors toggle", ConsoleKey.E, -2), + }) + " [blue]↑/↓[/] Scroll [blue]End[/] Latest [blue]Esc[/] Back"); } Render(); diff --git a/src/Tiger/Commands/HealthCommand.cs b/src/Tiger/Commands/HealthCommand.cs index 919eeff..8dc287e 100644 --- a/src/Tiger/Commands/HealthCommand.cs +++ b/src/Tiger/Commands/HealthCommand.cs @@ -107,7 +107,12 @@ private static void ShowStatePage(HealthAgentService agent, string repository, s PanelLayout.RenderPanelLine("[dim]No state summary available yet.[/]"); } }, - "[blue]R[/]e-run [blue]G[/]ist [blue]V[/]iew runs [blue]Esc[/] Back"); + PanelLayout.BuildCommandBarString(new List + { + new("Re-run", ConsoleKey.R, -2), + new("Gist", ConsoleKey.G, -3), + new("View runs", ConsoleKey.V, -4), + })); var key = Console.ReadKey(true); switch (key.Key) diff --git a/src/Tiger/Commands/PanelLayout.cs b/src/Tiger/Commands/PanelLayout.cs index 433795f..546ee78 100644 --- a/src/Tiger/Commands/PanelLayout.cs +++ b/src/Tiger/Commands/PanelLayout.cs @@ -45,9 +45,16 @@ public static class PanelLayout /// /// Renders a single line inside the panel with vertical borders. + /// When called during RenderDetailPanel's content capture phase, lines are buffered instead. /// public static void RenderPanelLine(string markupContent) { + if (_captureTarget is not null) + { + _captureTarget.Add(markupContent); + return; + } + AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/] "); AnsiConsole.Markup(markupContent); @@ -59,9 +66,16 @@ public static void RenderPanelLine(string markupContent) /// /// Renders an empty line inside the panel borders. + /// When called during RenderDetailPanel's content capture phase, an empty line is buffered. /// public static void RenderEmptyLine() { + if (_captureTarget is not null) + { + _captureTarget.Add(""); + return; + } + var width = Console.WindowWidth - 2; AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/]"); Console.Write(new string(' ', width)); @@ -523,32 +537,166 @@ public static int SelectInPanel(string[] breadcrumbs, string? context, List - /// Renders a detail view inside a panel frame. + /// Renders a detail view inside a panel frame with scrollable content. + /// Content is captured and paginated to fit the terminal. Callers should use + /// in their key loop for scroll support. /// public static void RenderDetailPanel(string[] breadcrumbs, string? context, Action renderContent, string hotkeys) + { + // Capture content lines + var contentLines = new List(); + _captureTarget = contentLines; + try + { + renderContent(); + } + finally + { + _captureTarget = null; + } + + // Store for scroll support + _lastDetailBreadcrumbs = breadcrumbs; + _lastDetailContext = context; + _lastDetailHotkeys = hotkeys; + _lastDetailLines = contentLines; + _lastDetailScrollOffset = 0; + + RenderDetailPanelFrame(breadcrumbs, context, contentLines, 0, hotkeys); + } + + /// + /// Handles scroll keys (Up/Down/PageUp/PageDown) for the last rendered detail panel. + /// Returns true if the key was handled (scrolled), false if the caller should process it. + /// + public static bool HandleDetailScroll(ConsoleKeyInfo key) + { + if (_lastDetailLines is null || _lastDetailLines.Count == 0) + { + return false; + } + + var availableHeight = GetDetailAvailableHeight(_lastDetailContext); + if (_lastDetailLines.Count <= availableHeight) + { + return false; // No scrolling needed + } + + var maxOffset = Math.Max(0, _lastDetailLines.Count - availableHeight); + var oldOffset = _lastDetailScrollOffset; + + switch (key.Key) + { + case ConsoleKey.UpArrow: + _lastDetailScrollOffset = Math.Max(0, _lastDetailScrollOffset - 1); + break; + case ConsoleKey.DownArrow: + _lastDetailScrollOffset = Math.Min(maxOffset, _lastDetailScrollOffset + 1); + break; + case ConsoleKey.PageUp: + _lastDetailScrollOffset = Math.Max(0, _lastDetailScrollOffset - 10); + break; + case ConsoleKey.PageDown: + _lastDetailScrollOffset = Math.Min(maxOffset, _lastDetailScrollOffset + 10); + break; + default: + return false; + } + + if (_lastDetailScrollOffset != oldOffset) + { + RenderDetailPanelFrame(_lastDetailBreadcrumbs!, _lastDetailContext, _lastDetailLines, _lastDetailScrollOffset, _lastDetailHotkeys!); + } + + return true; + } + + private static string[]? _lastDetailBreadcrumbs; + private static string? _lastDetailContext; + private static string? _lastDetailHotkeys; + private static List? _lastDetailLines; + private static int _lastDetailScrollOffset; + + private static int GetDetailAvailableHeight(string? context) + { + // Header: top border(1) + breadcrumb(1) + context?(0-1) + separator(1) + // Footer: separator(1) + hotkeys(1) + bottom border(1) + var headerRows = 3 + (context is not null ? 1 : 0); + var footerRows = 3; + return Math.Max(5, Console.WindowHeight - headerRows - footerRows); + } + + private static void RenderDetailPanelFrame(string[] breadcrumbs, string? context, List contentLines, int scrollOffset, string hotkeys) { AnsiConsole.Clear(); Console.CursorVisible = false; var width = Console.WindowWidth - 2; + // Header AnsiConsole.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); var crumbText = string.Join($" {Separator} ", breadcrumbs); - RenderPanelLine($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); + RenderPanelLineDirect($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); if (context is not null) { - RenderPanelLine(context); + RenderPanelLineDirect(context); } AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); - renderContent(); + // Content + var availableHeight = GetDetailAvailableHeight(context); + var visibleLines = contentLines.Skip(scrollOffset).Take(availableHeight).ToList(); + foreach (var line in visibleLines) + { + RenderPanelLineDirect(line); + } + + // Pad remaining space + for (var i = visibleLines.Count; i < availableHeight; i++) + { + RenderEmptyLineDirect(); + } + + // Footer AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); - RenderPanelLine(hotkeys); + var scrollHint = contentLines.Count > availableHeight + ? $" [dim]({scrollOffset + 1}-{Math.Min(scrollOffset + availableHeight, contentLines.Count)}/{contentLines.Count} ↑↓)[/]" + : ""; + RenderPanelLineDirect($"{hotkeys}{scrollHint}"); AnsiConsole.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); Console.CursorVisible = true; } + // Capture infrastructure for RenderDetailPanel scrolling + [ThreadStatic] + private static List? _captureTarget; + + /// + /// Renders a single line inside the panel with vertical borders (direct, no capture). + /// + private static void RenderPanelLineDirect(string markupContent) + { + AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/] "); + AnsiConsole.Markup(markupContent); + + var plainLen = Markup.Remove(markupContent).Length; + var padding = Math.Max(0, Console.WindowWidth - 4 - plainLen); + Console.Write(new string(' ', padding)); + AnsiConsole.MarkupLine($" [{BorderStyle}]{Vertical}[/]"); + } + + /// + /// Renders an empty line (direct, no capture). + /// + private static void RenderEmptyLineDirect() + { + var width = Console.WindowWidth - 2; + AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/]"); + Console.Write(new string(' ', width)); + AnsiConsole.MarkupLine($"[{BorderStyle}]{Vertical}[/]"); + } + // ── Text Prompt ───────────────────────────────────────────────── /// diff --git a/src/Tiger/Commands/TestBrowser.cs b/src/Tiger/Commands/TestBrowser.cs index 3ba4eac..8a60a92 100644 --- a/src/Tiger/Commands/TestBrowser.cs +++ b/src/Tiger/Commands/TestBrowser.cs @@ -46,7 +46,11 @@ public void Browse() ["Tests"], context, () => PanelLayout.RenderPanelLine(emptyMsg), - "[blue]E[/]dit filter [blue]F[/]ilter menu [blue]Esc[/] Back"); + PanelLayout.BuildCommandBarString(new List + { + new("Edit filter", ConsoleKey.E, -5), + new("Filter menu", ConsoleKey.F, -2), + })); var emptyKey = Console.ReadKey(true); if (emptyKey.Key == ConsoleKey.E) { EditFilter(); continue; } @@ -112,6 +116,10 @@ private void ShowTestDetail(TestRow test) new("Builds with failure", ConsoleKey.B, -2), new("Agent task", ConsoleKey.A, -3), }; + if (info.HelixJobName is not null) + { + commands.Add(new("Helix", ConsoleKey.H, -4)); + } PanelLayout.RenderDetailPanel( ["Tests", Markup.Escape(shortTitle)], @@ -122,6 +130,7 @@ private void ShowTestDetail(TestRow test) while (true) { var key = Console.ReadKey(true); + if (PanelLayout.HandleDetailScroll(key)) continue; if (key.Key == ConsoleKey.Escape) { return; @@ -136,10 +145,60 @@ private void ShowTestDetail(TestRow test) BrowserUI.CreateAgentTask(_db, info); break; // re-render detail after returning } + if (key.Key == ConsoleKey.H && info.HelixJobName is not null) + { + ShowHelixWorkItemDetail(info); + break; // re-render detail after returning + } } } } + private static void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) + { + PanelLayout.RenderDetailPanel( + ["Tests", "Helix Work Item"], + null, + () => + { + if (info.IsHelixDeadletter) + { + PanelLayout.RenderPanelLine("[bold red on yellow] ⚠ HELIX DEAD LETTER — Infrastructure failure [/]"); + PanelLayout.RenderEmptyLine(); + } + PanelLayout.RenderField("Job", Markup.Escape(info.HelixJobName!)); + if (info.HelixWorkItemName is not null) + { + PanelLayout.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); + var url = HelixClient.GetConsoleUrl(info.HelixJobName!, info.HelixWorkItemName); + PanelLayout.RenderField("Console", BrowserUI.FormatLink(url, "Console Log")); + } + if (info.HelixFiles is { Count: > 0 }) + { + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderSectionTitle($"Files ({info.HelixFiles.Count})"); + foreach (var (name, uri) in info.HelixFiles) + { + if (uri is not null) + { + PanelLayout.RenderPanelLine($" {BrowserUI.FormatLink(uri, name)}"); + } + else + { + PanelLayout.RenderPanelLine($" {Markup.Escape(name)}"); + } + } + } + }, + "[blue]Esc[/] Back"); + while (true) + { + var key = Console.ReadKey(true); + if (PanelLayout.HandleDetailScroll(key)) continue; + if (key.Key == ConsoleKey.Escape) return; + } + } + private void ShowTestBuilds(TestRow test) { var shortTitle = test.TestName.Length > 60 ? test.TestName[..57] + "..." : test.TestName; @@ -273,76 +332,86 @@ LIMIT 30 private void EditFilter() { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Edit Filter[/]"); - AnsiConsole.MarkupLine("[dim]Syntax: test:VALUE repo:VALUE def:VALUE pr:NUMBER[/]"); - AnsiConsole.MarkupLine("[dim]Examples: test:Serialization repo:roslyn def:*-CI[/]"); - AnsiConsole.MarkupLine("[dim]Append ! for exact match. Press Esc to cancel[/]"); - AnsiConsole.WriteLine(); - if (_filter.IsActive) - AnsiConsole.MarkupLine($"[dim]Current: {Markup.Escape(_filter.ToString())}[/]"); - AnsiConsole.Markup("[blue]> [/]"); - - var buffer = new System.Text.StringBuilder(); - while (true) + var currentValue = _filter.IsActive ? _filter.ToString() : null; + var result = PanelLayout.PromptInPanel( + ["Tests", "Edit Filter"], + "Enter filter expression (e.g. test:Serialization repo:roslyn def:*-CI)", + currentValue); + + if (result is not null) { - var key = Console.ReadKey(true); - if (key.Key == ConsoleKey.Escape) return; - if (key.Key == ConsoleKey.Enter) - { - AnsiConsole.WriteLine(); - var input = buffer.ToString().Trim(); - if (!string.IsNullOrEmpty(input)) - _filter.ParseExpression(input); - SaveFilter(); - return; - } - if (key.Key == ConsoleKey.Backspace) - { - if (buffer.Length > 0) { buffer.Remove(buffer.Length - 1, 1); Console.Write("\b \b"); } - continue; - } - if (key.KeyChar >= 32) { buffer.Append(key.KeyChar); Console.Write(key.KeyChar); } + _filter.ParseExpression(result); + SaveFilter(); } } private void ShowFilterMenu() { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Set Filter[/]"); - AnsiConsole.MarkupLine($"[dim]Current: {Markup.Escape(_filter.ToString())}[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine(" [blue]N[/]ame filter"); - AnsiConsole.MarkupLine(" [blue]R[/]epository"); - AnsiConsole.MarkupLine(" [blue]D[/]efinition"); - AnsiConsole.MarkupLine(" [blue]K[/]ind (pr, ci)"); - AnsiConsole.MarkupLine(" [blue]P[/]R number"); - AnsiConsole.MarkupLine(" [blue]C[/]lear all filters"); - AnsiConsole.MarkupLine(" [blue]Esc[/] Cancel"); - - var key = Console.ReadKey(true); - switch (key.Key) + while (true) { - case ConsoleKey.N: - _filter.TestNamePattern = BrowserUI.PromptPattern("Test name pattern (e.g. Serialization, *EditAndContinue*):"); - break; - case ConsoleKey.R: - _filter.RepoPattern = BrowserUI.PromptPattern("Repository pattern (e.g. roslyn, dotnet/*):"); - break; - case ConsoleKey.D: - _filter.DefinitionPattern = BrowserUI.PromptPattern("Definition pattern (e.g. ci, roslyn-CI*):"); - break; - case ConsoleKey.K: - _filter.KindPattern = BrowserUI.PromptKindFilter(); - break; - case ConsoleKey.P: - _filter.PrNumber = PromptPrNumber(); - break; - case ConsoleKey.C: - _filter.Clear(); - break; + var commands = new List + { + new("Name", ConsoleKey.N, 1), + new("Repository", ConsoleKey.R, 2), + new("Definition", ConsoleKey.D, 3), + new("Kind", ConsoleKey.K, 4), + new("PR number", ConsoleKey.P, 5), + new("Clear", ConsoleKey.C, 6), + }; + + PanelLayout.RenderDetailPanel( + ["Tests", "Filter"], + null, + () => + { + PanelLayout.RenderPanelLine("Filter test failures by name, repository, definition, kind, etc."); + PanelLayout.RenderEmptyLine(); + + if (_filter.IsActive) + { + PanelLayout.RenderPanelLine($"[bold]Current filter:[/] {Markup.Escape(_filter.ToString())}"); + } + else + { + PanelLayout.RenderPanelLine("[dim]No filter active[/]"); + } + + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderPanelLine("[dim]Syntax: substring match by default, * for wildcards, ! suffix for exact[/]"); + }, + PanelLayout.BuildCommandBarString(commands)); + + var key = Console.ReadKey(true); + switch (key.Key) + { + case ConsoleKey.N: + _filter.TestNamePattern = PanelLayout.PromptInPanel(["Tests", "Filter"], "Test name pattern (e.g. Serialization, *EditAndContinue*)"); + SaveFilter(); + continue; + case ConsoleKey.R: + _filter.RepoPattern = PanelLayout.PromptInPanel(["Tests", "Filter"], "Repository pattern (e.g. roslyn, dotnet/*)"); + SaveFilter(); + continue; + case ConsoleKey.D: + _filter.DefinitionPattern = PanelLayout.PromptInPanel(["Tests", "Filter"], "Definition pattern (e.g. ci, roslyn-CI*)"); + SaveFilter(); + continue; + case ConsoleKey.K: + _filter.KindPattern = BrowserUI.PromptKindFilter(); + SaveFilter(); + continue; + case ConsoleKey.P: + _filter.PrNumber = PromptPrNumber(); + SaveFilter(); + continue; + case ConsoleKey.C: + _filter.Clear(); + SaveFilter(); + continue; + default: + return; + } } - SaveFilter(); } /// @@ -350,8 +419,7 @@ private void ShowFilterMenu() /// private static int? PromptPrNumber() { - AnsiConsole.WriteLine(); - var raw = BrowserUI.PromptPattern("PR number (e.g. 12345):"); + var raw = PanelLayout.PromptInPanel(["Tests", "Filter"], "PR number (e.g. 12345)"); if (raw is null) { return null; @@ -361,29 +429,31 @@ private void ShowFilterMenu() private static void ShowFilterHelp() { - AnsiConsole.Clear(); - AnsiConsole.MarkupLine("[bold underline]Test Filter Help[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Quick filter (E):[/]"); - AnsiConsole.MarkupLine(" Type an expression like: [blue]test:Serialization repo:roslyn[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Matching (default: contains / LIKE):[/]"); - AnsiConsole.MarkupLine(" [dim]Serial → matches tests containing 'Serial'[/]"); - AnsiConsole.MarkupLine(" [dim]*EditAndContinue* → matches tests with 'EditAndContinue'[/]"); - AnsiConsole.MarkupLine(" [dim]roslyn → matches repos containing 'roslyn'[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Exact match (append !):[/]"); - AnsiConsole.MarkupLine(" [dim]dotnet/roslyn! → matches exactly 'dotnet/roslyn'[/]"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Filter prefixes:[/]"); - AnsiConsole.MarkupLine(" [blue]test:[/] Test name"); - AnsiConsole.MarkupLine(" [blue]repo:[/] Repository name"); - AnsiConsole.MarkupLine(" [blue]def:[/] Definition/pipeline name"); - AnsiConsole.MarkupLine(" [blue]kind:[/] Build kind (pr, ci)"); - AnsiConsole.MarkupLine(" [blue]pr:[/] PR number (e.g. 12345)"); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine("[bold]Multiple filters combine with AND.[/]"); - AnsiConsole.MarkupLine("[dim]Press any key to continue...[/]"); + PanelLayout.RenderDetailPanel( + ["Tests", "Filter Help"], + null, + () => + { + PanelLayout.RenderPanelLine("[bold]Quick filter (E):[/]"); + PanelLayout.RenderPanelLine(" Type an expression like: [blue]test:Serialization repo:roslyn[/]"); + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderPanelLine("[bold]Matching (default: contains / LIKE):[/]"); + PanelLayout.RenderPanelLine(" [dim]Serial → matches tests containing 'Serial'[/]"); + PanelLayout.RenderPanelLine(" [dim]*EditAndContinue* → matches tests with 'EditAndContinue'[/]"); + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderPanelLine("[bold]Exact match (append !):[/]"); + PanelLayout.RenderPanelLine(" [dim]dotnet/roslyn! → matches exactly 'dotnet/roslyn'[/]"); + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderPanelLine("[bold]Filter prefixes:[/]"); + PanelLayout.RenderPanelLine(" [blue]test:[/] Test name"); + PanelLayout.RenderPanelLine(" [blue]repo:[/] Repository name"); + PanelLayout.RenderPanelLine(" [blue]def:[/] Definition/pipeline name"); + PanelLayout.RenderPanelLine(" [blue]kind:[/] Build kind (pr, ci)"); + PanelLayout.RenderPanelLine(" [blue]pr:[/] PR number"); + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderPanelLine("[bold]Multiple filters combine with AND.[/]"); + }, + "[blue]Esc[/] Back"); Console.ReadKey(true); } From d49514e7b23f2386c319814619a5e8627b66dd6c Mon Sep 17 00:00:00 2001 From: Jared Parsons Date: Wed, 10 Jun 2026 08:09:19 -0700 Subject: [PATCH 04/10] Fix build display: ASCII symbols, helix format, scroll redraw - Replace all Unicode symbols with ASCII equivalents (they rendered as ? in Windows Terminal): arrows, checkmarks, bullets, etc. - Helix work items in build detail now show: X - Fix scroll redraw bug: use Console.Clear() + SetCursorPosition(0,0) instead of AnsiConsole.Clear() which didn't properly reset - Truncate long lines in RenderPanelLineDirect to prevent wrapping that pushes the frame off-screen Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tiger/Commands/AgentBrowser.cs | 14 +++---- src/Tiger/Commands/AnalysisBrowser.cs | 10 ++--- src/Tiger/Commands/BrowserUI.cs | 30 ++++++------- src/Tiger/Commands/BuildBrowser.cs | 58 ++++++++++++++++++-------- src/Tiger/Commands/DashboardCommand.cs | 2 +- src/Tiger/Commands/PanelLayout.cs | 34 ++++++++++----- src/Tiger/Commands/TestBrowser.cs | 10 ++--- 7 files changed, 98 insertions(+), 60 deletions(-) diff --git a/src/Tiger/Commands/AgentBrowser.cs b/src/Tiger/Commands/AgentBrowser.cs index 28af278..2b062ae 100644 --- a/src/Tiger/Commands/AgentBrowser.cs +++ b/src/Tiger/Commands/AgentBrowser.cs @@ -75,7 +75,7 @@ public void Browse() var prInfo = task.PullRequestNumber is not null ? $" PR #{task.PullRequestNumber}" : ""; - var tigerMark = isTracked ? " [yellow]★[/]" : ""; + var tigerMark = isTracked ? " [yellow]*[/]" : ""; items.Add($"{stateIcon} {Markup.Escape(name)}{prInfo}{tigerMark} [dim]{Markup.Escape(repo)}[/]"); } @@ -86,7 +86,7 @@ public void Browse() var selected = PanelLayout.SelectInPanel( ["Agents"], - $"[dim]{tasks.Count} task(s)[/] [yellow]★[/] = submitted from Tiger", + $"[dim]{tasks.Count} task(s)[/] [yellow]*[/] = submitted from Tiger", items, commands); @@ -306,11 +306,11 @@ private static void ShowLogs(AgentTaskInfo task) private static string FormatState(string? state) => state switch { - "completed" => "[green]✓ completed[/]", - "in_progress" => "[blue]● in progress[/]", - "cancelled" => "[dim]✕ cancelled[/]", - "waiting" => "[yellow]◌ waiting[/]", - "queued" => "[yellow]◌ queued[/]", + "completed" => "[green]+ completed[/]", + "in_progress" => "[blue]> in progress[/]", + "cancelled" => "[dim]X cancelled[/]", + "waiting" => "[yellow]- waiting[/]", + "queued" => "[yellow]- queued[/]", _ => Markup.Escape(state ?? "unknown"), }; diff --git a/src/Tiger/Commands/AnalysisBrowser.cs b/src/Tiger/Commands/AnalysisBrowser.cs index 96742a3..8194845 100644 --- a/src/Tiger/Commands/AnalysisBrowser.cs +++ b/src/Tiger/Commands/AnalysisBrowser.cs @@ -41,11 +41,11 @@ public void Browse() { var statusIcon = a.Status switch { - "complete" => "[green]✓[/]", - "running" => "[yellow]⟳[/]", - "pending" => "[dim]…[/]", - "skipped" => "[blue]⊘[/]", - "failed" => "[red]✗[/]", + "complete" => "[green]+[/]", + "running" => "[yellow]~[/]", + "pending" => "[dim]...[/]", + "skipped" => "[blue]-[/]", + "failed" => "[red]X[/]", _ => "[dim]?[/]", }; var category = a.Category is not null ? $"[dim]({Markup.Escape(a.Category)})[/]" : ""; diff --git a/src/Tiger/Commands/BrowserUI.cs b/src/Tiger/Commands/BrowserUI.cs index 28b5c3e..3a8b63a 100644 --- a/src/Tiger/Commands/BrowserUI.cs +++ b/src/Tiger/Commands/BrowserUI.cs @@ -66,7 +66,7 @@ public static int SelectWithEscape(string title, List items, int pageSiz Console.Write(new string(' ', Console.WindowWidth)); Console.SetCursorPosition(0, Console.CursorTop); - var builtIn = "[blue]↑↓[/] Navigate [blue]Enter[/] Select [blue]Esc[/] Back"; + var builtIn = "[blue]Up/Dn[/] Navigate [blue]Enter[/] Select [blue]Esc[/] Back"; var footer = hotkeys is not null ? $" {hotkeys} {builtIn}" : $" {builtIn}"; @@ -191,21 +191,21 @@ public static string FormatTime(string? isoTime) public static string FormatResult(string? result) => result switch { - "succeeded" => "[green]✓ succeeded[/]", - "failed" => "[red]✗ failed[/]", - "partiallySucceeded" => "[yellow]⚠ partial[/]", + "succeeded" => "[green]+ succeeded[/]", + "failed" => "[red]X failed[/]", + "partiallySucceeded" => "[yellow]! partial[/]", "canceled" => "[dim]canceled[/]", - null => "[dim]—[/]", + null => "[dim]-[/]", _ => result, }; public static string FormatResultIcon(string? result) => result switch { - "succeeded" => "[green]✓[/]", - "failed" => "[red]✗[/]", - "partiallySucceeded" => "[yellow]⚠[/]", - "canceled" => "[dim]⊘[/]", - _ => "[dim]—[/]", + "succeeded" => "[green]+[/]", + "failed" => "[red]X[/]", + "partiallySucceeded" => "[yellow]![/]", + "canceled" => "[dim]-[/]", + _ => "[dim]-[/]", }; /// @@ -217,7 +217,7 @@ public static string FormatBuildChoice(int buildId, string definitionName, strin var icon = FormatResultIcon(result); var pr = prNumber is not null ? $" PR#{prNumber}" : ""; var time = FormatTime(finishTime); - var pendingIcon = pending ? " ⏳" : ""; + var pendingIcon = pending ? " [dim]...[/]" : ""; return $"{icon} {buildId} {Markup.Escape(definitionName)} {time}{pr}{pendingIcon}"; } @@ -229,7 +229,7 @@ public static void RenderTestDetail(TestDetailInfo info) // Deadletter banner if (info.IsHelixDeadletter) { - AnsiConsole.MarkupLine("[bold red on yellow] ⚠ HELIX DEAD LETTER — Infrastructure failure, not a real test failure [/]"); + AnsiConsole.MarkupLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure, not a real test failure [/]"); AnsiConsole.WriteLine(); } @@ -281,7 +281,7 @@ public static void RenderTestDetail(TestDetailInfo info) { if (info.IsHelixDeadletter) { - AnsiConsole.MarkupLine(" [bold red]⚠ DEAD LETTER[/]"); + AnsiConsole.MarkupLine(" [bold red]!! DEAD LETTER[/]"); } AnsiConsole.MarkupLine($" [bold]Job:[/] {Markup.Escape(info.HelixJobName)}"); if (info.HelixWorkItemName is not null) @@ -321,7 +321,7 @@ public static void RenderTestDetailInPanel(TestDetailInfo info) { if (info.IsHelixDeadletter) { - PanelLayout.RenderPanelLine("[bold red on yellow] ⚠ HELIX DEAD LETTER — Infrastructure failure, not a real test failure [/]"); + PanelLayout.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure, not a real test failure [/]"); PanelLayout.RenderEmptyLine(); } @@ -375,7 +375,7 @@ public static void RenderTestDetailInPanel(TestDetailInfo info) { if (info.IsHelixDeadletter) { - PanelLayout.RenderPanelLine(" [bold red]⚠ DEAD LETTER[/]"); + PanelLayout.RenderPanelLine(" [bold red]!! DEAD LETTER[/]"); } PanelLayout.RenderField("Job", Markup.Escape(info.HelixJobName)); if (info.HelixWorkItemName is not null) diff --git a/src/Tiger/Commands/BuildBrowser.cs b/src/Tiger/Commands/BuildBrowser.cs index 4a53022..14d352f 100644 --- a/src/Tiger/Commands/BuildBrowser.cs +++ b/src/Tiger/Commands/BuildBrowser.cs @@ -161,7 +161,7 @@ private NavAction RenderBuildList() { var resultIcon = b.Result switch { - "succeeded" => "[green]✓[/]", + "succeeded" => "[green]+[/]", "failed" => "[red]X[/]", "partiallySucceeded" => "[yellow]![/]", "canceled" => "[dim]-[/]", @@ -450,12 +450,12 @@ private static void ShowFilterHelp() PanelLayout.RenderPanelLine(" Type an expression like: [blue]repo:roslyn def:ci[/]"); PanelLayout.RenderEmptyLine(); PanelLayout.RenderPanelLine("[bold]Matching (default: contains / LIKE):[/]"); - PanelLayout.RenderPanelLine(" [dim]ros → matches 'dotnet/roslyn', 'roslyn-CI', etc.[/]"); - PanelLayout.RenderPanelLine(" [dim]dotnet/* → matches 'dotnet/roslyn', 'dotnet/runtime'[/]"); - PanelLayout.RenderPanelLine(" [dim]*-CI → matches definition names ending with '-CI'[/]"); + PanelLayout.RenderPanelLine(" [dim]ros - matches 'dotnet/roslyn', 'roslyn-CI', etc.[/]"); + PanelLayout.RenderPanelLine(" [dim]dotnet/* - matches 'dotnet/roslyn', 'dotnet/runtime'[/]"); + PanelLayout.RenderPanelLine(" [dim]*-CI - matches definition names ending with '-CI'[/]"); PanelLayout.RenderEmptyLine(); PanelLayout.RenderPanelLine("[bold]Exact match (append !):[/]"); - PanelLayout.RenderPanelLine(" [dim]dotnet/roslyn! → matches exactly 'dotnet/roslyn'[/]"); + PanelLayout.RenderPanelLine(" [dim]dotnet/roslyn! - matches exactly 'dotnet/roslyn'[/]"); PanelLayout.RenderEmptyLine(); PanelLayout.RenderPanelLine("[bold]Filter prefixes:[/]"); PanelLayout.RenderPanelLine(" [blue]repo:[/] Repository name"); @@ -538,7 +538,7 @@ string TaskIcon(string taskType) } return t.Status switch { - "complete" => "[green]✓[/]", + "complete" => "[green]+[/]", "running" => "[blue]...[/]", "failed" => $"[yellow]X retry {t.Attempts}/5[/]", "abandoned" => "[red]abandoned[/]", @@ -735,27 +735,51 @@ LIMIT 50 } } - // Helix work items count - var helixCount = _db.WithCommand(cmd => + // Helix work items + var helixItems = _db.WithCommand(cmd => { cmd.CommandText = """ - SELECT COUNT(DISTINCT hw.job_name || '/' || hw.work_item_name) + SELECT DISTINCT tr.helix_job_name, tr.helix_work_item_name, hw.state, hw.exit_code, hw.is_deadletter FROM test_results tr JOIN test_runs trn ON tr.organization = trn.organization AND tr.project = trn.project AND tr.run_id = trn.run_id - JOIN helix_work_items hw ON tr.helix_job_name = hw.job_name + LEFT JOIN helix_work_items hw ON tr.helix_job_name = hw.job_name AND tr.helix_work_item_name = hw.work_item_name WHERE trn.organization = @org AND trn.project = @proj AND trn.build_id = @buildId AND tr.outcome = 'Failed' + AND tr.helix_job_name IS NOT NULL + ORDER BY tr.helix_job_name, tr.helix_work_item_name + LIMIT 15 """; cmd.Parameters.AddWithValue("@org", page.Org); cmd.Parameters.AddWithValue("@proj", page.Project); cmd.Parameters.AddWithValue("@buildId", page.BuildId); - return Convert.ToInt32(cmd.ExecuteScalar()); + + var items = new List<(string Job, string Wi, string? State, int? ExitCode, bool IsDeadletter)>(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + items.Add(( + reader.GetString(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3), + !reader.IsDBNull(4) && reader.GetInt32(4) != 0)); + } + return items; }); - if (helixCount > 0) + + if (helixItems.Count > 0) { - PanelLayout.RenderPanelLine($" [bold]Helix Work Items:[/] {helixCount}"); + PanelLayout.RenderEmptyLine(); + PanelLayout.RenderSectionTitle($"Helix Work Items ({helixItems.Count})"); + foreach (var (job, wi, state, exitCode, isDeadletter) in helixItems) + { + var exitInfo = exitCode is not null ? $" exit {exitCode}" : ""; + var extra = isDeadletter ? " [red]deadletter[/]" : ""; + var color = (exitCode ?? 1) == 0 ? "green" : "red"; + PanelLayout.RenderPanelLine($" [{color}]X[/] {Markup.Escape(wi)} [dim]{Markup.Escape(job)}[/]{exitInfo}{extra}"); + } } }, PanelLayout.BuildCommandBarString(detailCommands)); @@ -803,7 +827,7 @@ FROM test_results tr // Build grouped display: run name headers are non-selectable, tests are selectable var choices = new List(); - var selectableIndices = new List(); // maps choice index → tests list index + var selectableIndices = new List(); // maps choice index -> tests list index var skipIndices = new HashSet(); var grouped = tests.Select((t, i) => (t, i)).GroupBy(x => x.t.RunName); foreach (var group in grouped) @@ -903,7 +927,7 @@ private static void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) { if (info.IsHelixDeadletter) { - PanelLayout.RenderPanelLine("[bold red on yellow] ⚠ HELIX DEAD LETTER — Infrastructure failure [/]"); + PanelLayout.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure [/]"); PanelLayout.RenderEmptyLine(); } PanelLayout.RenderField("Job", Markup.Escape(info.HelixJobName!)); @@ -996,7 +1020,7 @@ LIMIT 30 { var resultIcon = b.Result switch { - "succeeded" => "[green]✓[/]", + "succeeded" => "[green]+[/]", "failed" => "[red]X[/]", "partiallySucceeded" => "[yellow]![/]", _ => "[dim]-[/]", @@ -1247,7 +1271,7 @@ LIMIT 30 { if (isDeadletter) { - PanelLayout.RenderPanelLine($" [bold red]⚠ DEAD LETTER[/] [bold]{Markup.Escape(wi)}[/]"); + PanelLayout.RenderPanelLine($" [bold red]!! DEAD LETTER[/] [bold]{Markup.Escape(wi)}[/]"); } else { diff --git a/src/Tiger/Commands/DashboardCommand.cs b/src/Tiger/Commands/DashboardCommand.cs index 3a3d15e..7b677dd 100644 --- a/src/Tiger/Commands/DashboardCommand.cs +++ b/src/Tiger/Commands/DashboardCommand.cs @@ -195,7 +195,7 @@ void Render() PanelLayout.BuildCommandBarString(new List { new("Errors toggle", ConsoleKey.E, -2), - }) + " [blue]↑/↓[/] Scroll [blue]End[/] Latest [blue]Esc[/] Back"); + }) + " [blue]Up/Dn[/] Scroll [blue]End[/] Latest [blue]Esc[/] Back"); } Render(); diff --git a/src/Tiger/Commands/PanelLayout.cs b/src/Tiger/Commands/PanelLayout.cs index 546ee78..6e4f98f 100644 --- a/src/Tiger/Commands/PanelLayout.cs +++ b/src/Tiger/Commands/PanelLayout.cs @@ -34,7 +34,7 @@ public static class PanelLayout private const char Vertical = '║'; private const char MiddleLeft = '╠'; private const char MiddleRight = '╣'; - private const char Separator = '▸'; + private const char Separator = '>'; private const string BorderStyle = "dim"; @@ -163,7 +163,7 @@ private static string BuildCommandBarMarkup(List commands, int f { if (commands.Count == 0) { - return "[dim]↑↓ Navigate Enter Select Esc Back[/]"; + return "[dim]Up/Dn Navigate Enter Select Esc Back[/]"; } var parts = new List(); @@ -180,7 +180,7 @@ private static string BuildCommandBarMarkup(List commands, int f } var barContent = string.Join(" ", parts); var tabHint = barFocused - ? " [dim]←→ Move Enter Execute Tab Content[/]" + ? " [dim]<-> Move Enter Execute Tab Content[/]" : " [dim]Tab Commands[/]"; return $"{barContent}{tabHint}"; } @@ -210,7 +210,7 @@ private static void RenderCommandBarAt(int row, List commands, i private static void RenderListLineAt(int row, string markupContent, bool isSelected) { Console.SetCursorPosition(0, row); - var prefix = isSelected ? "[blue]▸[/] " : " "; + var prefix = isSelected ? "[blue]>[/] " : " "; var line = $"{prefix}{markupContent}"; AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/] "); AnsiConsole.Markup(line); @@ -374,7 +374,7 @@ public static int SelectInPanel(string[] breadcrumbs, string? context, List[/] {items[idx]}"); } else { @@ -628,7 +628,8 @@ private static int GetDetailAvailableHeight(string? context) private static void RenderDetailPanelFrame(string[] breadcrumbs, string? context, List contentLines, int scrollOffset, string hotkeys) { - AnsiConsole.Clear(); + Console.Clear(); + Console.SetCursorPosition(0, 0); Console.CursorVisible = false; var width = Console.WindowWidth - 2; @@ -661,7 +662,7 @@ private static void RenderDetailPanelFrame(string[] breadcrumbs, string? context // Footer AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); var scrollHint = contentLines.Count > availableHeight - ? $" [dim]({scrollOffset + 1}-{Math.Min(scrollOffset + availableHeight, contentLines.Count)}/{contentLines.Count} ↑↓)[/]" + ? $" [dim]({scrollOffset + 1}-{Math.Min(scrollOffset + availableHeight, contentLines.Count)}/{contentLines.Count} Up/Dn)[/]" : ""; RenderPanelLineDirect($"{hotkeys}{scrollHint}"); AnsiConsole.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); @@ -674,14 +675,27 @@ private static void RenderDetailPanelFrame(string[] breadcrumbs, string? context /// /// Renders a single line inside the panel with vertical borders (direct, no capture). + /// Truncates content to fit within the panel width. /// private static void RenderPanelLineDirect(string markupContent) { + var maxContentWidth = Console.WindowWidth - 4; // borders + padding + var plainText = Markup.Remove(markupContent); + var displayContent = markupContent; + + // If plain text exceeds available width, truncate + if (plainText.Length > maxContentWidth) + { + // Truncate the plain text and use it directly (loses markup but prevents wrapping) + var truncated = plainText[..(maxContentWidth - 3)] + "..."; + displayContent = Markup.Escape(truncated); + } + AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/] "); - AnsiConsole.Markup(markupContent); + AnsiConsole.Markup(displayContent); - var plainLen = Markup.Remove(markupContent).Length; - var padding = Math.Max(0, Console.WindowWidth - 4 - plainLen); + var plainLen = Markup.Remove(displayContent).Length; + var padding = Math.Max(0, maxContentWidth - plainLen); Console.Write(new string(' ', padding)); AnsiConsole.MarkupLine($" [{BorderStyle}]{Vertical}[/]"); } diff --git a/src/Tiger/Commands/TestBrowser.cs b/src/Tiger/Commands/TestBrowser.cs index 8a60a92..4628e21 100644 --- a/src/Tiger/Commands/TestBrowser.cs +++ b/src/Tiger/Commands/TestBrowser.cs @@ -63,7 +63,7 @@ public void Browse() var choices = tests.Select(t => { var title = t.TestName.Length > 70 ? t.TestName[..67] + "..." : t.TestName; - return $"[red]✗[/] {Markup.Escape(title)} [dim]({t.FailCount} build(s))[/]"; + return $"[red]X[/] {Markup.Escape(title)} [dim]({t.FailCount} build(s))[/]"; }).ToList(); var commands = new List @@ -163,7 +163,7 @@ private static void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) { if (info.IsHelixDeadletter) { - PanelLayout.RenderPanelLine("[bold red on yellow] ⚠ HELIX DEAD LETTER — Infrastructure failure [/]"); + PanelLayout.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure [/]"); PanelLayout.RenderEmptyLine(); } PanelLayout.RenderField("Job", Markup.Escape(info.HelixJobName!)); @@ -438,11 +438,11 @@ private static void ShowFilterHelp() PanelLayout.RenderPanelLine(" Type an expression like: [blue]test:Serialization repo:roslyn[/]"); PanelLayout.RenderEmptyLine(); PanelLayout.RenderPanelLine("[bold]Matching (default: contains / LIKE):[/]"); - PanelLayout.RenderPanelLine(" [dim]Serial → matches tests containing 'Serial'[/]"); - PanelLayout.RenderPanelLine(" [dim]*EditAndContinue* → matches tests with 'EditAndContinue'[/]"); + PanelLayout.RenderPanelLine(" [dim]Serial - matches tests containing 'Serial'[/]"); + PanelLayout.RenderPanelLine(" [dim]*EditAndContinue* - matches tests with 'EditAndContinue'[/]"); PanelLayout.RenderEmptyLine(); PanelLayout.RenderPanelLine("[bold]Exact match (append !):[/]"); - PanelLayout.RenderPanelLine(" [dim]dotnet/roslyn! → matches exactly 'dotnet/roslyn'[/]"); + PanelLayout.RenderPanelLine(" [dim]dotnet/roslyn! - matches exactly 'dotnet/roslyn'[/]"); PanelLayout.RenderEmptyLine(); PanelLayout.RenderPanelLine("[bold]Filter prefixes:[/]"); PanelLayout.RenderPanelLine(" [blue]test:[/] Test name"); From 5c7fc0ad37572c9897246b9597214cd0912cef67 Mon Sep 17 00:00:00 2001 From: Jared Parsons Date: Wed, 10 Jun 2026 08:52:20 -0700 Subject: [PATCH 05/10] Replace static PanelLayout with single PanelRenderer instance class - Delete PanelLayout.cs static facade - Create PanelRenderer.cs backed by IAnsiConsole for full testability - Thread PanelRenderer instance through all browser/command classes - Add PanelRenderer parameter to BrowserUI.RenderTestDetailInPanel - Add 26 PanelRenderer unit tests using Spectre.Console.Testing - All rendering, input, cursor, and clear operations go through IAnsiConsole Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tiger.Tests/PanelRendererTests.cs | 334 +++++++++++ src/Tiger.Tests/Tiger.Tests.csproj | 1 + src/Tiger/Commands/AgentBrowser.cs | 38 +- src/Tiger/Commands/AnalysisBrowser.cs | 10 +- src/Tiger/Commands/BrowserUI.cs | 60 +- src/Tiger/Commands/BuildBrowser.cs | 228 ++++---- src/Tiger/Commands/DashboardCommand.cs | 18 +- src/Tiger/Commands/HealthCommand.cs | 42 +- src/Tiger/Commands/PanelLayout.cs | 775 ------------------------ src/Tiger/Commands/PanelRenderer.cs | 778 +++++++++++++++++++++++++ src/Tiger/Commands/TestBrowser.cs | 122 ++-- 11 files changed, 1385 insertions(+), 1021 deletions(-) create mode 100644 src/Tiger.Tests/PanelRendererTests.cs delete mode 100644 src/Tiger/Commands/PanelLayout.cs create mode 100644 src/Tiger/Commands/PanelRenderer.cs diff --git a/src/Tiger.Tests/PanelRendererTests.cs b/src/Tiger.Tests/PanelRendererTests.cs new file mode 100644 index 0000000..ea42e07 --- /dev/null +++ b/src/Tiger.Tests/PanelRendererTests.cs @@ -0,0 +1,334 @@ +using Spectre.Console; +using Spectre.Console.Testing; +using Tiger.Commands; +using Xunit; + +namespace Tiger.Tests; + +public class PanelRendererTests +{ + private static PanelRenderer CreateRenderer(int width = 80, int height = 24) + { + var console = new TestConsole().Width(width).Height(height); + return new PanelRenderer(console); + } + + // ── Content Capture ───────────────────────────────────────────── + + [Fact] + public void CaptureContent_CapturesAllLines() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + renderer.RenderPanelLine("Line 1"); + renderer.RenderPanelLine("Line 2"); + renderer.RenderEmptyLine(); + renderer.RenderPanelLine("Line 3"); + }); + + Assert.Equal(4, lines.Count); + Assert.Equal("Line 1", lines[0]); + Assert.Equal("Line 2", lines[1]); + Assert.Equal("", lines[2]); + Assert.Equal("Line 3", lines[3]); + } + + [Fact] + public void CaptureContent_SectionTitle_IncludesMarkup() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + renderer.RenderSectionTitle("My Section"); + }); + + Assert.Single(lines); + Assert.Equal("[bold underline]My Section[/]", lines[0]); + } + + [Fact] + public void CaptureContent_Field_IncludesLabelAndValue() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + renderer.RenderField("Status", "running"); + }); + + Assert.Single(lines); + Assert.Equal("[bold]Status:[/] running", lines[0]); + } + + [Fact] + public void CaptureContent_ReturnsLines() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + renderer.RenderPanelLine("stored"); + }); + + Assert.Single(lines); + Assert.Equal("stored", lines[0]); + } + + // ── Layout Calculations ───────────────────────────────────────── + + [Theory] + [InlineData(24, false, 18)] // 24 - 3 header - 3 footer = 18 + [InlineData(24, true, 17)] // 24 - 4 header (with context) - 3 footer = 17 + [InlineData(10, false, 5)] // minimum is 5 + [InlineData(30, false, 24)] // 30 - 3 - 3 = 24 + public void GetDetailAvailableHeight_CalculatesCorrectly(int terminalHeight, bool hasContext, int expected) + { + var renderer = CreateRenderer(width: 80, height: terminalHeight); + Assert.Equal(expected, renderer.GetDetailAvailableHeight(hasContext)); + } + + [Fact] + public void ContentWidth_Is_Width_Minus_4() + { + var renderer = CreateRenderer(width: 100, height: 24); + Assert.Equal(96, renderer.ContentWidth); + } + + [Fact] + public void ContentWidth_HasMinimum40() + { + var renderer = CreateRenderer(width: 30, height: 24); + Assert.Equal(40, renderer.ContentWidth); + } + + // ── Truncation ────────────────────────────────────────────────── + + [Fact] + public void TruncateToFit_ShortContent_Unchanged() + { + var renderer = CreateRenderer(width: 80); + var result = renderer.TruncateToFit("short text"); + Assert.Equal("short text", result); + } + + [Fact] + public void TruncateToFit_LongContent_Truncated() + { + var renderer = CreateRenderer(width: 20); // ContentWidth = 40 (minimum) + var longText = new string('x', 50); + var result = renderer.TruncateToFit(longText); + + var plainResult = Markup.Remove(result); + Assert.True(plainResult.Length <= 40); + Assert.EndsWith("...", plainResult); + } + + [Fact] + public void TruncateToFit_WithMarkup_UsesPlainTextLength() + { + var renderer = CreateRenderer(width: 50); // ContentWidth = 46 + // This markup has short plain text but long markup + var content = "[bold]short[/]"; + var result = renderer.TruncateToFit(content); + // Plain text "short" is 5 chars, fits in 46 + Assert.Equal(content, result); + } + + [Fact] + public void TruncateToFit_LongMarkupContent_TruncatesBasedOnPlainText() + { + var renderer = CreateRenderer(width: 24); // ContentWidth = 40 (minimum) + // Plain text will be 50 chars, exceeds 40 + var longText = $"[red]{new string('y', 50)}[/]"; + var result = renderer.TruncateToFit(longText); + + var plainResult = Markup.Remove(result); + Assert.True(plainResult.Length <= 40); + Assert.EndsWith("...", plainResult); + } + + // ── Hotkey Formatting ─────────────────────────────────────────── + + [Fact] + public void FormatHotkeyLabel_HighlightsFirstMatchingChar() + { + var item = new CommandBarItem("Builds", ConsoleKey.B, 1); + var result = PanelRenderer.FormatHotkeyLabel(item); + Assert.Equal("[blue][[B]][/]uilds", result); + } + + [Fact] + public void FormatHotkeyLabel_CaseInsensitiveMatch() + { + var item = new CommandBarItem("refresh", ConsoleKey.R, 2); + var result = PanelRenderer.FormatHotkeyLabel(item); + Assert.Equal("[blue][[r]][/]efresh", result); + } + + [Fact] + public void FormatHotkeyLabel_MiddleOfWord() + { + var item = new CommandBarItem("Agent task", ConsoleKey.A, 3); + var result = PanelRenderer.FormatHotkeyLabel(item); + Assert.Equal("[blue][[A]][/]gent task", result); + } + + [Fact] + public void FormatHotkeyLabel_NoMatch_ReturnsLabelUnchanged() + { + var item = new CommandBarItem("Builds", ConsoleKey.Z, 1); + var result = PanelRenderer.FormatHotkeyLabel(item); + Assert.Equal("Builds", result); + } + + // ── BuildCommandBarString ─────────────────────────────────────── + + [Fact] + public void BuildCommandBarString_FormatsMultipleCommands() + { + var commands = new List + { + new("Tests", ConsoleKey.T, 1), + new("Jobs", ConsoleKey.J, 2), + }; + var result = PanelRenderer.BuildCommandBarString(commands); + Assert.Contains("[blue][[T]][/]ests", result); + Assert.Contains("[blue][[J]][/]obs", result); + Assert.Contains("[blue]Esc[/] Back", result); + } + + [Fact] + public void BuildCommandBarString_EmptyList_JustEscBack() + { + var commands = new List(); + var result = PanelRenderer.BuildCommandBarString(commands); + Assert.Equal(" [blue]Esc[/] Back", result); + } + + // ── Frame Rendering (integration with TestConsole) ────────────── + + [Fact] + public void RenderDetailFrame_IncludesBreadcrumbs() + { + var console = new TestConsole().Width(80).Height(24); + var renderer = new PanelRenderer(console); + var lines = renderer.CaptureContent(() => + { + renderer.RenderPanelLine("Content here"); + }); + + renderer.RenderDetailFrame(["Builds", "#123"], null, lines, 0, "[blue]Esc[/] Back"); + + var output = console.Output; + Assert.Contains("TIGER", output); + Assert.Contains("Builds", output); + Assert.Contains("#123", output); + Assert.Contains("Content here", output); + } + + [Fact] + public void RenderDetailFrame_ShowsContext() + { + var console = new TestConsole().Width(80).Height(24); + var renderer = new PanelRenderer(console); + var lines = renderer.CaptureContent(() => + { + renderer.RenderPanelLine("data"); + }); + + renderer.RenderDetailFrame(["Tests"], "3 failures", lines, 0, "[blue]Esc[/] Back"); + + var output = console.Output; + Assert.Contains("3 failures", output); + } + + [Fact] + public void RenderDetailFrame_PaginatesContent() + { + var console = new TestConsole().Width(80).Height(12); + // Height 12: available = 12 - 3 header - 3 footer = 6 lines + var renderer = new PanelRenderer(console); + var lines = renderer.CaptureContent(() => + { + for (var i = 0; i < 20; i++) + { + renderer.RenderPanelLine($"Line {i}"); + } + }); + + renderer.RenderDetailFrame(["Test"], null, lines, 0, "[blue]Esc[/] Back"); + + var output = console.Output; + // First 6 lines should be visible + Assert.Contains("Line 0", output); + Assert.Contains("Line 5", output); + // Line 6+ should NOT be visible (paginated away) + Assert.DoesNotContain("Line 6", output); + // Scroll indicator should show + Assert.Contains("1-6/20", output); + } + + [Fact] + public void RenderDetailFrame_ScrollOffset_ShowsLaterContent() + { + var console = new TestConsole().Width(80).Height(12); + var renderer = new PanelRenderer(console); + var lines = renderer.CaptureContent(() => + { + for (var i = 0; i < 20; i++) + { + renderer.RenderPanelLine($"Line {i}"); + } + }); + + renderer.RenderDetailFrame(["Test"], null, lines, 5, "[blue]Esc[/] Back"); + + var output = console.Output; + Assert.DoesNotContain("Line 4", output); + Assert.Contains("Line 5", output); + Assert.Contains("Line 10", output); + Assert.Contains("6-11/20", output); + } + + // ── No Unicode in rendered output ─────────────────────────────── + + [Fact] + public void FormatHotkeyLabel_ProducesOnlyAsciiAndSpectreMarkup() + { + var items = new[] + { + new CommandBarItem("Builds", ConsoleKey.B, 1), + new CommandBarItem("Tests", ConsoleKey.T, 2), + new CommandBarItem("Helix", ConsoleKey.H, 3), + new CommandBarItem("Agent task", ConsoleKey.A, 4), + }; + + foreach (var item in items) + { + var result = PanelRenderer.FormatHotkeyLabel(item); + var plain = Markup.Remove(result); + AssertAsciiOnly(plain, $"FormatHotkeyLabel({item.Label})"); + } + } + + [Fact] + public void BuildCommandBarString_ProducesAsciiPlainText() + { + var commands = new List + { + new("Builds", ConsoleKey.B, 1), + new("Tests", ConsoleKey.T, 2), + }; + var result = PanelRenderer.BuildCommandBarString(commands); + var plain = Markup.Remove(result); + AssertAsciiOnly(plain, "BuildCommandBarString"); + } + + private static void AssertAsciiOnly(string text, string context) + { + for (var i = 0; i < text.Length; i++) + { + Assert.True(text[i] <= 127, + $"Non-ASCII char U+{(int)text[i]:X4} ('{text[i]}') at position {i} in {context}: \"{text}\""); + } + } +} diff --git a/src/Tiger.Tests/Tiger.Tests.csproj b/src/Tiger.Tests/Tiger.Tests.csproj index b6ae087..80404d5 100644 --- a/src/Tiger.Tests/Tiger.Tests.csproj +++ b/src/Tiger.Tests/Tiger.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Tiger/Commands/AgentBrowser.cs b/src/Tiger/Commands/AgentBrowser.cs index 2b062ae..3582f50 100644 --- a/src/Tiger/Commands/AgentBrowser.cs +++ b/src/Tiger/Commands/AgentBrowser.cs @@ -10,6 +10,8 @@ namespace Tiger.Commands; /// public sealed class AgentBrowser { + private readonly PanelRenderer _ui = PanelRenderer.Create(); + private readonly TigerDatabase _db; public AgentBrowser(TigerDatabase db) @@ -24,10 +26,10 @@ public void Browse() var tasks = LoadTasks(); if (tasks is null) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Agents"], null, - () => PanelLayout.RenderPanelLine("[red]Failed to load agent tasks from gh CLI.[/]"), + () => _ui.RenderPanelLine("[red]Failed to load agent tasks from gh CLI.[/]"), "[blue]Esc[/] Back"); Console.ReadKey(true); return; @@ -35,11 +37,11 @@ public void Browse() if (tasks.Count == 0) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Agents"], null, - () => PanelLayout.RenderPanelLine("[dim]No agent tasks found.[/]"), - PanelLayout.BuildCommandBarString(new List + () => _ui.RenderPanelLine("[dim]No agent tasks found.[/]"), + PanelRenderer.BuildCommandBarString(new List { new("Refresh", ConsoleKey.R, -2), })); @@ -84,7 +86,7 @@ public void Browse() new("Refresh", ConsoleKey.R, -2), }; - var selected = PanelLayout.SelectInPanel( + var selected = _ui.SelectInPanel( ["Agents"], $"[dim]{tasks.Count} task(s)[/] [yellow]*[/] = submitted from Tiger", items, @@ -129,41 +131,41 @@ private void ShowTaskDetail(AgentTaskInfo task, HashSet trackedIds) actions.Add("refresh"); // Use RenderDetailPanel for the header info, then SelectInPanel for menu - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Agents", Markup.Escape(task.Name ?? "unnamed")], $"{FormatState(task.State)} {Markup.Escape(task.Repository ?? "unknown")}", () => { - PanelLayout.RenderField("Name", Markup.Escape(task.Name ?? "unnamed")); - PanelLayout.RenderField("State", FormatState(task.State)); - PanelLayout.RenderField("Repository", Markup.Escape(task.Repository ?? "unknown")); + _ui.RenderField("Name", Markup.Escape(task.Name ?? "unnamed")); + _ui.RenderField("State", FormatState(task.State)); + _ui.RenderField("Repository", Markup.Escape(task.Repository ?? "unknown")); if (task.Id is not null) { - PanelLayout.RenderField("Session", Markup.Escape(task.Id)); + _ui.RenderField("Session", Markup.Escape(task.Id)); } if (task.CreatedAt is not null) { - PanelLayout.RenderField("Created", BrowserUI.FormatTime(task.CreatedAt)); + _ui.RenderField("Created", BrowserUI.FormatTime(task.CreatedAt)); } if (task.UpdatedAt is not null) { - PanelLayout.RenderField("Updated", BrowserUI.FormatTime(task.UpdatedAt)); + _ui.RenderField("Updated", BrowserUI.FormatTime(task.UpdatedAt)); } if (task.PullRequestNumber is not null && task.PullRequestUrl is not null) { - PanelLayout.RenderField("Pull Request", + _ui.RenderField("Pull Request", $"{BrowserUI.FormatLink(task.PullRequestUrl, $"PR #{task.PullRequestNumber}")} ({Markup.Escape(task.PullRequestState ?? "unknown")})"); } else if (task.PullRequestNumber is not null) { - PanelLayout.RenderField("Pull Request", $"#{task.PullRequestNumber}"); + _ui.RenderField("Pull Request", $"#{task.PullRequestNumber}"); } if (isTracked) { - PanelLayout.RenderField("Source", "[yellow]Submitted from Tiger[/]"); + _ui.RenderField("Source", "[yellow]Submitted from Tiger[/]"); } }, - PanelLayout.BuildCommandBarString(new List + PanelRenderer.BuildCommandBarString(new List { new("Open PR", ConsoleKey.O, -2), new("View logs", ConsoleKey.V, -3), @@ -349,3 +351,5 @@ internal sealed class AgentTaskInfo public string? PullRequestState { get; set; } } } + + diff --git a/src/Tiger/Commands/AnalysisBrowser.cs b/src/Tiger/Commands/AnalysisBrowser.cs index 8194845..42f3335 100644 --- a/src/Tiger/Commands/AnalysisBrowser.cs +++ b/src/Tiger/Commands/AnalysisBrowser.cs @@ -8,6 +8,8 @@ namespace Tiger.Commands; /// public sealed class AnalysisBrowser { + private readonly PanelRenderer _ui = PanelRenderer.Create(); + private readonly TigerDatabase _db; private readonly BuildAnalysisService? _analysisService; private readonly AzdoClientFactory _clientFactory; @@ -28,10 +30,10 @@ public void Browse() var analyses = _db.GetRecentAnalyses(50); if (analyses.Count == 0) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Analysis"], null, - () => PanelLayout.RenderPanelLine("[dim]No analyses yet. Failed builds will be analyzed automatically.[/]"), + () => _ui.RenderPanelLine("[dim]No analyses yet. Failed builds will be analyzed automatically.[/]"), "[blue]Esc[/] Back"); Console.ReadKey(true); return; @@ -66,7 +68,7 @@ public void Browse() var commands = new List(); - var selected = PanelLayout.SelectInPanel( + var selected = _ui.SelectInPanel( ["Analysis"], $"[dim]{analyses.Count} analysis result(s)[/]", items, @@ -244,3 +246,5 @@ private static void ShowFullLog(BuildAnalysisInfo analysis) _ => Markup.Escape(status), }; } + + diff --git a/src/Tiger/Commands/BrowserUI.cs b/src/Tiger/Commands/BrowserUI.cs index 3a8b63a..9c65b0d 100644 --- a/src/Tiger/Commands/BrowserUI.cs +++ b/src/Tiger/Commands/BrowserUI.cs @@ -315,87 +315,87 @@ public static void RenderTestDetail(TestDetailInfo info) } /// - /// Renders test detail info using PanelLayout (for use inside RenderDetailPanel content delegates). + /// Renders test detail info using PanelRenderer (for use inside RenderDetailPanel content delegates). /// - public static void RenderTestDetailInPanel(TestDetailInfo info) + public static void RenderTestDetailInPanel(PanelRenderer ui, TestDetailInfo info) { if (info.IsHelixDeadletter) { - PanelLayout.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure, not a real test failure [/]"); - PanelLayout.RenderEmptyLine(); + ui.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure, not a real test failure [/]"); + ui.RenderEmptyLine(); } - PanelLayout.RenderField("Test Name", Markup.Escape(info.TestName)); + ui.RenderField("Test Name", Markup.Escape(info.TestName)); var buildUrl = $"https://dev.azure.com/{Uri.EscapeDataString(info.Org)}/{Uri.EscapeDataString(info.Project)}/_build/results?buildId={info.BuildId}"; - PanelLayout.RenderField("Last Failed Build", FormatLink(buildUrl, $"Build #{info.BuildId}")); - PanelLayout.RenderField("Run", Markup.Escape(info.RunName)); - PanelLayout.RenderField("Failed In", $"{info.BuildCount} build(s)"); - PanelLayout.RenderEmptyLine(); + ui.RenderField("Last Failed Build", FormatLink(buildUrl, $"Build #{info.BuildId}")); + ui.RenderField("Run", Markup.Escape(info.RunName)); + ui.RenderField("Failed In", $"{info.BuildCount} build(s)"); + ui.RenderEmptyLine(); - PanelLayout.RenderSectionTitle("Error"); + ui.RenderSectionTitle("Error"); if (!string.IsNullOrWhiteSpace(info.ErrorMessage)) { var errorLines = info.ErrorMessage.ReplaceLineEndings("\n").Split('\n'); foreach (var line in errorLines.Take(5)) { - PanelLayout.RenderPanelLine($" [red]{Markup.Escape(line)}[/]"); + ui.RenderPanelLine($" [red]{Markup.Escape(line)}[/]"); } if (errorLines.Length > 5) { - PanelLayout.RenderPanelLine($" [dim]... ({errorLines.Length - 5} more lines)[/]"); + ui.RenderPanelLine($" [dim]... ({errorLines.Length - 5} more lines)[/]"); } } else { - PanelLayout.RenderPanelLine(" [dim]No error message available[/]"); + ui.RenderPanelLine(" [dim]No error message available[/]"); } - PanelLayout.RenderEmptyLine(); + ui.RenderEmptyLine(); - PanelLayout.RenderSectionTitle("Stack Trace"); + ui.RenderSectionTitle("Stack Trace"); if (!string.IsNullOrWhiteSpace(info.StackTrace)) { var stackLines = info.StackTrace.ReplaceLineEndings("\n").Split('\n'); foreach (var line in stackLines.Take(10)) { - PanelLayout.RenderPanelLine($" [dim]{Markup.Escape(line)}[/]"); + ui.RenderPanelLine($" [dim]{Markup.Escape(line)}[/]"); } if (stackLines.Length > 10) { - PanelLayout.RenderPanelLine($" [dim]... ({stackLines.Length - 10} more lines)[/]"); + ui.RenderPanelLine($" [dim]... ({stackLines.Length - 10} more lines)[/]"); } } else { - PanelLayout.RenderPanelLine(" [dim]No stack trace available[/]"); + ui.RenderPanelLine(" [dim]No stack trace available[/]"); } - PanelLayout.RenderEmptyLine(); + ui.RenderEmptyLine(); - PanelLayout.RenderSectionTitle("Helix"); + ui.RenderSectionTitle("Helix"); if (info.HelixJobName is not null) { if (info.IsHelixDeadletter) { - PanelLayout.RenderPanelLine(" [bold red]!! DEAD LETTER[/]"); + ui.RenderPanelLine(" [bold red]!! DEAD LETTER[/]"); } - PanelLayout.RenderField("Job", Markup.Escape(info.HelixJobName)); + ui.RenderField("Job", Markup.Escape(info.HelixJobName)); if (info.HelixWorkItemName is not null) { - PanelLayout.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); + ui.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); var consoleUrl = HelixClient.GetConsoleUrl(info.HelixJobName, info.HelixWorkItemName); - PanelLayout.RenderField("Console", FormatLink(consoleUrl, "Console Log")); + ui.RenderField("Console", FormatLink(consoleUrl, "Console Log")); if (info.HelixFiles is { Count: > 0 }) { - PanelLayout.RenderPanelLine($" [bold]Files ({info.HelixFiles.Count}):[/]"); + ui.RenderPanelLine($" [bold]Files ({info.HelixFiles.Count}):[/]"); foreach (var (name, uri) in info.HelixFiles) { if (uri is not null) { - PanelLayout.RenderPanelLine($" {FormatLink(uri, name)}"); + ui.RenderPanelLine($" {FormatLink(uri, name)}"); } else { - PanelLayout.RenderPanelLine($" {Markup.Escape(name)}"); + ui.RenderPanelLine($" {Markup.Escape(name)}"); } } } @@ -403,10 +403,10 @@ public static void RenderTestDetailInPanel(TestDetailInfo info) } else { - PanelLayout.RenderPanelLine(" [dim]No Helix information available[/]"); + ui.RenderPanelLine(" [dim]No Helix information available[/]"); } } - /// + public static (string Pattern, bool IsExact) ToSqlPattern(string input) { if (input.EndsWith('!')) @@ -748,3 +748,5 @@ static void AppendContext(System.Text.StringBuilder sb, TestDetailInfo info, str return match.Success ? match.Value : null; } } + + diff --git a/src/Tiger/Commands/BuildBrowser.cs b/src/Tiger/Commands/BuildBrowser.cs index 14d352f..61a73c8 100644 --- a/src/Tiger/Commands/BuildBrowser.cs +++ b/src/Tiger/Commands/BuildBrowser.cs @@ -8,6 +8,8 @@ namespace Tiger.Commands; /// public sealed class BuildBrowser { + private readonly PanelRenderer _ui = PanelRenderer.Create(); + private readonly TigerDatabase _db; private readonly AzdoClientFactory _clientFactory; private readonly BuildAnalysisService? _analysisService; @@ -122,11 +124,11 @@ private NavAction RenderBuildList() ? "[yellow]No builds match the current filter.[/]" : "[yellow]No builds ingested yet.[/]"; - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds"], context, - () => PanelLayout.RenderPanelLine(emptyMsg), - PanelLayout.BuildCommandBarString(new List + () => _ui.RenderPanelLine(emptyMsg), + PanelRenderer.BuildCommandBarString(new List { new("Edit filter", ConsoleKey.E, -5), new("Filter menu", ConsoleKey.F, -2), @@ -187,7 +189,7 @@ private NavAction RenderBuildList() commands.Add(new("Clear", ConsoleKey.C, -4)); } - var selected = PanelLayout.SelectInPanel( + var selected = _ui.SelectInPanel( ["Builds"], context, choices, @@ -310,7 +312,7 @@ LIMIT 50 private void EditFilter() { var currentValue = _filter.IsActive ? _filter.ToString() : null; - var result = PanelLayout.PromptInPanel( + var result = _ui.PromptInPanel( ["Builds", "Edit Filter"], "Enter filter expression (e.g. repo:roslyn def:ci result:failed)", currentValue); @@ -338,28 +340,28 @@ private void ShowFilterMenu() new("Clear", ConsoleKey.C, 8), }; - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds", "Filter"], null, () => { - PanelLayout.RenderPanelLine("Filter available builds by repository, definition, kind, branch, etc."); - PanelLayout.RenderEmptyLine(); + _ui.RenderPanelLine("Filter available builds by repository, definition, kind, branch, etc."); + _ui.RenderEmptyLine(); if (_filter.IsActive) { - PanelLayout.RenderPanelLine($"[bold]Current filter:[/] {Markup.Escape(_filter.ToString())}"); + _ui.RenderPanelLine($"[bold]Current filter:[/] {Markup.Escape(_filter.ToString())}"); } else { - PanelLayout.RenderPanelLine("[dim]No filter active[/]"); + _ui.RenderPanelLine("[dim]No filter active[/]"); } - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderPanelLine("[dim]Syntax: substring match by default, * for wildcards, ! suffix for exact[/]"); - PanelLayout.RenderPanelLine("[dim]Example: repo:roslyn def:*-CI result:failed branch:main[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[dim]Syntax: substring match by default, * for wildcards, ! suffix for exact[/]"); + _ui.RenderPanelLine("[dim]Example: repo:roslyn def:*-CI result:failed branch:main[/]"); }, - PanelLayout.BuildCommandBarString(commands)); + PanelRenderer.BuildCommandBarString(commands)); var key = Console.ReadKey(true); switch (key.Key) @@ -402,19 +404,19 @@ private void ShowFilterMenu() } } - private static string? PromptFilterField(string prompt) + private string? PromptFilterField(string prompt) { - return PanelLayout.PromptInPanel(["Builds", "Filter"], prompt); + return _ui.PromptInPanel(["Builds", "Filter"], prompt); } /// /// Selection menu for outcome filter. Returns null if cancelled. /// - private static string? PromptResultFilter() + private string? PromptResultFilter() { var outcomes = new List { "all", "failed", "succeeded", "partiallySucceeded" }; var commands = new List(); - var selected = PanelLayout.SelectInPanel( + var selected = _ui.SelectInPanel( ["Builds", "Filter", "Outcome"], "[dim]Select build outcome to filter on[/]", outcomes, @@ -429,9 +431,9 @@ private void ShowFilterMenu() /// /// Prompts the user to enter a PR number. Returns null if cancelled or invalid. /// - private static int? PromptPrNumber() + private int? PromptPrNumber() { - var raw = PanelLayout.PromptInPanel(["Builds", "Filter"], "PR number (e.g. 12345)"); + var raw = _ui.PromptInPanel(["Builds", "Filter"], "PR number (e.g. 12345)"); if (raw is null) { return null; @@ -439,34 +441,34 @@ private void ShowFilterMenu() return int.TryParse(raw, out var pr) ? pr : null; } - private static void ShowFilterHelp() + private void ShowFilterHelp() { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds", "Filter Help"], null, () => { - PanelLayout.RenderPanelLine("[bold]Quick filter (E):[/]"); - PanelLayout.RenderPanelLine(" Type an expression like: [blue]repo:roslyn def:ci[/]"); - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderPanelLine("[bold]Matching (default: contains / LIKE):[/]"); - PanelLayout.RenderPanelLine(" [dim]ros - matches 'dotnet/roslyn', 'roslyn-CI', etc.[/]"); - PanelLayout.RenderPanelLine(" [dim]dotnet/* - matches 'dotnet/roslyn', 'dotnet/runtime'[/]"); - PanelLayout.RenderPanelLine(" [dim]*-CI - matches definition names ending with '-CI'[/]"); - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderPanelLine("[bold]Exact match (append !):[/]"); - PanelLayout.RenderPanelLine(" [dim]dotnet/roslyn! - matches exactly 'dotnet/roslyn'[/]"); - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderPanelLine("[bold]Filter prefixes:[/]"); - PanelLayout.RenderPanelLine(" [blue]repo:[/] Repository name"); - PanelLayout.RenderPanelLine(" [blue]def:[/] Definition/pipeline name"); - PanelLayout.RenderPanelLine(" [blue]id:[/] Build ID"); - PanelLayout.RenderPanelLine(" [blue]result:[/] Outcome (failed, succeeded, partiallySucceeded)"); - PanelLayout.RenderPanelLine(" [blue]kind:[/] Build kind (pr, ci)"); - PanelLayout.RenderPanelLine(" [blue]branch:[/] Source branch"); - PanelLayout.RenderPanelLine(" [blue]pr:[/] PR number"); - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderPanelLine("[bold]Multiple filters combine with AND.[/]"); + _ui.RenderPanelLine("[bold]Quick filter (E):[/]"); + _ui.RenderPanelLine(" Type an expression like: [blue]repo:roslyn def:ci[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Matching (default: contains / LIKE):[/]"); + _ui.RenderPanelLine(" [dim]ros - matches 'dotnet/roslyn', 'roslyn-CI', etc.[/]"); + _ui.RenderPanelLine(" [dim]dotnet/* - matches 'dotnet/roslyn', 'dotnet/runtime'[/]"); + _ui.RenderPanelLine(" [dim]*-CI - matches definition names ending with '-CI'[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Exact match (append !):[/]"); + _ui.RenderPanelLine(" [dim]dotnet/roslyn! - matches exactly 'dotnet/roslyn'[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Filter prefixes:[/]"); + _ui.RenderPanelLine(" [blue]repo:[/] Repository name"); + _ui.RenderPanelLine(" [blue]def:[/] Definition/pipeline name"); + _ui.RenderPanelLine(" [blue]id:[/] Build ID"); + _ui.RenderPanelLine(" [blue]result:[/] Outcome (failed, succeeded, partiallySucceeded)"); + _ui.RenderPanelLine(" [blue]kind:[/] Build kind (pr, ci)"); + _ui.RenderPanelLine(" [blue]branch:[/] Source branch"); + _ui.RenderPanelLine(" [blue]pr:[/] PR number"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Multiple filters combine with AND.[/]"); }, "[blue]Esc[/] Back"); Console.ReadKey(true); @@ -508,10 +510,10 @@ FROM builds if (!buildInfo.Found) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds", $"#{page.BuildId}"], null, - () => PanelLayout.RenderPanelLine("[red]Build not found.[/]"), + () => _ui.RenderPanelLine("[red]Build not found.[/]"), "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; @@ -574,14 +576,14 @@ string TaskIcon(string taskType) detailCommands.Add(new("Prev", ConsoleKey.P, -16)); } - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds", $"#{page.BuildId} {defName}"], $"{BrowserUI.FormatResult(result)} {BrowserUI.FormatTime(finishTime)}", () => { // Build info fields - PanelLayout.RenderField("Build", $"#{page.BuildId} — {defName} {buildNumber}"); - PanelLayout.RenderField("Result", BrowserUI.FormatResult(result)); + _ui.RenderField("Build", $"#{page.BuildId} — {defName} {buildNumber}"); + _ui.RenderField("Result", BrowserUI.FormatResult(result)); if (prNumber is not null && repoName is not null) { var prUrl = $"https://github.com/{repoName}/pull/{prNumber}"; @@ -601,31 +603,31 @@ string TaskIcon(string taskType) if (prInfo.Found) { var prefix = $"#{prNumber} {prInfo.Author} "; - var maxTitleLen = Math.Max(10, PanelLayout.ContentWidth - prefix.Length - 20); + var maxTitleLen = Math.Max(10, _ui.ContentWidth - prefix.Length - 20); var truncatedTitle = prInfo.Title.Length > maxTitleLen ? prInfo.Title[..maxTitleLen] + "..." : prInfo.Title; - PanelLayout.RenderField("PR", $"#{prNumber} [blue]{Markup.Escape(prInfo.Author)}[/] {Markup.Escape(truncatedTitle)}"); + _ui.RenderField("PR", $"#{prNumber} [blue]{Markup.Escape(prInfo.Author)}[/] {Markup.Escape(truncatedTitle)}"); } else { - PanelLayout.RenderField("PR", $"#{prNumber}"); + _ui.RenderField("PR", $"#{prNumber}"); } - PanelLayout.RenderField("PR URL", BrowserUI.FormatLink(prUrl, $"PR #{prNumber}")); + _ui.RenderField("PR URL", BrowserUI.FormatLink(prUrl, $"PR #{prNumber}")); } else if (prNumber is not null) { - PanelLayout.RenderField("PR", $"#{prNumber}"); + _ui.RenderField("PR", $"#{prNumber}"); } else { - PanelLayout.RenderField("Branch", branch); + _ui.RenderField("Branch", branch); } if (finishTime is not null) { - PanelLayout.RenderField("Finished", BrowserUI.FormatTime(finishTime)); + _ui.RenderField("Finished", BrowserUI.FormatTime(finishTime)); } - PanelLayout.RenderField("URL", BrowserUI.FormatLink(url, url)); - PanelLayout.RenderField("Data", $"Timeline: {TaskIcon("timeline")} Tests: {TaskIcon("tests")} Helix: {TaskIcon("helix")}"); - PanelLayout.RenderEmptyLine(); + _ui.RenderField("URL", BrowserUI.FormatLink(url, url)); + _ui.RenderField("Data", $"Timeline: {TaskIcon("timeline")} Tests: {TaskIcon("tests")} Helix: {TaskIcon("helix")}"); + _ui.RenderEmptyLine(); // Failed jobs section if (timelineStatus == "complete") @@ -652,26 +654,26 @@ ORDER BY parent_name return names; }); - PanelLayout.RenderSectionTitle("Failed Jobs"); + _ui.RenderSectionTitle("Failed Jobs"); if (failedJobNames.Count > 0) { foreach (var jobName in failedJobNames.Take(15)) { - PanelLayout.RenderPanelLine($" [red]X[/] {Markup.Escape(jobName)}"); + _ui.RenderPanelLine($" [red]X[/] {Markup.Escape(jobName)}"); } } else { - PanelLayout.RenderPanelLine(" [green]No failed jobs[/]"); + _ui.RenderPanelLine(" [green]No failed jobs[/]"); } - PanelLayout.RenderEmptyLine(); + _ui.RenderEmptyLine(); } // Failed tests section - PanelLayout.RenderSectionTitle("Failed Tests"); + _ui.RenderSectionTitle("Failed Tests"); if (testsStatus != "complete") { - PanelLayout.RenderPanelLine(" [yellow]Tests not available yet[/]"); + _ui.RenderPanelLine(" [yellow]Tests not available yet[/]"); } else { @@ -702,13 +704,13 @@ LIMIT 50 if (failedTests.Count == 0) { - PanelLayout.RenderPanelLine(" [green]All tests passed[/]"); + _ui.RenderPanelLine(" [green]All tests passed[/]"); } else { foreach (var group in failedTests.GroupBy(t => t.RunName)) { - PanelLayout.RenderPanelLine($" [bold yellow]{Markup.Escape(group.Key)}[/]"); + _ui.RenderPanelLine($" [bold yellow]{Markup.Escape(group.Key)}[/]"); var shown = 0; var total = group.Count(); foreach (var test in group.Take(5)) @@ -720,16 +722,16 @@ LIMIT 50 error = error[..57] + "..."; } error = error.ReplaceLineEndings(" "); - PanelLayout.RenderPanelLine($" [red]X[/] {Markup.Escape(title)}"); + _ui.RenderPanelLine($" [red]X[/] {Markup.Escape(title)}"); if (!string.IsNullOrWhiteSpace(error)) { - PanelLayout.RenderPanelLine($" [dim]{Markup.Escape(error)}[/]"); + _ui.RenderPanelLine($" [dim]{Markup.Escape(error)}[/]"); } shown++; } if (total > shown) { - PanelLayout.RenderPanelLine($" [dim]... {total - shown} more failure(s), press T to see all[/]"); + _ui.RenderPanelLine($" [dim]... {total - shown} more failure(s), press T to see all[/]"); } } } @@ -771,18 +773,18 @@ LIMIT 15 if (helixItems.Count > 0) { - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderSectionTitle($"Helix Work Items ({helixItems.Count})"); + _ui.RenderEmptyLine(); + _ui.RenderSectionTitle($"Helix Work Items ({helixItems.Count})"); foreach (var (job, wi, state, exitCode, isDeadletter) in helixItems) { var exitInfo = exitCode is not null ? $" exit {exitCode}" : ""; var extra = isDeadletter ? " [red]deadletter[/]" : ""; var color = (exitCode ?? 1) == 0 ? "green" : "red"; - PanelLayout.RenderPanelLine($" [{color}]X[/] {Markup.Escape(wi)} [dim]{Markup.Escape(job)}[/]{exitInfo}{extra}"); + _ui.RenderPanelLine($" [{color}]X[/] {Markup.Escape(wi)} [dim]{Markup.Escape(job)}[/]{exitInfo}{extra}"); } } }, - PanelLayout.BuildCommandBarString(detailCommands)); + PanelRenderer.BuildCommandBarString(detailCommands)); return ReadNavKey(page); } @@ -816,10 +818,10 @@ FROM test_results tr if (tests.Count == 0) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds", $"#{page.BuildId}", "Tests"], null, - () => PanelLayout.RenderPanelLine("[green]No failed tests in this build.[/]"), + () => _ui.RenderPanelLine("[green]No failed tests in this build.[/]"), "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; @@ -847,7 +849,7 @@ FROM test_results tr var totalFailed = tests.Select(t => t.Title).Distinct().Count(); var commands = new List(); - var selected = PanelLayout.SelectInPanel( + var selected = _ui.SelectInPanel( ["Builds", $"#{page.BuildId}", "Tests"], $"[dim]{totalFailed} failed test(s) across {grouped.Count()} run(s)[/]", choices, @@ -871,10 +873,10 @@ private NavAction RenderTestDetail(TestDetailPage page) var info = BrowserUI.LoadTestDetail(_db, page.Org, page.Project, page.TestName); if (info is null) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds", "Tests", "Detail"], null, - () => PanelLayout.RenderPanelLine("[yellow]No test failure data found.[/]"), + () => _ui.RenderPanelLine("[yellow]No test failure data found.[/]"), "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; @@ -891,16 +893,16 @@ private NavAction RenderTestDetail(TestDetailPage page) commands.Add(new("Helix", ConsoleKey.H, -4)); } - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds", "Tests", Markup.Escape(shortTitle)], null, - () => BrowserUI.RenderTestDetailInPanel(info), - PanelLayout.BuildCommandBarString(commands)); + () => BrowserUI.RenderTestDetailInPanel(_ui, info), + PanelRenderer.BuildCommandBarString(commands)); while (true) { var key = Console.ReadKey(true); - if (PanelLayout.HandleDetailScroll(key)) continue; + if (_ui.HandleDetailScroll(key)) continue; switch (key.Key) { case ConsoleKey.B: @@ -917,39 +919,39 @@ private NavAction RenderTestDetail(TestDetailPage page) } } - private static void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) + private void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) { var commands = new List(); - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Tests", "Helix Work Item"], null, () => { if (info.IsHelixDeadletter) { - PanelLayout.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure [/]"); - PanelLayout.RenderEmptyLine(); + _ui.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure [/]"); + _ui.RenderEmptyLine(); } - PanelLayout.RenderField("Job", Markup.Escape(info.HelixJobName!)); + _ui.RenderField("Job", Markup.Escape(info.HelixJobName!)); if (info.HelixWorkItemName is not null) { - PanelLayout.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); + _ui.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); var url = HelixClient.GetConsoleUrl(info.HelixJobName!, info.HelixWorkItemName); - PanelLayout.RenderField("Console", BrowserUI.FormatLink(url, "Console Log")); + _ui.RenderField("Console", BrowserUI.FormatLink(url, "Console Log")); } if (info.HelixFiles is { Count: > 0 }) { - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderSectionTitle($"Files ({info.HelixFiles.Count})"); + _ui.RenderEmptyLine(); + _ui.RenderSectionTitle($"Files ({info.HelixFiles.Count})"); foreach (var (name, uri) in info.HelixFiles) { if (uri is not null) { - PanelLayout.RenderPanelLine($" {BrowserUI.FormatLink(uri, name)}"); + _ui.RenderPanelLine($" {BrowserUI.FormatLink(uri, name)}"); } else { - PanelLayout.RenderPanelLine($" {Markup.Escape(name)}"); + _ui.RenderPanelLine($" {Markup.Escape(name)}"); } } } @@ -958,7 +960,7 @@ private static void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) while (true) { var key = Console.ReadKey(true); - if (PanelLayout.HandleDetailScroll(key)) continue; + if (_ui.HandleDetailScroll(key)) continue; if (key.Key == ConsoleKey.Escape) return; } } @@ -1007,10 +1009,10 @@ LIMIT 30 if (builds.Count == 0) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds", "Tests", "Builds with failure"], null, - () => PanelLayout.RenderPanelLine("[yellow]No builds found with this test failure.[/]"), + () => _ui.RenderPanelLine("[yellow]No builds found with this test failure.[/]"), "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; @@ -1031,7 +1033,7 @@ LIMIT 30 }).ToList(); var commands = new List(); - var selected = PanelLayout.SelectInPanel( + var selected = _ui.SelectInPanel( ["Builds", "Tests", Markup.Escape(shortTitle), "Builds"], $"[dim]{builds.Count} build(s) with this failure[/]", choices, @@ -1087,10 +1089,10 @@ FROM build_timeline_issues if (jobIssues.Count == 0) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds", $"#{page.BuildId}", "Jobs"], null, - () => PanelLayout.RenderPanelLine("[green]No timeline issues recorded for this build.[/]"), + () => _ui.RenderPanelLine("[green]No timeline issues recorded for this build.[/]"), "[blue]Esc[/] Back"); Console.ReadKey(true); return NavAction.Back.Instance; @@ -1107,7 +1109,7 @@ FROM build_timeline_issues new(truncate ? "Truncate: off" : "Truncate: on", ConsoleKey.T, -3), }; - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds", $"#{page.BuildId}", "Jobs"], errorsOnly ? "[dim]Showing errors only[/]" : null, () => @@ -1134,7 +1136,7 @@ FROM build_timeline_issues { summary.Add($"[yellow]{warnCount} warning(s)[/]"); } - PanelLayout.RenderPanelLine($"[bold]{Markup.Escape(jobName)}[/] {string.Join(" ", summary)}"); + _ui.RenderPanelLine($"[bold]{Markup.Escape(jobName)}[/] {string.Join(" ", summary)}"); foreach (var (type, message) in filtered.Take(10)) { @@ -1144,18 +1146,18 @@ FROM build_timeline_issues { msg = msg[..117] + "..."; } - PanelLayout.RenderPanelLine($" {icon}: {Markup.Escape(msg)}"); + _ui.RenderPanelLine($" {icon}: {Markup.Escape(msg)}"); } if (filtered.Count > 10) { - PanelLayout.RenderPanelLine($" [dim]... and {filtered.Count - 10} more[/]"); + _ui.RenderPanelLine($" [dim]... and {filtered.Count - 10} more[/]"); } - PanelLayout.RenderEmptyLine(); + _ui.RenderEmptyLine(); } }, - PanelLayout.BuildCommandBarString(commands)); + PanelRenderer.BuildCommandBarString(commands)); var key = Console.ReadKey(true); if (key.Key is ConsoleKey.Escape or ConsoleKey.B) @@ -1180,7 +1182,7 @@ private NavAction ReadNavKey(BuildDetailPage page) while (true) { var key = Console.ReadKey(true); - if (PanelLayout.HandleDetailScroll(key)) continue; + if (_ui.HandleDetailScroll(key)) continue; switch (key.Key) { @@ -1256,14 +1258,14 @@ LIMIT 30 return helixItems; }); - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Builds", $"#{page.BuildId}", "Helix Work Items"], $"[dim]{helixItems.Count} work item(s)[/]", () => { if (helixItems.Count == 0) { - PanelLayout.RenderPanelLine("[yellow]No Helix work items found for failed tests in this build.[/]"); + _ui.RenderPanelLine("[yellow]No Helix work items found for failed tests in this build.[/]"); return; } @@ -1271,23 +1273,23 @@ LIMIT 30 { if (isDeadletter) { - PanelLayout.RenderPanelLine($" [bold red]!! DEAD LETTER[/] [bold]{Markup.Escape(wi)}[/]"); + _ui.RenderPanelLine($" [bold red]!! DEAD LETTER[/] [bold]{Markup.Escape(wi)}[/]"); } else { var stateInfo = state is not null ? $" [{(exitCode == 0 ? "green" : "red")}]{state} (exit {exitCode})[/]" : ""; - PanelLayout.RenderPanelLine($" [bold]{Markup.Escape(wi)}[/]{stateInfo}"); + _ui.RenderPanelLine($" [bold]{Markup.Escape(wi)}[/]{stateInfo}"); } var url = consoleUri ?? HelixClient.GetConsoleUrl(job, wi); - PanelLayout.RenderPanelLine($" {BrowserUI.FormatLink(url, "Console Log")}"); + _ui.RenderPanelLine($" {BrowserUI.FormatLink(url, "Console Log")}"); } }, "[blue]Esc[/] Back"); while (true) { var key = Console.ReadKey(true); - if (PanelLayout.HandleDetailScroll(key)) continue; + if (_ui.HandleDetailScroll(key)) continue; if (key.Key == ConsoleKey.Escape) return; } } @@ -1497,3 +1499,5 @@ private record BuildRow( int? PrNumber, string? FinishTime, string IngestionStatus = "pending", int DefinitionId = 0, string? RepositoryName = null); } + + diff --git a/src/Tiger/Commands/DashboardCommand.cs b/src/Tiger/Commands/DashboardCommand.cs index 7b677dd..fa5a917 100644 --- a/src/Tiger/Commands/DashboardCommand.cs +++ b/src/Tiger/Commands/DashboardCommand.cs @@ -9,6 +9,8 @@ namespace Tiger.Commands; /// public sealed class DashboardCommand : AsyncCommand { + private readonly PanelRenderer _ui = PanelRenderer.Create(); + private const int MenuBuilds = 0; private const int MenuTests = 1; private const int MenuHealth = 2; @@ -70,7 +72,7 @@ protected override async Task ExecuteAsync(CommandContext context, Cancella return 0; } - private static async Task RunMenuLoopAsync( + private async Task RunMenuLoopAsync( TigerContext tigerContext, TigerDatabase db, AzdoClientFactory clientFactory, BuildBackfillService backfill, @@ -91,7 +93,7 @@ private static async Task RunMenuLoopAsync( while (!ct.IsCancellationRequested) { - var choice = PanelLayout.ShowMainMenu(commands); + var choice = _ui.ShowMainMenu(commands); switch (choice) { @@ -140,7 +142,7 @@ private static async Task RunMenuLoopAsync( /// Hotkeys: E = toggle errors only, Escape = return to menu, /// Up/Down = scroll, End = jump to latest (live tail). /// - private static async Task ShowLiveStatusAsync(ServiceLog serviceLog, CancellationToken ct) + private async Task ShowLiveStatusAsync(ServiceLog serviceLog, CancellationToken ct) { var errorsOnly = false; var scrollOffset = 0; @@ -165,14 +167,14 @@ void Render() var start = Math.Max(0, end - maxVisible); var visible = filtered.Skip(start).Take(end - start).ToList(); - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Status", "Service Log"], context, () => { if (visible.Count == 0) { - PanelLayout.RenderPanelLine("[dim]No log entries yet...[/]"); + _ui.RenderPanelLine("[dim]No log entries yet...[/]"); } else { @@ -188,11 +190,11 @@ void Render() }; var service = Markup.Escape(entry.Service); var message = Markup.Escape(entry.Message); - PanelLayout.RenderPanelLine($"[dim]{time}[/] [{levelColor}]{service}[/] {message}"); + _ui.RenderPanelLine($"[dim]{time}[/] [{levelColor}]{service}[/] {message}"); } } }, - PanelLayout.BuildCommandBarString(new List + PanelRenderer.BuildCommandBarString(new List { new("Errors toggle", ConsoleKey.E, -2), }) + " [blue]Up/Dn[/] Scroll [blue]End[/] Latest [blue]Esc[/] Back"); @@ -266,3 +268,5 @@ void OnEntry() } } } + + diff --git a/src/Tiger/Commands/HealthCommand.cs b/src/Tiger/Commands/HealthCommand.cs index 8dc287e..deadda7 100644 --- a/src/Tiger/Commands/HealthCommand.cs +++ b/src/Tiger/Commands/HealthCommand.cs @@ -9,6 +9,8 @@ namespace Tiger.Commands; /// public sealed class HealthCommand : AsyncCommand { + private readonly PanelRenderer _ui = PanelRenderer.Create(); + public async Task RunAsync(CancellationToken ct) { await ExecuteAsync(null!, ct); @@ -36,17 +38,17 @@ protected override Task ExecuteAsync(Spectre.Console.Cli.CommandContext con /// /// Top-level page: list of repo + pipeline combos that have health reports. /// - private static void ShowCombosPage(HealthAgentService agent) + private void ShowCombosPage(HealthAgentService agent) { while (true) { var runs = agent.GetRecentRuns(); if (runs.Count == 0) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Health"], null, - () => PanelLayout.RenderPanelLine("[yellow]No health reports available yet. The agent runs every 15 minutes.[/]"), + () => _ui.RenderPanelLine("[yellow]No health reports available yet. The agent runs every 15 minutes.[/]"), "[blue]Esc[/] Back"); Console.ReadKey(true); return; @@ -59,7 +61,7 @@ private static void ShowCombosPage(HealthAgentService agent) var items = combos.Select(c => $"{c.Repository} / {c.Definition}").ToList(); - var selected = PanelLayout.SelectInPanel( + var selected = _ui.SelectInPanel( ["Health"], $"[dim]{combos.Count} pipeline(s)[/]", items, @@ -78,13 +80,13 @@ private static void ShowCombosPage(HealthAgentService agent) /// Second level: shows the current state-of-the-build for a combo. /// User can drill into individual agent runs from here. /// - private static void ShowStatePage(HealthAgentService agent, string repository, string definition) + private void ShowStatePage(HealthAgentService agent, string repository, string definition) { while (true) { var state = agent.GetCurrentState(repository, definition); - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Health", $"{Markup.Escape(repository)} / {Markup.Escape(definition)}"], null, () => @@ -95,19 +97,19 @@ private static void ShowStatePage(HealthAgentService agent, string repository, s var lines = state.ReplaceLineEndings("\n").Split('\n'); foreach (var line in lines.Take(30)) { - PanelLayout.RenderPanelLine(Markup.Escape(line)); + _ui.RenderPanelLine(Markup.Escape(line)); } if (lines.Length > 30) { - PanelLayout.RenderPanelLine($"[dim]... ({lines.Length - 30} more lines)[/]"); + _ui.RenderPanelLine($"[dim]... ({lines.Length - 30} more lines)[/]"); } } else { - PanelLayout.RenderPanelLine("[dim]No state summary available yet.[/]"); + _ui.RenderPanelLine("[dim]No state summary available yet.[/]"); } }, - PanelLayout.BuildCommandBarString(new List + PanelRenderer.BuildCommandBarString(new List { new("Re-run", ConsoleKey.R, -2), new("Gist", ConsoleKey.G, -3), @@ -199,17 +201,17 @@ private static void CreateGist(string repository, string definition, string? mar /// /// Third level: list of individual agent runs for a combo, most recent first. /// - private static void ShowRunsPage(HealthAgentService agent, string repository, string definition) + private void ShowRunsPage(HealthAgentService agent, string repository, string definition) { while (true) { var runs = agent.GetRecentRuns(repository, definition); if (runs.Count == 0) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Health", $"{Markup.Escape(repository)}", "Runs"], null, - () => PanelLayout.RenderPanelLine("[dim]No runs found.[/]"), + () => _ui.RenderPanelLine("[dim]No runs found.[/]"), "[blue]Esc[/] Back"); Console.ReadKey(true); return; @@ -217,7 +219,7 @@ private static void ShowRunsPage(HealthAgentService agent, string repository, st var items = runs.Select(r => r.Timestamp.Replace("_", " ")).ToList(); - var selected = PanelLayout.SelectInPanel( + var selected = _ui.SelectInPanel( ["Health", $"{Markup.Escape(repository)}", "Runs"], $"[dim]{runs.Count} run(s)[/]", items, @@ -231,9 +233,9 @@ private static void ShowRunsPage(HealthAgentService agent, string repository, st } } - private static void ShowRunDetail(HealthRunInfo run) + private void ShowRunDetail(HealthRunInfo run) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Health", "Run", Markup.Escape(run.Timestamp.Replace("_", " "))], null, () => @@ -244,16 +246,16 @@ private static void ShowRunDetail(HealthRunInfo run) var lines = content.ReplaceLineEndings("\n").Split('\n'); foreach (var line in lines.Take(40)) { - PanelLayout.RenderPanelLine(Markup.Escape(line)); + _ui.RenderPanelLine(Markup.Escape(line)); } if (lines.Length > 40) { - PanelLayout.RenderPanelLine($"[dim]... ({lines.Length - 40} more lines)[/]"); + _ui.RenderPanelLine($"[dim]... ({lines.Length - 40} more lines)[/]"); } } else { - PanelLayout.RenderPanelLine("[red]Log file not found.[/]"); + _ui.RenderPanelLine("[red]Log file not found.[/]"); } }, "[blue]Esc[/] Back"); @@ -261,3 +263,5 @@ private static void ShowRunDetail(HealthRunInfo run) Console.ReadKey(true); } } + + diff --git a/src/Tiger/Commands/PanelLayout.cs b/src/Tiger/Commands/PanelLayout.cs deleted file mode 100644 index 6e4f98f..0000000 --- a/src/Tiger/Commands/PanelLayout.cs +++ /dev/null @@ -1,775 +0,0 @@ -using Spectre.Console; - -namespace Tiger.Commands; - -/// -/// Represents a single command in the command bar. -/// -public record CommandBarItem(string Label, ConsoleKey Hotkey, int ReturnValue); - -/// -/// Renders a consistent "command and control" panel layout for all screens. -/// -/// Structure: -/// ╔═══════════════════════════════════════════════════╗ -/// ║ TIGER ▸ Section ▸ Subsection ║ -/// ║ Context line (filter, counts, etc.) ║ -/// ╠═══════════════════════════════════════════════════╣ -/// ║ Content area (list or detail) ║ -/// ╠═══════════════════════════════════════════════════╣ -/// ║ Command bar (focusable via Tab) ║ -/// ╚═══════════════════════════════════════════════════╝ -/// -/// Border color is dim gray; hotkeys are blue for contrast. -/// Tab moves focus between content and command bar. -/// In the command bar, left/right navigates, Enter executes. -/// -public static class PanelLayout -{ - private const char TopLeft = '╔'; - private const char TopRight = '╗'; - private const char BottomLeft = '╚'; - private const char BottomRight = '╝'; - private const char Horizontal = '═'; - private const char Vertical = '║'; - private const char MiddleLeft = '╠'; - private const char MiddleRight = '╣'; - private const char Separator = '>'; - - private const string BorderStyle = "dim"; - - /// - /// Gets the usable content width inside the panel borders (total width - 4 for borders and padding). - /// - public static int ContentWidth => Math.Max(40, Console.WindowWidth - 4); - - /// - /// Renders a single line inside the panel with vertical borders. - /// When called during RenderDetailPanel's content capture phase, lines are buffered instead. - /// - public static void RenderPanelLine(string markupContent) - { - if (_captureTarget is not null) - { - _captureTarget.Add(markupContent); - return; - } - - AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/] "); - AnsiConsole.Markup(markupContent); - - var plainLen = Markup.Remove(markupContent).Length; - var padding = Math.Max(0, Console.WindowWidth - 4 - plainLen); - Console.Write(new string(' ', padding)); - AnsiConsole.MarkupLine($" [{BorderStyle}]{Vertical}[/]"); - } - - /// - /// Renders an empty line inside the panel borders. - /// When called during RenderDetailPanel's content capture phase, an empty line is buffered. - /// - public static void RenderEmptyLine() - { - if (_captureTarget is not null) - { - _captureTarget.Add(""); - return; - } - - var width = Console.WindowWidth - 2; - AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/]"); - Console.Write(new string(' ', width)); - AnsiConsole.MarkupLine($"[{BorderStyle}]{Vertical}[/]"); - } - - /// - /// Renders a section title inside the panel content area. - /// - public static void RenderSectionTitle(string title) - { - RenderPanelLine($"[bold underline]{title}[/]"); - } - - /// - /// Renders a labeled value pair inside the panel. - /// - public static void RenderField(string label, string value) - { - RenderPanelLine($"[bold]{label}:[/] {value}"); - } - - /// - /// Renders the Tiger ASCII art logo using Spectre.Console FigletText, - /// plus the TIGER text branding. - /// - public static void RenderLogo() - { - // Render figlet to a string buffer and extract lines - var writer = new StringWriter(); - var console = AnsiConsole.Create(new AnsiConsoleSettings - { - Out = new AnsiConsoleOutput(writer), - ColorSystem = ColorSystemSupport.NoColors, - }); - console.Write(new FigletText("tiger")); - var figletLines = writer.ToString().Split('\n', StringSplitOptions.None); - - foreach (var line in figletLines) - { - var trimmed = line.TrimEnd('\r', '\n'); - if (trimmed.Length > 0) - { - RenderPanelLine($"[bold orange1]{Markup.Escape(trimmed)}[/]"); - } - } - - RenderEmptyLine(); - RenderPanelLine("[bold orange1]TIGER[/] — CI/CD Infrastructure Management"); - RenderEmptyLine(); - } - - // ── Command Bar ───────────────────────────────────────────────── - - /// - /// Formats a label with its hotkey letter highlighted in [X] bracket style. - /// - private static string FormatHotkeyLabel(CommandBarItem item) - { - var label = item.Label; - var hotkeyChar = item.Hotkey switch - { - >= ConsoleKey.A and <= ConsoleKey.Z => (char)('A' + (item.Hotkey - ConsoleKey.A)), - _ => '\0' - }; - - if (hotkeyChar == '\0') - { - return label; - } - - // Find the hotkey character in the label (case-insensitive) - for (var i = 0; i < label.Length; i++) - { - if (char.ToUpperInvariant(label[i]) == hotkeyChar) - { - return $"{label[..i]}[blue][[{label[i]}]][/]{label[(i + 1)..]}"; - } - } - - return label; - } - - private static string BuildCommandBarMarkup(List commands, int focusedIndex, bool barFocused) - { - if (commands.Count == 0) - { - return "[dim]Up/Dn Navigate Enter Select Esc Back[/]"; - } - - var parts = new List(); - for (var i = 0; i < commands.Count; i++) - { - if (barFocused && i == focusedIndex) - { - parts.Add($"[bold white on blue] {commands[i].Label} [/]"); - } - else - { - parts.Add(FormatHotkeyLabel(commands[i])); - } - } - var barContent = string.Join(" ", parts); - var tabHint = barFocused - ? " [dim]<-> Move Enter Execute Tab Content[/]" - : " [dim]Tab Commands[/]"; - return $"{barContent}{tabHint}"; - } - - /// - /// Builds a static hotkey string from a list of commands (for use in RenderDetailPanel footers). - /// Renders each command with the [X] bracket-style hotkey highlighting. - /// - public static string BuildCommandBarString(List commands) - { - var parts = new List(); - foreach (var cmd in commands) - { - parts.Add(FormatHotkeyLabel(cmd)); - } - return string.Join(" ", parts) + " [blue]Esc[/] Back"; - } - - private static void RenderCommandBarAt(int row, List commands, int focusedIndex, bool barFocused) - { - Console.SetCursorPosition(0, row); - RenderPanelLine(BuildCommandBarMarkup(commands, focusedIndex, barFocused)); - } - - // ── Flicker-free list line ────────────────────────────────────── - - private static void RenderListLineAt(int row, string markupContent, bool isSelected) - { - Console.SetCursorPosition(0, row); - var prefix = isSelected ? "[blue]>[/] " : " "; - var line = $"{prefix}{markupContent}"; - AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/] "); - AnsiConsole.Markup(line); - var plainLen = Markup.Remove(line).Length; - var padding = Math.Max(0, Console.WindowWidth - 4 - plainLen); - Console.Write(new string(' ', padding)); - AnsiConsole.Markup($" [{BorderStyle}]{Vertical}[/]"); - } - - // ── Main Menu (logo + focusable command bar, no list) ─────────── - - /// - /// Renders the main dashboard: logo in the content area, navigation only via the command bar. - /// Returns the selected command's ReturnValue, or -1 on Escape. - /// - public static int ShowMainMenu(List commands) - { - var barIndex = 0; - - while (true) - { - AnsiConsole.Clear(); - Console.CursorVisible = false; - var width = Console.WindowWidth - 2; - - AnsiConsole.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); - RenderPanelLine("[bold orange1]TIGER[/]"); - AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); - - RenderLogo(); - - AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); - var barRow = Console.CursorTop; - RenderPanelLine(BuildCommandBarMarkup(commands, barIndex, true)); - AnsiConsole.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); - - while (true) - { - var key = Console.ReadKey(true); - - switch (key.Key) - { - case ConsoleKey.LeftArrow: - barIndex = (barIndex - 1 + commands.Count) % commands.Count; - RenderCommandBarAt(barRow, commands, barIndex, true); - continue; - case ConsoleKey.RightArrow: - barIndex = (barIndex + 1) % commands.Count; - RenderCommandBarAt(barRow, commands, barIndex, true); - continue; - case ConsoleKey.Enter: - Console.CursorVisible = true; - return commands[barIndex].ReturnValue; - case ConsoleKey.Escape: - Console.CursorVisible = true; - return -1; - default: - // Check hotkeys - for (var i = 0; i < commands.Count; i++) - { - if (key.Key == commands[i].Hotkey) - { - Console.CursorVisible = true; - return commands[i].ReturnValue; - } - } - continue; - } - } - } - } - - // ── Select in Panel (with focusable command bar) ──────────────── - - /// - /// List selection with a focusable command bar. Tab switches focus. - /// Returns list index on Enter (list focused), command ReturnValue on Enter (bar focused), - /// or -1 on Escape. - /// - public static int SelectInPanel(string[] breadcrumbs, string? context, List items, - List commands, int pageSize = 0, - int startIndex = 0, HashSet? skipIndices = null, Action? renderAboveList = null) - { - if (items.Count == 0) - { - return -1; - } - - // Calculate available page size dynamically to ensure command bar stays visible. - // Reserve rows: top border(1) + breadcrumb(1) + context(0-1) + separator(1) - // + position indicator(0-1) + separator(1) + command bar(1) + bottom border(1) = 6-8 - var headerRows = 3 + (context is not null ? 1 : 0); // top border + breadcrumb + context + separator - var footerRows = 3; // separator + command bar + bottom border - var reservedIndicatorRows = 1; // position indicator when items > visible - var maxPageSize = Math.Max(5, Console.WindowHeight - headerRows - footerRows - reservedIndicatorRows); - - if (pageSize <= 0) - { - pageSize = maxPageSize; - } - else - { - pageSize = Math.Min(pageSize, maxPageSize); - } - - var selected = Math.Clamp(startIndex, 0, items.Count - 1); - if (skipIndices is not null) - { - while (selected < items.Count && skipIndices.Contains(selected)) - { - selected++; - } - if (selected >= items.Count) - { - selected = Math.Clamp(startIndex, 0, items.Count - 1); - while (selected > 0 && skipIndices.Contains(selected)) - { - selected--; - } - } - } - - var scrollOffset = Math.Max(0, selected - pageSize + 1); - var visibleCount = Math.Min(pageSize, items.Count); - var listStartRow = -1; - var barRow = -1; - var barFocused = false; - var barIndex = 0; - var needsFullRedraw = true; - - while (true) - { - if (needsFullRedraw) - { - AnsiConsole.Clear(); - Console.CursorVisible = false; - var width = Console.WindowWidth - 2; - - AnsiConsole.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); - var crumbText = string.Join($" {Separator} ", breadcrumbs); - RenderPanelLine($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); - - if (context is not null) - { - RenderPanelLine(context); - } - - AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); - renderAboveList?.Invoke(); - - listStartRow = Console.CursorTop; - - for (var i = 0; i < visibleCount; i++) - { - var idx = scrollOffset + i; - if (idx >= items.Count) - { - RenderEmptyLine(); - continue; - } - - if (!barFocused && idx == selected) - { - RenderPanelLine($"[blue]>[/] {items[idx]}"); - } - else - { - RenderPanelLine($" {items[idx]}"); - } - } - - if (items.Count > visibleCount) - { - RenderPanelLine($"[dim]({selected + 1}/{items.Count})[/]"); - } - - AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); - barRow = Console.CursorTop; - RenderPanelLine(BuildCommandBarMarkup(commands, barIndex, barFocused)); - AnsiConsole.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); - - needsFullRedraw = false; - } - - var key = Console.ReadKey(true); - var prevSelected = selected; - var prevScrollOffset = scrollOffset; - - if (key.Key == ConsoleKey.Tab) - { - if (commands.Count == 0) - { - continue; - } - - barFocused = !barFocused; - RenderCommandBarAt(barRow, commands, barIndex, barFocused); - if (listStartRow >= 0 && selected - scrollOffset >= 0 && selected - scrollOffset < visibleCount) - { - RenderListLineAt(listStartRow + (selected - scrollOffset), items[selected], !barFocused); - } - continue; - } - - if (barFocused) - { - switch (key.Key) - { - case ConsoleKey.LeftArrow: - barIndex = (barIndex - 1 + commands.Count) % commands.Count; - RenderCommandBarAt(barRow, commands, barIndex, true); - continue; - case ConsoleKey.RightArrow: - barIndex = (barIndex + 1) % commands.Count; - RenderCommandBarAt(barRow, commands, barIndex, true); - continue; - case ConsoleKey.Enter: - Console.CursorVisible = true; - return commands[barIndex].ReturnValue; - case ConsoleKey.Escape: - Console.CursorVisible = true; - return -1; - default: - for (var i = 0; i < commands.Count; i++) - { - if (key.Key == commands[i].Hotkey) - { - Console.CursorVisible = true; - return commands[i].ReturnValue; - } - } - continue; - } - } - - // List navigation - switch (key.Key) - { - case ConsoleKey.UpArrow: - if (selected > 0) - { - selected--; - while (selected > 0 && skipIndices is not null && skipIndices.Contains(selected)) - { - selected--; - } - if (skipIndices is not null && skipIndices.Contains(selected)) - { - selected++; - } - if (selected < scrollOffset) - { - scrollOffset = selected; - } - } - break; - case ConsoleKey.DownArrow: - if (selected < items.Count - 1) - { - selected++; - while (selected < items.Count - 1 && skipIndices is not null && skipIndices.Contains(selected)) - { - selected++; - } - if (skipIndices is not null && skipIndices.Contains(selected)) - { - selected--; - } - if (selected >= scrollOffset + visibleCount) - { - scrollOffset = selected - visibleCount + 1; - } - } - break; - case ConsoleKey.Enter: - Console.CursorVisible = true; - return selected; - case ConsoleKey.Escape: - Console.CursorVisible = true; - return -1; - default: - for (var i = 0; i < commands.Count; i++) - { - if (key.Key == commands[i].Hotkey) - { - Console.CursorVisible = true; - return commands[i].ReturnValue; - } - } - break; - } - - if (scrollOffset != prevScrollOffset) - { - needsFullRedraw = true; - continue; - } - - if (selected != prevSelected && listStartRow >= 0) - { - var prevRow = listStartRow + (prevSelected - scrollOffset); - var newRow = listStartRow + (selected - scrollOffset); - - if (prevSelected - scrollOffset >= 0 && prevSelected - scrollOffset < visibleCount) - { - RenderListLineAt(prevRow, items[prevSelected], false); - } - if (selected - scrollOffset >= 0 && selected - scrollOffset < visibleCount) - { - RenderListLineAt(newRow, items[selected], true); - } - - if (items.Count > visibleCount) - { - var indicatorRow = listStartRow + visibleCount; - Console.SetCursorPosition(0, indicatorRow); - RenderPanelLine($"[dim]({selected + 1}/{items.Count})[/]"); - } - } - } - } - - - // ── Detail Panel ──────────────────────────────────────────────── - - /// - /// Renders a detail view inside a panel frame with scrollable content. - /// Content is captured and paginated to fit the terminal. Callers should use - /// in their key loop for scroll support. - /// - public static void RenderDetailPanel(string[] breadcrumbs, string? context, Action renderContent, string hotkeys) - { - // Capture content lines - var contentLines = new List(); - _captureTarget = contentLines; - try - { - renderContent(); - } - finally - { - _captureTarget = null; - } - - // Store for scroll support - _lastDetailBreadcrumbs = breadcrumbs; - _lastDetailContext = context; - _lastDetailHotkeys = hotkeys; - _lastDetailLines = contentLines; - _lastDetailScrollOffset = 0; - - RenderDetailPanelFrame(breadcrumbs, context, contentLines, 0, hotkeys); - } - - /// - /// Handles scroll keys (Up/Down/PageUp/PageDown) for the last rendered detail panel. - /// Returns true if the key was handled (scrolled), false if the caller should process it. - /// - public static bool HandleDetailScroll(ConsoleKeyInfo key) - { - if (_lastDetailLines is null || _lastDetailLines.Count == 0) - { - return false; - } - - var availableHeight = GetDetailAvailableHeight(_lastDetailContext); - if (_lastDetailLines.Count <= availableHeight) - { - return false; // No scrolling needed - } - - var maxOffset = Math.Max(0, _lastDetailLines.Count - availableHeight); - var oldOffset = _lastDetailScrollOffset; - - switch (key.Key) - { - case ConsoleKey.UpArrow: - _lastDetailScrollOffset = Math.Max(0, _lastDetailScrollOffset - 1); - break; - case ConsoleKey.DownArrow: - _lastDetailScrollOffset = Math.Min(maxOffset, _lastDetailScrollOffset + 1); - break; - case ConsoleKey.PageUp: - _lastDetailScrollOffset = Math.Max(0, _lastDetailScrollOffset - 10); - break; - case ConsoleKey.PageDown: - _lastDetailScrollOffset = Math.Min(maxOffset, _lastDetailScrollOffset + 10); - break; - default: - return false; - } - - if (_lastDetailScrollOffset != oldOffset) - { - RenderDetailPanelFrame(_lastDetailBreadcrumbs!, _lastDetailContext, _lastDetailLines, _lastDetailScrollOffset, _lastDetailHotkeys!); - } - - return true; - } - - private static string[]? _lastDetailBreadcrumbs; - private static string? _lastDetailContext; - private static string? _lastDetailHotkeys; - private static List? _lastDetailLines; - private static int _lastDetailScrollOffset; - - private static int GetDetailAvailableHeight(string? context) - { - // Header: top border(1) + breadcrumb(1) + context?(0-1) + separator(1) - // Footer: separator(1) + hotkeys(1) + bottom border(1) - var headerRows = 3 + (context is not null ? 1 : 0); - var footerRows = 3; - return Math.Max(5, Console.WindowHeight - headerRows - footerRows); - } - - private static void RenderDetailPanelFrame(string[] breadcrumbs, string? context, List contentLines, int scrollOffset, string hotkeys) - { - Console.Clear(); - Console.SetCursorPosition(0, 0); - Console.CursorVisible = false; - var width = Console.WindowWidth - 2; - - // Header - AnsiConsole.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); - var crumbText = string.Join($" {Separator} ", breadcrumbs); - RenderPanelLineDirect($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); - - if (context is not null) - { - RenderPanelLineDirect(context); - } - - AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); - - // Content - var availableHeight = GetDetailAvailableHeight(context); - var visibleLines = contentLines.Skip(scrollOffset).Take(availableHeight).ToList(); - foreach (var line in visibleLines) - { - RenderPanelLineDirect(line); - } - - // Pad remaining space - for (var i = visibleLines.Count; i < availableHeight; i++) - { - RenderEmptyLineDirect(); - } - - // Footer - AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); - var scrollHint = contentLines.Count > availableHeight - ? $" [dim]({scrollOffset + 1}-{Math.Min(scrollOffset + availableHeight, contentLines.Count)}/{contentLines.Count} Up/Dn)[/]" - : ""; - RenderPanelLineDirect($"{hotkeys}{scrollHint}"); - AnsiConsole.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); - Console.CursorVisible = true; - } - - // Capture infrastructure for RenderDetailPanel scrolling - [ThreadStatic] - private static List? _captureTarget; - - /// - /// Renders a single line inside the panel with vertical borders (direct, no capture). - /// Truncates content to fit within the panel width. - /// - private static void RenderPanelLineDirect(string markupContent) - { - var maxContentWidth = Console.WindowWidth - 4; // borders + padding - var plainText = Markup.Remove(markupContent); - var displayContent = markupContent; - - // If plain text exceeds available width, truncate - if (plainText.Length > maxContentWidth) - { - // Truncate the plain text and use it directly (loses markup but prevents wrapping) - var truncated = plainText[..(maxContentWidth - 3)] + "..."; - displayContent = Markup.Escape(truncated); - } - - AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/] "); - AnsiConsole.Markup(displayContent); - - var plainLen = Markup.Remove(displayContent).Length; - var padding = Math.Max(0, maxContentWidth - plainLen); - Console.Write(new string(' ', padding)); - AnsiConsole.MarkupLine($" [{BorderStyle}]{Vertical}[/]"); - } - - /// - /// Renders an empty line (direct, no capture). - /// - private static void RenderEmptyLineDirect() - { - var width = Console.WindowWidth - 2; - AnsiConsole.Markup($"[{BorderStyle}]{Vertical}[/]"); - Console.Write(new string(' ', width)); - AnsiConsole.MarkupLine($"[{BorderStyle}]{Vertical}[/]"); - } - - // ── Text Prompt ───────────────────────────────────────────────── - - /// - /// Prompts for text input inside the panel frame. - /// - public static string? PromptInPanel(string[] breadcrumbs, string prompt, string? currentValue = null) - { - AnsiConsole.Clear(); - Console.CursorVisible = true; - var width = Console.WindowWidth - 2; - - AnsiConsole.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); - var crumbText = string.Join($" {Separator} ", breadcrumbs); - RenderPanelLine($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); - - AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); - - RenderPanelLine($"[bold]{prompt}[/]"); - if (currentValue is not null) - { - RenderPanelLine($"[dim]Current: {Markup.Escape(currentValue)}[/]"); - } - RenderEmptyLine(); - - AnsiConsole.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); - RenderPanelLine("[blue]Enter[/] Confirm [blue]Esc[/] Cancel"); - AnsiConsole.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); - - var inputRow = Console.CursorTop - 4; - Console.SetCursorPosition(4, inputRow); - AnsiConsole.Markup("[blue]>[/] "); - - var buffer = new System.Text.StringBuilder(); - while (true) - { - var key = Console.ReadKey(true); - if (key.Key == ConsoleKey.Escape) - { - return null; - } - if (key.Key == ConsoleKey.Enter) - { - var result = buffer.ToString().Trim(); - return string.IsNullOrEmpty(result) ? null : result; - } - if (key.Key == ConsoleKey.Backspace) - { - if (buffer.Length > 0) - { - buffer.Remove(buffer.Length - 1, 1); - Console.Write("\b \b"); - } - continue; - } - if (key.KeyChar >= 32) - { - buffer.Append(key.KeyChar); - Console.Write(key.KeyChar); - } - } - } -} diff --git a/src/Tiger/Commands/PanelRenderer.cs b/src/Tiger/Commands/PanelRenderer.cs new file mode 100644 index 0000000..c2f0210 --- /dev/null +++ b/src/Tiger/Commands/PanelRenderer.cs @@ -0,0 +1,778 @@ +using Spectre.Console; + +namespace Tiger.Commands; + +/// +/// Represents a single command in the command bar. +/// +public record CommandBarItem(string Label, ConsoleKey Hotkey, int ReturnValue); + +/// +/// Renders and manages the "command and control" panel UI. Uses +/// for all output, input, and cursor operations — fully testable with Spectre.Console.Testing. +/// +/// Structure: +/// ╔═══════════════════════════════════════════════════╗ +/// ║ TIGER > Section > Subsection ║ +/// ║ Context line (filter, counts, etc.) ║ +/// ╠═══════════════════════════════════════════════════╣ +/// ║ Content area (list or detail) ║ +/// ╠═══════════════════════════════════════════════════╣ +/// ║ Command bar (focusable via Tab) ║ +/// ╚═══════════════════════════════════════════════════╝ +/// +public class PanelRenderer +{ + private const char TopLeft = '╔'; + private const char TopRight = '╗'; + private const char BottomLeft = '╚'; + private const char BottomRight = '╝'; + private const char Horizontal = '═'; + private const char Vertical = '║'; + private const char MiddleLeft = '╠'; + private const char MiddleRight = '╣'; + private const char Separator = '>'; + private const string BorderStyle = "dim"; + + private readonly IAnsiConsole _console; + + [ThreadStatic] + private static List? _captureTarget; + + // Scroll state for detail panels + private string[]? _lastDetailBreadcrumbs; + private string? _lastDetailContext; + private string? _lastDetailHotkeys; + private List? _lastDetailLines; + private int _lastDetailScrollOffset; + + public PanelRenderer(IAnsiConsole console) + { + _console = console; + } + + /// + /// Creates a renderer backed by the real terminal (AnsiConsole.Console). + /// + public static PanelRenderer Create() => new(AnsiConsole.Console); + + /// + /// The underlying console. + /// + public IAnsiConsole Console => _console; + + /// + /// Terminal width from the console profile. + /// + public int Width => _console.Profile.Width; + + /// + /// Terminal height from the console profile. + /// + public int Height => _console.Profile.Height; + + /// + /// Gets the usable content width (total width - 4 for borders and padding). + /// + public int ContentWidth => Math.Max(40, Width - 4); + + // ── Content building ──────────────────────────────────────────── + + /// + /// Renders a single line inside the panel with vertical borders. + /// During capture phase, buffers the markup string instead. + /// + public void RenderPanelLine(string markupContent) + { + if (_captureTarget is not null) + { + _captureTarget.Add(markupContent); + return; + } + + RenderPanelLineDirect(markupContent); + } + + /// + /// Renders an empty line inside the panel borders. + /// During capture phase, buffers an empty string. + /// + public void RenderEmptyLine() + { + if (_captureTarget is not null) + { + _captureTarget.Add(""); + return; + } + + RenderEmptyLineDirect(); + } + + /// + /// Renders a section title. + /// + public void RenderSectionTitle(string title) + { + RenderPanelLine($"[bold underline]{title}[/]"); + } + + /// + /// Renders a labeled value pair. + /// + public void RenderField(string label, string value) + { + RenderPanelLine($"[bold]{label}:[/] {value}"); + } + + /// + /// Renders the Tiger ASCII art logo. + /// + public void RenderLogo() + { + var writer = new StringWriter(); + var figletConsole = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(writer), + ColorSystem = ColorSystemSupport.NoColors, + }); + figletConsole.Write(new FigletText("tiger")); + var figletLines = writer.ToString().Split('\n', StringSplitOptions.None); + + foreach (var line in figletLines) + { + var trimmed = line.TrimEnd('\r', '\n'); + if (trimmed.Length > 0) + { + RenderPanelLine($"[bold orange1]{Markup.Escape(trimmed)}[/]"); + } + } + + RenderEmptyLine(); + RenderPanelLine("[bold orange1]TIGER[/] — CI/CD Infrastructure Management"); + RenderEmptyLine(); + } + + // ── Content Capture ───────────────────────────────────────────── + + /// + /// Executes capturing all RenderPanelLine/RenderEmptyLine + /// calls into a list of markup strings. + /// + public List CaptureContent(Action renderContent) + { + var lines = new List(); + _captureTarget = lines; + try + { + renderContent(); + } + finally + { + _captureTarget = null; + } + return lines; + } + + // ── Layout calculations ───────────────────────────────────────── + + /// + /// Computes how many content lines fit in the detail panel. + /// + public int GetDetailAvailableHeight(bool hasContext) + { + var headerRows = 3 + (hasContext ? 1 : 0); + var footerRows = 3; + return Math.Max(5, Height - headerRows - footerRows); + } + + /// + /// Computes the max page size for list panels. + /// + public int GetListPageSize(bool hasContext) + { + var headerRows = 3 + (hasContext ? 1 : 0); + var footerRows = 3; + var reservedIndicatorRows = 1; + return Math.Max(5, Height - headerRows - footerRows - reservedIndicatorRows); + } + + // ── Hotkey formatting ─────────────────────────────────────────── + + /// + /// Formats a command label with its hotkey in [X] bracket style. + /// + public static string FormatHotkeyLabel(CommandBarItem item) + { + var label = item.Label; + var hotkeyChar = item.Hotkey switch + { + >= ConsoleKey.A and <= ConsoleKey.Z => (char)('A' + (item.Hotkey - ConsoleKey.A)), + _ => '\0' + }; + + if (hotkeyChar == '\0') + { + return label; + } + + for (var i = 0; i < label.Length; i++) + { + if (char.ToUpperInvariant(label[i]) == hotkeyChar) + { + return $"{label[..i]}[blue][[{label[i]}]][/]{label[(i + 1)..]}"; + } + } + + return label; + } + + /// + /// Builds a static hotkey string for detail panel footers. + /// + public static string BuildCommandBarString(List commands) + { + var parts = new List(); + foreach (var cmd in commands) + { + parts.Add(FormatHotkeyLabel(cmd)); + } + return string.Join(" ", parts) + " [blue]Esc[/] Back"; + } + + /// + /// Builds the command bar markup with focus state for interactive panels. + /// + public static string BuildCommandBarMarkup(List commands, int focusedIndex, bool barFocused) + { + if (commands.Count == 0) + { + return "[dim]Up/Dn Navigate Enter Select Esc Back[/]"; + } + + var parts = new List(); + for (var i = 0; i < commands.Count; i++) + { + if (barFocused && i == focusedIndex) + { + parts.Add($"[bold white on blue] {commands[i].Label} [/]"); + } + else + { + parts.Add(FormatHotkeyLabel(commands[i])); + } + } + var barContent = string.Join(" ", parts); + var tabHint = barFocused + ? " [dim]<-> Move Enter Execute Tab Content[/]" + : " [dim]Tab Commands[/]"; + return $"{barContent}{tabHint}"; + } + + // ── Line truncation ───────────────────────────────────────────── + + /// + /// Truncates markup content so its plain-text length fits within the panel width. + /// + public string TruncateToFit(string markupContent) + { + var maxContentWidth = ContentWidth; + var plainText = Markup.Remove(markupContent); + + if (plainText.Length <= maxContentWidth) + { + return markupContent; + } + + var truncated = plainText[..(maxContentWidth - 3)] + "..."; + return Markup.Escape(truncated); + } + + // ── Interactive: Main Menu ─────────────────────────────────────── + + /// + /// Renders the main dashboard with logo and command bar. + /// Returns the selected command's ReturnValue, or -1 on Escape. + /// + public int ShowMainMenu(List commands) + { + var barIndex = 0; + + while (true) + { + Clear(); + _console.Cursor.Show(false); + RenderMainMenuFrame(commands, barIndex); + + while (true) + { + var key = ReadKey(); + switch (key.Key) + { + case ConsoleKey.LeftArrow: + barIndex = (barIndex - 1 + commands.Count) % commands.Count; + // Full redraw for simplicity (menu is small) + break; + case ConsoleKey.RightArrow: + barIndex = (barIndex + 1) % commands.Count; + break; + case ConsoleKey.Enter: + _console.Cursor.Show(true); + return commands[barIndex].ReturnValue; + case ConsoleKey.Escape: + _console.Cursor.Show(true); + return -1; + default: + for (var i = 0; i < commands.Count; i++) + { + if (key.Key == commands[i].Hotkey) + { + _console.Cursor.Show(true); + return commands[i].ReturnValue; + } + } + continue; // Unknown key, don't redraw + } + break; // Redraw on arrow keys + } + } + } + + // ── Interactive: Select in Panel ──────────────────────────────── + + /// + /// List selection with a focusable command bar. + /// Returns list index on Enter, command ReturnValue, or -1 on Escape. + /// + public int SelectInPanel(string[] breadcrumbs, string? context, List items, + List commands, int pageSize = 0, + int startIndex = 0, HashSet? skipIndices = null, Action? renderAboveList = null) + { + if (items.Count == 0) + { + return -1; + } + + var maxPageSize = GetListPageSize(context is not null); + if (pageSize <= 0) + { + pageSize = maxPageSize; + } + else + { + pageSize = Math.Min(pageSize, maxPageSize); + } + + var selected = Math.Clamp(startIndex, 0, items.Count - 1); + if (skipIndices is not null) + { + while (selected < items.Count && skipIndices.Contains(selected)) + { + selected++; + } + if (selected >= items.Count) + { + selected = Math.Clamp(startIndex, 0, items.Count - 1); + while (selected > 0 && skipIndices.Contains(selected)) + { + selected--; + } + } + } + + var scrollOffset = Math.Max(0, selected - pageSize + 1); + var visibleCount = Math.Min(pageSize, items.Count); + var barFocused = false; + var barIndex = 0; + var needsFullRedraw = true; + + while (true) + { + if (needsFullRedraw) + { + Clear(); + _console.Cursor.Show(false); + RenderListFrame(breadcrumbs, context, items, selected, scrollOffset, + visibleCount, barFocused, commands, barIndex, renderAboveList); + needsFullRedraw = false; + } + + var key = ReadKey(); + var prevSelected = selected; + var prevScrollOffset = scrollOffset; + + if (key.Key == ConsoleKey.Tab) + { + if (commands.Count == 0) + { + continue; + } + barFocused = !barFocused; + needsFullRedraw = true; + continue; + } + + if (barFocused) + { + switch (key.Key) + { + case ConsoleKey.LeftArrow: + barIndex = (barIndex - 1 + commands.Count) % commands.Count; + needsFullRedraw = true; + continue; + case ConsoleKey.RightArrow: + barIndex = (barIndex + 1) % commands.Count; + needsFullRedraw = true; + continue; + case ConsoleKey.Enter: + _console.Cursor.Show(true); + return commands[barIndex].ReturnValue; + case ConsoleKey.Escape: + _console.Cursor.Show(true); + return -1; + default: + for (var i = 0; i < commands.Count; i++) + { + if (key.Key == commands[i].Hotkey) + { + _console.Cursor.Show(true); + return commands[i].ReturnValue; + } + } + continue; + } + } + + switch (key.Key) + { + case ConsoleKey.UpArrow: + if (selected > 0) + { + selected--; + while (selected > 0 && skipIndices is not null && skipIndices.Contains(selected)) + { + selected--; + } + if (skipIndices is not null && skipIndices.Contains(selected)) + { + selected++; + } + if (selected < scrollOffset) + { + scrollOffset = selected; + } + } + break; + case ConsoleKey.DownArrow: + if (selected < items.Count - 1) + { + selected++; + while (selected < items.Count - 1 && skipIndices is not null && skipIndices.Contains(selected)) + { + selected++; + } + if (skipIndices is not null && skipIndices.Contains(selected)) + { + selected--; + } + if (selected >= scrollOffset + visibleCount) + { + scrollOffset = selected - visibleCount + 1; + } + } + break; + case ConsoleKey.Enter: + _console.Cursor.Show(true); + return selected; + case ConsoleKey.Escape: + _console.Cursor.Show(true); + return -1; + default: + for (var i = 0; i < commands.Count; i++) + { + if (key.Key == commands[i].Hotkey) + { + _console.Cursor.Show(true); + return commands[i].ReturnValue; + } + } + break; + } + + if (selected != prevSelected || scrollOffset != prevScrollOffset) + { + needsFullRedraw = true; + } + } + } + + // ── Interactive: Detail Panel ─────────────────────────────────── + + /// + /// Renders a detail view with scrollable content. + /// Use in the caller's key loop. + /// + public void RenderDetailPanel(string[] breadcrumbs, string? context, Action renderContent, string hotkeys) + { + var contentLines = CaptureContent(renderContent); + + _lastDetailBreadcrumbs = breadcrumbs; + _lastDetailContext = context; + _lastDetailHotkeys = hotkeys; + _lastDetailLines = contentLines; + _lastDetailScrollOffset = 0; + + Clear(); + _console.Cursor.Show(false); + RenderDetailFrame(breadcrumbs, context, contentLines, 0, hotkeys); + _console.Cursor.Show(true); + } + + /// + /// Handles scroll keys for the last rendered detail panel. + /// Returns true if handled, false if the caller should process the key. + /// + public bool HandleDetailScroll(ConsoleKeyInfo key) + { + if (_lastDetailLines is null || _lastDetailLines.Count == 0) + { + return false; + } + + var availableHeight = GetDetailAvailableHeight(_lastDetailContext is not null); + if (_lastDetailLines.Count <= availableHeight) + { + return false; + } + + var maxOffset = Math.Max(0, _lastDetailLines.Count - availableHeight); + var oldOffset = _lastDetailScrollOffset; + + switch (key.Key) + { + case ConsoleKey.UpArrow: + _lastDetailScrollOffset = Math.Max(0, _lastDetailScrollOffset - 1); + break; + case ConsoleKey.DownArrow: + _lastDetailScrollOffset = Math.Min(maxOffset, _lastDetailScrollOffset + 1); + break; + case ConsoleKey.PageUp: + _lastDetailScrollOffset = Math.Max(0, _lastDetailScrollOffset - 10); + break; + case ConsoleKey.PageDown: + _lastDetailScrollOffset = Math.Min(maxOffset, _lastDetailScrollOffset + 10); + break; + default: + return false; + } + + if (_lastDetailScrollOffset != oldOffset) + { + Clear(); + _console.Cursor.Show(false); + RenderDetailFrame(_lastDetailBreadcrumbs!, _lastDetailContext, _lastDetailLines, _lastDetailScrollOffset, _lastDetailHotkeys!); + _console.Cursor.Show(true); + } + + return true; + } + + // ── Interactive: Text Prompt ───────────────────────────────────── + + /// + /// Prompts for text input inside the panel frame. + /// + public string? PromptInPanel(string[] breadcrumbs, string prompt, string? currentValue = null) + { + Clear(); + _console.Cursor.Show(true); + var width = Width - 2; + + _console.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + var crumbText = string.Join($" {Separator} ", breadcrumbs); + RenderPanelLineDirect($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + + RenderPanelLineDirect($"[bold]{prompt}[/]"); + if (currentValue is not null) + { + RenderPanelLineDirect($"[dim]Current: {Markup.Escape(currentValue)}[/]"); + } + RenderEmptyLineDirect(); + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + RenderPanelLineDirect("[blue]Enter[/] Confirm [blue]Esc[/] Cancel"); + _console.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + + // For text input we need System.Console since IAnsiConsole doesn't support + // character-by-character echo with backspace. This is the one place we + // fall back to System.Console for input handling. + var inputRow = System.Console.CursorTop - 4; + System.Console.SetCursorPosition(4, inputRow); + _console.Markup("[blue]>[/] "); + + var buffer = new System.Text.StringBuilder(); + while (true) + { + var key = ReadKey(); + if (key.Key == ConsoleKey.Escape) + { + return null; + } + if (key.Key == ConsoleKey.Enter) + { + var result = buffer.ToString().Trim(); + return string.IsNullOrEmpty(result) ? null : result; + } + if (key.Key == ConsoleKey.Backspace) + { + if (buffer.Length > 0) + { + buffer.Remove(buffer.Length - 1, 1); + System.Console.Write("\b \b"); + } + continue; + } + if (key.KeyChar >= 32) + { + buffer.Append(key.KeyChar); + System.Console.Write(key.KeyChar); + } + } + } + + // ── Frame rendering ───────────────────────────────────────────── + + internal void RenderDetailFrame(string[] breadcrumbs, string? context, List contentLines, int scrollOffset, string hotkeys) + { + var width = Width - 2; + + _console.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + var crumbText = string.Join($" {Separator} ", breadcrumbs); + RenderPanelLineDirect($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); + + if (context is not null) + { + RenderPanelLineDirect(context); + } + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + + var availableHeight = GetDetailAvailableHeight(context is not null); + var visibleLines = contentLines.Skip(scrollOffset).Take(availableHeight).ToList(); + foreach (var line in visibleLines) + { + RenderPanelLineDirect(line); + } + + for (var i = visibleLines.Count; i < availableHeight; i++) + { + RenderEmptyLineDirect(); + } + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + var scrollHint = contentLines.Count > availableHeight + ? $" [dim]({scrollOffset + 1}-{Math.Min(scrollOffset + availableHeight, contentLines.Count)}/{contentLines.Count} Up/Dn)[/]" + : ""; + RenderPanelLineDirect($"{hotkeys}{scrollHint}"); + _console.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + } + + private void RenderMainMenuFrame(List commands, int barIndex) + { + var width = Width - 2; + + _console.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + RenderPanelLineDirect("[bold orange1]TIGER[/]"); + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + + RenderLogo(); + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + RenderPanelLineDirect(BuildCommandBarMarkup(commands, barIndex, true)); + _console.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + } + + private void RenderListFrame(string[] breadcrumbs, string? context, List items, + int selected, int scrollOffset, int visibleCount, bool barFocused, + List commands, int barIndex, Action? renderAboveList) + { + var width = Width - 2; + + _console.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + var crumbText = string.Join($" {Separator} ", breadcrumbs); + RenderPanelLineDirect($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); + + if (context is not null) + { + RenderPanelLineDirect(context); + } + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + renderAboveList?.Invoke(); + + for (var i = 0; i < visibleCount; i++) + { + var idx = scrollOffset + i; + if (idx >= items.Count) + { + RenderEmptyLineDirect(); + continue; + } + + if (!barFocused && idx == selected) + { + RenderPanelLineDirect($"[blue]>[/] {items[idx]}"); + } + else + { + RenderPanelLineDirect($" {items[idx]}"); + } + } + + if (items.Count > visibleCount) + { + RenderPanelLineDirect($"[dim]({selected + 1}/{items.Count})[/]"); + } + + _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + RenderPanelLineDirect(BuildCommandBarMarkup(commands, barIndex, barFocused)); + _console.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + } + + // ── Direct rendering helpers ──────────────────────────────────── + + private void RenderPanelLineDirect(string markupContent) + { + var displayContent = TruncateToFit(markupContent); + + _console.Markup($"[{BorderStyle}]{Vertical}[/] "); + _console.Markup(displayContent); + + var plainLen = Markup.Remove(displayContent).Length; + var padding = Math.Max(0, ContentWidth - plainLen); + _console.Markup(new string(' ', padding)); + _console.MarkupLine($" [{BorderStyle}]{Vertical}[/]"); + } + + private void RenderEmptyLineDirect() + { + var width = Width - 2; + _console.Markup($"[{BorderStyle}]{Vertical}[/]"); + _console.Markup(new string(' ', width)); + _console.MarkupLine($"[{BorderStyle}]{Vertical}[/]"); + } + + // ── Console operations ────────────────────────────────────────── + + private void Clear() + { + _console.Clear(); + } + + private ConsoleKeyInfo ReadKey() + { + var result = _console.Input.ReadKey(true); + return result ?? new ConsoleKeyInfo('\0', ConsoleKey.None, false, false, false); + } +} diff --git a/src/Tiger/Commands/TestBrowser.cs b/src/Tiger/Commands/TestBrowser.cs index 4628e21..2b9a4af 100644 --- a/src/Tiger/Commands/TestBrowser.cs +++ b/src/Tiger/Commands/TestBrowser.cs @@ -8,6 +8,8 @@ namespace Tiger.Commands; /// public sealed class TestBrowser { + private readonly PanelRenderer _ui = PanelRenderer.Create(); + private readonly TigerDatabase _db; private readonly AzdoClientFactory _clientFactory; private readonly string _configDirectory; @@ -42,11 +44,11 @@ public void Browse() ? "[yellow]No test failures match the current filter.[/]" : "[yellow]No test failures recorded yet.[/]"; - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Tests"], context, - () => PanelLayout.RenderPanelLine(emptyMsg), - PanelLayout.BuildCommandBarString(new List + () => _ui.RenderPanelLine(emptyMsg), + PanelRenderer.BuildCommandBarString(new List { new("Edit filter", ConsoleKey.E, -5), new("Filter menu", ConsoleKey.F, -2), @@ -77,7 +79,7 @@ public void Browse() commands.Add(new("Clear", ConsoleKey.C, -4)); } - var selected = PanelLayout.SelectInPanel( + var selected = _ui.SelectInPanel( ["Tests"], context, choices, @@ -101,10 +103,10 @@ private void ShowTestDetail(TestRow test) var info = BrowserUI.LoadTestDetail(_db, test.Org, test.Project, test.TestName); if (info is null) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Tests", "Detail"], null, - () => PanelLayout.RenderPanelLine("[yellow]No test failure data found.[/]"), + () => _ui.RenderPanelLine("[yellow]No test failure data found.[/]"), "[blue]Esc[/] Back"); Console.ReadKey(true); return; @@ -121,16 +123,16 @@ private void ShowTestDetail(TestRow test) commands.Add(new("Helix", ConsoleKey.H, -4)); } - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Tests", Markup.Escape(shortTitle)], null, - () => BrowserUI.RenderTestDetailInPanel(info), - PanelLayout.BuildCommandBarString(commands)); + () => BrowserUI.RenderTestDetailInPanel(_ui, info), + PanelRenderer.BuildCommandBarString(commands)); while (true) { var key = Console.ReadKey(true); - if (PanelLayout.HandleDetailScroll(key)) continue; + if (_ui.HandleDetailScroll(key)) continue; if (key.Key == ConsoleKey.Escape) { return; @@ -154,38 +156,38 @@ private void ShowTestDetail(TestRow test) } } - private static void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) + private void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Tests", "Helix Work Item"], null, () => { if (info.IsHelixDeadletter) { - PanelLayout.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure [/]"); - PanelLayout.RenderEmptyLine(); + _ui.RenderPanelLine("[bold red on yellow] !! HELIX DEAD LETTER — Infrastructure failure [/]"); + _ui.RenderEmptyLine(); } - PanelLayout.RenderField("Job", Markup.Escape(info.HelixJobName!)); + _ui.RenderField("Job", Markup.Escape(info.HelixJobName!)); if (info.HelixWorkItemName is not null) { - PanelLayout.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); + _ui.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); var url = HelixClient.GetConsoleUrl(info.HelixJobName!, info.HelixWorkItemName); - PanelLayout.RenderField("Console", BrowserUI.FormatLink(url, "Console Log")); + _ui.RenderField("Console", BrowserUI.FormatLink(url, "Console Log")); } if (info.HelixFiles is { Count: > 0 }) { - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderSectionTitle($"Files ({info.HelixFiles.Count})"); + _ui.RenderEmptyLine(); + _ui.RenderSectionTitle($"Files ({info.HelixFiles.Count})"); foreach (var (name, uri) in info.HelixFiles) { if (uri is not null) { - PanelLayout.RenderPanelLine($" {BrowserUI.FormatLink(uri, name)}"); + _ui.RenderPanelLine($" {BrowserUI.FormatLink(uri, name)}"); } else { - PanelLayout.RenderPanelLine($" {Markup.Escape(name)}"); + _ui.RenderPanelLine($" {Markup.Escape(name)}"); } } } @@ -194,7 +196,7 @@ private static void ShowHelixWorkItemDetail(BrowserUI.TestDetailInfo info) while (true) { var key = Console.ReadKey(true); - if (PanelLayout.HandleDetailScroll(key)) continue; + if (_ui.HandleDetailScroll(key)) continue; if (key.Key == ConsoleKey.Escape) return; } } @@ -206,10 +208,10 @@ private void ShowTestBuilds(TestRow test) if (builds.Count == 0) { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Tests", Markup.Escape(shortTitle), "Builds"], null, - () => PanelLayout.RenderPanelLine("[yellow]No builds found.[/]"), + () => _ui.RenderPanelLine("[yellow]No builds found.[/]"), "[blue]Esc[/] Back"); Console.ReadKey(true); return; @@ -220,7 +222,7 @@ private void ShowTestBuilds(TestRow test) b.FinishTime, b.PrNumber)).ToList(); var commands = new List(); - var selected = PanelLayout.SelectInPanel( + var selected = _ui.SelectInPanel( ["Tests", Markup.Escape(shortTitle), "Builds"], $"[dim]{builds.Count} build(s) with this failure[/]", choices, @@ -333,7 +335,7 @@ LIMIT 30 private void EditFilter() { var currentValue = _filter.IsActive ? _filter.ToString() : null; - var result = PanelLayout.PromptInPanel( + var result = _ui.PromptInPanel( ["Tests", "Edit Filter"], "Enter filter expression (e.g. test:Serialization repo:roslyn def:*-CI)", currentValue); @@ -359,41 +361,41 @@ private void ShowFilterMenu() new("Clear", ConsoleKey.C, 6), }; - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Tests", "Filter"], null, () => { - PanelLayout.RenderPanelLine("Filter test failures by name, repository, definition, kind, etc."); - PanelLayout.RenderEmptyLine(); + _ui.RenderPanelLine("Filter test failures by name, repository, definition, kind, etc."); + _ui.RenderEmptyLine(); if (_filter.IsActive) { - PanelLayout.RenderPanelLine($"[bold]Current filter:[/] {Markup.Escape(_filter.ToString())}"); + _ui.RenderPanelLine($"[bold]Current filter:[/] {Markup.Escape(_filter.ToString())}"); } else { - PanelLayout.RenderPanelLine("[dim]No filter active[/]"); + _ui.RenderPanelLine("[dim]No filter active[/]"); } - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderPanelLine("[dim]Syntax: substring match by default, * for wildcards, ! suffix for exact[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[dim]Syntax: substring match by default, * for wildcards, ! suffix for exact[/]"); }, - PanelLayout.BuildCommandBarString(commands)); + PanelRenderer.BuildCommandBarString(commands)); var key = Console.ReadKey(true); switch (key.Key) { case ConsoleKey.N: - _filter.TestNamePattern = PanelLayout.PromptInPanel(["Tests", "Filter"], "Test name pattern (e.g. Serialization, *EditAndContinue*)"); + _filter.TestNamePattern = _ui.PromptInPanel(["Tests", "Filter"], "Test name pattern (e.g. Serialization, *EditAndContinue*)"); SaveFilter(); continue; case ConsoleKey.R: - _filter.RepoPattern = PanelLayout.PromptInPanel(["Tests", "Filter"], "Repository pattern (e.g. roslyn, dotnet/*)"); + _filter.RepoPattern = _ui.PromptInPanel(["Tests", "Filter"], "Repository pattern (e.g. roslyn, dotnet/*)"); SaveFilter(); continue; case ConsoleKey.D: - _filter.DefinitionPattern = PanelLayout.PromptInPanel(["Tests", "Filter"], "Definition pattern (e.g. ci, roslyn-CI*)"); + _filter.DefinitionPattern = _ui.PromptInPanel(["Tests", "Filter"], "Definition pattern (e.g. ci, roslyn-CI*)"); SaveFilter(); continue; case ConsoleKey.K: @@ -417,9 +419,9 @@ private void ShowFilterMenu() /// /// Prompts the user to enter a PR number. Returns null if cancelled or invalid. /// - private static int? PromptPrNumber() + private int? PromptPrNumber() { - var raw = PanelLayout.PromptInPanel(["Tests", "Filter"], "PR number (e.g. 12345)"); + var raw = _ui.PromptInPanel(["Tests", "Filter"], "PR number (e.g. 12345)"); if (raw is null) { return null; @@ -427,31 +429,31 @@ private void ShowFilterMenu() return int.TryParse(raw, out var pr) ? pr : null; } - private static void ShowFilterHelp() + private void ShowFilterHelp() { - PanelLayout.RenderDetailPanel( + _ui.RenderDetailPanel( ["Tests", "Filter Help"], null, () => { - PanelLayout.RenderPanelLine("[bold]Quick filter (E):[/]"); - PanelLayout.RenderPanelLine(" Type an expression like: [blue]test:Serialization repo:roslyn[/]"); - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderPanelLine("[bold]Matching (default: contains / LIKE):[/]"); - PanelLayout.RenderPanelLine(" [dim]Serial - matches tests containing 'Serial'[/]"); - PanelLayout.RenderPanelLine(" [dim]*EditAndContinue* - matches tests with 'EditAndContinue'[/]"); - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderPanelLine("[bold]Exact match (append !):[/]"); - PanelLayout.RenderPanelLine(" [dim]dotnet/roslyn! - matches exactly 'dotnet/roslyn'[/]"); - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderPanelLine("[bold]Filter prefixes:[/]"); - PanelLayout.RenderPanelLine(" [blue]test:[/] Test name"); - PanelLayout.RenderPanelLine(" [blue]repo:[/] Repository name"); - PanelLayout.RenderPanelLine(" [blue]def:[/] Definition/pipeline name"); - PanelLayout.RenderPanelLine(" [blue]kind:[/] Build kind (pr, ci)"); - PanelLayout.RenderPanelLine(" [blue]pr:[/] PR number"); - PanelLayout.RenderEmptyLine(); - PanelLayout.RenderPanelLine("[bold]Multiple filters combine with AND.[/]"); + _ui.RenderPanelLine("[bold]Quick filter (E):[/]"); + _ui.RenderPanelLine(" Type an expression like: [blue]test:Serialization repo:roslyn[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Matching (default: contains / LIKE):[/]"); + _ui.RenderPanelLine(" [dim]Serial - matches tests containing 'Serial'[/]"); + _ui.RenderPanelLine(" [dim]*EditAndContinue* - matches tests with 'EditAndContinue'[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Exact match (append !):[/]"); + _ui.RenderPanelLine(" [dim]dotnet/roslyn! - matches exactly 'dotnet/roslyn'[/]"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Filter prefixes:[/]"); + _ui.RenderPanelLine(" [blue]test:[/] Test name"); + _ui.RenderPanelLine(" [blue]repo:[/] Repository name"); + _ui.RenderPanelLine(" [blue]def:[/] Definition/pipeline name"); + _ui.RenderPanelLine(" [blue]kind:[/] Build kind (pr, ci)"); + _ui.RenderPanelLine(" [blue]pr:[/] PR number"); + _ui.RenderEmptyLine(); + _ui.RenderPanelLine("[bold]Multiple filters combine with AND.[/]"); }, "[blue]Esc[/] Back"); Console.ReadKey(true); @@ -546,3 +548,5 @@ public void Save(string configDirectory) } } } + + From 3e6ceee926edcc5107beacddf834181ecfbd32d8 Mon Sep 17 00:00:00 2001 From: Jared Parsons Date: Wed, 10 Jun 2026 09:00:54 -0700 Subject: [PATCH 06/10] Fix helix display: consistent section title and exit code format - Rename section title to 'Failed Helix Work Items' for consistency - Helix drill-down now uses same format as build summary: X exit - Add 3 tests verifying helix display format Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tiger.Tests/PanelRendererTests.cs | 65 +++++++++++++++++++++++++++ src/Tiger/Commands/BuildBrowser.cs | 17 +++---- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/Tiger.Tests/PanelRendererTests.cs b/src/Tiger.Tests/PanelRendererTests.cs index ea42e07..6fb5e31 100644 --- a/src/Tiger.Tests/PanelRendererTests.cs +++ b/src/Tiger.Tests/PanelRendererTests.cs @@ -331,4 +331,69 @@ private static void AssertAsciiOnly(string text, string context) $"Non-ASCII char U+{(int)text[i]:X4} ('{text[i]}') at position {i} in {context}: \"{text}\""); } } + + // ── Helix Work Item Display Format ────────────────────────────── + + [Fact] + public void HelixWorkItem_SectionTitle_IncludesFailed() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + // Mirrors BuildBrowser.RenderBuildDetail helix section + var count = 2; + renderer.RenderSectionTitle($"Failed Helix Work Items ({count})"); + }); + + Assert.Single(lines); + Assert.Contains("Failed Helix Work Items", lines[0]); + } + + [Fact] + public void HelixWorkItem_Format_IncludesExitCode() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + // Mirrors the per-item rendering in BuildBrowser + var wi = "workitem1"; + var job = "job-abc123"; + int? exitCode = 1; + var isDeadletter = false; + var exitInfo = exitCode is not null ? $" exit {exitCode}" : ""; + var extra = isDeadletter ? " [red]deadletter[/]" : ""; + var color = (exitCode ?? 1) == 0 ? "green" : "red"; + renderer.RenderPanelLine($" [{color}]X[/] {Markup.Escape(wi)} [dim]{Markup.Escape(job)}[/]{exitInfo}{extra}"); + }); + + Assert.Single(lines); + var plain = Markup.Remove(lines[0]); + Assert.Contains("workitem1", plain); + Assert.Contains("job-abc123", plain); + Assert.Contains("exit 1", plain); + } + + [Fact] + public void HelixWorkItem_Deadletter_Format_IncludesExitCodeAndDeadletter() + { + var renderer = CreateRenderer(); + var lines = renderer.CaptureContent(() => + { + var wi = "workitem2"; + var job = "job-def456"; + int? exitCode = -1; + var isDeadletter = true; + var exitInfo = exitCode is not null ? $" exit {exitCode}" : ""; + var extra = isDeadletter ? " [red]deadletter[/]" : ""; + var color = (exitCode ?? 1) == 0 ? "green" : "red"; + renderer.RenderPanelLine($" [{color}]X[/] {Markup.Escape(wi)} [dim]{Markup.Escape(job)}[/]{exitInfo}{extra}"); + }); + + Assert.Single(lines); + var plain = Markup.Remove(lines[0]); + Assert.Contains("workitem2", plain); + Assert.Contains("job-def456", plain); + Assert.Contains("exit -1", plain); + Assert.Contains("deadletter", plain); + } } diff --git a/src/Tiger/Commands/BuildBrowser.cs b/src/Tiger/Commands/BuildBrowser.cs index 61a73c8..62d64aa 100644 --- a/src/Tiger/Commands/BuildBrowser.cs +++ b/src/Tiger/Commands/BuildBrowser.cs @@ -774,7 +774,7 @@ LIMIT 15 if (helixItems.Count > 0) { _ui.RenderEmptyLine(); - _ui.RenderSectionTitle($"Helix Work Items ({helixItems.Count})"); + _ui.RenderSectionTitle($"Failed Helix Work Items ({helixItems.Count})"); foreach (var (job, wi, state, exitCode, isDeadletter) in helixItems) { var exitInfo = exitCode is not null ? $" exit {exitCode}" : ""; @@ -1259,7 +1259,7 @@ LIMIT 30 }); _ui.RenderDetailPanel( - ["Builds", $"#{page.BuildId}", "Helix Work Items"], + ["Builds", $"#{page.BuildId}", "Failed Helix Work Items"], $"[dim]{helixItems.Count} work item(s)[/]", () => { @@ -1271,15 +1271,10 @@ LIMIT 30 foreach (var (job, wi, state, exitCode, consoleUri, isDeadletter) in helixItems) { - if (isDeadletter) - { - _ui.RenderPanelLine($" [bold red]!! DEAD LETTER[/] [bold]{Markup.Escape(wi)}[/]"); - } - else - { - var stateInfo = state is not null ? $" [{(exitCode == 0 ? "green" : "red")}]{state} (exit {exitCode})[/]" : ""; - _ui.RenderPanelLine($" [bold]{Markup.Escape(wi)}[/]{stateInfo}"); - } + var exitInfo = exitCode is not null ? $" exit {exitCode}" : ""; + var extra = isDeadletter ? " [red]deadletter[/]" : ""; + var color = (exitCode ?? 1) == 0 ? "green" : "red"; + _ui.RenderPanelLine($" [{color}]X[/] {Markup.Escape(wi)} [dim]{Markup.Escape(job)}[/]{exitInfo}{extra}"); var url = consoleUri ?? HelixClient.GetConsoleUrl(job, wi); _ui.RenderPanelLine($" {BrowserUI.FormatLink(url, "Console Log")}"); From 9a67e9ae6f5e93db9793f16d1dcb27a667cd43e8 Mon Sep 17 00:00:00 2001 From: Jared Parsons Date: Wed, 10 Jun 2026 09:33:00 -0700 Subject: [PATCH 07/10] Fix list cursor partial redraw: 1-based SetPosition and no-scroll bottom border - Spectre's Cursor.SetPosition passes directly to ANSI CUP (1-based), so add +1 to all row calculations for partial redraw - Remove trailing newline from bottom border to prevent terminal scroll when frame fills the screen (which shifts all absolute positions) - Add SpyConsole test helper that records SetPosition calls - Add tests verifying correct row targeting (fail without fix, pass with) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tiger.Tests/PanelRendererTests.cs | 183 ++++++++++++++++++++++++++ src/Tiger/Commands/PanelRenderer.cs | 52 +++++++- 2 files changed, 230 insertions(+), 5 deletions(-) diff --git a/src/Tiger.Tests/PanelRendererTests.cs b/src/Tiger.Tests/PanelRendererTests.cs index 6fb5e31..e7051bf 100644 --- a/src/Tiger.Tests/PanelRendererTests.cs +++ b/src/Tiger.Tests/PanelRendererTests.cs @@ -1,4 +1,5 @@ using Spectre.Console; +using Spectre.Console.Rendering; using Spectre.Console.Testing; using Tiger.Commands; using Xunit; @@ -396,4 +397,186 @@ public void HelixWorkItem_Deadletter_Format_IncludesExitCodeAndDeadletter() Assert.Contains("exit -1", plain); Assert.Contains("deadletter", plain); } + + // ── Cursor Redraw (partial update) ────────────────────────────── + + [Fact] + public void SelectInPanel_CursorMove_PreservesSeparator() + { + // Simulates: list with context, press Down, then Escape + // Verifies the partial redraw targets correct rows (not the separator) + var console = new TestConsole().Width(80).Height(24); + // Push keys: Down to move cursor, then Escape to exit + console.Input.PushKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false)); + console.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(console); + var items = new List { "Item A", "Item B", "Item C" }; + var commands = new List { new("Back", ConsoleKey.Escape, -1) }; + + var result = renderer.SelectInPanel( + ["Builds"], + "[dim]3 items[/]", + items, + commands); + + Assert.Equal(-1, result); // escaped + + var output = console.Output; + + // Verify the partial redraw wrote "Item B" with ">" prefix (cursor moved to it) + var lastItemB = output.LastIndexOf("Item B"); + Assert.True(lastItemB > 0); + // The last "Item B" should have > before it (the cursor indicator) + var segmentAroundB = output[(lastItemB - 10)..lastItemB]; + Assert.Contains(">", segmentAroundB); + } + + [Fact] + public void SelectInPanel_CursorMove_SetPosition_Uses1BasedRows() + { + // Verifies that SetPosition is called with 1-based row coordinates. + // With context, the layout is: + // Row 1 (1-based): top border + // Row 2: header + // Row 3: context + // Row 4: mid separator + // Row 5: first list item (initially selected) + // Row 6: second list item + // Row 7: third list item + // + // After pressing Down, partial redraw should call: + // SetPosition(0, 5) to deselect first item + // SetPosition(0, 6) to select second item + // NOT row 4 (the separator) or row 3! + + var spy = new SpyConsole(width: 80, height: 24); + spy.Inner.Input.PushKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false)); + spy.Inner.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(spy); + var items = new List { "Item A", "Item B", "Item C" }; + var commands = new List { new("Back", ConsoleKey.Escape, -1) }; + + renderer.SelectInPanel(["Builds"], "[dim]3 items[/]", items, commands); + + // Verify SetPosition calls target the correct 1-based rows + Assert.True(spy.SetPositionCalls.Count >= 2, + $"Expected at least 2 SetPosition calls, got {spy.SetPositionCalls.Count}"); + + // First call: deselect first item at row 5 (1-based) + var (col1, row1) = spy.SetPositionCalls[0]; + Assert.Equal(5, row1); // row 5 = first list item (1-based) + + // Second call: select second item at row 6 (1-based) + var (col2, row2) = spy.SetPositionCalls[1]; + Assert.Equal(6, row2); // row 6 = second list item (1-based) + + // Crucially: no SetPosition should target row 4 (the separator) + Assert.DoesNotContain(spy.SetPositionCalls, call => call.Line == 4); + } + + [Fact] + public void SelectInPanel_CursorMove_NoContext_SetPosition_Uses1BasedRows() + { + // Without context, layout is: + // Row 1: top border + // Row 2: header + // Row 3: mid separator + // Row 4: first list item + // Row 5: second list item + // + // After Down, SetPosition should target rows 4 and 5. + + var spy = new SpyConsole(width: 80, height: 24); + spy.Inner.Input.PushKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false)); + spy.Inner.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(spy); + var items = new List { "Item A", "Item B", "Item C" }; + var commands = new List { new("Back", ConsoleKey.Escape, -1) }; + + renderer.SelectInPanel(["Test"], null, items, commands); + + Assert.True(spy.SetPositionCalls.Count >= 2); + + // First call: deselect first item at row 4 (1-based, no context) + Assert.Equal(4, spy.SetPositionCalls[0].Line); + // Second call: select second item at row 5 + Assert.Equal(5, spy.SetPositionCalls[1].Line); + // No call should target row 3 (the separator) + Assert.DoesNotContain(spy.SetPositionCalls, call => call.Line == 3); + } + + [Fact] + public void SelectInPanel_BottomBorder_NoTrailingNewline() + { + // Verifies that the frame doesn't emit a trailing newline after the bottom border. + // A trailing newline would cause terminal scroll when the frame fills the screen, + // which breaks absolute cursor positioning for partial redraws. + var console = new TestConsole().Width(80).Height(24); + console.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(console); + var items = new List { "Item A", "Item B" }; + var commands = new List { new("Back", ConsoleKey.Escape, -1) }; + + renderer.SelectInPanel(["Test"], null, items, commands); + + var output = console.Output; + + // The output should NOT end with a newline — the bottom border + // is rendered with Markup (not MarkupLine) to prevent scroll + Assert.False(output.EndsWith("\n"), "Frame should not end with trailing newline"); + } +} + +/// +/// A wrapper around TestConsole that records Cursor.SetPosition calls +/// for verifying cursor positioning in tests. +/// +file class SpyConsole : IAnsiConsole +{ + public TestConsole Inner { get; } + public List<(int Column, int Line)> SetPositionCalls { get; } = new(); + + private readonly SpyCursor _cursor; + + public SpyConsole(int width = 80, int height = 24) + { + Inner = new TestConsole().Width(width).Height(height); + _cursor = new SpyCursor(Inner.Cursor, this); + } + + public string Output => Inner.Output; + public IAnsiConsoleCursor Cursor => _cursor; + public IAnsiConsoleInput Input => Inner.Input; + public IExclusivityMode ExclusivityMode => Inner.ExclusivityMode; + public RenderPipeline Pipeline => Inner.Pipeline; + public Profile Profile => Inner.Profile; + + public void Clear(bool home) => Inner.Clear(home); + public void Write(IRenderable renderable) => Inner.Write(renderable); + public void WriteAnsi(Action action) => Inner.WriteAnsi(action); + + private class SpyCursor : IAnsiConsoleCursor + { + private readonly IAnsiConsoleCursor _inner; + private readonly SpyConsole _spy; + + public SpyCursor(IAnsiConsoleCursor inner, SpyConsole spy) + { + _inner = inner; + _spy = spy; + } + + public void SetPosition(int column, int line) + { + _spy.SetPositionCalls.Add((column, line)); + _inner.SetPosition(column, line); + } + + public void Move(CursorDirection direction, int steps) => _inner.Move(direction, steps); + public void Show(bool show) => _inner.Show(show); + } } diff --git a/src/Tiger/Commands/PanelRenderer.cs b/src/Tiger/Commands/PanelRenderer.cs index c2f0210..848b458 100644 --- a/src/Tiger/Commands/PanelRenderer.cs +++ b/src/Tiger/Commands/PanelRenderer.cs @@ -500,7 +500,45 @@ public int SelectInPanel(string[] breadcrumbs, string? context, List ite if (selected != prevSelected || scrollOffset != prevScrollOffset) { - needsFullRedraw = true; + if (scrollOffset != prevScrollOffset) + { + // Scroll changed — need full redraw + needsFullRedraw = true; + } + else + { + // Only cursor moved within same page — redraw just the two affected lines + // Row calculation: 0-based row index of first list item + var listStartRow = 1 + 1 + (context is not null ? 1 : 0) + 1; // top border + header + context? + mid border + if (renderAboveList is not null) + { + // renderAboveList adds content before the list; we can't easily count lines + // so fall back to full redraw + needsFullRedraw = true; + } + else + { + // SetPosition uses 1-based coordinates (ANSI CUP), so add 1 + var prevRow = listStartRow + (prevSelected - scrollOffset) + 1; + var newRow = listStartRow + (selected - scrollOffset) + 1; + + // Redraw old line (remove cursor) + _console.Cursor.SetPosition(0, prevRow); + RenderPanelLineDirect($" {items[prevSelected]}"); + + // Redraw new line (add cursor) + _console.Cursor.SetPosition(0, newRow); + RenderPanelLineDirect($"[blue]>[/] {items[selected]}"); + + // Update counter if visible + if (items.Count > visibleCount) + { + var counterRow = listStartRow + visibleCount + 1; + _console.Cursor.SetPosition(0, counterRow); + RenderPanelLineDirect($"[dim]({selected + 1}/{items.Count})[/]"); + } + } + } } } } @@ -602,7 +640,8 @@ public bool HandleDetailScroll(ConsoleKeyInfo key) _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); RenderPanelLineDirect("[blue]Enter[/] Confirm [blue]Esc[/] Cancel"); - _console.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + // Use Markup (no trailing newline) to prevent terminal scroll when frame fills the screen + _console.Markup($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); // For text input we need System.Console since IAnsiConsole doesn't support // character-by-character echo with backspace. This is the one place we @@ -675,7 +714,8 @@ internal void RenderDetailFrame(string[] breadcrumbs, string? context, List commands, int barIndex) @@ -690,7 +730,8 @@ private void RenderMainMenuFrame(List commands, int barIndex) _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); RenderPanelLineDirect(BuildCommandBarMarkup(commands, barIndex, true)); - _console.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + // Use Markup (no trailing newline) to prevent terminal scroll when frame fills the screen + _console.Markup($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); } private void RenderListFrame(string[] breadcrumbs, string? context, List items, @@ -737,7 +778,8 @@ private void RenderListFrame(string[] breadcrumbs, string? context, List _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); RenderPanelLineDirect(BuildCommandBarMarkup(commands, barIndex, barFocused)); - _console.MarkupLine($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); + // Use Markup (no trailing newline) to prevent terminal scroll when frame fills the screen + _console.Markup($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); } // ── Direct rendering helpers ──────────────────────────────────── From f74b678b5fb5a986094d6cb54d1ab60c23edc032 Mon Sep 17 00:00:00 2001 From: Jared Parsons Date: Wed, 10 Jun 2026 09:48:23 -0700 Subject: [PATCH 08/10] Test detail: full build URI, helix exit code, truncation toggle - Show full AzDO build URL in 'Last Failed Build' field - Add HelixExitCode to TestDetailInfo, display with color in Helix section - Add [T]runcate toggle to test detail in both BuildBrowser and TestBrowser - Add TruncationEnabled property to PanelRenderer for line wrap control Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tiger/Commands/BrowserUI.cs | 21 ++++++--- src/Tiger/Commands/BuildBrowser.cs | 71 +++++++++++++++++------------ src/Tiger/Commands/PanelRenderer.cs | 8 +++- src/Tiger/Commands/TestBrowser.cs | 12 +++++ 4 files changed, 77 insertions(+), 35 deletions(-) diff --git a/src/Tiger/Commands/BrowserUI.cs b/src/Tiger/Commands/BrowserUI.cs index 9c65b0d..c1b7351 100644 --- a/src/Tiger/Commands/BrowserUI.cs +++ b/src/Tiger/Commands/BrowserUI.cs @@ -327,7 +327,7 @@ public static void RenderTestDetailInPanel(PanelRenderer ui, TestDetailInfo info ui.RenderField("Test Name", Markup.Escape(info.TestName)); var buildUrl = $"https://dev.azure.com/{Uri.EscapeDataString(info.Org)}/{Uri.EscapeDataString(info.Project)}/_build/results?buildId={info.BuildId}"; - ui.RenderField("Last Failed Build", FormatLink(buildUrl, $"Build #{info.BuildId}")); + ui.RenderField("Last Failed Build", Markup.Escape(buildUrl)); ui.RenderField("Run", Markup.Escape(info.RunName)); ui.RenderField("Failed In", $"{info.BuildCount} build(s)"); ui.RenderEmptyLine(); @@ -381,6 +381,11 @@ public static void RenderTestDetailInPanel(PanelRenderer ui, TestDetailInfo info if (info.HelixWorkItemName is not null) { ui.RenderField("Work Item", Markup.Escape(info.HelixWorkItemName)); + if (info.HelixExitCode is not null) + { + var exitColor = info.HelixExitCode == 0 ? "green" : "red"; + ui.RenderField("Exit Code", $"[{exitColor}]{info.HelixExitCode}[/]"); + } var consoleUrl = HelixClient.GetConsoleUrl(info.HelixJobName, info.HelixWorkItemName); ui.RenderField("Console", FormatLink(consoleUrl, "Console Log")); @@ -444,7 +449,8 @@ public record TestDetailInfo( string? ErrorMessage, string? StackTrace, string? HelixJobName, string? HelixWorkItemName, List<(string Name, string? Uri)>? HelixFiles = null, - bool IsHelixDeadletter = false); + bool IsHelixDeadletter = false, + int? HelixExitCode = null); /// /// Loads test detail info from the database. @@ -507,12 +513,13 @@ FROM test_results tr // Load helix files and deadletter status if available List<(string Name, string? Uri)>? helixFiles = null; var isDeadletter = false; + int? helixExitCode = null; if (detail.HelixJob is not null && detail.HelixWorkItem is not null) { var helixInfo = db.WithCommand(cmd => { cmd.CommandText = """ - SELECT files, is_deadletter FROM helix_work_items + SELECT files, is_deadletter, exit_code FROM helix_work_items WHERE job_name = @job AND work_item_name = @wi """; cmd.Parameters.AddWithValue("@job", detail.HelixJob); @@ -522,12 +529,14 @@ FROM test_results tr { return ( FilesJson: reader.IsDBNull(0) ? null : reader.GetString(0), - IsDeadletter: !reader.IsDBNull(1) && reader.GetInt32(1) != 0); + IsDeadletter: !reader.IsDBNull(1) && reader.GetInt32(1) != 0, + ExitCode: reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2)); } - return (FilesJson: (string?)null, IsDeadletter: false); + return (FilesJson: (string?)null, IsDeadletter: false, ExitCode: (int?)null); }); isDeadletter = helixInfo.IsDeadletter; + helixExitCode = helixInfo.ExitCode; if (!string.IsNullOrWhiteSpace(helixInfo.FilesJson)) { @@ -547,7 +556,7 @@ FROM test_results tr } return new TestDetailInfo(testName, org, project, detail.BuildId, detail.RunName, buildCount, - detail.ErrorMessage, detail.StackTrace, detail.HelixJob, detail.HelixWorkItem, helixFiles, isDeadletter); + detail.ErrorMessage, detail.StackTrace, detail.HelixJob, detail.HelixWorkItem, helixFiles, isDeadletter, helixExitCode); } /// diff --git a/src/Tiger/Commands/BuildBrowser.cs b/src/Tiger/Commands/BuildBrowser.cs index 62d64aa..98aa505 100644 --- a/src/Tiger/Commands/BuildBrowser.cs +++ b/src/Tiger/Commands/BuildBrowser.cs @@ -883,38 +883,53 @@ private NavAction RenderTestDetail(TestDetailPage page) } var shortTitle = page.TestName.Length > 60 ? page.TestName[..57] + "..." : page.TestName; - var commands = new List - { - new("Builds with failure", ConsoleKey.B, -2), - new("Agent task", ConsoleKey.A, -3), - }; - if (info.HelixJobName is not null) - { - commands.Add(new("Helix", ConsoleKey.H, -4)); - } - - _ui.RenderDetailPanel( - ["Builds", "Tests", Markup.Escape(shortTitle)], - null, - () => BrowserUI.RenderTestDetailInPanel(_ui, info), - PanelRenderer.BuildCommandBarString(commands)); + var truncate = true; while (true) { - var key = Console.ReadKey(true); - if (_ui.HandleDetailScroll(key)) continue; - switch (key.Key) + _ui.TruncationEnabled = truncate; + var commands = new List { - case ConsoleKey.B: - return new NavAction.Push(new TestBuildsPage(page.Org, page.Project, page.TestName)); - case ConsoleKey.A: - BrowserUI.CreateAgentTask(_db, info); - return NavAction.Refresh.Instance; - case ConsoleKey.H when info.HelixJobName is not null: - ShowHelixWorkItemDetail(info); - return NavAction.Refresh.Instance; - case ConsoleKey.Escape: - return NavAction.Back.Instance; + new("Builds with failure", ConsoleKey.B, -2), + new("Agent task", ConsoleKey.A, -3), + new(truncate ? "Truncate: off" : "Truncate: on", ConsoleKey.T, -5), + }; + if (info.HelixJobName is not null) + { + commands.Add(new("Helix", ConsoleKey.H, -4)); + } + + _ui.RenderDetailPanel( + ["Builds", "Tests", Markup.Escape(shortTitle)], + null, + () => BrowserUI.RenderTestDetailInPanel(_ui, info), + PanelRenderer.BuildCommandBarString(commands)); + + while (true) + { + var key = Console.ReadKey(true); + if (_ui.HandleDetailScroll(key)) continue; + switch (key.Key) + { + case ConsoleKey.B: + _ui.TruncationEnabled = true; + return new NavAction.Push(new TestBuildsPage(page.Org, page.Project, page.TestName)); + case ConsoleKey.A: + BrowserUI.CreateAgentTask(_db, info); + _ui.TruncationEnabled = true; + return NavAction.Refresh.Instance; + case ConsoleKey.H when info.HelixJobName is not null: + ShowHelixWorkItemDetail(info); + _ui.TruncationEnabled = true; + return NavAction.Refresh.Instance; + case ConsoleKey.T: + truncate = !truncate; + break; + case ConsoleKey.Escape: + _ui.TruncationEnabled = true; + return NavAction.Back.Instance; + } + break; // re-render with updated truncate setting } } } diff --git a/src/Tiger/Commands/PanelRenderer.cs b/src/Tiger/Commands/PanelRenderer.cs index 848b458..58c845f 100644 --- a/src/Tiger/Commands/PanelRenderer.cs +++ b/src/Tiger/Commands/PanelRenderer.cs @@ -76,6 +76,12 @@ public PanelRenderer(IAnsiConsole console) /// public int ContentWidth => Math.Max(40, Width - 4); + /// + /// When true (default), lines are truncated to fit panel width. + /// When false, lines are allowed to wrap. + /// + public bool TruncationEnabled { get; set; } = true; + // ── Content building ──────────────────────────────────────────── /// @@ -786,7 +792,7 @@ private void RenderListFrame(string[] breadcrumbs, string? context, List private void RenderPanelLineDirect(string markupContent) { - var displayContent = TruncateToFit(markupContent); + var displayContent = TruncationEnabled ? TruncateToFit(markupContent) : markupContent; _console.Markup($"[{BorderStyle}]{Vertical}[/] "); _console.Markup(displayContent); diff --git a/src/Tiger/Commands/TestBrowser.cs b/src/Tiger/Commands/TestBrowser.cs index 2b9a4af..2c7afdb 100644 --- a/src/Tiger/Commands/TestBrowser.cs +++ b/src/Tiger/Commands/TestBrowser.cs @@ -98,6 +98,7 @@ public void Browse() private void ShowTestDetail(TestRow test) { + var truncate = true; while (true) { var info = BrowserUI.LoadTestDetail(_db, test.Org, test.Project, test.TestName); @@ -112,11 +113,13 @@ private void ShowTestDetail(TestRow test) return; } + _ui.TruncationEnabled = truncate; var shortTitle = test.TestName.Length > 60 ? test.TestName[..57] + "..." : test.TestName; var commands = new List { new("Builds with failure", ConsoleKey.B, -2), new("Agent task", ConsoleKey.A, -3), + new(truncate ? "Truncate: off" : "Truncate: on", ConsoleKey.T, -5), }; if (info.HelixJobName is not null) { @@ -135,20 +138,29 @@ private void ShowTestDetail(TestRow test) if (_ui.HandleDetailScroll(key)) continue; if (key.Key == ConsoleKey.Escape) { + _ui.TruncationEnabled = true; return; } + if (key.Key == ConsoleKey.T) + { + truncate = !truncate; + break; // re-render with updated truncate + } if (key.Key == ConsoleKey.B) { + _ui.TruncationEnabled = true; ShowTestBuilds(test); break; // re-render detail after returning } if (key.Key == ConsoleKey.A) { + _ui.TruncationEnabled = true; BrowserUI.CreateAgentTask(_db, info); break; // re-render detail after returning } if (key.Key == ConsoleKey.H && info.HelixJobName is not null) { + _ui.TruncationEnabled = true; ShowHelixWorkItemDetail(info); break; // re-render detail after returning } From 826f26fe42c757ff3771438bb2e9a6070fc4181d Mon Sep 17 00:00:00 2001 From: Jared Parsons Date: Wed, 10 Jun 2026 10:12:51 -0700 Subject: [PATCH 09/10] Fix filter edit display: prompt text overwritten by input cursor - PromptInPanel was positioning '> ' cursor at row -4 (prompt line) instead of row -3 (empty input line), overwriting text like 'fi' in 'Definition' - Replaced System.Console with deterministic row counting + IAnsiConsole Cursor.SetPosition and Markup for character echo (fully testable now) - Converted PromptKindFilter to use SelectInPanel (renders in panel) - Fixed filter menu exit: only Escape returns (not any unhandled key) - Added 8 tests: PromptInPanel cursor position, text capture, PromptKindFilter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tiger.Tests/PanelRendererTests.cs | 132 ++++++++++++++++++++++++++ src/Tiger/Commands/BrowserUI.cs | 12 ++- src/Tiger/Commands/BuildBrowser.cs | 6 +- src/Tiger/Commands/PanelRenderer.cs | 23 +++-- src/Tiger/Commands/TestBrowser.cs | 6 +- 5 files changed, 161 insertions(+), 18 deletions(-) diff --git a/src/Tiger.Tests/PanelRendererTests.cs b/src/Tiger.Tests/PanelRendererTests.cs index e7051bf..1319934 100644 --- a/src/Tiger.Tests/PanelRendererTests.cs +++ b/src/Tiger.Tests/PanelRendererTests.cs @@ -529,6 +529,138 @@ public void SelectInPanel_BottomBorder_NoTrailingNewline() // is rendered with Markup (not MarkupLine) to prevent scroll Assert.False(output.EndsWith("\n"), "Frame should not end with trailing newline"); } + + // ── PromptInPanel ──────────────────────────────────────────────── + + [Fact] + public void PromptInPanel_PromptTextNotOverwritten() + { + // Verifies the prompt text line is rendered intact — the "> " input cursor + // must NOT overwrite part of the prompt text (regression: was writing at row -4 + // which landed on the prompt line instead of the empty input line at row -3). + var console = new TestConsole().Width(80).Height(24); + // Press Escape immediately to exit the prompt + console.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = renderer.PromptInPanel(["Builds", "Filter"], "Definition pattern (e.g. ci, roslyn-CI*)"); + + Assert.Null(result); + + var output = console.Output; + // The prompt text must appear fully intact (not overwritten by "> ") + Assert.Contains("Definition pattern (e.g. ci, roslyn-CI*)", output); + // Breadcrumbs must appear + Assert.Contains("Builds", output); + Assert.Contains("Filter", output); + } + + [Fact] + public void PromptInPanel_CursorPosition_TargetsInputLine() + { + // Verifies SetPosition targets the empty input line (row 5 with no currentValue) + // Layout: row1=border, row2=header, row3=separator, row4=prompt, row5=input line + var spy = new SpyConsole(width: 80, height: 24); + spy.Inner.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(spy); + renderer.PromptInPanel(["Builds", "Filter"], "Definition pattern"); + + // SetPosition should target row 5 (the empty input line), NOT row 4 (prompt text) + Assert.Contains(spy.SetPositionCalls, call => call.Line == 5); + Assert.DoesNotContain(spy.SetPositionCalls, call => call.Line == 4); + } + + [Fact] + public void PromptInPanel_EnterReturnsTypedText() + { + var console = new TestConsole().Width(80).Height(24); + // Type "roslyn" then press Enter + foreach (var c in "roslyn") + { + console.Input.PushKey(new ConsoleKeyInfo(c, ConsoleKey.A, false, false, false)); + } + console.Input.PushKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = renderer.PromptInPanel(["Builds", "Filter"], "Definition pattern"); + + Assert.Equal("roslyn", result); + } + + [Fact] + public void PromptInPanel_EmptyInput_ReturnsNull() + { + var console = new TestConsole().Width(80).Height(24); + // Just press Enter with no text + console.Input.PushKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = renderer.PromptInPanel(["Builds", "Filter"], "Definition pattern"); + + Assert.Null(result); + } + + // ── PromptKindFilter ───────────────────────────────────────────── + + [Fact] + public void PromptKindFilter_Escape_ReturnsNull() + { + var console = new TestConsole().Width(80).Height(24); + console.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = BrowserUI.PromptKindFilter(renderer); + + Assert.Null(result); + } + + [Fact] + public void PromptKindFilter_SelectPr_ReturnsPr() + { + var console = new TestConsole().Width(80).Height(24); + // Items are: all, pr, ci — "pr" is at index 1, so press Down then Enter + console.Input.PushKey(new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false)); + console.Input.PushKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = BrowserUI.PromptKindFilter(renderer); + + Assert.Equal("pr", result); + } + + [Fact] + public void PromptKindFilter_SelectAll_ReturnsNull() + { + var console = new TestConsole().Width(80).Height(24); + // "all" is at index 0 (default selection), just press Enter + console.Input.PushKey(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false)); + + var renderer = new PanelRenderer(console); + var result = BrowserUI.PromptKindFilter(renderer); + + Assert.Null(result); + } + + [Fact] + public void PromptKindFilter_RendersInPanel_WithBreadcrumbs() + { + var console = new TestConsole().Width(80).Height(24); + console.Input.PushKey(new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false)); + + var renderer = new PanelRenderer(console); + BrowserUI.PromptKindFilter(renderer); + + var output = console.Output; + // Breadcrumbs should appear in header + Assert.Contains("Builds", output); + Assert.Contains("Filter", output); + Assert.Contains("Kind", output); + // Items should appear in content + Assert.Contains("all", output); + Assert.Contains("pr", output); + Assert.Contains("ci", output); + } } /// diff --git a/src/Tiger/Commands/BrowserUI.cs b/src/Tiger/Commands/BrowserUI.cs index c1b7351..f79a167 100644 --- a/src/Tiger/Commands/BrowserUI.cs +++ b/src/Tiger/Commands/BrowserUI.cs @@ -160,11 +160,15 @@ public static int SelectWithEscape(string title, List items, int pageSiz /// /// Selection menu for build kind filter (pr/ci). Returns null if cancelled or "all" selected. /// - public static string? PromptKindFilter() + public static string? PromptKindFilter(PanelRenderer ui) { - AnsiConsole.WriteLine(); - var choices = new[] { "all", "pr", "ci" }; - var selected = SelectWithEscape("Select build kind:", choices.ToList(), pageSize: 5); + var choices = new List { "all", "pr", "ci" }; + var commands = new List(); + var selected = ui.SelectInPanel( + ["Builds", "Filter", "Kind"], + "[dim]Select build kind to filter on[/]", + choices, + commands); if (selected < 0) return null; return choices[selected] == "all" ? null : choices[selected]; } diff --git a/src/Tiger/Commands/BuildBrowser.cs b/src/Tiger/Commands/BuildBrowser.cs index 98aa505..b01c996 100644 --- a/src/Tiger/Commands/BuildBrowser.cs +++ b/src/Tiger/Commands/BuildBrowser.cs @@ -383,7 +383,7 @@ private void ShowFilterMenu() SaveFilter(); continue; case ConsoleKey.K: - _filter.KindPattern = BrowserUI.PromptKindFilter(); + _filter.KindPattern = BrowserUI.PromptKindFilter(_ui); SaveFilter(); continue; case ConsoleKey.B: @@ -398,8 +398,10 @@ private void ShowFilterMenu() _filter.Clear(); SaveFilter(); continue; - default: + case ConsoleKey.Escape: return; + default: + continue; } } } diff --git a/src/Tiger/Commands/PanelRenderer.cs b/src/Tiger/Commands/PanelRenderer.cs index 58c845f..8933545 100644 --- a/src/Tiger/Commands/PanelRenderer.cs +++ b/src/Tiger/Commands/PanelRenderer.cs @@ -631,29 +631,32 @@ public bool HandleDetailScroll(ConsoleKeyInfo key) _console.Cursor.Show(true); var width = Width - 2; + int row = 1; // 1-based row tracking _console.MarkupLine($"[{BorderStyle}]{TopLeft}{new string(Horizontal, width)}{TopRight}[/]"); + row++; // row 2: header var crumbText = string.Join($" {Separator} ", breadcrumbs); RenderPanelLineDirect($"[bold orange1]TIGER[/] [dim]{Separator}[/] {crumbText}"); - + row++; // row 3: mid separator _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); - + row++; // row 4: prompt text RenderPanelLineDirect($"[bold]{prompt}[/]"); if (currentValue is not null) { + row++; // current value line RenderPanelLineDirect($"[dim]Current: {Markup.Escape(currentValue)}[/]"); } + row++; // empty input line + var inputRow = row; RenderEmptyLineDirect(); - + row++; // mid separator _console.MarkupLine($"[{BorderStyle}]{MiddleLeft}{new string(Horizontal, width)}{MiddleRight}[/]"); + row++; // footer RenderPanelLineDirect("[blue]Enter[/] Confirm [blue]Esc[/] Cancel"); // Use Markup (no trailing newline) to prevent terminal scroll when frame fills the screen _console.Markup($"[{BorderStyle}]{BottomLeft}{new string(Horizontal, width)}{BottomRight}[/]"); - // For text input we need System.Console since IAnsiConsole doesn't support - // character-by-character echo with backspace. This is the one place we - // fall back to System.Console for input handling. - var inputRow = System.Console.CursorTop - 4; - System.Console.SetCursorPosition(4, inputRow); + // Position cursor on the empty input line and render the "> " prompt + _console.Cursor.SetPosition(2, inputRow); _console.Markup("[blue]>[/] "); var buffer = new System.Text.StringBuilder(); @@ -674,14 +677,14 @@ public bool HandleDetailScroll(ConsoleKeyInfo key) if (buffer.Length > 0) { buffer.Remove(buffer.Length - 1, 1); - System.Console.Write("\b \b"); + _console.Markup("\b \b"); } continue; } if (key.KeyChar >= 32) { buffer.Append(key.KeyChar); - System.Console.Write(key.KeyChar); + _console.Markup(Markup.Escape(key.KeyChar.ToString())); } } } diff --git a/src/Tiger/Commands/TestBrowser.cs b/src/Tiger/Commands/TestBrowser.cs index 2c7afdb..d99c42b 100644 --- a/src/Tiger/Commands/TestBrowser.cs +++ b/src/Tiger/Commands/TestBrowser.cs @@ -411,7 +411,7 @@ private void ShowFilterMenu() SaveFilter(); continue; case ConsoleKey.K: - _filter.KindPattern = BrowserUI.PromptKindFilter(); + _filter.KindPattern = BrowserUI.PromptKindFilter(_ui); SaveFilter(); continue; case ConsoleKey.P: @@ -422,8 +422,10 @@ private void ShowFilterMenu() _filter.Clear(); SaveFilter(); continue; - default: + case ConsoleKey.Escape: return; + default: + continue; } } } From 22d83a5eabfb1864501e6f4b98d97d162e273d24 Mon Sep 17 00:00:00 2001 From: Jared Parsons Date: Wed, 10 Jun 2026 10:14:57 -0700 Subject: [PATCH 10/10] Add GitHub Actions PR validation workflow Simple build + test pipeline for pull requests and pushes to main. Uses .NET 10 preview SDK on ubuntu-latest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-validation.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/pr-validation.yml diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..20dcdf2 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,28 @@ +name: PR Validation + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + dotnet-quality: 'preview' + + - name: Restore + run: dotnet restore --nologo + + - name: Build + run: dotnet build --nologo --no-restore -c Release + + - name: Test + run: dotnet test --nologo --no-build -c Release