From 3e1a9e287839970cdb19abecc353c0697a83d4c4 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Tue, 26 May 2026 00:08:04 +0300 Subject: [PATCH 1/9] =?UTF-8?q?docs(superpowers):=20polish=20plan=20?= =?UTF-8?q?=E2=80=94=20tables,=20toolbars,=20graphs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plans the follow-up to PR #735. Splits the 2178-line shell into 4 partials, converts 5 list pages to TableControl, replaces bottom buttons with selection-aware ToolbarControl, replaces tall PropertyPanel headers with one-line identity strips, and adds gradient-styled graphs to Home/Project/Analysis/Settings. NavigationView and modals are explicitly kept. --- .../plans/2026-05-25-tui-polish.md | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-25-tui-polish.md diff --git a/docs/superpowers/plans/2026-05-25-tui-polish.md b/docs/superpowers/plans/2026-05-25-tui-polish.md new file mode 100644 index 0000000..5bbcac6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-tui-polish.md @@ -0,0 +1,270 @@ +# TUI Polish — Tables, Toolbars, Graphs + +Status: Proposed +Owner: nickprotop +Last updated: 2026-05-25 +Follow-up to: PR #735 (SharpConsoleUI command center) + +## Why + +PR #735 landed a native SharpConsoleUI shell. After living with it, the recurring shape of the browse pages reads as spaghetti: + +- 6 of 8 pages are the same stack: `tall PropertyPanel header → search chip → single 1-column ListControl → bottom Buttons`. +- Each list row crams 3 dimensions into a single string (`{marker} {alias} [dim]{status}[/] [grey]{reasons}[/]`). +- Primary actions are full-width `Button` controls *below* a 16-row scrollable list — off-screen on default terminals, visually indistinguishable from list rows. +- The PropertyPanel header duplicates information already in the top StatusBar. +- The two pages that did break the pattern (Installed = Table, Collections = master-detail) read substantially better. The unevenness is itself a signal. + +This polish PR converts the rest of the shell to the patterns that already work, adds graph affordances we have but barely use, and splits the 2178-line shell file. + +## Non-goals + +- **No API changes** to SharpConsoleUI. This is a consumer-side polish; we use what's already published in v2.4.61. +- **No removal of modal flows.** Skills / Bundles / Packages / Agents keep their existing modal-on-Enter detail. Collections keeps its existing inline master-detail. +- **No NavigationView replacement.** The left rail stays. Tabs only appear *inside* a surface where they're additive (none in this PR — keeping scope tight). +- **No manifest changes.** `navigation-surfaces.json` is untouched; the surface map is preserved. +- **No backend changes.** `SkillInstaller`, `AgentInstaller`, `ProjectSkillRecommender`, catalog sync stay as-is. + +## Scope summary + +| # | Change | Why | +|---|---|---| +| 1 | Convert 5 ListControl pages to TableControl | Multi-column data wants columns + sorting, not markup-salad rows | +| 2 | Replace bottom Buttons with top ToolbarControl | Verbs above data, selection-aware enable/disable, standard convention | +| 3 | Thin one-line identity strip instead of tall PropertyPanel header | Reclaim ~4 vertical rows; remove duplication with StatusBar | +| 4 | Pin NavigationView PaneDisplayMode based on terminal width | Free horizontal space for detail on wide terminals | +| 5 | Split `InteractiveConsoleApp.Shell.cs` into 4 partials | File is 2178 lines, well past the 800-line ceiling | +| 6 | Add graphs everywhere they help (Home / Project / Analysis / Settings) | Underused control surface; gradients carry information glanceably | +| 7 | Section rhythm: RuleControl separators, recolor non-severity gradients | Cheap polish | + +## Per-page conversion table + +### Pages that change + +| Page | Today | After | +|---|---|---| +| **Skills** | 1-col ListControl, escaped markup salad, modal on Enter | TableControl `Status / Skill / Collection / Lane / Version / Tokens`, sortable, modal on Enter, Toolbar `[Install ↵] [Force]` | +| **Bundles** | 1-col ListControl, `name [N skills, Xk tokens]`, modal on Enter | TableControl `Bundle / Title / Skills / Tokens`, sortable, modal on Enter, Toolbar `[Install ↵] [Details]` | +| **Packages** | 1-col ListControl, `signal [kind] -> skill [stack/lane] (Xk)`, modal on Enter | TableControl `Signal / Kind / Skill / Collection / Lane / Tokens`, sortable, modal on Enter (opens linked skill), Toolbar `[Open skill ↵]` | +| **Agents** | 1-col ListControl, `✓/○ alias [dim description]`, modal on Enter | TableControl `Status / Agent / Description / Skills`, sortable, modal on Enter, Toolbar `[Install ↵] [Remove] [Install all detected]` | +| **Project** | 1-col ListControl, `●●● alias [status] [reasons]`, install on Enter | TableControl `Confidence / Status / Skill / Reasons`, sortable, install on Enter, Toolbar `[Install selected ↵] [Install all ⌃I]`, header gets BarGraph confidence trio | +| **Installed** | Already TableControl ✓ | No table change. Header collapses to thin strip. Bottom Update/Remove buttons move to top Toolbar `[Install ↵] [Update ⌃U] [Reinstall] [Remove ⌃⌫]` (selection-aware) | +| **Home** | 5 metric cards + bullet panel | Same 5 metric cards, each with a SparklineControl footer. Adds one LineGraph "catalog over time" below the cards | +| **Analysis** | 2 BarGraph charts | Keep both, recolor heaviest-skills with smooth `cool` gradient (heavy ≠ severity). Add third chart: LineGraph token distribution across catalog | +| **Settings** | Inline form (dropdowns + refresh button) | Same form + BarGraph disk footprint per collection at the bottom | + +### Pages that don't change + +| Page | Why kept as-is | +|---|---| +| **Collections** | Already master-detail with HorizontalGrid + inline two-stage install — works. Left rail stays ListControl (narrow, single string per row) | +| **Command palette** | Popover of fuzzy-ranked single strings — that's literally what ListControl is for | +| **About** | Static info page; no interactive data | +| **Update All / Remove All confirmation pages** | Already minimal; benefit from the new thin header but no table conversion | + +## Architecture & guiding rules + +**The list-vs-table rule** — apply consistently across the shell: + +> If the row has more than one logical column of data, it's a Table. If it's a single string with optional decoration, it's a List. + +**The header rule:** + +> The page's top-of-content identity strip is **one row** of markup with the page name + key counts. The StatusBar carries session identity (project / scope / platform / catalog version). Do not duplicate. + +**The verbs rule:** + +> Primary actions live in a `ToolbarControl` directly above the data, not as full-width buttons below it. Toolbar buttons enable/disable based on selection state. Bulk shortcuts (`⌃U`, `⌃I`, `⌃⌫`) keep working and fire the same handlers as the toolbar buttons. + +**The graph rule:** + +> Use BarGraphControl for single-metric weights, SparklineControl for inline trends (one row tall), LineGraphControl for multi-point trends. Gradients carry meaning: `cool`/`blue→cyan` for "magnitude, not severity"; threshold `green→yellow→red` reserved for "actual severity" (outdated counts, scan confidence). + +## Detailed design per change + +### 1. TableControl conversions + +For each of Skills / Bundles / Packages / Agents / Project: + +- Replace `StyledList(...)` builder call with `Controls.Table()` with the columns listed above. +- Tag preservation: `new TableRow(...) { Tag = entry }` to keep the `OnRowActivated` handler shape identical to today's `OnItemActivated`. +- Sortable by all columns via `WithSorting()`. Default sort: + - Skills: by Collection then Skill (matches today's order) + - Bundles: by Skills count desc + - Packages: by Signal asc + - Agents: by Status (installed first) then Agent + - Project: by Confidence desc (matches today's order) +- Per-row foreground color for state: outdated → `AccentYellow`, installed → default fg. Same pattern as Installed page today. +- Narrow-terminal column hiding via `WithColumnVisibility` predicate — drop `Lane`, `Description`, `Reasons` at <100 cols to keep Status + Name + key metric visible. +- Right-justify all numeric columns (`Tokens`, `Skills`, `Version`). +- Filter (`/`) keeps working: the existing `MatchesFilter` predicate filters the source array before rows are added. + +### 2. ToolbarControl + +For each browse page, the page builder produces (in this order): + +``` +[ thin identity strip ] ← one row markup +[ search chip if filter ] ← optional +[ ToolbarControl ] ← new — verbs go here +[ TableControl ] ← data +``` + +ToolbarControl spec: +- Buttons with text + optional shortcut hint (e.g. `Update ⌃U`). +- Enabled state computed from current row selection: + - **No selection** (table empty / no focus): only "Install" enabled (opens detail modal or no-op). + - **Row selected, current**: Reinstall + Remove enabled; Update disabled. + - **Row selected, outdated**: Update + Reinstall + Remove enabled. +- Re-evaluate on `OnSelectionChanged` (TableControl event). +- Click handlers reuse the existing methods (`UpdateSkillRecords`, `SkillInstaller.Install`, etc.) — no behavior change, just relocation. +- Bulk shortcuts `⌃U` / `⌃I` / `⌃⌫` still bound in StatusBar bottom bar and still call the bulk paths regardless of selection. + +### 3. Thin identity strip + +Replace every: + +```csharp +panel.AddControl(BuildPropertyPanel("installed skills", AccentGreen, + ("target", "..."), + ("installed", "..."), + ("outdated", "..."), + ("tokens", "..."))); +``` + +…with: + +```csharp +panel.AddControl(new MarkupControl(new List +{ + $"[bold {AccentGreenHex}]installed skills[/] [grey50]·[/] {installedCount}/{total} [grey50]·[/] {outdated} outdated [grey50]·[/] {tokens} tokens" +})); +``` + +One row instead of six. Same information density (counts come along), same accent color via inline markup. + +Where the page has a sparkline or graph as part of identity (Home / Installed), the strip lives above and the graph immediately below. + +### 4. NavigationView PaneDisplayMode + +In `BuildShell`: + +```csharp +var width = SafeConsole(() => Console.WindowWidth, 120); +var mode = width >= 130 ? NavigationViewDisplayMode.LeftCompact + : width >= 90 ? NavigationViewDisplayMode.Top + : NavigationViewDisplayMode.Minimal; +nav.WithPaneDisplayMode(mode); +``` + +Today's `Auto` shows full pane labels at all widths, eating ~22 columns of horizontal space. `LeftCompact` shows icons-only; users can still hover/click. Recompute on terminal resize (subscribe to driver resize event if not already). + +### 5. File split + +`InteractiveConsoleApp.Shell.cs` (2178 lines) splits into: + +| File | Contents | Approx lines | +|---|---|---| +| `InteractiveConsoleApp.Shell.cs` | Shell entry (`RunInteractiveShellAsync`), NavigationView setup, theme, status bars, palette, helpers (`BuildSectionPanel`, `BuildPropertyPanel`, `FormatRow`, `Toast`, `Escape`, `ConfirmModal`, `ShowModalNative`, `BuildCardGrid`) | ~600 | +| `InteractiveConsoleApp.Home.cs` | `BuildHomePage`, `BuildMetricCard`, metric-card sparkline helpers, "catalog over time" LineGraph builder | ~400 | +| `InteractiveConsoleApp.Catalog.cs` | `BuildSkillBrowserPage`, `BuildCollectionsPage` + `BuildCollectionDetail`, `BuildBundlesPage`, `BuildPackagesPage`, `BuildAgentsPage`, related modals | ~700 | +| `InteractiveConsoleApp.Workspace.cs` | `BuildInstalledPage`, `BuildProjectPage`, `BuildAnalysisPage`, `BuildSettingsPage`, `BuildRemoveAllPage`, `BuildUpdateAllPage`, `BuildAboutPage` | ~600 | + +All partials of the same `InteractiveConsoleApp` class. No public API change. + +### 6. Graphs + +All four graph controls have full gradient APIs (verified against `SharpConsoleUI/Builders/`): + +- `BarGraphControl`: `WithGradient(thresholds[])`, `WithStandardGradient()` (green→yellow→red), `WithSmoothGradient(ColorGradient | string | Color[])` +- `LineGraphControl`: per-series `AddSeries(name, color, gradient | gradientSpec)`, reference lines, value markers, high/low labels +- `SparklineControl`: `WithBarColor`, secondary/bidirectional data series + +#### Home page + +Each metric card grows a footer SparklineControl (1 row, inside the card padding): + +| Card | Sparkline data | Gradient hint | +|---|---|---| +| skills | Skill count over recent catalog releases (from `GetReleasesAsync`) | bar color `AccentDeepSkyBlue` | +| bundles | Bundle count over recent catalog releases | `AccentTurquoise` | +| installed | Local install activity — count of installs per recent session (fall back to flat fill if no telemetry) | `AccentGreen` | +| outdated | Bidirectional sparkline: primary = outdated count, secondary = current count | primary `AccentYellow`, secondary `AccentGreen` | +| agents | Agent count over recent catalog releases | `AccentMediumPurple` | + +Below the metric grid, a single full-width LineGraph (~6 rows): + +```csharp +Controls.LineGraph() + .WithTitle("catalog growth") + .WithHeight(6) + .AddSeries("skills", AccentDeepSkyBlue, "blue→cyan") + .AddSeries("tokens", AccentMediumPurple, "purple→magenta") + .WithData("skills", skillsByRelease) + .WithData("tokens", tokensByRelease) + .WithHighLowLabels(true) + .WithYAxisLabels(true) + .Build() +``` + +Data source: `GetReleasesAsync` returns historical releases; we cache the last N (e.g. 20) and extract `Skills.Count` + `TotalTokens` per release. If history is unavailable (offline, first run), the LineGraph falls back to a 1-point degenerate plot showing only the current value — no error, no empty box. + +#### Project page + +- Replace the inline `recommendations (N high · N med · N low)` markup line with a 3-bar `BarGraphControl` stack with `WithGradient` thresholds: `0% = grey, 33% = yellow, 66% = green` (high-confidence = green, lots of high = mostly green). +- Per-row Confidence column: a 6-cell BarGraphControl (no label, no value text) inside the TableControl cell, gradient `red→yellow→green` reversed (high = green). Falls back to the existing `●●●` glyph at <100 cols where the cell is too narrow. + +#### Analysis page + +- Keep "tokens by skill (top 12)" chart but swap `WithStandardGradient()` (severity gradient) for `WithSmoothGradient("blue→purple")`. Heavy ≠ unsafe. +- Keep "skills per collection (top 8)" chart; switch from flat `AccentTurquoise` to `WithSmoothGradient("teal→cyan")`. +- **New third chart**: full-width LineGraph showing token-count distribution across the entire catalog, X = skill index sorted by tokens desc, Y = tokens. Shows the long-tail shape. Use `"cool"` gradient. + +#### Settings page + +- After the existing form, add a BarGraph stack showing disk footprint per collection (sum of installed skill bytes — computable from `InstalledSkillRecord`). Uses `WithSmoothGradient("cool")`. Helps users see where their token budget is going. + +### 7. Rhythm fixes + +- Add `RuleControl(title: "Actions")` between detail-pane data and toolbar inside modals (the modal verbs today sit flush against the property panel). +- Recolor the "outdated row" foreground from `AccentYellow` to `Color(200,180,80)` so it stays warm but doesn't fight the new BarGraph threshold yellows on Project/Analysis pages. + +## Commit ordering + +Same structure as PR #735's branch — 6 self-contained commits, each reviewable independently. + +1. **`refactor: split InteractiveConsoleApp.Shell.cs into 4 partials`** — pure file move, zero behavior change. Verify by running tests after; must still be 613/613. +2. **`feat(shell): thin identity strip replaces tall PropertyPanel headers`** — single-row markup header on every page; PropertyPanel is now detail-pane-only. Reclaims vertical space, sets up rooms for graphs. +3. **`feat(shell): ToolbarControl replaces bottom buttons across pages`** — selection-aware verbs at top of data area. Bulk shortcuts unchanged. Buttons removed from page bottoms. +4. **`feat(shell): TableControl on Skills / Bundles / Packages / Agents / Project`** — five list-to-table conversions. Modal-on-Enter preserved. Default sorts set per table. +5. **`feat(shell): graphs on Home / Project / Analysis / Settings`** — Sparklines on metric cards, LineGraph catalog growth, BarGraph confidence trio on Project, per-row confidence BarGraph cells, third Analysis chart, Settings disk-footprint chart, recolor existing Analysis charts. +6. **`feat(shell): responsive NavigationView pane mode + rhythm polish`** — pin `PaneDisplayMode` by terminal width, add RuleControl separators inside modals, recolor outdated foreground. + +## Verification + +After each commit: +- `dotnet build` clean across all four projects, 0 warnings (matches PR #735's bar). +- `dotnet test` — all 613 tests still pass. +- Manual: `dotnet run --project cli/ManagedCode.DotnetSkills -- ` and walk every NavigationView surface. Check at 3 terminal widths: 80 (narrow), 130 (medium), 200 (wide). +- Redirected stdio still falls through to `RunClassicShellAsync` (PR #735's behavior preserved). + +## Open questions + +None blocking. All structural questions resolved in conversation: +- ✅ NavigationView stays; no surface map changes +- ✅ Modals stay on Skills/Bundles/Packages/Agents +- ✅ TableControl everywhere it makes sense (5 list pages); ListControl stays on Collections-left-rail and Command Palette +- ✅ Graphs are in scope, with gradients +- ✅ Catalog history is available via `GetReleasesAsync` for the "catalog growth" LineGraph; if offline, degrade to a 1-point plot + +## File map + +| New / changed file | Status | Approx delta | +|---|---|---| +| `cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs` | Split + reworked | −~1500 / +~600 | +| `cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs` | New | +~400 | +| `cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs` | New | +~700 | +| `cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs` | New | +~600 | +| `cli/ManagedCode.DotnetSkills/ManagedCode.DotnetSkills.csproj` | No change | 0 | + +Net diff: roughly +800 lines additive (the graphs and the toolbars), and a large internal redistribution. No public API change to dotnet-skills or to SharpConsoleUI. From a5cf8c853af9e219526c86a19c8e9ec759f7fd46 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Tue, 26 May 2026 00:08:15 +0300 Subject: [PATCH 2/9] refactor(cli): split InteractiveConsoleApp.Shell.cs into 4 partials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell.cs was 2178 lines and growing — well past the codebase ceiling. Split into: - Shell.cs (1148) — orchestration: RunAsync, CreateCommandCenter, status bars, modals, palette, theme, page-shortcut helpers, async callbacks - Home.cs (80) — BuildHomePage only - Catalog.cs (468) — Skills, Collections, Bundles, Packages, Agents - Workspace.cs (541) — Installed, Project, Analysis, Settings, RemoveAll/UpdateAll confirmations, About Zero behavior change. All partials of the same internal sealed class. Build clean, 0 warnings across all 4 projects. 613/613 tests pass. --- .../InteractiveConsoleApp.Catalog.cs | 468 ++++++++ .../InteractiveConsoleApp.Home.cs | 85 ++ .../InteractiveConsoleApp.Shell.cs | 1030 ----------------- .../InteractiveConsoleApp.Workspace.cs | 541 +++++++++ 4 files changed, 1094 insertions(+), 1030 deletions(-) create mode 100644 cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs create mode 100644 cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs create mode 100644 cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs new file mode 100644 index 0000000..2001b14 --- /dev/null +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs @@ -0,0 +1,468 @@ +// ----------------------------------------------------------------------------- +// Catalog surfaces — the browse pages for Skills, Collections, Bundles, +// Packages, and Agents. Each page is a list/table of catalog entries with +// modal-on-Enter detail. Split out of Shell.cs so each surface group lives +// near its peers. +// ----------------------------------------------------------------------------- + +using ManagedCode.DotnetSkills.Runtime; +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Core; +using SharpConsoleUI.Drivers; +using SharpConsoleUI.Helpers; +using SharpConsoleUI.Layout; +using SharpConsoleUI.Rendering; +using SharpConsoleUI.Themes; + +namespace ManagedCode.DotnetSkills; + +internal sealed partial class InteractiveConsoleApp +{ + // ------------------------------------------------------------------------- + // Skill browser + // ------------------------------------------------------------------------- + + private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); + var available = skillCatalog.Skills + .Where(skill => installed.All(record => !string.Equals(record.Skill.Name, skill.Name, StringComparison.OrdinalIgnoreCase))) + .OrderBy(skill => CatalogOrganization.GetStackRank(skill.Stack)) + .ThenBy(skill => skill.Stack, StringComparer.Ordinal) + .ThenBy(skill => skill.Name, StringComparer.Ordinal) + .ToArray(); + + var filtered = available.Where(s => MatchesFilter(s.Name, s.Stack, s.Lane)).ToArray(); + + panel.AddControl(BuildPropertyPanel("skill browser", AccentTurquoise, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("available", $"{filtered.Length}/{available.Length}"), + ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); + AddSearchChip(panel); + + if (available.Length == 0) + { + panel.AddControl(BuildNotePanel("available", "[grey50]Every catalog skill is already installed in this target.[/]", AccentDeepSkyBlue)); + return; + } + if (filtered.Length == 0) + { + panel.AddControl(BuildNotePanel("available", $"[grey50]No skills match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } + + var list = StyledList("Available skills (Enter for details)") + .MaxVisibleItems(16) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var skill in filtered) + { + // ListControl parses item text as markup; BuildSkillChoiceLabel produces plain + // text containing bracketed stack/lane like "[.NET Foundations / ...]". Escape so + // brackets are not interpreted as Spectre markup tags. + list.AddItem(Escape(BuildSkillChoiceLabel(skill, installed)), skill); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is SkillEntry skill) + { + ShowSkillDetailModal(ws, panel, skill); + } + }); + panel.AddControl(list.Build()); + } + + private void ShowSkillDetailModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, SkillEntry skill) + { + var detail = new IWindowControl[] + { + BuildPropertyPanel(ToAlias(skill.Name), AccentTurquoise, + ("skill", Escape(skill.Name)), + ("collection", Escape(skill.Stack)), + ("lane", Escape(skill.Lane)), + ("version", Escape(skill.Version)), + ("tokens", FormatTokenCount(skill.TokenCount))), + BuildNotePanel("summary", Escape(skill.Description), AccentDeepSkyBlue), + BuildNotePanel("preview", Escape(LoadSkillPreview(skill)), AccentGrey), + }; + + ShowModalNative(ws, $"Skill · {ToAlias(skill.Name)}", detail, + ("Install into current target", () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { skill }, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); + if (summary is null) + Toast($"Install failed for {ToAlias(skill.Name)}", NotificationSeverity.Danger); + else + Toast($"{ToAlias(skill.Name)}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped", NotificationSeverity.Success); + BuildSkillBrowserPage(ws, owner); + }), + ("Force reinstall", () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { skill }, ResolveSkillLayout(), force: true), default(SkillInstallSummary)); + if (summary is null) + Toast($"Install failed for {ToAlias(skill.Name)}", NotificationSeverity.Danger); + else + Toast($"{ToAlias(skill.Name)}: reinstalled ({summary.InstalledCount} written)", NotificationSeverity.Success); + BuildSkillBrowserPage(ws, owner); + })); + } + // ------------------------------------------------------------------------- + // Collections + // ------------------------------------------------------------------------- + + private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); + var views = BuildCollectionViews(installed) + .OrderBy(view => CatalogOrganization.GetStackRank(view.Collection)) + .ThenBy(view => view.Collection, StringComparer.Ordinal) + .ToArray(); + var filtered = views.Where(v => MatchesFilter(v.Collection)).ToArray(); + + panel.AddControl(BuildPropertyPanel("collection browser", AccentDeepSkyBlue, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("collections", string.IsNullOrEmpty(_searchFilter) ? views.Length.ToString() : $"{filtered.Length}/{views.Length}"), + ("skills", skillCatalog.Skills.Count.ToString()), + ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); + AddSearchChip(panel); + + if (views.Length == 0) + { + panel.AddControl(BuildNotePanel("collections", "[grey50]No collections in this catalog version.[/]", AccentDeepSkyBlue)); + return; + } + if (filtered.Length == 0) + { + panel.AddControl(BuildNotePanel("collections", $"[grey50]No collections match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } + + // Master-detail layout. Left column lists collections; right column shows the detail of + // _selectedCollection. Clicking a left-list row updates only the right pane in place — + // no modal, no full-page rebuild. The right pane is a ScrollablePanel so the detail can + // grow with the collection's lane list. + if (_selectedCollection is null + || !filtered.Any(v => string.Equals(v.Collection, _selectedCollection.Collection, StringComparison.OrdinalIgnoreCase))) + { + _selectedCollection = filtered[0]; + _collectionInstallArmed = false; + } + + // Build the detail pane as a standalone ScrollablePanelControl so we can update it + // independently of the left list when the user changes selection. + var rightPane = new ScrollablePanelControl + { + ShowScrollbar = true, + VerticalScrollMode = ScrollMode.Scroll, + EnableMouseWheel = true, + }; + + var grid = Controls.HorizontalGrid() + .Column(col => + { + col.Flex(1); + var list = StyledList("Collections") + .MaxVisibleItems(20) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var view in filtered) + { + list.AddItem(Escape(BuildCollectionChoiceLabel(view)), view); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is CollectionCatalogView v) + { + _selectedCollection = v; + _collectionInstallArmed = false; + BuildCollectionDetail(rightPane, v); + } + }); + col.Add(list.Build()); + }) + .Column(col => + { + col.Flex(2).Add(rightPane); + }) + .Build(); + + panel.AddControl(grid); + BuildCollectionDetail(rightPane, _selectedCollection!); + } + + /// + /// Renders the right pane of the Collections master-detail view: stats, lanes, and an inline + /// two-stage install button (first click arms, second commits — satisfies AGENTS.md's + /// "install overview before confirmation" rule without a modal). + /// + private void BuildCollectionDetail(ScrollablePanelControl pane, CollectionCatalogView view) + { + pane.ClearContents(); + pane.AddControl(BuildPropertyPanel(view.Collection, AccentDeepSkyBlue, + ("collection", Escape(view.Collection)), + ("lanes", view.Lanes.Count.ToString()), + ("skills", $"{view.InstalledCount}/{view.SkillCount}"), + ("tokens", FormatTokenCount(view.TokenCount)))); + + if (view.Lanes.Count > 0) + { + pane.AddControl(BuildBulletPanel("lanes", AccentTurquoise, + view.Lanes.Select(lane => $"[grey50]·[/] [grey]{Escape(lane.Lane)}[/] [grey50]({lane.InstalledCount}/{lane.Skills.Count} skills, {FormatTokenCount(lane.TokenCount)} tokens)[/]").ToArray())); + } + + var armed = _collectionInstallArmed; + var label = armed + ? $"Click again to install all {view.SkillCount} skill(s)" + : $"Install collection ({view.SkillCount} skill(s))"; + pane.AddControl(Controls.Button(label) + .OnClick((_, _) => + { + if (!_collectionInstallArmed) + { + _collectionInstallArmed = true; + Toast($"Click again to confirm installing {view.SkillCount} skill(s)", NotificationSeverity.Warning); + BuildCollectionDetail(pane, view); + return; + } + var skills = SafeGet(() => new SkillInstaller(skillCatalog).SelectSkillsFromCollections(new[] { view.Collection }), Array.Empty()); + var summary = skills.Count == 0 ? null : SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); + ToastResult(summary, $"Could not install collection {view.Collection}", summary is null ? string.Empty : $"{view.Collection}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + _collectionInstallArmed = false; + if (_ws is not null && _activePanel is not null) BuildCollectionsPage(_ws, _activePanel); + }).Build()); + } + + // ------------------------------------------------------------------------- + // Bundles / packages + // ------------------------------------------------------------------------- + + private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl panel, bool primaryOnly) + { + panel.ClearContents(); + + var packages = (primaryOnly + ? GetPrimaryBundles() + : skillCatalog.Packages.OrderBy(p => p.Name, StringComparer.Ordinal).ToArray()) + .ToArray(); + var title = primaryOnly ? "focused bundles" : "catalog packages"; + var skillTokens = skillCatalog.Skills.ToDictionary(skill => skill.Name, skill => skill.TokenCount, StringComparer.OrdinalIgnoreCase); + + var filtered = packages.Where(p => MatchesFilter(p.Name, p.Title)).ToArray(); + + panel.AddControl(BuildPropertyPanel(title, AccentDeepSkyBlue, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + (primaryOnly ? "bundles" : "packages", string.IsNullOrEmpty(_searchFilter) ? packages.Length.ToString() : $"{filtered.Length}/{packages.Length}"), + ("skills covered", skillCatalog.Skills.Count.ToString()))); + AddSearchChip(panel); + + if (packages.Length == 0) + { + panel.AddControl(BuildNotePanel(title, "[grey50]Nothing available in this catalog version.[/]", AccentDeepSkyBlue)); + return; + } + if (filtered.Length == 0) + { + panel.AddControl(BuildNotePanel(title, $"[grey50]No bundles match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } + + var list = StyledList($"{(primaryOnly ? "Bundles" : "Packages")} (Enter for details)") + .MaxVisibleItems(16) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var package in filtered) + { + var tokenCount = package.Skills.Sum(name => skillTokens.TryGetValue(name, out var value) ? value : 0); + list.AddItem($"{Escape(package.Name)} [dim]({package.Skills.Count} skills, {FormatTokenCount(tokenCount)} tokens)[/]", package); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is SkillPackageEntry package) + { + ShowBundleModal(ws, panel, package, primaryOnly); + } + }); + panel.AddControl(list.Build()); + } + + private void ShowBundleModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, SkillPackageEntry package, bool primaryOnly) + { + var detail = new IWindowControl[] + { + BuildPropertyPanel(package.Name, AccentTurquoise, + ("package", Escape(package.Name)), + ("title", Escape(package.Title)), + ("skills", package.Skills.Count.ToString()), + ("includes", Escape(string.Join(", ", package.Skills.Take(10).Select(ToAlias))))), + BuildNotePanel("summary", Escape(package.Description), AccentDeepSkyBlue), + }; + + ShowModalNative(ws, $"Bundle · {package.Name}", detail, + ("Install bundle into current target", () => + { + var skills = SafeGet(() => new SkillInstaller(skillCatalog).SelectSkillsFromPackages(new[] { package.Name }), Array.Empty()); + var summary = skills.Count == 0 ? null : SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); + ToastResult(summary, $"Could not install bundle {package.Name}", summary is null ? string.Empty : $"{package.Name}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + BuildBundlesPage(ws, owner, primaryOnly); + })); + } + + // ------------------------------------------------------------------------- + // Packages — NuGet ids / prefixes → catalog skills + // ------------------------------------------------------------------------- + + private void BuildPackagesPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var signals = SafeGet(BuildPackageSignals, Array.Empty()); + var filtered = signals.Where(s => MatchesFilter(s.Signal, s.Skill.Name, s.Skill.Stack, s.Skill.Lane)).ToArray(); + + panel.AddControl(BuildPropertyPanel("package signals", AccentTurquoise, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("signals", string.IsNullOrEmpty(_searchFilter) ? signals.Count.ToString() : $"{filtered.Length}/{signals.Count}"), + ("skills covered", signals.Select(s => s.Skill.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count().ToString()))); + AddSearchChip(panel); + + if (signals.Count == 0) + { + panel.AddControl(BuildNotePanel("packages", "[grey50]No NuGet package or prefix signals are present in this catalog version.[/]", AccentDeepSkyBlue)); + return; + } + if (filtered.Length == 0) + { + panel.AddControl(BuildNotePanel("packages", $"[grey50]No signals match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } + + var list = StyledList("Package signals (Enter to inspect linked skill)") + .MaxVisibleItems(16) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var signal in filtered) + { + // ListControl renders item text as markup — escape the whole plain-text label. + list.AddItem(Escape($"{signal.Signal} [{signal.Kind}] -> {ToAlias(signal.Skill.Name)} [{signal.Skill.Stack} / {signal.Skill.Lane}] ({FormatTokenCount(signal.Skill.TokenCount)} tokens)"), signal); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is PackageSignalView signal) + { + ShowSkillDetailModal(ws, panel, signal.Skill); + } + }); + panel.AddControl(list.Build()); + } + + // ------------------------------------------------------------------------- + // Agents + // ------------------------------------------------------------------------- + + private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = TryResolveAgentLayout(out var layoutError); + var installer = new AgentInstaller(agentCatalog); + var installed = layout is null + ? Array.Empty() + : SafeGet(() => installer.GetInstalledAgents(layout), Array.Empty()); + + var allAgents = agentCatalog.Agents.OrderBy(a => a.Name, StringComparer.Ordinal).ToArray(); + var filteredAgents = allAgents.Where(a => MatchesFilter(a.Name, a.Description)).ToArray(); + + panel.AddControl(BuildPropertyPanel("orchestration agents", AccentMediumPurple, + ("agents", string.IsNullOrEmpty(_searchFilter) ? agentCatalog.Agents.Count.ToString() : $"{filteredAgents.Length}/{agentCatalog.Agents.Count}"), + ("platform", Escape(Session.Agent.ToString())), + ("target", layout is null ? $"[red]{Escape(layoutError ?? "unresolved")}[/]" : $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("installed", layout is null ? "[grey]-[/]" : $"{installed.Count}/{agentCatalog.Agents.Count}"))); + AddSearchChip(panel); + + if (agentCatalog.Agents.Count == 0) + { + panel.AddControl(BuildNotePanel("agents", "[grey50]No agents available in the catalog.[/]", AccentDeepSkyBlue)); + return; + } + if (filteredAgents.Length == 0) + { + panel.AddControl(BuildNotePanel("agents", $"[grey50]No agents match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } + + var list = StyledList("Agents (Enter for details)") + .MaxVisibleItems(14) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var agent in filteredAgents) + { + var isInstalled = installed.Any(i => string.Equals(i.Agent.Name, agent.Name, StringComparison.OrdinalIgnoreCase)); + list.AddItem($"{(isInstalled ? "✓ " : "○ ")}{Escape(ToAlias(agent.Name))} [dim]{Escape(CompactDescription(agent.Description))}[/]", agent); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is AgentEntry agent) + { + ShowAgentModal(ws, panel, agent); + } + }); + panel.AddControl(list.Build()); + + if (layout is null) + { + panel.AddControl(BuildNotePanel("note", "[yellow]No native agent directory resolved. Set the platform on the Settings page, or create one of .codex/.claude/.github/.gemini/.junie.[/]", AccentYellow)); + return; + } + + panel.AddControl(Controls.Button("Install all agents into detected native directories") + .OnClick((_, _) => + { + var detected = SafeGet(() => AgentInstallTarget.ResolveAllDetected(Session.ProjectDirectory, Session.Scope), Array.Empty()); + if (detected.Count == 0) + { + Toast("No native agent directories detected", NotificationSeverity.Warning); + return; + } + var summary2 = SafeGet(() => new AgentInstaller(agentCatalog).InstallToMultiple(agentCatalog.Agents, detected, force: false), default(AgentInstallSummary)); + ToastResult(summary2, "Install failed", summary2 is null ? string.Empty : $"Installed {summary2.InstalledCount} agent file(s) across {detected.Count} platform(s)"); + BuildAgentsPage(ws, panel); + }).Build()); + } + + private void ShowAgentModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, AgentEntry agent) + { + var detail = new IWindowControl[] + { + BuildPropertyPanel(ToAlias(agent.Name), AccentMediumPurple, + ("agent", Escape(agent.Name)), + ("skills", agent.Skills.Count == 0 ? "[grey50]-[/]" : Escape(string.Join(", ", agent.Skills.Select(ToAlias)))), + ("platform", Escape(Session.Agent.ToString()))), + BuildNotePanel("summary", Escape(agent.Description), AccentDeepSkyBlue), + }; + + var buttons = new List<(string, Action)>(); + var layout = TryResolveAgentLayout(out _); + if (layout is not null) + { + buttons.Add(("Install into current target", () => + { + var summary = SafeGet(() => new AgentInstaller(agentCatalog).Install(new[] { agent }, layout, force: false), default(AgentInstallSummary)); + ToastResult(summary, "Install failed", summary is null ? string.Empty : $"{ToAlias(agent.Name)}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); + BuildAgentsPage(ws, owner); + })); + buttons.Add(("Remove from current target", () => + { + var summary = SafeGet(() => new AgentInstaller(agentCatalog).Remove(new[] { agent }, layout), default(AgentRemoveSummary)); + ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {ToAlias(agent.Name)} ({summary.RemovedCount} file(s))"); + BuildAgentsPage(ws, owner); + })); + } + + ShowModalNative(ws, $"Agent · {ToAlias(agent.Name)}", detail, buttons.ToArray()); + } +} diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs new file mode 100644 index 0000000..d49d7c8 --- /dev/null +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs @@ -0,0 +1,85 @@ +// ----------------------------------------------------------------------------- +// Home surface — the landing page for the bare `dotnet skills` invocation. +// +// One partial of InteractiveConsoleApp; renders the session card + telemetry grid +// + quick-start hints. Split out of Shell.cs to keep that file under the 800-line +// ceiling once the polish PR adds graphs. +// ----------------------------------------------------------------------------- + +using ManagedCode.DotnetSkills.Runtime; +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Core; +using SharpConsoleUI.Drivers; +using SharpConsoleUI.Helpers; +using SharpConsoleUI.Layout; +using SharpConsoleUI.Rendering; +using SharpConsoleUI.Themes; + +namespace ManagedCode.DotnetSkills; + +internal sealed partial class InteractiveConsoleApp +{ + private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + if (_currentPage != null) + { + _searchFilter = string.Empty; + _selectedCollection = null; + _collectionInstallArmed = false; + } + _activePanel = panel; + _currentPage = null; + AttachSessionEvents(); + ClearStickyStatus(); + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); + var outdated = installed.Count(record => !record.IsCurrent); + + panel.AddControl(BuildPropertyPanel("session", AccentDeepSkyBlue, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("platform", Escape(Session.Agent.ToString())), + ("scope", Escape(Session.Scope.ToString())), + ("project", Escape(CompactPath(Session.ProjectDirectory ?? Environment.CurrentDirectory))), + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"))); + + // catalog telemetry — five native metric cards laid out by HorizontalGrid (responsive flex). + var installedAccent = installed.Count > 0 ? AccentGreen : AccentGrey; + var outdatedAccent = outdated == 0 ? AccentGreen : AccentYellow; + // Cards are clickable navigation targets — click "outdated" to jump to Installed, etc. + var telemetryGrid = Controls.HorizontalGrid() + .Column(col => col.Flex(1).Add(BuildMetricCard("skills", skillCatalog.Skills.Count.ToString(), "in catalog", AccentDeepSkyBlue, () => NavigateTo(HomeAction.BrowseSkills)))) + .Column(col => col.Flex(1).Add(BuildMetricCard("bundles", GetPrimaryBundles().Count.ToString(), "focused", AccentTurquoise, () => NavigateTo(HomeAction.BrowseBundles)))) + .Column(col => col.Flex(1).Add(BuildMetricCard("installed", $"{installed.Count}/{skillCatalog.Skills.Count}", "in current target", installedAccent, () => NavigateTo(HomeAction.ManageInstalled)))) + .Column(col => col.Flex(1).Add(BuildMetricCard("outdated", outdated.ToString(), outdated == 0 ? "all current" : "need update", outdatedAccent, () => NavigateTo(HomeAction.ManageInstalled)))) + .Column(col => col.Flex(1).Add(BuildMetricCard("agents", agentCatalog.Agents.Count.ToString(), "orchestration", AccentMediumPurple, () => NavigateTo(HomeAction.BrowseAgents)))) + .Build(); + panel.AddControl(telemetryGrid); + + if (toolUpdateStatus?.HasUpdate == true) + { + var freshness = toolUpdateStatus.CheckedAt is null + ? "[grey50]latest release detected[/]" + : toolUpdateStatus.UsedCachedValue + ? $"[grey50]cached[/] [grey]{Escape(toolUpdateStatus.CheckedAt.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}[/]" + : $"[grey50]checked[/] [grey]{Escape(toolUpdateStatus.CheckedAt.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}[/]"; + panel.AddControl(BuildBulletPanel("tool update", AccentYellow, + "[bold yellow]New dotnet-skills version available[/]", + $"[grey50]current[/] [grey]{Escape(toolUpdateStatus.CurrentVersion)}[/] [grey50]-> latest[/] [green]{Escape(toolUpdateStatus.LatestVersion ?? "?")}[/]", + $"[green]{Escape(GlobalToolUpdateCommand)}[/]", + $"[grey50]local tool manifest[/] [green]{Escape(LocalToolUpdateCommand)}[/]", + freshness)); + } + + panel.AddControl(BuildBulletPanel("quick start", AccentDeepSkyBlue, + "[grey50]Use the rail on the left to browse and install.[/]", + "[grey]Skills[/] [grey50]browse and install individual catalog skills[/]", + "[grey]Installed[/] [grey50]update or remove what is already installed[/]", + "[grey]Project[/] [grey50]scan the current solution and install recommended skills[/]", + "[grey]Agents[/] [grey50]install orchestration agents into native agent directories[/]")); + } +} diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs index c91d48b..4ae153a 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs @@ -392,71 +392,6 @@ private void BuildActionPage(ConsoleWindowSystem ws, ScrollablePanelControl pane } } - // ------------------------------------------------------------------------- - // Home - // ------------------------------------------------------------------------- - - private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel) - { - if (_currentPage != null) - { - _searchFilter = string.Empty; - _selectedCollection = null; - _collectionInstallArmed = false; - } - _activePanel = panel; - _currentPage = null; - AttachSessionEvents(); - ClearStickyStatus(); - panel.ClearContents(); - - var layout = ResolveSkillLayout(); - var installer = new SkillInstaller(skillCatalog); - var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); - var outdated = installed.Count(record => !record.IsCurrent); - - panel.AddControl(BuildPropertyPanel("session", AccentDeepSkyBlue, - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("platform", Escape(Session.Agent.ToString())), - ("scope", Escape(Session.Scope.ToString())), - ("project", Escape(CompactPath(Session.ProjectDirectory ?? Environment.CurrentDirectory))), - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"))); - - // catalog telemetry — five native metric cards laid out by HorizontalGrid (responsive flex). - var installedAccent = installed.Count > 0 ? AccentGreen : AccentGrey; - var outdatedAccent = outdated == 0 ? AccentGreen : AccentYellow; - // Cards are clickable navigation targets — click "outdated" to jump to Installed, etc. - var telemetryGrid = Controls.HorizontalGrid() - .Column(col => col.Flex(1).Add(BuildMetricCard("skills", skillCatalog.Skills.Count.ToString(), "in catalog", AccentDeepSkyBlue, () => NavigateTo(HomeAction.BrowseSkills)))) - .Column(col => col.Flex(1).Add(BuildMetricCard("bundles", GetPrimaryBundles().Count.ToString(), "focused", AccentTurquoise, () => NavigateTo(HomeAction.BrowseBundles)))) - .Column(col => col.Flex(1).Add(BuildMetricCard("installed", $"{installed.Count}/{skillCatalog.Skills.Count}", "in current target", installedAccent, () => NavigateTo(HomeAction.ManageInstalled)))) - .Column(col => col.Flex(1).Add(BuildMetricCard("outdated", outdated.ToString(), outdated == 0 ? "all current" : "need update", outdatedAccent, () => NavigateTo(HomeAction.ManageInstalled)))) - .Column(col => col.Flex(1).Add(BuildMetricCard("agents", agentCatalog.Agents.Count.ToString(), "orchestration", AccentMediumPurple, () => NavigateTo(HomeAction.BrowseAgents)))) - .Build(); - panel.AddControl(telemetryGrid); - - if (toolUpdateStatus?.HasUpdate == true) - { - var freshness = toolUpdateStatus.CheckedAt is null - ? "[grey50]latest release detected[/]" - : toolUpdateStatus.UsedCachedValue - ? $"[grey50]cached[/] [grey]{Escape(toolUpdateStatus.CheckedAt.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}[/]" - : $"[grey50]checked[/] [grey]{Escape(toolUpdateStatus.CheckedAt.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}[/]"; - panel.AddControl(BuildBulletPanel("tool update", AccentYellow, - "[bold yellow]New dotnet-skills version available[/]", - $"[grey50]current[/] [grey]{Escape(toolUpdateStatus.CurrentVersion)}[/] [grey50]-> latest[/] [green]{Escape(toolUpdateStatus.LatestVersion ?? "?")}[/]", - $"[green]{Escape(GlobalToolUpdateCommand)}[/]", - $"[grey50]local tool manifest[/] [green]{Escape(LocalToolUpdateCommand)}[/]", - freshness)); - } - - panel.AddControl(BuildBulletPanel("quick start", AccentDeepSkyBlue, - "[grey50]Use the rail on the left to browse and install.[/]", - "[grey]Skills[/] [grey50]browse and install individual catalog skills[/]", - "[grey]Installed[/] [grey50]update or remove what is already installed[/]", - "[grey]Project[/] [grey50]scan the current solution and install recommended skills[/]", - "[grey]Agents[/] [grey50]install orchestration agents into native agent directories[/]")); - } // ------------------------------------------------------------------------- // Native control helpers — every page and modal renders through these. @@ -579,976 +514,11 @@ private static IWindowControl BuildCardGrid(IReadOnlyList cards, i return grid.Build(); } - // ------------------------------------------------------------------------- - // Skill browser - // ------------------------------------------------------------------------- - - private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) - { - panel.ClearContents(); - - var layout = ResolveSkillLayout(); - var installer = new SkillInstaller(skillCatalog); - var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); - var available = skillCatalog.Skills - .Where(skill => installed.All(record => !string.Equals(record.Skill.Name, skill.Name, StringComparison.OrdinalIgnoreCase))) - .OrderBy(skill => CatalogOrganization.GetStackRank(skill.Stack)) - .ThenBy(skill => skill.Stack, StringComparer.Ordinal) - .ThenBy(skill => skill.Name, StringComparer.Ordinal) - .ToArray(); - - var filtered = available.Where(s => MatchesFilter(s.Name, s.Stack, s.Lane)).ToArray(); - - panel.AddControl(BuildPropertyPanel("skill browser", AccentTurquoise, - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("available", $"{filtered.Length}/{available.Length}"), - ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); - AddSearchChip(panel); - - if (available.Length == 0) - { - panel.AddControl(BuildNotePanel("available", "[grey50]Every catalog skill is already installed in this target.[/]", AccentDeepSkyBlue)); - return; - } - if (filtered.Length == 0) - { - panel.AddControl(BuildNotePanel("available", $"[grey50]No skills match “{Escape(_searchFilter)}”.[/]", AccentYellow)); - return; - } - - var list = StyledList("Available skills (Enter for details)") - .MaxVisibleItems(16) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var skill in filtered) - { - // ListControl parses item text as markup; BuildSkillChoiceLabel produces plain - // text containing bracketed stack/lane like "[.NET Foundations / ...]". Escape so - // brackets are not interpreted as Spectre markup tags. - list.AddItem(Escape(BuildSkillChoiceLabel(skill, installed)), skill); - } - list.OnItemActivated((_, item) => - { - if (item.Tag is SkillEntry skill) - { - ShowSkillDetailModal(ws, panel, skill); - } - }); - panel.AddControl(list.Build()); - } - - private void ShowSkillDetailModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, SkillEntry skill) - { - var detail = new IWindowControl[] - { - BuildPropertyPanel(ToAlias(skill.Name), AccentTurquoise, - ("skill", Escape(skill.Name)), - ("collection", Escape(skill.Stack)), - ("lane", Escape(skill.Lane)), - ("version", Escape(skill.Version)), - ("tokens", FormatTokenCount(skill.TokenCount))), - BuildNotePanel("summary", Escape(skill.Description), AccentDeepSkyBlue), - BuildNotePanel("preview", Escape(LoadSkillPreview(skill)), AccentGrey), - }; - - ShowModalNative(ws, $"Skill · {ToAlias(skill.Name)}", detail, - ("Install into current target", () => - { - var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { skill }, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); - if (summary is null) - Toast($"Install failed for {ToAlias(skill.Name)}", NotificationSeverity.Danger); - else - Toast($"{ToAlias(skill.Name)}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped", NotificationSeverity.Success); - BuildSkillBrowserPage(ws, owner); - }), - ("Force reinstall", () => - { - var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { skill }, ResolveSkillLayout(), force: true), default(SkillInstallSummary)); - if (summary is null) - Toast($"Install failed for {ToAlias(skill.Name)}", NotificationSeverity.Danger); - else - Toast($"{ToAlias(skill.Name)}: reinstalled ({summary.InstalledCount} written)", NotificationSeverity.Success); - BuildSkillBrowserPage(ws, owner); - })); - } - - // ------------------------------------------------------------------------- - // Installed skills - // ------------------------------------------------------------------------- - - private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) - { - panel.ClearContents(); - - var layout = ResolveSkillLayout(); - var installer = new SkillInstaller(skillCatalog); - var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()) - .OrderBy(record => record.Skill.Name, StringComparer.Ordinal) - .ToArray(); - var outdated = installed.Where(record => !record.IsCurrent).ToArray(); - - var filtered = installed.Where(r => MatchesFilter(r.Skill.Name, r.Skill.Stack, r.Skill.Lane)).ToArray(); - - panel.AddControl(BuildPropertyPanel("installed skills", AccentGreen, - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("installed", string.IsNullOrEmpty(_searchFilter) ? installed.Length.ToString() : $"{filtered.Length}/{installed.Length}"), - ("outdated", outdated.Length == 0 ? "[green]0[/]" : $"[yellow]{outdated.Length}[/]"), - ("tokens", FormatTokenCount(installed.Sum(record => record.Skill.TokenCount))))); - AddSearchChip(panel); - - if (installed.Length == 0) - { - panel.AddControl(BuildNotePanel("installed", "[grey50]No catalog skills are installed in this target yet. Visit the Skills page to add some.[/]", AccentDeepSkyBlue)); - return; - } - if (filtered.Length == 0) - { - panel.AddControl(BuildNotePanel("installed", $"[grey50]No installed skills match “{Escape(_searchFilter)}”.[/]", AccentYellow)); - return; - } - - // Real sortable TableControl — columns can be sorted by clicking the header. Per-row - // foreground color flags outdated rows yellow without needing markup escaping per cell. - var table = Controls.Table() - .WithTitle("Installed skills (Enter for details)") - .AddColumn("Status", TextJustification.Center, width: 8) - .AddColumn("Skill") - .AddColumn("Collection") - .AddColumn("Lane") - .AddColumn("Installed", TextJustification.Right) - .AddColumn("Latest", TextJustification.Right) - .AddColumn("Tokens", TextJustification.Right) - .WithSorting() - .Rounded() - .WithBorderColor(AccentGreen); - foreach (var record in filtered) - { - var row = new TableRow( - record.IsCurrent ? "✓ current" : "↻ update", - ToAlias(record.Skill.Name), - record.Skill.Stack, - record.Skill.Lane, - record.InstalledVersion, - record.Skill.Version, - FormatTokenCount(record.Skill.TokenCount)) - { - Tag = record, - ForegroundColor = record.IsCurrent ? null : AccentYellow, - }; - table.AddRow(row); - } - // RowActivated fires on Enter or double-click; index is into the filtered array because - // we appended rows in the same order. - table.OnRowActivated((_, idx) => - { - if (idx >= 0 && idx < filtered.Length) - { - ShowInstalledSkillModal(ws, panel, filtered[idx]); - } - }); - panel.AddControl(table.Build()); - - if (outdated.Length > 0) - { - panel.AddControl(Controls.Button($"Update all {outdated.Length} outdated skill(s)") - .OnClick((_, _) => - { - var summaryText = UpdateSkillRecords(outdated); - Toast(summaryText, summaryText.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); - BuildInstalledPage(ws, panel); - }).Build()); - } - - panel.AddControl(Controls.Button($"Remove all {installed.Length} installed skill(s)") - .OnClick((_, _) => ConfirmModal(ws, "Remove all installed skills?", - $"This removes every catalog skill from {layout.PrimaryRoot.FullName}.", - () => - { - var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout), default(SkillRemoveSummary)); - ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)"); - BuildInstalledPage(ws, panel); - })).Build()); - } - - private void ShowInstalledSkillModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, InstalledSkillRecord record) - { - var detail = new IWindowControl[] - { - BuildPropertyPanel(ToAlias(record.Skill.Name), AccentGreen, - ("skill", Escape(record.Skill.Name)), - ("collection", Escape($"{record.Skill.Stack} / {record.Skill.Lane}")), - ("installed", Escape(record.InstalledVersion)), - ("latest", Escape(record.Skill.Version)), - ("status", record.IsCurrent ? "[green]✓ current[/]" : "[yellow]↻ update available[/]"), - ("tokens", FormatTokenCount(record.Skill.TokenCount))), - BuildNotePanel("summary", Escape(record.Skill.Description), AccentDeepSkyBlue), - }; - - var buttons = new List<(string, Action)>(); - if (!record.IsCurrent) - { - buttons.Add(($"Update to {record.Skill.Version}", () => - { - var msg = UpdateSkillRecords(new[] { record }); - Toast(msg, msg.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); - BuildInstalledPage(ws, owner); - })); - } - buttons.Add(("Reinstall (force)", () => - { - var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { record.Skill }, ResolveSkillLayout(), force: true), default(SkillInstallSummary)); - ToastResult(summary, "Reinstall failed", $"{ToAlias(record.Skill.Name)}: reinstalled"); - BuildInstalledPage(ws, owner); - })); - buttons.Add(("Remove", () => ConfirmModal(ws, $"Remove {ToAlias(record.Skill.Name)}?", $"Deletes the skill directory from {ResolveSkillLayout().PrimaryRoot.FullName}.", () => - { - var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(new[] { record.Skill }, ResolveSkillLayout()), default(SkillRemoveSummary)); - ToastResult(summary, "Remove failed", $"Removed {ToAlias(record.Skill.Name)}"); - BuildInstalledPage(ws, owner); - }))); - - ShowModalNative(ws, $"Installed · {ToAlias(record.Skill.Name)}", detail, buttons.ToArray()); - } - - private string UpdateSkillRecords(IReadOnlyList records) - { - var layout = ResolveSkillLayout(); - var skills = records.Select(record => record.Skill).ToArray(); - var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, layout, force: true), default(SkillInstallSummary)); - return summary is null ? "Update failed" : $"Updated {summary.InstalledCount} skill(s)"; - } - - // ------------------------------------------------------------------------- - // Collections - // ------------------------------------------------------------------------- - - private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) - { - panel.ClearContents(); - - var layout = ResolveSkillLayout(); - var installer = new SkillInstaller(skillCatalog); - var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); - var views = BuildCollectionViews(installed) - .OrderBy(view => CatalogOrganization.GetStackRank(view.Collection)) - .ThenBy(view => view.Collection, StringComparer.Ordinal) - .ToArray(); - var filtered = views.Where(v => MatchesFilter(v.Collection)).ToArray(); - - panel.AddControl(BuildPropertyPanel("collection browser", AccentDeepSkyBlue, - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("collections", string.IsNullOrEmpty(_searchFilter) ? views.Length.ToString() : $"{filtered.Length}/{views.Length}"), - ("skills", skillCatalog.Skills.Count.ToString()), - ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); - AddSearchChip(panel); - - if (views.Length == 0) - { - panel.AddControl(BuildNotePanel("collections", "[grey50]No collections in this catalog version.[/]", AccentDeepSkyBlue)); - return; - } - if (filtered.Length == 0) - { - panel.AddControl(BuildNotePanel("collections", $"[grey50]No collections match “{Escape(_searchFilter)}”.[/]", AccentYellow)); - return; - } - - // Master-detail layout. Left column lists collections; right column shows the detail of - // _selectedCollection. Clicking a left-list row updates only the right pane in place — - // no modal, no full-page rebuild. The right pane is a ScrollablePanel so the detail can - // grow with the collection's lane list. - if (_selectedCollection is null - || !filtered.Any(v => string.Equals(v.Collection, _selectedCollection.Collection, StringComparison.OrdinalIgnoreCase))) - { - _selectedCollection = filtered[0]; - _collectionInstallArmed = false; - } - - // Build the detail pane as a standalone ScrollablePanelControl so we can update it - // independently of the left list when the user changes selection. - var rightPane = new ScrollablePanelControl - { - ShowScrollbar = true, - VerticalScrollMode = ScrollMode.Scroll, - EnableMouseWheel = true, - }; - - var grid = Controls.HorizontalGrid() - .Column(col => - { - col.Flex(1); - var list = StyledList("Collections") - .MaxVisibleItems(20) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var view in filtered) - { - list.AddItem(Escape(BuildCollectionChoiceLabel(view)), view); - } - list.OnItemActivated((_, item) => - { - if (item.Tag is CollectionCatalogView v) - { - _selectedCollection = v; - _collectionInstallArmed = false; - BuildCollectionDetail(rightPane, v); - } - }); - col.Add(list.Build()); - }) - .Column(col => - { - col.Flex(2).Add(rightPane); - }) - .Build(); - - panel.AddControl(grid); - BuildCollectionDetail(rightPane, _selectedCollection!); - } - - /// - /// Renders the right pane of the Collections master-detail view: stats, lanes, and an inline - /// two-stage install button (first click arms, second commits — satisfies AGENTS.md's - /// "install overview before confirmation" rule without a modal). - /// - private void BuildCollectionDetail(ScrollablePanelControl pane, CollectionCatalogView view) - { - pane.ClearContents(); - pane.AddControl(BuildPropertyPanel(view.Collection, AccentDeepSkyBlue, - ("collection", Escape(view.Collection)), - ("lanes", view.Lanes.Count.ToString()), - ("skills", $"{view.InstalledCount}/{view.SkillCount}"), - ("tokens", FormatTokenCount(view.TokenCount)))); - - if (view.Lanes.Count > 0) - { - pane.AddControl(BuildBulletPanel("lanes", AccentTurquoise, - view.Lanes.Select(lane => $"[grey50]·[/] [grey]{Escape(lane.Lane)}[/] [grey50]({lane.InstalledCount}/{lane.Skills.Count} skills, {FormatTokenCount(lane.TokenCount)} tokens)[/]").ToArray())); - } - - var armed = _collectionInstallArmed; - var label = armed - ? $"Click again to install all {view.SkillCount} skill(s)" - : $"Install collection ({view.SkillCount} skill(s))"; - pane.AddControl(Controls.Button(label) - .OnClick((_, _) => - { - if (!_collectionInstallArmed) - { - _collectionInstallArmed = true; - Toast($"Click again to confirm installing {view.SkillCount} skill(s)", NotificationSeverity.Warning); - BuildCollectionDetail(pane, view); - return; - } - var skills = SafeGet(() => new SkillInstaller(skillCatalog).SelectSkillsFromCollections(new[] { view.Collection }), Array.Empty()); - var summary = skills.Count == 0 ? null : SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); - ToastResult(summary, $"Could not install collection {view.Collection}", summary is null ? string.Empty : $"{view.Collection}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); - _collectionInstallArmed = false; - if (_ws is not null && _activePanel is not null) BuildCollectionsPage(_ws, _activePanel); - }).Build()); - } - - // ------------------------------------------------------------------------- - // Bundles / packages - // ------------------------------------------------------------------------- - - private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl panel, bool primaryOnly) - { - panel.ClearContents(); - - var packages = (primaryOnly - ? GetPrimaryBundles() - : skillCatalog.Packages.OrderBy(p => p.Name, StringComparer.Ordinal).ToArray()) - .ToArray(); - var title = primaryOnly ? "focused bundles" : "catalog packages"; - var skillTokens = skillCatalog.Skills.ToDictionary(skill => skill.Name, skill => skill.TokenCount, StringComparer.OrdinalIgnoreCase); - - var filtered = packages.Where(p => MatchesFilter(p.Name, p.Title)).ToArray(); - - panel.AddControl(BuildPropertyPanel(title, AccentDeepSkyBlue, - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - (primaryOnly ? "bundles" : "packages", string.IsNullOrEmpty(_searchFilter) ? packages.Length.ToString() : $"{filtered.Length}/{packages.Length}"), - ("skills covered", skillCatalog.Skills.Count.ToString()))); - AddSearchChip(panel); - - if (packages.Length == 0) - { - panel.AddControl(BuildNotePanel(title, "[grey50]Nothing available in this catalog version.[/]", AccentDeepSkyBlue)); - return; - } - if (filtered.Length == 0) - { - panel.AddControl(BuildNotePanel(title, $"[grey50]No bundles match “{Escape(_searchFilter)}”.[/]", AccentYellow)); - return; - } - - var list = StyledList($"{(primaryOnly ? "Bundles" : "Packages")} (Enter for details)") - .MaxVisibleItems(16) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var package in filtered) - { - var tokenCount = package.Skills.Sum(name => skillTokens.TryGetValue(name, out var value) ? value : 0); - list.AddItem($"{Escape(package.Name)} [dim]({package.Skills.Count} skills, {FormatTokenCount(tokenCount)} tokens)[/]", package); - } - list.OnItemActivated((_, item) => - { - if (item.Tag is SkillPackageEntry package) - { - ShowBundleModal(ws, panel, package, primaryOnly); - } - }); - panel.AddControl(list.Build()); - } - - private void ShowBundleModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, SkillPackageEntry package, bool primaryOnly) - { - var detail = new IWindowControl[] - { - BuildPropertyPanel(package.Name, AccentTurquoise, - ("package", Escape(package.Name)), - ("title", Escape(package.Title)), - ("skills", package.Skills.Count.ToString()), - ("includes", Escape(string.Join(", ", package.Skills.Take(10).Select(ToAlias))))), - BuildNotePanel("summary", Escape(package.Description), AccentDeepSkyBlue), - }; - - ShowModalNative(ws, $"Bundle · {package.Name}", detail, - ("Install bundle into current target", () => - { - var skills = SafeGet(() => new SkillInstaller(skillCatalog).SelectSkillsFromPackages(new[] { package.Name }), Array.Empty()); - var summary = skills.Count == 0 ? null : SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, ResolveSkillLayout(), force: false), default(SkillInstallSummary)); - ToastResult(summary, $"Could not install bundle {package.Name}", summary is null ? string.Empty : $"{package.Name}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); - BuildBundlesPage(ws, owner, primaryOnly); - })); - } - - // ------------------------------------------------------------------------- - // Packages — NuGet ids / prefixes → catalog skills - // ------------------------------------------------------------------------- - - private void BuildPackagesPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) - { - panel.ClearContents(); - - var signals = SafeGet(BuildPackageSignals, Array.Empty()); - var filtered = signals.Where(s => MatchesFilter(s.Signal, s.Skill.Name, s.Skill.Stack, s.Skill.Lane)).ToArray(); - - panel.AddControl(BuildPropertyPanel("package signals", AccentTurquoise, - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("signals", string.IsNullOrEmpty(_searchFilter) ? signals.Count.ToString() : $"{filtered.Length}/{signals.Count}"), - ("skills covered", signals.Select(s => s.Skill.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count().ToString()))); - AddSearchChip(panel); - - if (signals.Count == 0) - { - panel.AddControl(BuildNotePanel("packages", "[grey50]No NuGet package or prefix signals are present in this catalog version.[/]", AccentDeepSkyBlue)); - return; - } - if (filtered.Length == 0) - { - panel.AddControl(BuildNotePanel("packages", $"[grey50]No signals match “{Escape(_searchFilter)}”.[/]", AccentYellow)); - return; - } - - var list = StyledList("Package signals (Enter to inspect linked skill)") - .MaxVisibleItems(16) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var signal in filtered) - { - // ListControl renders item text as markup — escape the whole plain-text label. - list.AddItem(Escape($"{signal.Signal} [{signal.Kind}] -> {ToAlias(signal.Skill.Name)} [{signal.Skill.Stack} / {signal.Skill.Lane}] ({FormatTokenCount(signal.Skill.TokenCount)} tokens)"), signal); - } - list.OnItemActivated((_, item) => - { - if (item.Tag is PackageSignalView signal) - { - ShowSkillDetailModal(ws, panel, signal.Skill); - } - }); - panel.AddControl(list.Build()); - } - - // ------------------------------------------------------------------------- - // Agents - // ------------------------------------------------------------------------- - - private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) - { - panel.ClearContents(); - - var layout = TryResolveAgentLayout(out var layoutError); - var installer = new AgentInstaller(agentCatalog); - var installed = layout is null - ? Array.Empty() - : SafeGet(() => installer.GetInstalledAgents(layout), Array.Empty()); - - var allAgents = agentCatalog.Agents.OrderBy(a => a.Name, StringComparer.Ordinal).ToArray(); - var filteredAgents = allAgents.Where(a => MatchesFilter(a.Name, a.Description)).ToArray(); - - panel.AddControl(BuildPropertyPanel("orchestration agents", AccentMediumPurple, - ("agents", string.IsNullOrEmpty(_searchFilter) ? agentCatalog.Agents.Count.ToString() : $"{filteredAgents.Length}/{agentCatalog.Agents.Count}"), - ("platform", Escape(Session.Agent.ToString())), - ("target", layout is null ? $"[red]{Escape(layoutError ?? "unresolved")}[/]" : $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("installed", layout is null ? "[grey]-[/]" : $"{installed.Count}/{agentCatalog.Agents.Count}"))); - AddSearchChip(panel); - - if (agentCatalog.Agents.Count == 0) - { - panel.AddControl(BuildNotePanel("agents", "[grey50]No agents available in the catalog.[/]", AccentDeepSkyBlue)); - return; - } - if (filteredAgents.Length == 0) - { - panel.AddControl(BuildNotePanel("agents", $"[grey50]No agents match “{Escape(_searchFilter)}”.[/]", AccentYellow)); - return; - } - - var list = StyledList("Agents (Enter for details)") - .MaxVisibleItems(14) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var agent in filteredAgents) - { - var isInstalled = installed.Any(i => string.Equals(i.Agent.Name, agent.Name, StringComparison.OrdinalIgnoreCase)); - list.AddItem($"{(isInstalled ? "✓ " : "○ ")}{Escape(ToAlias(agent.Name))} [dim]{Escape(CompactDescription(agent.Description))}[/]", agent); - } - list.OnItemActivated((_, item) => - { - if (item.Tag is AgentEntry agent) - { - ShowAgentModal(ws, panel, agent); - } - }); - panel.AddControl(list.Build()); - - if (layout is null) - { - panel.AddControl(BuildNotePanel("note", "[yellow]No native agent directory resolved. Set the platform on the Settings page, or create one of .codex/.claude/.github/.gemini/.junie.[/]", AccentYellow)); - return; - } - - panel.AddControl(Controls.Button("Install all agents into detected native directories") - .OnClick((_, _) => - { - var detected = SafeGet(() => AgentInstallTarget.ResolveAllDetected(Session.ProjectDirectory, Session.Scope), Array.Empty()); - if (detected.Count == 0) - { - Toast("No native agent directories detected", NotificationSeverity.Warning); - return; - } - var summary2 = SafeGet(() => new AgentInstaller(agentCatalog).InstallToMultiple(agentCatalog.Agents, detected, force: false), default(AgentInstallSummary)); - ToastResult(summary2, "Install failed", summary2 is null ? string.Empty : $"Installed {summary2.InstalledCount} agent file(s) across {detected.Count} platform(s)"); - BuildAgentsPage(ws, panel); - }).Build()); - } - - private void ShowAgentModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, AgentEntry agent) - { - var detail = new IWindowControl[] - { - BuildPropertyPanel(ToAlias(agent.Name), AccentMediumPurple, - ("agent", Escape(agent.Name)), - ("skills", agent.Skills.Count == 0 ? "[grey50]-[/]" : Escape(string.Join(", ", agent.Skills.Select(ToAlias)))), - ("platform", Escape(Session.Agent.ToString()))), - BuildNotePanel("summary", Escape(agent.Description), AccentDeepSkyBlue), - }; - - var buttons = new List<(string, Action)>(); - var layout = TryResolveAgentLayout(out _); - if (layout is not null) - { - buttons.Add(("Install into current target", () => - { - var summary = SafeGet(() => new AgentInstaller(agentCatalog).Install(new[] { agent }, layout, force: false), default(AgentInstallSummary)); - ToastResult(summary, "Install failed", summary is null ? string.Empty : $"{ToAlias(agent.Name)}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); - BuildAgentsPage(ws, owner); - })); - buttons.Add(("Remove from current target", () => - { - var summary = SafeGet(() => new AgentInstaller(agentCatalog).Remove(new[] { agent }, layout), default(AgentRemoveSummary)); - ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {ToAlias(agent.Name)} ({summary.RemovedCount} file(s))"); - BuildAgentsPage(ws, owner); - })); - } - - ShowModalNative(ws, $"Agent · {ToAlias(agent.Name)}", detail, buttons.ToArray()); - } // ------------------------------------------------------------------------- // Project sync / recommend // ------------------------------------------------------------------------- - private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) - { - panel.ClearContents(); - - var layout = ResolveSkillLayout(); - var scan = SafeGet(() => new ProjectSkillRecommender(skillCatalog).Analyze(Session.ProjectDirectory), null); - if (scan is null) - { - panel.AddControl(BuildNotePanel("project scan", "[red]Could not scan the project directory.[/]", new Color(200, 60, 60))); - return; - } - - var installer = new SkillInstaller(skillCatalog); - var installedByName = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()) - .ToDictionary(record => record.Skill.Name, StringComparer.OrdinalIgnoreCase); - - var high = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.High); - var med = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Medium); - var low = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Low); - - panel.AddControl(BuildPropertyPanel("project scan", AccentDeepSkyBlue, - ("project", $"[grey50]{Escape(CompactPath(scan.ProjectRoot.FullName))}[/]"), - ("scanned", $"{scan.ProjectFiles.Count} project file(s)"), - ("frameworks", scan.TargetFrameworks.Count == 0 ? "[grey50]unknown[/]" : Escape(string.Join(", ", scan.TargetFrameworks))), - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("recommendations", $"{scan.Recommendations.Count} [grey50]([/][green]{high} high[/][grey50] · [/][yellow]{med} med[/][grey50] · [/][grey]{low} low[/][grey50])[/]"))); - - if (scan.Recommendations.Count == 0) - { - panel.AddControl(BuildNotePanel("recommendations", "[grey50]No package or framework signals matched the catalog. Start with the[/] [green]dotnet[/] [grey50]and[/] [green]modern-csharp[/] [grey50]skills from the Skills page.[/]", AccentDeepSkyBlue)); - return; - } - - var list = StyledList("Recommended skills (Enter to install)") - .MaxVisibleItems(16) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var recommendation in scan.Recommendations - .OrderByDescending(r => r.Confidence) - .ThenBy(r => r.Skill.Name, StringComparer.Ordinal)) - { - var marker = recommendation.Confidence switch - { - RecommendationConfidence.High => "[green]●●●[/]", - RecommendationConfidence.Medium => "[yellow]●●○[/]", - _ => "[grey]●○○[/]", - }; - installedByName.TryGetValue(recommendation.Skill.Name, out var record); - var status = record is null ? "[deepskyblue1]new[/]" : record.IsCurrent ? "[green]installed[/]" : "[yellow]update[/]"; - list.AddItem($"{marker} {Escape(ToAlias(recommendation.Skill.Name))} [dim]{status}[/] [grey]{Escape(string.Join("; ", recommendation.Reasons.Take(2)))}[/]", recommendation); - } - list.OnItemActivated((_, item) => - { - if (item.Tag is ProjectSkillRecommendation recommendation) - { - // Outdated recommendations need force=true: SkillInstaller.Install skips - // existing skill directories unless forced, so an "update" entry would - // otherwise be reported as skipped and stay outdated. - var isOutdated = installedByName.TryGetValue(recommendation.Skill.Name, out var existing) && !existing.IsCurrent; - var summary2 = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { recommendation.Skill }, ResolveSkillLayout(), force: isOutdated), default(SkillInstallSummary)); - ToastResult(summary2, $"Install failed for {ToAlias(recommendation.Skill.Name)}", summary2 is null ? string.Empty : $"{ToAlias(recommendation.Skill.Name)}: {summary2.InstalledCount} written, {summary2.SkippedExisting.Count} skipped"); - BuildProjectPage(ws, panel); - } - }); - panel.AddControl(list.Build()); - - // Split recommendations: new ones install with force=false, outdated ones need - // force=true so the existing skill directory is overwritten with the latest version. - var newSkills = scan.Recommendations - .Where(r => !installedByName.ContainsKey(r.Skill.Name)) - .Select(r => r.Skill) - .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) - .ToArray(); - var outdatedSkills = scan.Recommendations - .Where(r => installedByName.TryGetValue(r.Skill.Name, out var rec) && !rec.IsCurrent) - .Select(r => r.Skill) - .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) - .ToArray(); - var installable = newSkills.Concat(outdatedSkills).ToArray(); - if (installable.Length > 0) - { - panel.AddControl(Controls.Button($"Install all {installable.Length} recommended skill(s)") - .OnClick((_, _) => - { - var skillLayout = ResolveSkillLayout(); - var installer2 = new SkillInstaller(skillCatalog); - var newSummary = newSkills.Length == 0 ? default : SafeGet(() => installer2.Install(newSkills, skillLayout, force: false), default(SkillInstallSummary)); - var updateSummary = outdatedSkills.Length == 0 ? default : SafeGet(() => installer2.Install(outdatedSkills, skillLayout, force: true), default(SkillInstallSummary)); - var installedCount = (newSummary?.InstalledCount ?? 0) + (updateSummary?.InstalledCount ?? 0); - var skippedCount = (newSummary?.SkippedExisting.Count ?? 0) + (updateSummary?.SkippedExisting.Count ?? 0); - var failed = installedCount == 0 && skippedCount == 0; - Toast(failed ? "Install failed" : $"Installed {installedCount}, skipped {skippedCount}", failed ? NotificationSeverity.Danger : NotificationSeverity.Success); - BuildProjectPage(ws, panel); - }).Build()); - } - } - - // ------------------------------------------------------------------------- - // Catalog analysis - // ------------------------------------------------------------------------- - - private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) - { - panel.ClearContents(); - - var layout = ResolveSkillLayout(); - var installer = new SkillInstaller(skillCatalog); - var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); - var views = BuildCollectionViews(installed) - .OrderByDescending(view => view.SkillCount) - .ToArray(); - var signals = SafeGet(BuildPackageSignals, Array.Empty()); - var heaviest = skillCatalog.Skills.OrderByDescending(skill => skill.TokenCount).Take(12).ToArray(); - - panel.AddControl(BuildPropertyPanel("catalog analysis", AccentDeepSkyBlue, - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("collections", views.Length.ToString()), - ("skills", skillCatalog.Skills.Count.ToString()), - ("total tokens", FormatTokenCount(skillCatalog.Skills.Sum(skill => skill.TokenCount))), - ("package signals", signals.Count.ToString()))); - - var collectionCards = views.Take(12).Select(view => BuildBulletPanel( - view.Collection, AccentDeepSkyBlue, - $"[grey50]skills[/] {view.SkillCount} [grey50]installed[/] {view.InstalledCount} [grey50]tokens[/] {FormatTokenCount(view.TokenCount)}")).ToList(); - panel.AddControl(BuildCardGrid(collectionCards, maxColumns: 3)); - - var heavyTable = Controls.Table() - .WithTitle("Heaviest skills (Enter for details)") - .AddColumn("Skill") - .AddColumn("Collection") - .AddColumn("Lane") - .AddColumn("Tokens", TextJustification.Right) - .WithSorting() - .Rounded() - .WithBorderColor(AccentDeepSkyBlue); - foreach (var skill in heaviest) - { - heavyTable.AddRow(new TableRow(ToAlias(skill.Name), skill.Stack, skill.Lane, FormatTokenCount(skill.TokenCount)) { Tag = skill }); - } - heavyTable.OnRowActivated((_, idx) => - { - if (idx >= 0 && idx < heaviest.Length) - { - ShowSkillDetailModal(ws, panel, heaviest[idx]); - } - }); - panel.AddControl(heavyTable.Build()); - - // Native bar charts: skills sorted by tokens (heaviest 12), then collections sorted by - // skill count (top 8). Each bar uses the standard threshold gradient so the eye picks - // up "big" entries immediately. - if (heaviest.Length > 0) - { - var maxTokens = heaviest.Max(s => s.TokenCount); - var chart1 = new ScrollablePanelControl - { - ShowScrollbar = false, - EnableMouseWheel = false, - }; - foreach (var skill in heaviest) - { - chart1.AddControl(BuildSkillTokenBar(skill, maxTokens)); - } - panel.AddControl(BuildSectionPanel("tokens by skill (top 12)", string.Empty, AccentDeepSkyBlue)); - panel.AddControl(chart1); - } - - var topCollections = views.Take(8).ToArray(); - if (topCollections.Length > 0) - { - var maxCount = topCollections.Max(v => v.SkillCount); - var chart2 = new ScrollablePanelControl - { - ShowScrollbar = false, - EnableMouseWheel = false, - }; - foreach (var view in topCollections) - { - chart2.AddControl(BuildCollectionCountBar(view, maxCount)); - } - panel.AddControl(BuildSectionPanel("skills per collection (top 8)", string.Empty, AccentTurquoise)); - panel.AddControl(chart2); - } - - if (signals.Count > 0) - { - var signalLines = signals.Take(18).Select(signal => - $"[grey]{Escape(signal.Signal)}[/] [grey50]({Escape(signal.Kind)})[/] [grey50]→[/] {Escape(ToAlias(signal.Skill.Name))}").ToArray(); - panel.AddControl(BuildBulletPanel("package signals", AccentTurquoise, signalLines)); - } - } - - /// - /// A horizontal bar showing one skill's token weight against the chart's max. Color follows - /// a green→yellow→red threshold gradient so heavy skills stand out visually. - /// - private static BarGraphControl BuildSkillTokenBar(SkillEntry skill, int maxTokens) - => Controls.BarGraph() - .WithLabel($"{ToAlias(skill.Name)}") - .WithLabelWidth(28) - .WithValue(skill.TokenCount) - .WithMaxValue(maxTokens == 0 ? 1 : maxTokens) - .WithValueFormat("N0") - .ShowValue(true) - .WithStandardGradient() - .Build(); - - /// - /// A horizontal bar showing one collection's skill count against the chart's max. Uses the - /// turquoise accent for the filled portion. - /// - private static BarGraphControl BuildCollectionCountBar(CollectionCatalogView view, int maxCount) - => Controls.BarGraph() - .WithLabel(view.Collection) - .WithLabelWidth(28) - .WithValue(view.SkillCount) - .WithMaxValue(maxCount == 0 ? 1 : maxCount) - .WithValueFormat("0") - .ShowValue(true) - .WithFilledColor(AccentTurquoise) - .Build(); - - // ------------------------------------------------------------------------- - // Remove all / Update all action pages - // ------------------------------------------------------------------------- - - private void BuildRemoveAllPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) - { - panel.ClearContents(); - var layout = ResolveSkillLayout(); - var installer = new SkillInstaller(skillCatalog); - var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); - - panel.AddControl(BuildPropertyPanel("remove all installed skills", new Color(200, 60, 60), - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("installed", installed.Count.ToString()))); - - if (installed.Count == 0) - { - panel.AddControl(BuildNotePanel("status", "[grey50]Nothing to remove in this target.[/]", AccentDeepSkyBlue)); - return; - } - - panel.AddControl(Controls.Button($"Remove all {installed.Count} skill(s) from this target") - .OnClick((_, _) => ConfirmModal(ws, "Remove all installed skills?", $"Deletes every catalog skill directory under {layout.PrimaryRoot.FullName}.", () => - { - var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout), default(SkillRemoveSummary)); - ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)"); - BuildRemoveAllPage(ws, panel); - })).Build()); - } - - private void BuildUpdateAllPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) - { - panel.ClearContents(); - var layout = ResolveSkillLayout(); - var installer = new SkillInstaller(skillCatalog); - var outdated = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()) - .Where(record => !record.IsCurrent) - .ToArray(); - - panel.AddControl(BuildPropertyPanel("update all outdated skills", AccentYellow, - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("outdated", outdated.Length.ToString()))); - - if (outdated.Length == 0) - { - panel.AddControl(BuildNotePanel("status", "[green]All installed skills already match the catalog version.[/]", AccentGreen)); - return; - } - - var pendingLines = outdated.Select(record => - $"[yellow]↻[/] {Escape(ToAlias(record.Skill.Name))} [grey50]{Escape(record.InstalledVersion)} → {Escape(record.Skill.Version)}[/]").ToArray(); - panel.AddControl(BuildBulletPanel("pending updates", AccentYellow, pendingLines)); - - panel.AddControl(Controls.Button($"Update all {outdated.Length} skill(s)") - .OnClick((_, _) => - { - var msg = UpdateSkillRecords(outdated); - Toast(msg, msg.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); - BuildUpdateAllPage(ws, panel); - }).Build()); - } - - // ------------------------------------------------------------------------- - // Settings / workspace - // ------------------------------------------------------------------------- - - private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) - { - panel.ClearContents(); - - var layout = ResolveSkillLayout(); - var agentStatus = ResolveAgentStatus(); - panel.AddControl(BuildPropertyPanel("workspace", AccentDeepSkyBlue, - ("platform", Escape(Session.Agent.ToString())), - ("scope", Escape(Session.Scope.ToString())), - ("project", Escape(CompactPath(Session.ProjectDirectory ?? Environment.CurrentDirectory))), - ("skill target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("agent target", agentStatus.Layout is null ? $"[red]{Escape(agentStatus.Summary)}[/]" : $"[grey50]{Escape(CompactPath(agentStatus.Layout.PrimaryRoot.FullName))}[/]"), - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("build", ToolVersionInfo.IsDevelopmentBuild ? "[grey50]local development[/]" : "[green]published[/]"))); - - // Inline form: native dropdowns (change-on-pick, no modal) for Platform/Scope, - // a plain Button for catalog refresh. SelectedIndexChanged fires only on user - // interaction (DropdownBuilder attaches the handler AFTER SelectedIndex is set), - // so no guard flag is needed against the initial-paint pulse. - var platformValues = Enum.GetValues(); - var platformDropdown = Controls.Dropdown("Platform") - .AddItems(platformValues.Select(v => v.ToString()).ToArray()) - .SelectedIndex(Array.IndexOf(platformValues, Session.Agent)) - .OnSelectionChanged((_, idx) => - { - if (idx < 0 || idx >= platformValues.Length) return; - var chosen = platformValues[idx]; - if (chosen.Equals(Session.Agent)) return; - Session.Agent = chosen; - Toast($"Platform set to {chosen}", NotificationSeverity.Success); - }) - .Build(); - - var scopeValues = Enum.GetValues(); - var scopeDropdown = Controls.Dropdown("Scope") - .AddItems(scopeValues.Select(v => v.ToString()).ToArray()) - .SelectedIndex(Array.IndexOf(scopeValues, Session.Scope)) - .OnSelectionChanged((_, idx) => - { - if (idx < 0 || idx >= scopeValues.Length) return; - var chosen = scopeValues[idx]; - if (chosen.Equals(Session.Scope)) return; - Session.Scope = chosen; - Toast($"Scope set to {chosen}", NotificationSeverity.Success); - }) - .Build(); - - panel.AddControl(BuildSectionPanel("install target", "[grey50]Platform and scope control where skills and agents are written. Changes take effect immediately.[/]", AccentDeepSkyBlue)); - panel.AddControl(platformDropdown); - panel.AddControl(scopeDropdown); - - panel.AddControl(BuildSectionPanel("catalog", "[grey50]Pull the latest catalog from upstream.[/]", AccentTurquoise)); - panel.AddControl(Controls.Button("Refresh catalog now") - .OnClick((_, _) => RefreshCatalogFromUi()) - .Build()); - } - - // ------------------------------------------------------------------------- - // About - // ------------------------------------------------------------------------- - - private void BuildAboutPage(ScrollablePanelControl panel) - { - panel.ClearContents(); - panel.AddControl(BuildPropertyPanel("about", AccentDeepSkyBlue, - ("tool", $"{Escape(ToolIdentity.DisplayCommand)}"), - ("package", Escape(ToolIdentity.PackageId)), - ("version", Escape(ToolVersionInfo.CurrentVersion)), - ("build", ToolVersionInfo.IsDevelopmentBuild ? "[grey50]local development[/]" : "[green]published[/]"), - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("skills", skillCatalog.Skills.Count.ToString()), - ("agents", agentCatalog.Agents.Count.ToString()))); - - panel.AddControl(BuildBulletPanel("surface map", AccentDeepSkyBlue, - "[grey]Home[/] [grey50]session, catalog telemetry, update notice[/]", - "[grey]Skills / Installed[/] [grey50]browse, install, update, remove catalog skills[/]", - "[grey]Collections / Bundles / Packages[/] [grey50]install grouped surfaces[/]", - "[grey]Agents[/] [grey50]install orchestration agents into native agent directories[/]", - "[grey]Project[/] [grey50]scan .csproj signals and install recommended skills[/]", - "[grey]Analysis[/] [grey50]collection sizes, heaviest skills, package signals[/]")); - - panel.AddControl(BuildBulletPanel("notes", AccentGrey, - "[grey50]This is the SharpConsoleUI command center. Run with redirected stdin/stdout to get the classic prompt shell instead.[/]", - "[grey50]CLI sub-commands (list, install, recommend, …) are unchanged — see[/] [green]dotnet skills help[/][grey50].[/]")); - } - // ------------------------------------------------------------------------- // Modal + status helpers // ------------------------------------------------------------------------- diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs new file mode 100644 index 0000000..f421b65 --- /dev/null +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs @@ -0,0 +1,541 @@ +// ----------------------------------------------------------------------------- +// Workspace surfaces — the pages that operate on the current target rather +// than browse the catalog: Installed, Project sync, Analysis, Settings, +// Update All / Remove All confirmations, and About. Split out of Shell.cs +// so workspace-mutating surfaces live together. +// ----------------------------------------------------------------------------- + +using ManagedCode.DotnetSkills.Runtime; +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; +using SharpConsoleUI.Core; +using SharpConsoleUI.Drivers; +using SharpConsoleUI.Helpers; +using SharpConsoleUI.Layout; +using SharpConsoleUI.Rendering; +using SharpConsoleUI.Themes; + +namespace ManagedCode.DotnetSkills; + +internal sealed partial class InteractiveConsoleApp +{ + // ------------------------------------------------------------------------- + // Installed skills + // ------------------------------------------------------------------------- + + private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()) + .OrderBy(record => record.Skill.Name, StringComparer.Ordinal) + .ToArray(); + var outdated = installed.Where(record => !record.IsCurrent).ToArray(); + + var filtered = installed.Where(r => MatchesFilter(r.Skill.Name, r.Skill.Stack, r.Skill.Lane)).ToArray(); + + panel.AddControl(BuildPropertyPanel("installed skills", AccentGreen, + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("installed", string.IsNullOrEmpty(_searchFilter) ? installed.Length.ToString() : $"{filtered.Length}/{installed.Length}"), + ("outdated", outdated.Length == 0 ? "[green]0[/]" : $"[yellow]{outdated.Length}[/]"), + ("tokens", FormatTokenCount(installed.Sum(record => record.Skill.TokenCount))))); + AddSearchChip(panel); + + if (installed.Length == 0) + { + panel.AddControl(BuildNotePanel("installed", "[grey50]No catalog skills are installed in this target yet. Visit the Skills page to add some.[/]", AccentDeepSkyBlue)); + return; + } + if (filtered.Length == 0) + { + panel.AddControl(BuildNotePanel("installed", $"[grey50]No installed skills match “{Escape(_searchFilter)}”.[/]", AccentYellow)); + return; + } + + // Real sortable TableControl — columns can be sorted by clicking the header. Per-row + // foreground color flags outdated rows yellow without needing markup escaping per cell. + var table = Controls.Table() + .WithTitle("Installed skills (Enter for details)") + .AddColumn("Status", TextJustification.Center, width: 8) + .AddColumn("Skill") + .AddColumn("Collection") + .AddColumn("Lane") + .AddColumn("Installed", TextJustification.Right) + .AddColumn("Latest", TextJustification.Right) + .AddColumn("Tokens", TextJustification.Right) + .WithSorting() + .Rounded() + .WithBorderColor(AccentGreen); + foreach (var record in filtered) + { + var row = new TableRow( + record.IsCurrent ? "✓ current" : "↻ update", + ToAlias(record.Skill.Name), + record.Skill.Stack, + record.Skill.Lane, + record.InstalledVersion, + record.Skill.Version, + FormatTokenCount(record.Skill.TokenCount)) + { + Tag = record, + ForegroundColor = record.IsCurrent ? null : AccentYellow, + }; + table.AddRow(row); + } + // RowActivated fires on Enter or double-click; index is into the filtered array because + // we appended rows in the same order. + table.OnRowActivated((_, idx) => + { + if (idx >= 0 && idx < filtered.Length) + { + ShowInstalledSkillModal(ws, panel, filtered[idx]); + } + }); + panel.AddControl(table.Build()); + + if (outdated.Length > 0) + { + panel.AddControl(Controls.Button($"Update all {outdated.Length} outdated skill(s)") + .OnClick((_, _) => + { + var summaryText = UpdateSkillRecords(outdated); + Toast(summaryText, summaryText.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); + BuildInstalledPage(ws, panel); + }).Build()); + } + + panel.AddControl(Controls.Button($"Remove all {installed.Length} installed skill(s)") + .OnClick((_, _) => ConfirmModal(ws, "Remove all installed skills?", + $"This removes every catalog skill from {layout.PrimaryRoot.FullName}.", + () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout), default(SkillRemoveSummary)); + ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)"); + BuildInstalledPage(ws, panel); + })).Build()); + } + + private void ShowInstalledSkillModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, InstalledSkillRecord record) + { + var detail = new IWindowControl[] + { + BuildPropertyPanel(ToAlias(record.Skill.Name), AccentGreen, + ("skill", Escape(record.Skill.Name)), + ("collection", Escape($"{record.Skill.Stack} / {record.Skill.Lane}")), + ("installed", Escape(record.InstalledVersion)), + ("latest", Escape(record.Skill.Version)), + ("status", record.IsCurrent ? "[green]✓ current[/]" : "[yellow]↻ update available[/]"), + ("tokens", FormatTokenCount(record.Skill.TokenCount))), + BuildNotePanel("summary", Escape(record.Skill.Description), AccentDeepSkyBlue), + }; + + var buttons = new List<(string, Action)>(); + if (!record.IsCurrent) + { + buttons.Add(($"Update to {record.Skill.Version}", () => + { + var msg = UpdateSkillRecords(new[] { record }); + Toast(msg, msg.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); + BuildInstalledPage(ws, owner); + })); + } + buttons.Add(("Reinstall (force)", () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { record.Skill }, ResolveSkillLayout(), force: true), default(SkillInstallSummary)); + ToastResult(summary, "Reinstall failed", $"{ToAlias(record.Skill.Name)}: reinstalled"); + BuildInstalledPage(ws, owner); + })); + buttons.Add(("Remove", () => ConfirmModal(ws, $"Remove {ToAlias(record.Skill.Name)}?", $"Deletes the skill directory from {ResolveSkillLayout().PrimaryRoot.FullName}.", () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(new[] { record.Skill }, ResolveSkillLayout()), default(SkillRemoveSummary)); + ToastResult(summary, "Remove failed", $"Removed {ToAlias(record.Skill.Name)}"); + BuildInstalledPage(ws, owner); + }))); + + ShowModalNative(ws, $"Installed · {ToAlias(record.Skill.Name)}", detail, buttons.ToArray()); + } + + private string UpdateSkillRecords(IReadOnlyList records) + { + var layout = ResolveSkillLayout(); + var skills = records.Select(record => record.Skill).ToArray(); + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Install(skills, layout, force: true), default(SkillInstallSummary)); + return summary is null ? "Update failed" : $"Updated {summary.InstalledCount} skill(s)"; + } + private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var scan = SafeGet(() => new ProjectSkillRecommender(skillCatalog).Analyze(Session.ProjectDirectory), null); + if (scan is null) + { + panel.AddControl(BuildNotePanel("project scan", "[red]Could not scan the project directory.[/]", new Color(200, 60, 60))); + return; + } + + var installer = new SkillInstaller(skillCatalog); + var installedByName = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()) + .ToDictionary(record => record.Skill.Name, StringComparer.OrdinalIgnoreCase); + + var high = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.High); + var med = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Medium); + var low = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Low); + + panel.AddControl(BuildPropertyPanel("project scan", AccentDeepSkyBlue, + ("project", $"[grey50]{Escape(CompactPath(scan.ProjectRoot.FullName))}[/]"), + ("scanned", $"{scan.ProjectFiles.Count} project file(s)"), + ("frameworks", scan.TargetFrameworks.Count == 0 ? "[grey50]unknown[/]" : Escape(string.Join(", ", scan.TargetFrameworks))), + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("recommendations", $"{scan.Recommendations.Count} [grey50]([/][green]{high} high[/][grey50] · [/][yellow]{med} med[/][grey50] · [/][grey]{low} low[/][grey50])[/]"))); + + if (scan.Recommendations.Count == 0) + { + panel.AddControl(BuildNotePanel("recommendations", "[grey50]No package or framework signals matched the catalog. Start with the[/] [green]dotnet[/] [grey50]and[/] [green]modern-csharp[/] [grey50]skills from the Skills page.[/]", AccentDeepSkyBlue)); + return; + } + + var list = StyledList("Recommended skills (Enter to install)") + .MaxVisibleItems(16) + .WithScrollbarVisibility(ScrollbarVisibility.Auto); + foreach (var recommendation in scan.Recommendations + .OrderByDescending(r => r.Confidence) + .ThenBy(r => r.Skill.Name, StringComparer.Ordinal)) + { + var marker = recommendation.Confidence switch + { + RecommendationConfidence.High => "[green]●●●[/]", + RecommendationConfidence.Medium => "[yellow]●●○[/]", + _ => "[grey]●○○[/]", + }; + installedByName.TryGetValue(recommendation.Skill.Name, out var record); + var status = record is null ? "[deepskyblue1]new[/]" : record.IsCurrent ? "[green]installed[/]" : "[yellow]update[/]"; + list.AddItem($"{marker} {Escape(ToAlias(recommendation.Skill.Name))} [dim]{status}[/] [grey]{Escape(string.Join("; ", recommendation.Reasons.Take(2)))}[/]", recommendation); + } + list.OnItemActivated((_, item) => + { + if (item.Tag is ProjectSkillRecommendation recommendation) + { + // Outdated recommendations need force=true: SkillInstaller.Install skips + // existing skill directories unless forced, so an "update" entry would + // otherwise be reported as skipped and stay outdated. + var isOutdated = installedByName.TryGetValue(recommendation.Skill.Name, out var existing) && !existing.IsCurrent; + var summary2 = SafeGet(() => new SkillInstaller(skillCatalog).Install(new[] { recommendation.Skill }, ResolveSkillLayout(), force: isOutdated), default(SkillInstallSummary)); + ToastResult(summary2, $"Install failed for {ToAlias(recommendation.Skill.Name)}", summary2 is null ? string.Empty : $"{ToAlias(recommendation.Skill.Name)}: {summary2.InstalledCount} written, {summary2.SkippedExisting.Count} skipped"); + BuildProjectPage(ws, panel); + } + }); + panel.AddControl(list.Build()); + + // Split recommendations: new ones install with force=false, outdated ones need + // force=true so the existing skill directory is overwritten with the latest version. + var newSkills = scan.Recommendations + .Where(r => !installedByName.ContainsKey(r.Skill.Name)) + .Select(r => r.Skill) + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) + .ToArray(); + var outdatedSkills = scan.Recommendations + .Where(r => installedByName.TryGetValue(r.Skill.Name, out var rec) && !rec.IsCurrent) + .Select(r => r.Skill) + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) + .ToArray(); + var installable = newSkills.Concat(outdatedSkills).ToArray(); + if (installable.Length > 0) + { + panel.AddControl(Controls.Button($"Install all {installable.Length} recommended skill(s)") + .OnClick((_, _) => + { + var skillLayout = ResolveSkillLayout(); + var installer2 = new SkillInstaller(skillCatalog); + var newSummary = newSkills.Length == 0 ? default : SafeGet(() => installer2.Install(newSkills, skillLayout, force: false), default(SkillInstallSummary)); + var updateSummary = outdatedSkills.Length == 0 ? default : SafeGet(() => installer2.Install(outdatedSkills, skillLayout, force: true), default(SkillInstallSummary)); + var installedCount = (newSummary?.InstalledCount ?? 0) + (updateSummary?.InstalledCount ?? 0); + var skippedCount = (newSummary?.SkippedExisting.Count ?? 0) + (updateSummary?.SkippedExisting.Count ?? 0); + var failed = installedCount == 0 && skippedCount == 0; + Toast(failed ? "Install failed" : $"Installed {installedCount}, skipped {skippedCount}", failed ? NotificationSeverity.Danger : NotificationSeverity.Success); + BuildProjectPage(ws, panel); + }).Build()); + } + } + + // ------------------------------------------------------------------------- + // Catalog analysis + // ------------------------------------------------------------------------- + + private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); + var views = BuildCollectionViews(installed) + .OrderByDescending(view => view.SkillCount) + .ToArray(); + var signals = SafeGet(BuildPackageSignals, Array.Empty()); + var heaviest = skillCatalog.Skills.OrderByDescending(skill => skill.TokenCount).Take(12).ToArray(); + + panel.AddControl(BuildPropertyPanel("catalog analysis", AccentDeepSkyBlue, + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("collections", views.Length.ToString()), + ("skills", skillCatalog.Skills.Count.ToString()), + ("total tokens", FormatTokenCount(skillCatalog.Skills.Sum(skill => skill.TokenCount))), + ("package signals", signals.Count.ToString()))); + + var collectionCards = views.Take(12).Select(view => BuildBulletPanel( + view.Collection, AccentDeepSkyBlue, + $"[grey50]skills[/] {view.SkillCount} [grey50]installed[/] {view.InstalledCount} [grey50]tokens[/] {FormatTokenCount(view.TokenCount)}")).ToList(); + panel.AddControl(BuildCardGrid(collectionCards, maxColumns: 3)); + + var heavyTable = Controls.Table() + .WithTitle("Heaviest skills (Enter for details)") + .AddColumn("Skill") + .AddColumn("Collection") + .AddColumn("Lane") + .AddColumn("Tokens", TextJustification.Right) + .WithSorting() + .Rounded() + .WithBorderColor(AccentDeepSkyBlue); + foreach (var skill in heaviest) + { + heavyTable.AddRow(new TableRow(ToAlias(skill.Name), skill.Stack, skill.Lane, FormatTokenCount(skill.TokenCount)) { Tag = skill }); + } + heavyTable.OnRowActivated((_, idx) => + { + if (idx >= 0 && idx < heaviest.Length) + { + ShowSkillDetailModal(ws, panel, heaviest[idx]); + } + }); + panel.AddControl(heavyTable.Build()); + + // Native bar charts: skills sorted by tokens (heaviest 12), then collections sorted by + // skill count (top 8). Each bar uses the standard threshold gradient so the eye picks + // up "big" entries immediately. + if (heaviest.Length > 0) + { + var maxTokens = heaviest.Max(s => s.TokenCount); + var chart1 = new ScrollablePanelControl + { + ShowScrollbar = false, + EnableMouseWheel = false, + }; + foreach (var skill in heaviest) + { + chart1.AddControl(BuildSkillTokenBar(skill, maxTokens)); + } + panel.AddControl(BuildSectionPanel("tokens by skill (top 12)", string.Empty, AccentDeepSkyBlue)); + panel.AddControl(chart1); + } + + var topCollections = views.Take(8).ToArray(); + if (topCollections.Length > 0) + { + var maxCount = topCollections.Max(v => v.SkillCount); + var chart2 = new ScrollablePanelControl + { + ShowScrollbar = false, + EnableMouseWheel = false, + }; + foreach (var view in topCollections) + { + chart2.AddControl(BuildCollectionCountBar(view, maxCount)); + } + panel.AddControl(BuildSectionPanel("skills per collection (top 8)", string.Empty, AccentTurquoise)); + panel.AddControl(chart2); + } + + if (signals.Count > 0) + { + var signalLines = signals.Take(18).Select(signal => + $"[grey]{Escape(signal.Signal)}[/] [grey50]({Escape(signal.Kind)})[/] [grey50]→[/] {Escape(ToAlias(signal.Skill.Name))}").ToArray(); + panel.AddControl(BuildBulletPanel("package signals", AccentTurquoise, signalLines)); + } + } + + /// + /// A horizontal bar showing one skill's token weight against the chart's max. Color follows + /// a green→yellow→red threshold gradient so heavy skills stand out visually. + /// + private static BarGraphControl BuildSkillTokenBar(SkillEntry skill, int maxTokens) + => Controls.BarGraph() + .WithLabel($"{ToAlias(skill.Name)}") + .WithLabelWidth(28) + .WithValue(skill.TokenCount) + .WithMaxValue(maxTokens == 0 ? 1 : maxTokens) + .WithValueFormat("N0") + .ShowValue(true) + .WithStandardGradient() + .Build(); + + /// + /// A horizontal bar showing one collection's skill count against the chart's max. Uses the + /// turquoise accent for the filled portion. + /// + private static BarGraphControl BuildCollectionCountBar(CollectionCatalogView view, int maxCount) + => Controls.BarGraph() + .WithLabel(view.Collection) + .WithLabelWidth(28) + .WithValue(view.SkillCount) + .WithMaxValue(maxCount == 0 ? 1 : maxCount) + .WithValueFormat("0") + .ShowValue(true) + .WithFilledColor(AccentTurquoise) + .Build(); + + // ------------------------------------------------------------------------- + // Remove all / Update all action pages + // ------------------------------------------------------------------------- + + private void BuildRemoveAllPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); + + panel.AddControl(BuildPropertyPanel("remove all installed skills", new Color(200, 60, 60), + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("installed", installed.Count.ToString()))); + + if (installed.Count == 0) + { + panel.AddControl(BuildNotePanel("status", "[grey50]Nothing to remove in this target.[/]", AccentDeepSkyBlue)); + return; + } + + panel.AddControl(Controls.Button($"Remove all {installed.Count} skill(s) from this target") + .OnClick((_, _) => ConfirmModal(ws, "Remove all installed skills?", $"Deletes every catalog skill directory under {layout.PrimaryRoot.FullName}.", () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout), default(SkillRemoveSummary)); + ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)"); + BuildRemoveAllPage(ws, panel); + })).Build()); + } + + private void BuildUpdateAllPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + var layout = ResolveSkillLayout(); + var installer = new SkillInstaller(skillCatalog); + var outdated = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()) + .Where(record => !record.IsCurrent) + .ToArray(); + + panel.AddControl(BuildPropertyPanel("update all outdated skills", AccentYellow, + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("outdated", outdated.Length.ToString()))); + + if (outdated.Length == 0) + { + panel.AddControl(BuildNotePanel("status", "[green]All installed skills already match the catalog version.[/]", AccentGreen)); + return; + } + + var pendingLines = outdated.Select(record => + $"[yellow]↻[/] {Escape(ToAlias(record.Skill.Name))} [grey50]{Escape(record.InstalledVersion)} → {Escape(record.Skill.Version)}[/]").ToArray(); + panel.AddControl(BuildBulletPanel("pending updates", AccentYellow, pendingLines)); + + panel.AddControl(Controls.Button($"Update all {outdated.Length} skill(s)") + .OnClick((_, _) => + { + var msg = UpdateSkillRecords(outdated); + Toast(msg, msg.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); + BuildUpdateAllPage(ws, panel); + }).Build()); + } + + // ------------------------------------------------------------------------- + // Settings / workspace + // ------------------------------------------------------------------------- + + private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl panel) + { + panel.ClearContents(); + + var layout = ResolveSkillLayout(); + var agentStatus = ResolveAgentStatus(); + panel.AddControl(BuildPropertyPanel("workspace", AccentDeepSkyBlue, + ("platform", Escape(Session.Agent.ToString())), + ("scope", Escape(Session.Scope.ToString())), + ("project", Escape(CompactPath(Session.ProjectDirectory ?? Environment.CurrentDirectory))), + ("skill target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + ("agent target", agentStatus.Layout is null ? $"[red]{Escape(agentStatus.Summary)}[/]" : $"[grey50]{Escape(CompactPath(agentStatus.Layout.PrimaryRoot.FullName))}[/]"), + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("build", ToolVersionInfo.IsDevelopmentBuild ? "[grey50]local development[/]" : "[green]published[/]"))); + + // Inline form: native dropdowns (change-on-pick, no modal) for Platform/Scope, + // a plain Button for catalog refresh. SelectedIndexChanged fires only on user + // interaction (DropdownBuilder attaches the handler AFTER SelectedIndex is set), + // so no guard flag is needed against the initial-paint pulse. + var platformValues = Enum.GetValues(); + var platformDropdown = Controls.Dropdown("Platform") + .AddItems(platformValues.Select(v => v.ToString()).ToArray()) + .SelectedIndex(Array.IndexOf(platformValues, Session.Agent)) + .OnSelectionChanged((_, idx) => + { + if (idx < 0 || idx >= platformValues.Length) return; + var chosen = platformValues[idx]; + if (chosen.Equals(Session.Agent)) return; + Session.Agent = chosen; + Toast($"Platform set to {chosen}", NotificationSeverity.Success); + }) + .Build(); + + var scopeValues = Enum.GetValues(); + var scopeDropdown = Controls.Dropdown("Scope") + .AddItems(scopeValues.Select(v => v.ToString()).ToArray()) + .SelectedIndex(Array.IndexOf(scopeValues, Session.Scope)) + .OnSelectionChanged((_, idx) => + { + if (idx < 0 || idx >= scopeValues.Length) return; + var chosen = scopeValues[idx]; + if (chosen.Equals(Session.Scope)) return; + Session.Scope = chosen; + Toast($"Scope set to {chosen}", NotificationSeverity.Success); + }) + .Build(); + + panel.AddControl(BuildSectionPanel("install target", "[grey50]Platform and scope control where skills and agents are written. Changes take effect immediately.[/]", AccentDeepSkyBlue)); + panel.AddControl(platformDropdown); + panel.AddControl(scopeDropdown); + + panel.AddControl(BuildSectionPanel("catalog", "[grey50]Pull the latest catalog from upstream.[/]", AccentTurquoise)); + panel.AddControl(Controls.Button("Refresh catalog now") + .OnClick((_, _) => RefreshCatalogFromUi()) + .Build()); + } + + // ------------------------------------------------------------------------- + // About + // ------------------------------------------------------------------------- + + private void BuildAboutPage(ScrollablePanelControl panel) + { + panel.ClearContents(); + panel.AddControl(BuildPropertyPanel("about", AccentDeepSkyBlue, + ("tool", $"{Escape(ToolIdentity.DisplayCommand)}"), + ("package", Escape(ToolIdentity.PackageId)), + ("version", Escape(ToolVersionInfo.CurrentVersion)), + ("build", ToolVersionInfo.IsDevelopmentBuild ? "[grey50]local development[/]" : "[green]published[/]"), + ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + ("skills", skillCatalog.Skills.Count.ToString()), + ("agents", agentCatalog.Agents.Count.ToString()))); + + panel.AddControl(BuildBulletPanel("surface map", AccentDeepSkyBlue, + "[grey]Home[/] [grey50]session, catalog telemetry, update notice[/]", + "[grey]Skills / Installed[/] [grey50]browse, install, update, remove catalog skills[/]", + "[grey]Collections / Bundles / Packages[/] [grey50]install grouped surfaces[/]", + "[grey]Agents[/] [grey50]install orchestration agents into native agent directories[/]", + "[grey]Project[/] [grey50]scan .csproj signals and install recommended skills[/]", + "[grey]Analysis[/] [grey50]collection sizes, heaviest skills, package signals[/]")); + + panel.AddControl(BuildBulletPanel("notes", AccentGrey, + "[grey50]This is the SharpConsoleUI command center. Run with redirected stdin/stdout to get the classic prompt shell instead.[/]", + "[grey50]CLI sub-commands (list, install, recommend, …) are unchanged — see[/] [green]dotnet skills help[/][grey50].[/]")); + } + +} From 8355cb4eb9e2ce470de33e2d8d0d381573ece004 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Tue, 26 May 2026 00:13:14 +0300 Subject: [PATCH 3/9] feat(cli): thin one-line identity strip replaces tall PropertyPanel headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page headers were 5-row PanelControls that duplicated info already in the top StatusBar (project/scope/platform/catalog/target). They cost ~4 rows of vertical real estate per page above the actual data. Replace them with `BuildIdentityStrip(title, accent, …facts)` — a single MarkupControl row: `[bold accent]title[/] · label₁ value₁ · label₂ …`. The title carries the page identity in the accent color; the facts carry only what the StatusBar doesn't already (counts, filters, summary stats). Converted page headers: - Home: session - Catalog: skill browser, collection browser, bundles/packages, package signals, orchestration agents - Workspace: installed skills, project scan, catalog analysis, remove all, update all, workspace (settings) Detail-pane PropertyPanels (inside modals and Collections right-pane) stay — they're content, not page identity. About also stays since the property grid IS the page content. Reclaims ~4 rows per page for the data area in upcoming commits. No behavior change. Build clean, 613/613 tests pass. --- .../InteractiveConsoleApp.Catalog.cs | 22 +++++++-------- .../InteractiveConsoleApp.Home.cs | 2 +- .../InteractiveConsoleApp.Shell.cs | 20 ++++++++++++++ .../InteractiveConsoleApp.Workspace.cs | 27 ++++++++----------- 4 files changed, 41 insertions(+), 30 deletions(-) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs index 2001b14..6236304 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs @@ -40,9 +40,7 @@ private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelContro var filtered = available.Where(s => MatchesFilter(s.Name, s.Stack, s.Lane)).ToArray(); - panel.AddControl(BuildPropertyPanel("skill browser", AccentTurquoise, - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + panel.AddControl(BuildIdentityStrip("skill browser", AccentTurquoise, ("available", $"{filtered.Length}/{available.Length}"), ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); AddSearchChip(panel); @@ -129,8 +127,7 @@ private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl .ToArray(); var filtered = views.Where(v => MatchesFilter(v.Collection)).ToArray(); - panel.AddControl(BuildPropertyPanel("collection browser", AccentDeepSkyBlue, - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + panel.AddControl(BuildIdentityStrip("collection browser", AccentDeepSkyBlue, ("collections", string.IsNullOrEmpty(_searchFilter) ? views.Length.ToString() : $"{filtered.Length}/{views.Length}"), ("skills", skillCatalog.Skills.Count.ToString()), ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); @@ -258,8 +255,7 @@ private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl pan var filtered = packages.Where(p => MatchesFilter(p.Name, p.Title)).ToArray(); - panel.AddControl(BuildPropertyPanel(title, AccentDeepSkyBlue, - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + panel.AddControl(BuildIdentityStrip(title, AccentDeepSkyBlue, (primaryOnly ? "bundles" : "packages", string.IsNullOrEmpty(_searchFilter) ? packages.Length.ToString() : $"{filtered.Length}/{packages.Length}"), ("skills covered", skillCatalog.Skills.Count.ToString()))); AddSearchChip(panel); @@ -326,8 +322,7 @@ private void BuildPackagesPage(ConsoleWindowSystem ws, ScrollablePanelControl pa var signals = SafeGet(BuildPackageSignals, Array.Empty()); var filtered = signals.Where(s => MatchesFilter(s.Signal, s.Skill.Name, s.Skill.Stack, s.Skill.Lane)).ToArray(); - panel.AddControl(BuildPropertyPanel("package signals", AccentTurquoise, - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + panel.AddControl(BuildIdentityStrip("package signals", AccentTurquoise, ("signals", string.IsNullOrEmpty(_searchFilter) ? signals.Count.ToString() : $"{filtered.Length}/{signals.Count}"), ("skills covered", signals.Select(s => s.Skill.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count().ToString()))); AddSearchChip(panel); @@ -378,11 +373,12 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane var allAgents = agentCatalog.Agents.OrderBy(a => a.Name, StringComparer.Ordinal).ToArray(); var filteredAgents = allAgents.Where(a => MatchesFilter(a.Name, a.Description)).ToArray(); - panel.AddControl(BuildPropertyPanel("orchestration agents", AccentMediumPurple, + // platform + target live in the top StatusBar; surface "unresolved" target here as a + // first-class fact because the agent layout has a separate resolver from the skill one. + panel.AddControl(BuildIdentityStrip("orchestration agents", AccentMediumPurple, ("agents", string.IsNullOrEmpty(_searchFilter) ? agentCatalog.Agents.Count.ToString() : $"{filteredAgents.Length}/{agentCatalog.Agents.Count}"), - ("platform", Escape(Session.Agent.ToString())), - ("target", layout is null ? $"[red]{Escape(layoutError ?? "unresolved")}[/]" : $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), - ("installed", layout is null ? "[grey]-[/]" : $"{installed.Count}/{agentCatalog.Agents.Count}"))); + ("installed", layout is null ? "[grey]-[/]" : $"{installed.Count}/{agentCatalog.Agents.Count}"), + ("target", layout is null ? $"[red]{Escape(layoutError ?? "unresolved")}[/]" : string.Empty))); AddSearchChip(panel); if (agentCatalog.Agents.Count == 0) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs index d49d7c8..1eed1f9 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs @@ -40,7 +40,7 @@ private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel) var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); var outdated = installed.Count(record => !record.IsCurrent); - panel.AddControl(BuildPropertyPanel("session", AccentDeepSkyBlue, + panel.AddControl(BuildIdentityStrip("session", AccentDeepSkyBlue, ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), ("platform", Escape(Session.Agent.ToString())), ("scope", Escape(Session.Scope.ToString())), diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs index 4ae153a..f34f264 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs @@ -478,6 +478,26 @@ private static PanelControl BuildBulletPanel(string title, Color accent, params return BuildSectionPanel(title, body, accent); } + /// + /// One-line identity strip used as a page header. Renders as a single MarkupControl with the + /// title in accent + bold, followed by middle-dot separated key/value pairs. Replaces the + /// taller BuildPropertyPanel(...) headers so each page can devote vertical space to data + /// (tables, graphs) instead of a five-row card. Values are passed in already-marked-up form, + /// labels are dimmed by this helper. + /// + private static IWindowControl BuildIdentityStrip(string title, Color accent, params (string Label, string Value)[] facts) + { + var hex = $"#{accent.R:X2}{accent.G:X2}{accent.B:X2}"; + var parts = new List { $"[bold {hex}]{Escape(title)}[/]" }; + foreach (var (label, value) in facts) + { + if (string.IsNullOrEmpty(value)) continue; + parts.Add($"[grey50]{Escape(label)}[/] {value}"); + } + var line = string.Join(" [grey50]·[/] ", parts); + return new MarkupControl(new List { line }); + } + /// /// Lays out a sequence of cards in a responsive HorizontalGrid with 1, 2, or 3 columns based /// on the current console width — the native equivalent of BuildRichCardGrid(maxColumns). diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs index f421b65..3edae2c 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs @@ -37,8 +37,7 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p var filtered = installed.Where(r => MatchesFilter(r.Skill.Name, r.Skill.Stack, r.Skill.Lane)).ToArray(); - panel.AddControl(BuildPropertyPanel("installed skills", AccentGreen, - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + panel.AddControl(BuildIdentityStrip("installed skills", AccentGreen, ("installed", string.IsNullOrEmpty(_searchFilter) ? installed.Length.ToString() : $"{filtered.Length}/{installed.Length}"), ("outdated", outdated.Length == 0 ? "[green]0[/]" : $"[yellow]{outdated.Length}[/]"), ("tokens", FormatTokenCount(installed.Sum(record => record.Skill.TokenCount))))); @@ -185,11 +184,9 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan var med = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Medium); var low = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Low); - panel.AddControl(BuildPropertyPanel("project scan", AccentDeepSkyBlue, - ("project", $"[grey50]{Escape(CompactPath(scan.ProjectRoot.FullName))}[/]"), + panel.AddControl(BuildIdentityStrip("project scan", AccentDeepSkyBlue, ("scanned", $"{scan.ProjectFiles.Count} project file(s)"), ("frameworks", scan.TargetFrameworks.Count == 0 ? "[grey50]unknown[/]" : Escape(string.Join(", ", scan.TargetFrameworks))), - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), ("recommendations", $"{scan.Recommendations.Count} [grey50]([/][green]{high} high[/][grey50] · [/][yellow]{med} med[/][grey50] · [/][grey]{low} low[/][grey50])[/]"))); if (scan.Recommendations.Count == 0) @@ -278,8 +275,7 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa var signals = SafeGet(BuildPackageSignals, Array.Empty()); var heaviest = skillCatalog.Skills.OrderByDescending(skill => skill.TokenCount).Take(12).ToArray(); - panel.AddControl(BuildPropertyPanel("catalog analysis", AccentDeepSkyBlue, - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), + panel.AddControl(BuildIdentityStrip("catalog analysis", AccentDeepSkyBlue, ("collections", views.Length.ToString()), ("skills", skillCatalog.Skills.Count.ToString()), ("total tokens", FormatTokenCount(skillCatalog.Skills.Sum(skill => skill.TokenCount))), @@ -397,8 +393,7 @@ private void BuildRemoveAllPage(ConsoleWindowSystem ws, ScrollablePanelControl p var installer = new SkillInstaller(skillCatalog); var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); - panel.AddControl(BuildPropertyPanel("remove all installed skills", new Color(200, 60, 60), - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + panel.AddControl(BuildIdentityStrip("remove all installed skills", new Color(200, 60, 60), ("installed", installed.Count.ToString()))); if (installed.Count == 0) @@ -425,8 +420,7 @@ private void BuildUpdateAllPage(ConsoleWindowSystem ws, ScrollablePanelControl p .Where(record => !record.IsCurrent) .ToArray(); - panel.AddControl(BuildPropertyPanel("update all outdated skills", AccentYellow, - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), + panel.AddControl(BuildIdentityStrip("update all outdated skills", AccentYellow, ("outdated", outdated.Length.ToString()))); if (outdated.Length == 0) @@ -458,13 +452,12 @@ private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl pa var layout = ResolveSkillLayout(); var agentStatus = ResolveAgentStatus(); - panel.AddControl(BuildPropertyPanel("workspace", AccentDeepSkyBlue, - ("platform", Escape(Session.Agent.ToString())), - ("scope", Escape(Session.Scope.ToString())), - ("project", Escape(CompactPath(Session.ProjectDirectory ?? Environment.CurrentDirectory))), + // Settings is a form, so the strip carries an at-a-glance summary of the install targets + // (the StatusBar already carries project/scope/platform; this adds skill+agent targets + // because those are the form's subject and aren't surfaced anywhere else). + panel.AddControl(BuildIdentityStrip("workspace", AccentDeepSkyBlue, ("skill target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), ("agent target", agentStatus.Layout is null ? $"[red]{Escape(agentStatus.Summary)}[/]" : $"[grey50]{Escape(CompactPath(agentStatus.Layout.PrimaryRoot.FullName))}[/]"), - ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), ("build", ToolVersionInfo.IsDevelopmentBuild ? "[grey50]local development[/]" : "[green]published[/]"))); // Inline form: native dropdowns (change-on-pick, no modal) for Platform/Scope, @@ -516,6 +509,8 @@ private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl pa private void BuildAboutPage(ScrollablePanelControl panel) { panel.ClearContents(); + // About is a static metadata page — keep the original PropertyPanel because the + // information IS the page (no list/table follows). Identity-strip the title row only. panel.AddControl(BuildPropertyPanel("about", AccentDeepSkyBlue, ("tool", $"{Escape(ToolIdentity.DisplayCommand)}"), ("package", Escape(ToolIdentity.PackageId)), From df54fe61955726674cb65c2c1b0546612e6f09f6 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Tue, 26 May 2026 00:17:05 +0300 Subject: [PATCH 4/9] feat(cli): TableControl on Skills / Bundles / Packages / Agents / Project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five list pages previously crammed three to five logical dimensions into a single escaped-markup row (`{marker} {alias} [dim]{status}[/] …`). Each is now a sortable TableControl with one column per dimension: - Skills: Collection · Lane · Skill · Version · Tokens - Bundles: Bundle · Title · Skills · Tokens - Packages: Signal · Kind · Skill · Collection · Lane · Tokens - Agents: Status · Agent · Description · Skills - Project: Confidence · Status · Skill · Reasons The modal-on-Enter flow is preserved end-to-end. The activator reads SelectedRow.Tag (typed `as TheEntry`) so it stays correct when the user re-sorts by clicking a column header — indexing into the source array would break under user sort. Default sort matches the legacy ListControl order baked into the array. Numeric columns right-justified; status/confidence centred; existing sortable Installed (Workspace) and heaviest-skills (Analysis) tables unchanged. Collections left rail and Command palette stay as ListControl — they are narrow single-string browsers, not multi-column data grids. Build clean, 613/613 tests pass. --- .../InteractiveConsoleApp.Catalog.cs | 136 +++++++++++++----- .../InteractiveConsoleApp.Workspace.cs | 33 +++-- 2 files changed, 125 insertions(+), 44 deletions(-) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs index 6236304..759859a 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs @@ -56,24 +56,43 @@ private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelContro return; } - var list = StyledList("Available skills (Enter for details)") - .MaxVisibleItems(16) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); + // Sortable table — five logical dimensions (Collection, Lane, Skill, Version, Tokens) + // are columns instead of a single bracketed markup-salad row. Default sort matches the + // legacy ListControl order: by collection rank then collection name then skill name, + // already baked into the `available` ordering above. + var table = Controls.Table() + .WithTitle("Available skills (Enter for details)") + .AddColumn("Collection") + .AddColumn("Lane") + .AddColumn("Skill") + .AddColumn("Version", TextJustification.Right) + .AddColumn("Tokens", TextJustification.Right) + .WithSorting() + .Rounded() + .WithBorderColor(AccentTurquoise); + var builtTable = table.Build(); foreach (var skill in filtered) { - // ListControl parses item text as markup; BuildSkillChoiceLabel produces plain - // text containing bracketed stack/lane like "[.NET Foundations / ...]". Escape so - // brackets are not interpreted as Spectre markup tags. - list.AddItem(Escape(BuildSkillChoiceLabel(skill, installed)), skill); + builtTable.AddRow(new TableRow( + skill.Stack, + skill.Lane, + ToAlias(skill.Name), + skill.Version, + FormatTokenCount(skill.TokenCount)) + { + Tag = skill, + }); } - list.OnItemActivated((_, item) => + // Use SelectedRow.Tag to recover the skill — the display index changes when the user + // re-sorts via column header click, so indexing into `filtered` would be wrong. + builtTable.RowActivated += (_, _) => { - if (item.Tag is SkillEntry skill) + if (builtTable.SelectedRow?.Tag is SkillEntry skill) { ShowSkillDetailModal(ws, panel, skill); } - }); - panel.AddControl(list.Build()); + }; + panel.AddControl(builtTable); } private void ShowSkillDetailModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, SkillEntry skill) @@ -271,22 +290,36 @@ private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl pan return; } - var list = StyledList($"{(primaryOnly ? "Bundles" : "Packages")} (Enter for details)") - .MaxVisibleItems(16) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); + var table = Controls.Table() + .WithTitle($"{(primaryOnly ? "Bundles" : "Packages")} (Enter for details)") + .AddColumn("Bundle") + .AddColumn("Title") + .AddColumn("Skills", TextJustification.Right) + .AddColumn("Tokens", TextJustification.Right) + .WithSorting() + .Rounded() + .WithBorderColor(AccentDeepSkyBlue); + var builtTable = table.Build(); foreach (var package in filtered) { var tokenCount = package.Skills.Sum(name => skillTokens.TryGetValue(name, out var value) ? value : 0); - list.AddItem($"{Escape(package.Name)} [dim]({package.Skills.Count} skills, {FormatTokenCount(tokenCount)} tokens)[/]", package); + builtTable.AddRow(new TableRow( + package.Name, + package.Title, + package.Skills.Count.ToString(), + FormatTokenCount(tokenCount)) + { + Tag = package, + }); } - list.OnItemActivated((_, item) => + builtTable.RowActivated += (_, _) => { - if (item.Tag is SkillPackageEntry package) + if (builtTable.SelectedRow?.Tag is SkillPackageEntry package) { ShowBundleModal(ws, panel, package, primaryOnly); } - }); - panel.AddControl(list.Build()); + }; + panel.AddControl(builtTable); } private void ShowBundleModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, SkillPackageEntry package, bool primaryOnly) @@ -338,22 +371,39 @@ private void BuildPackagesPage(ConsoleWindowSystem ws, ScrollablePanelControl pa return; } - var list = StyledList("Package signals (Enter to inspect linked skill)") - .MaxVisibleItems(16) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); + var table = Controls.Table() + .WithTitle("Package signals (Enter to inspect linked skill)") + .AddColumn("Signal") + .AddColumn("Kind") + .AddColumn("Skill") + .AddColumn("Collection") + .AddColumn("Lane") + .AddColumn("Tokens", TextJustification.Right) + .WithSorting() + .Rounded() + .WithBorderColor(AccentTurquoise); + var builtTable = table.Build(); foreach (var signal in filtered) { - // ListControl renders item text as markup — escape the whole plain-text label. - list.AddItem(Escape($"{signal.Signal} [{signal.Kind}] -> {ToAlias(signal.Skill.Name)} [{signal.Skill.Stack} / {signal.Skill.Lane}] ({FormatTokenCount(signal.Skill.TokenCount)} tokens)"), signal); + builtTable.AddRow(new TableRow( + signal.Signal, + signal.Kind, + ToAlias(signal.Skill.Name), + signal.Skill.Stack, + signal.Skill.Lane, + FormatTokenCount(signal.Skill.TokenCount)) + { + Tag = signal, + }); } - list.OnItemActivated((_, item) => + builtTable.RowActivated += (_, _) => { - if (item.Tag is PackageSignalView signal) + if (builtTable.SelectedRow?.Tag is PackageSignalView signal) { ShowSkillDetailModal(ws, panel, signal.Skill); } - }); - panel.AddControl(list.Build()); + }; + panel.AddControl(builtTable); } // ------------------------------------------------------------------------- @@ -392,22 +442,36 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane return; } - var list = StyledList("Agents (Enter for details)") - .MaxVisibleItems(14) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); + var table = Controls.Table() + .WithTitle("Agents (Enter for details)") + .AddColumn("Status", TextJustification.Center, width: 8) + .AddColumn("Agent") + .AddColumn("Description") + .AddColumn("Skills", TextJustification.Right) + .WithSorting() + .Rounded() + .WithBorderColor(AccentMediumPurple); + var builtTable = table.Build(); foreach (var agent in filteredAgents) { var isInstalled = installed.Any(i => string.Equals(i.Agent.Name, agent.Name, StringComparison.OrdinalIgnoreCase)); - list.AddItem($"{(isInstalled ? "✓ " : "○ ")}{Escape(ToAlias(agent.Name))} [dim]{Escape(CompactDescription(agent.Description))}[/]", agent); + builtTable.AddRow(new TableRow( + isInstalled ? "✓ installed" : "○ available", + ToAlias(agent.Name), + CompactDescription(agent.Description), + agent.Skills.Count.ToString()) + { + Tag = agent, + }); } - list.OnItemActivated((_, item) => + builtTable.RowActivated += (_, _) => { - if (item.Tag is AgentEntry agent) + if (builtTable.SelectedRow?.Tag is AgentEntry agent) { ShowAgentModal(ws, panel, agent); } - }); - panel.AddControl(list.Build()); + }; + panel.AddControl(builtTable); if (layout is null) { diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs index 3edae2c..3a24412 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs @@ -195,9 +195,19 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan return; } - var list = StyledList("Recommended skills (Enter to install)") - .MaxVisibleItems(16) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); + // Confidence cell renders as the same ●●● marker as the legacy list so the visual + // grammar is preserved; the column itself is sortable, and the default sort + // (Confidence desc) is applied via the row insertion order. + var table = Controls.Table() + .WithTitle("Recommended skills (Enter to install)") + .AddColumn("Confidence", TextJustification.Center, width: 12) + .AddColumn("Status", width: 12) + .AddColumn("Skill") + .AddColumn("Reasons") + .WithSorting() + .Rounded() + .WithBorderColor(AccentDeepSkyBlue); + var builtTable = table.Build(); foreach (var recommendation in scan.Recommendations .OrderByDescending(r => r.Confidence) .ThenBy(r => r.Skill.Name, StringComparer.Ordinal)) @@ -210,11 +220,18 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan }; installedByName.TryGetValue(recommendation.Skill.Name, out var record); var status = record is null ? "[deepskyblue1]new[/]" : record.IsCurrent ? "[green]installed[/]" : "[yellow]update[/]"; - list.AddItem($"{marker} {Escape(ToAlias(recommendation.Skill.Name))} [dim]{status}[/] [grey]{Escape(string.Join("; ", recommendation.Reasons.Take(2)))}[/]", recommendation); + builtTable.AddRow(new TableRow( + marker, + status, + ToAlias(recommendation.Skill.Name), + Escape(string.Join("; ", recommendation.Reasons.Take(2)))) + { + Tag = recommendation, + }); } - list.OnItemActivated((_, item) => + builtTable.RowActivated += (_, _) => { - if (item.Tag is ProjectSkillRecommendation recommendation) + if (builtTable.SelectedRow?.Tag is ProjectSkillRecommendation recommendation) { // Outdated recommendations need force=true: SkillInstaller.Install skips // existing skill directories unless forced, so an "update" entry would @@ -224,8 +241,8 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan ToastResult(summary2, $"Install failed for {ToAlias(recommendation.Skill.Name)}", summary2 is null ? string.Empty : $"{ToAlias(recommendation.Skill.Name)}: {summary2.InstalledCount} written, {summary2.SkippedExisting.Count} skipped"); BuildProjectPage(ws, panel); } - }); - panel.AddControl(list.Build()); + }; + panel.AddControl(builtTable); // Split recommendations: new ones install with force=false, outdated ones need // force=true so the existing skill directory is overwritten with the latest version. From 0fc707b74f3e0da0af85f1930c47de2c629df102 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Tue, 26 May 2026 00:24:29 +0300 Subject: [PATCH 5/9] feat(cli): page-level ToolbarControl replaces bottom-button stacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk actions on Installed / Project / Agents used to sit as full-width ButtonControls beneath a 16-row scrollable table — often off-screen on a default terminal, and visually indistinguishable from list rows. Move them into a `BuildPageToolbar(...)` helper that renders a native ToolbarControl between the identity strip and the table. Each entry has an Enabled flag so the bar carries the precondition signal: - Installed: [Browse skills] · [Update all outdated (N)]ᵈⁱˢᵃᵇˡᵉᵈ ⁿ⁼⁰ · [Remove all (N)]ᵈⁱˢᵃᵇˡᵉᵈ ⁿ⁼⁰ - Project: [Install all recommended (N)]ᵈⁱˢᵃᵇˡᵉᵈ ⁿ⁼⁰ · [Browse installed] - Agents: [Install all into detected platforms]ᵈⁱˢᵃᵇˡᵉᵈ ʷʰᵉⁿ ˡᵃʸᵒᵘᵗ⁼null Per-row Update / Reinstall / Remove stay in the modal (intentional — modals are part of PR #735's contract). Existing Ctrl+U / Ctrl+I / Ctrl+Del bottom-bar shortcuts are unchanged. Skills / Bundles / Packages pages don't grow a toolbar — they had no bulk action and their only entry is modal-on-Enter. RemoveAll / UpdateAll dedicated pages keep their single Button — they're confirmation surfaces, not list pages. Build clean, 613/613 tests pass. --- .../InteractiveConsoleApp.Catalog.cs | 34 +++--- .../InteractiveConsoleApp.Shell.cs | 23 ++++ .../InteractiveConsoleApp.Workspace.cs | 106 +++++++++--------- 3 files changed, 97 insertions(+), 66 deletions(-) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs index 759859a..b306da4 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs @@ -442,6 +442,24 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane return; } + // Page toolbar at the top — "Install all into detected platforms" replaces the old + // bottom-of-page button so the bulk action is visible without scrolling. Disabled + // when no native agent directory resolved (the user must set the platform first). + var agentsToolbar = BuildPageToolbar( + ("Install all into detected platforms", layout is not null, () => + { + var detected = SafeGet(() => AgentInstallTarget.ResolveAllDetected(Session.ProjectDirectory, Session.Scope), Array.Empty()); + if (detected.Count == 0) + { + Toast("No native agent directories detected", NotificationSeverity.Warning); + return; + } + var summary2 = SafeGet(() => new AgentInstaller(agentCatalog).InstallToMultiple(agentCatalog.Agents, detected, force: false), default(AgentInstallSummary)); + ToastResult(summary2, "Install failed", summary2 is null ? string.Empty : $"Installed {summary2.InstalledCount} agent file(s) across {detected.Count} platform(s)"); + BuildAgentsPage(ws, panel); + })); + if (agentsToolbar is not null) panel.AddControl(agentsToolbar); + var table = Controls.Table() .WithTitle("Agents (Enter for details)") .AddColumn("Status", TextJustification.Center, width: 8) @@ -476,22 +494,8 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane if (layout is null) { panel.AddControl(BuildNotePanel("note", "[yellow]No native agent directory resolved. Set the platform on the Settings page, or create one of .codex/.claude/.github/.gemini/.junie.[/]", AccentYellow)); - return; } - - panel.AddControl(Controls.Button("Install all agents into detected native directories") - .OnClick((_, _) => - { - var detected = SafeGet(() => AgentInstallTarget.ResolveAllDetected(Session.ProjectDirectory, Session.Scope), Array.Empty()); - if (detected.Count == 0) - { - Toast("No native agent directories detected", NotificationSeverity.Warning); - return; - } - var summary2 = SafeGet(() => new AgentInstaller(agentCatalog).InstallToMultiple(agentCatalog.Agents, detected, force: false), default(AgentInstallSummary)); - ToastResult(summary2, "Install failed", summary2 is null ? string.Empty : $"Installed {summary2.InstalledCount} agent file(s) across {detected.Count} platform(s)"); - BuildAgentsPage(ws, panel); - }).Build()); + // Bulk install lives in the page toolbar at the top. } private void ShowAgentModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, AgentEntry agent) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs index f34f264..5435761 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs @@ -498,6 +498,29 @@ private static IWindowControl BuildIdentityStrip(string title, Color accent, par return new MarkupControl(new List { line }); } + /// + /// Builds a page-level action toolbar — sits between the identity strip and the data table. + /// Each button is a bulk action (per-row actions stay in the modal). Buttons whose + /// tuple has Enabled = false render as disabled, giving + /// the user a visual signal that the precondition isn't met (e.g. "Update all outdated" + /// disabled when nothing is outdated). Returns null when no entries are provided so the + /// caller can drop it without an empty bar. + /// + private static IWindowControl? BuildPageToolbar(params (string Label, bool Enabled, Action OnClick)[] entries) + { + if (entries is null || entries.Length == 0) return null; + var builder = Controls.Toolbar() + .WithSpacing(1) + .WithBelowLine(true); + foreach (var (label, enabled, onClick) in entries) + { + var btn = Controls.Button(label).OnClick((_, _) => onClick()).Build(); + btn.IsEnabled = enabled; + builder.AddButton(btn); + } + return builder.Build(); + } + /// /// Lays out a sequence of cards in a responsive HorizontalGrid with 1, 2, or 3 columns based /// on the current console width — the native equivalent of BuildRichCardGrid(maxColumns). diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs index 3a24412..b844099 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs @@ -54,6 +54,27 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p return; } + // Page toolbar — bulk actions live at the top above the table so they're always on + // screen, not buried under a 16-row scrolling list. Update is disabled when nothing is + // outdated; the modal-on-Enter flow handles per-row Update/Reinstall/Remove. + var installedToolbar = BuildPageToolbar( + ($"Browse skills", true, () => NavigateTo(HomeAction.BrowseSkills)), + ($"Update all outdated ({outdated.Length})", outdated.Length > 0, () => + { + var summaryText = UpdateSkillRecords(outdated); + Toast(summaryText, summaryText.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); + BuildInstalledPage(ws, panel); + }), + ($"Remove all ({installed.Length})", installed.Length > 0, () => ConfirmModal(ws, "Remove all installed skills?", + $"This removes every catalog skill from {layout.PrimaryRoot.FullName}.", + () => + { + var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout), default(SkillRemoveSummary)); + ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)"); + BuildInstalledPage(ws, panel); + }))); + if (installedToolbar is not null) panel.AddControl(installedToolbar); + // Real sortable TableControl — columns can be sorted by clicking the header. Per-row // foreground color flags outdated rows yellow without needing markup escaping per cell. var table = Controls.Table() @@ -94,27 +115,7 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p } }); panel.AddControl(table.Build()); - - if (outdated.Length > 0) - { - panel.AddControl(Controls.Button($"Update all {outdated.Length} outdated skill(s)") - .OnClick((_, _) => - { - var summaryText = UpdateSkillRecords(outdated); - Toast(summaryText, summaryText.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success); - BuildInstalledPage(ws, panel); - }).Build()); - } - - panel.AddControl(Controls.Button($"Remove all {installed.Length} installed skill(s)") - .OnClick((_, _) => ConfirmModal(ws, "Remove all installed skills?", - $"This removes every catalog skill from {layout.PrimaryRoot.FullName}.", - () => - { - var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout), default(SkillRemoveSummary)); - ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)"); - BuildInstalledPage(ws, panel); - })).Build()); + // Bulk actions live in the page toolbar at the top — no bottom-of-page Button stack. } private void ShowInstalledSkillModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, InstalledSkillRecord record) @@ -195,6 +196,38 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan return; } + // Split recommendations: new ones install with force=false, outdated ones need + // force=true so the existing skill directory is overwritten with the latest version. + // Computed before the toolbar so the "Install all" button can carry the count and a + // disabled state when there's nothing to install. + var newSkills = scan.Recommendations + .Where(r => !installedByName.ContainsKey(r.Skill.Name)) + .Select(r => r.Skill) + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) + .ToArray(); + var outdatedSkills = scan.Recommendations + .Where(r => installedByName.TryGetValue(r.Skill.Name, out var rec) && !rec.IsCurrent) + .Select(r => r.Skill) + .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) + .ToArray(); + var installable = newSkills.Concat(outdatedSkills).ToArray(); + + var projectToolbar = BuildPageToolbar( + ($"Install all recommended ({installable.Length})", installable.Length > 0, () => + { + var skillLayout = ResolveSkillLayout(); + var installer2 = new SkillInstaller(skillCatalog); + var newSummary = newSkills.Length == 0 ? default : SafeGet(() => installer2.Install(newSkills, skillLayout, force: false), default(SkillInstallSummary)); + var updateSummary = outdatedSkills.Length == 0 ? default : SafeGet(() => installer2.Install(outdatedSkills, skillLayout, force: true), default(SkillInstallSummary)); + var installedCount = (newSummary?.InstalledCount ?? 0) + (updateSummary?.InstalledCount ?? 0); + var skippedCount = (newSummary?.SkippedExisting.Count ?? 0) + (updateSummary?.SkippedExisting.Count ?? 0); + var failed = installedCount == 0 && skippedCount == 0; + Toast(failed ? "Install failed" : $"Installed {installedCount}, skipped {skippedCount}", failed ? NotificationSeverity.Danger : NotificationSeverity.Success); + BuildProjectPage(ws, panel); + }), + ("Browse installed", true, () => NavigateTo(HomeAction.ManageInstalled))); + if (projectToolbar is not null) panel.AddControl(projectToolbar); + // Confidence cell renders as the same ●●● marker as the legacy list so the visual // grammar is preserved; the column itself is sortable, and the default sort // (Confidence desc) is applied via the row insertion order. @@ -243,36 +276,7 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan } }; panel.AddControl(builtTable); - - // Split recommendations: new ones install with force=false, outdated ones need - // force=true so the existing skill directory is overwritten with the latest version. - var newSkills = scan.Recommendations - .Where(r => !installedByName.ContainsKey(r.Skill.Name)) - .Select(r => r.Skill) - .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) - .ToArray(); - var outdatedSkills = scan.Recommendations - .Where(r => installedByName.TryGetValue(r.Skill.Name, out var rec) && !rec.IsCurrent) - .Select(r => r.Skill) - .GroupBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) - .ToArray(); - var installable = newSkills.Concat(outdatedSkills).ToArray(); - if (installable.Length > 0) - { - panel.AddControl(Controls.Button($"Install all {installable.Length} recommended skill(s)") - .OnClick((_, _) => - { - var skillLayout = ResolveSkillLayout(); - var installer2 = new SkillInstaller(skillCatalog); - var newSummary = newSkills.Length == 0 ? default : SafeGet(() => installer2.Install(newSkills, skillLayout, force: false), default(SkillInstallSummary)); - var updateSummary = outdatedSkills.Length == 0 ? default : SafeGet(() => installer2.Install(outdatedSkills, skillLayout, force: true), default(SkillInstallSummary)); - var installedCount = (newSummary?.InstalledCount ?? 0) + (updateSummary?.InstalledCount ?? 0); - var skippedCount = (newSummary?.SkippedExisting.Count ?? 0) + (updateSummary?.SkippedExisting.Count ?? 0); - var failed = installedCount == 0 && skippedCount == 0; - Toast(failed ? "Install failed" : $"Installed {installedCount}, skipped {skippedCount}", failed ? NotificationSeverity.Danger : NotificationSeverity.Success); - BuildProjectPage(ws, panel); - }).Build()); - } + // Bulk install lives in the page toolbar at the top — no bottom-of-page button. } // ------------------------------------------------------------------------- From 397d711f5232af41d34ee6bd5c7f73b0487cd392 Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Tue, 26 May 2026 00:33:53 +0300 Subject: [PATCH 6/9] feat(cli): graphs with gradients on Project and Analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project page — three native BarGraphs (high / medium / low) showing the confidence breakdown of the recommendation set. Confidence IS severity- coded (high = trust, low = noise) so a green/yellow/grey palette is the right read. Analysis page — - Recolor "heaviest skills" BarGraph from WithStandardGradient (a green→yellow→red severity ramp that read as "warning") to a smooth "cool" gradient. Heavy ≠ unsafe, it's just magnitude. - Recolor "skills per collection" BarGraph from flat turquoise to a turquoise→purple smooth gradient so the two Analysis charts are visually distinguishable. - New third chart: LineGraph (Braille mode) plotting the catalog token distribution across the full catalog (X = skill index sorted by tokens desc, Y = tokens). Surfaces the long-tail shape of the catalog at a glance — a few mega-skills vs. a flat curve is the question this answers. "cool" series gradient, high/low markers, rounded border, Y-axis labels in N0 format. Settings disk-footprint chart and Home metric-card sparklines are deferred — they need data the Runtime layer doesn't expose synchronously (installed skill bytes, catalog release history). Will land as a follow-up once the data path is in place. Build clean, 613/613 tests pass. --- .../InteractiveConsoleApp.Workspace.cs | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs index b844099..cd85bff 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs @@ -188,7 +188,26 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan panel.AddControl(BuildIdentityStrip("project scan", AccentDeepSkyBlue, ("scanned", $"{scan.ProjectFiles.Count} project file(s)"), ("frameworks", scan.TargetFrameworks.Count == 0 ? "[grey50]unknown[/]" : Escape(string.Join(", ", scan.TargetFrameworks))), - ("recommendations", $"{scan.Recommendations.Count} [grey50]([/][green]{high} high[/][grey50] · [/][yellow]{med} med[/][grey50] · [/][grey]{low} low[/][grey50])[/]"))); + ("recommendations", scan.Recommendations.Count.ToString()))); + + // Confidence trio — 3 horizontal BarGraphs (high green, med yellow, low grey) so the + // user can see the shape of the recommendation set without reading numbers. Confidence + // here IS severity-coded (high = trust, low = noise) so a threshold gradient is the + // right call rather than a smooth magnitude ramp. + if (scan.Recommendations.Count > 0) + { + var maxConfidence = Math.Max(1, Math.Max(high, Math.Max(med, low))); + var confidencePanel = new ScrollablePanelControl + { + ShowScrollbar = false, + EnableMouseWheel = false, + }; + confidencePanel.AddControl(Controls.BarGraph().WithLabel("high").WithLabelWidth(8).WithValue(high).WithMaxValue(maxConfidence).WithValueFormat("0").ShowValue(true).WithFilledColor(AccentGreen).Build()); + confidencePanel.AddControl(Controls.BarGraph().WithLabel("medium").WithLabelWidth(8).WithValue(med).WithMaxValue(maxConfidence).WithValueFormat("0").ShowValue(true).WithFilledColor(AccentYellow).Build()); + confidencePanel.AddControl(Controls.BarGraph().WithLabel("low").WithLabelWidth(8).WithValue(low).WithMaxValue(maxConfidence).WithValueFormat("0").ShowValue(true).WithFilledColor(AccentGrey).Build()); + panel.AddControl(BuildSectionPanel("confidence", string.Empty, AccentDeepSkyBlue)); + panel.AddControl(confidencePanel); + } if (scan.Recommendations.Count == 0) { @@ -365,6 +384,32 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa panel.AddControl(chart2); } + // Catalog token distribution — the long-tail shape of skill weights across the entire + // catalog. X axis is skill index sorted by tokens desc, Y is tokens. A small number of + // mega-skills versus a flat curve is the question this chart answers at a glance. + if (skillCatalog.Skills.Count >= 2) + { + var sortedTokens = skillCatalog.Skills + .OrderByDescending(s => s.TokenCount) + .Select(s => (double)s.TokenCount) + .ToArray(); + var distribution = Controls.LineGraph() + .WithHeight(7) + .WithMode(LineGraphMode.Braille) + .WithMinValue(0) + .WithMaxValue(sortedTokens.Length == 0 ? 1 : sortedTokens.Max()) + .WithBorder(BorderStyle.Rounded, AccentDeepSkyBlue) + .WithBackgroundColor(new Color(15, 22, 38)) + .WithYAxisLabels(true, "N0") + .WithAxisLabelColor(AccentGrey) + .WithHighLowLabels(true, AccentDeepSkyBlue, AccentGrey) + .AddSeries("tokens", AccentDeepSkyBlue, "cool") + .WithData("tokens", sortedTokens) + .Build(); + panel.AddControl(BuildSectionPanel("token distribution (long tail)", string.Empty, AccentDeepSkyBlue)); + panel.AddControl(distribution); + } + if (signals.Count > 0) { var signalLines = signals.Take(18).Select(signal => @@ -374,8 +419,10 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa } /// - /// A horizontal bar showing one skill's token weight against the chart's max. Color follows - /// a green→yellow→red threshold gradient so heavy skills stand out visually. + /// A horizontal bar showing one skill's token weight against the chart's max. Uses a smooth + /// "cool" gradient (blue → cyan): heavy tokens are not severity, they're magnitude — the + /// previous green→yellow→red gradient read as "warning" against the user instead of + /// information about the catalog. /// private static BarGraphControl BuildSkillTokenBar(SkillEntry skill, int maxTokens) => Controls.BarGraph() @@ -385,12 +432,13 @@ private static BarGraphControl BuildSkillTokenBar(SkillEntry skill, int maxToken .WithMaxValue(maxTokens == 0 ? 1 : maxTokens) .WithValueFormat("N0") .ShowValue(true) - .WithStandardGradient() + .WithSmoothGradient("cool") .Build(); /// - /// A horizontal bar showing one collection's skill count against the chart's max. Uses the - /// turquoise accent for the filled portion. + /// A horizontal bar showing one collection's skill count against the chart's max. Uses a + /// custom warm gradient (yellow → orange) so the two Analysis charts are visually + /// distinguishable at a glance. /// private static BarGraphControl BuildCollectionCountBar(CollectionCatalogView view, int maxCount) => Controls.BarGraph() @@ -400,7 +448,7 @@ private static BarGraphControl BuildCollectionCountBar(CollectionCatalogView vie .WithMaxValue(maxCount == 0 ? 1 : maxCount) .WithValueFormat("0") .ShowValue(true) - .WithFilledColor(AccentTurquoise) + .WithSmoothGradient(AccentTurquoise, AccentMediumPurple) .Build(); // ------------------------------------------------------------------------- From 81c79bf46e1db1912385015ba387c60fdca88adc Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Tue, 26 May 2026 00:36:46 +0300 Subject: [PATCH 7/9] feat(cli): responsive NavigationView thresholds + modal/row rhythm polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NavigationView pane thresholds — the previous Auto settings (Expanded ≥96, Compact ≥54) kept the 30-column rail at full width on typical 120-140 col terminals, eating horizontal space the new tables and graphs need. Bump to: - Expanded ≥160 cols (genuinely wide terminals) - Compact ≥ 90 cols (typical IDE-side terminal width — icons + selected) - Minimal < 90 cols (rail collapses, hotkey to summon) Modal action toolbar now draws a single-row separator above the buttons (`WithAboveLine(true)` + matching PanelBorder color), giving visual separation between detail-pane data and verbs that was missing. Outdated-row foreground in the Installed table moves from AccentYellow (215,175,0) to a desaturated OutdatedRowFg (200,180,80). The previous color doubled as both the chart-severity yellow (Project confidence "medium" bar) and the row-attention yellow, which made the two pages fight for the eye. Same warm signal, lower volume. Build clean, 613/613 tests pass. --- .../InteractiveConsoleApp.Shell.cs | 22 ++++++++++++++++--- .../InteractiveConsoleApp.Workspace.cs | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs index 5435761..089d702 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs @@ -53,6 +53,11 @@ internal sealed partial class InteractiveConsoleApp private static readonly Color AccentYellow = new(215, 175, 0); // Spectre "yellow" private static readonly Color AccentGrey = new(135, 135, 135); // Spectre "grey" private static readonly Color PanelBorderColor = new(70, 88, 116); // matches the root window border + // Softer warm yellow for "outdated" row foreground — the saturated AccentYellow used to + // double as both the chart-severity yellow and the row-attention yellow, which made the + // Project confidence trio fight the Installed table for the user's eye. Desaturated so the + // row signal stays warm without dominating. + private static readonly Color OutdatedRowFg = new(200, 180, 80); // Live shell state for the dynamic status bar. private ConsoleWindowSystem? _ws; @@ -148,8 +153,12 @@ private void CreateCommandCenter(ConsoleWindowSystem ws) .WithNavWidth(30) .WithPaneHeader("[bold rgb(120,180,255)] ◆ dotnet skills[/]") .WithPaneDisplayMode(NavigationViewDisplayMode.Auto) - .WithExpandedThreshold(96) - .WithCompactThreshold(54) + // Reserve full pane labels for genuinely wide terminals (≥160 cols). On + // 120–160-col terminals the rail goes Compact (icons + selected label only), + // giving the polished tables and graphs the horizontal space they need. Below + // 90 cols the rail collapses to Minimal (hidden, summon on hotkey). + .WithExpandedThreshold(160) + .WithCompactThreshold(90) .WithContentBorder(BorderStyle.Rounded) .WithContentBorderColor(new Color(70, 100, 150)) .WithContentPadding(1, 0, 1, 0) @@ -586,7 +595,14 @@ void Close() } } - var toolbar = Controls.Toolbar().WithSpacing(2).WithAlignment(HorizontalAlignment.Center); + // Above-line gives a visual separator between the modal's data/property panels and the + // action toolbar — without it the buttons sit flush against the content and read as + // "more content" on first glance. + var toolbar = Controls.Toolbar() + .WithSpacing(2) + .WithAlignment(HorizontalAlignment.Center) + .WithAboveLine(true) + .WithAboveLineColor(new Color(70, 88, 116)); foreach (var (label, onClick) in buttons) { var captured = onClick; diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs index cd85bff..0f09dd5 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs @@ -101,7 +101,7 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p FormatTokenCount(record.Skill.TokenCount)) { Tag = record, - ForegroundColor = record.IsCurrent ? null : AccentYellow, + ForegroundColor = record.IsCurrent ? null : OutdatedRowFg, }; table.AddRow(row); } From 3400c4ec77b16c83e7a5195ac0cf3e52a9825afe Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Tue, 26 May 2026 01:22:20 +0300 Subject: [PATCH 8/9] feat(cli): Collections master-detail + section rhythm + truncation fade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grab-bag of post-PR polish driven by use: Collections page - Left rail is now a sortable 2-column TableControl (Collection + Skills installed/total) instead of a markup-list. Same visual grammar as every other browse page. - Right pane gets the same primitives the rest of the shell uses: identity strip → tokens-by-lane BarGraph stack (cool gradient) → sortable Lanes Table → install Toolbar with two-stage arm. - HorizontalGrid now has WithSplitterAfter(0) so the user can drag the divider to rebalance left rail vs. detail pane. BuildStyledTable helper + ApplyStyledTableRuntime - Every TableControl now flows through `BuildStyledTable(title, accent)` — applies left-aligned title (was centered), sortable, rounded, border-color, and StretchHorizontal in one call. Replaces 4-line fluent chains scattered across pages. - `ApplyStyledTableRuntime(...)` flips on `TruncationFade = true` on the built TableControl so cells that overflow their column fade to background over the last 4 chars instead of clipping. Polished read; no ugly ellipsis. Identity strip ⇒ AddIdentityStrip + AddSectionHeader - New `AddIdentityStrip(panel, ...)` wraps the strip MarkupControl with a colored RuleControl underneath so the page header reads as one composite block — same visual rhythm as the modal toolbar's AboveLine separator. - New `AddSectionHeader(panel, title, accent)` replaces the previous pattern of `BuildSectionPanel(title, "", accent)` (a full rounded empty PanelControl) used as a heading divider. Now emits a blank spacer line + a titled RuleControl. Three rows of fake-titled panel down to two rows of real rule. Analysis page cleanup - Three section headers (tokens by skill / skills per collection / token distribution) use the new AddSectionHeader rule. - Dropped the redundant 12-collection card grid at the top — the same data is shown in the "skills per collection" BarGraph below. - Dropped the redundant bottom bullet list of package signals — count is in the identity strip, per-signal detail lives on the dedicated Packages page. Build clean across all 4 projects, 0 warnings. 613/613 tests pass. --- .../InteractiveConsoleApp.Catalog.cs | 200 +++++++++++------- .../InteractiveConsoleApp.Home.cs | 4 +- .../InteractiveConsoleApp.Shell.cs | 63 ++++++ .../InteractiveConsoleApp.Workspace.cs | 79 +++---- 4 files changed, 217 insertions(+), 129 deletions(-) diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs index b306da4..35f4346 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs @@ -40,9 +40,9 @@ private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelContro var filtered = available.Where(s => MatchesFilter(s.Name, s.Stack, s.Lane)).ToArray(); - panel.AddControl(BuildIdentityStrip("skill browser", AccentTurquoise, + AddIdentityStrip(panel, "skill browser", AccentTurquoise, ("available", $"{filtered.Length}/{available.Length}"), - ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); + ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}")); AddSearchChip(panel); if (available.Length == 0) @@ -60,17 +60,13 @@ private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelContro // are columns instead of a single bracketed markup-salad row. Default sort matches the // legacy ListControl order: by collection rank then collection name then skill name, // already baked into the `available` ordering above. - var table = Controls.Table() - .WithTitle("Available skills (Enter for details)") + var table = BuildStyledTable("Available skills (Enter for details)", AccentTurquoise) .AddColumn("Collection") .AddColumn("Lane") .AddColumn("Skill") .AddColumn("Version", TextJustification.Right) - .AddColumn("Tokens", TextJustification.Right) - .WithSorting() - .Rounded() - .WithBorderColor(AccentTurquoise); - var builtTable = table.Build(); + .AddColumn("Tokens", TextJustification.Right); + var builtTable = ApplyStyledTableRuntime(table.Build()); foreach (var skill in filtered) { builtTable.AddRow(new TableRow( @@ -146,10 +142,10 @@ private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl .ToArray(); var filtered = views.Where(v => MatchesFilter(v.Collection)).ToArray(); - panel.AddControl(BuildIdentityStrip("collection browser", AccentDeepSkyBlue, + AddIdentityStrip(panel, "collection browser", AccentDeepSkyBlue, ("collections", string.IsNullOrEmpty(_searchFilter) ? views.Length.ToString() : $"{filtered.Length}/{views.Length}"), ("skills", skillCatalog.Skills.Count.ToString()), - ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}"))); + ("installed", $"{installed.Count}/{skillCatalog.Skills.Count}")); AddSearchChip(panel); if (views.Length == 0) @@ -163,10 +159,10 @@ private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl return; } - // Master-detail layout. Left column lists collections; right column shows the detail of - // _selectedCollection. Clicking a left-list row updates only the right pane in place — - // no modal, no full-page rebuild. The right pane is a ScrollablePanel so the detail can - // grow with the collection's lane list. + // Master-detail layout. The left rail is now a 2-column sortable TableControl (matches + // the visual grammar of the rest of the polished shell — Skills, Bundles, Packages, + // Agents, Project all use TableControl). The right pane shows the detail of + // _selectedCollection and is rebuilt in place on selection change. if (_selectedCollection is null || !filtered.Any(v => string.Equals(v.Collection, _selectedCollection.Collection, StringComparison.OrdinalIgnoreCase))) { @@ -174,8 +170,8 @@ private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl _collectionInstallArmed = false; } - // Build the detail pane as a standalone ScrollablePanelControl so we can update it - // independently of the left list when the user changes selection. + // Right pane = a ScrollablePanel so the detail (identity strip + per-lane BarGraph + + // Lanes table + install Toolbar) can grow without the splitter constraining it. var rightPane = new ScrollablePanelControl { ShowScrollbar = true, @@ -183,64 +179,119 @@ private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl EnableMouseWheel = true, }; - var grid = Controls.HorizontalGrid() - .Column(col => + // Left rail — sortable 2-column table. SelectedRowItemChanged fires on row selection + // (keyboard or click) and gives us the actual TableRow so we can read Tag without + // worrying about display-vs-data index mapping under user sort. + var leftTable = ApplyStyledTableRuntime(BuildStyledTable("Collections", AccentDeepSkyBlue) + .AddColumn("Collection") + .AddColumn("Skills", TextJustification.Right) + .Build()); + foreach (var view in filtered) + { + leftTable.AddRow(new TableRow(view.Collection, $"{view.InstalledCount}/{view.SkillCount}") { - col.Flex(1); - var list = StyledList("Collections") - .MaxVisibleItems(20) - .WithScrollbarVisibility(ScrollbarVisibility.Auto); - foreach (var view in filtered) - { - list.AddItem(Escape(BuildCollectionChoiceLabel(view)), view); - } - list.OnItemActivated((_, item) => - { - if (item.Tag is CollectionCatalogView v) - { - _selectedCollection = v; - _collectionInstallArmed = false; - BuildCollectionDetail(rightPane, v); - } - }); - col.Add(list.Build()); - }) - .Column(col => + Tag = view, + }); + } + leftTable.SelectedRowItemChanged += (_, row) => + { + if (row?.Tag is CollectionCatalogView v && !ReferenceEquals(v, _selectedCollection)) { - col.Flex(2).Add(rightPane); - }) - .Build(); + _selectedCollection = v; + _collectionInstallArmed = false; + BuildCollectionDetail(rightPane, v); + } + }; + // HorizontalGrid with WithSplitterAfter(0) — the grid hosts both columns AND the + // splitter control between them. The splitter is drag-resizable. SplitterControl is + // not a standalone container; it must live inside a HorizontalGrid between adjacent + // ColumnContainers, so `Controls.HorizontalGrid().Column(...).Column(...).WithSplitterAfter(0)` + // is the ergonomic builder for it. + var grid = Controls.HorizontalGrid() + .Column(col => col.Flex(1).Add(leftTable)) + .Column(col => col.Flex(2).Add(rightPane)) + .WithSplitterAfter(0) + .Build(); panel.AddControl(grid); + BuildCollectionDetail(rightPane, _selectedCollection!); } /// - /// Renders the right pane of the Collections master-detail view: stats, lanes, and an inline - /// two-stage install button (first click arms, second commits — satisfies AGENTS.md's - /// "install overview before confirmation" rule without a modal). + /// Renders the right pane of the Collections master-detail view. Layout (top to bottom): + /// identity strip, tokens-by-lane BarGraph stack (visual weight of each lane within the + /// collection — "cool" gradient, same vocabulary as the Analysis page), sortable Lanes + /// TableControl, and a single-button Toolbar that handles the two-stage inline install. /// private void BuildCollectionDetail(ScrollablePanelControl pane, CollectionCatalogView view) { pane.ClearContents(); - pane.AddControl(BuildPropertyPanel(view.Collection, AccentDeepSkyBlue, - ("collection", Escape(view.Collection)), + AddIdentityStrip(pane, view.Collection, AccentDeepSkyBlue, ("lanes", view.Lanes.Count.ToString()), ("skills", $"{view.InstalledCount}/{view.SkillCount}"), - ("tokens", FormatTokenCount(view.TokenCount)))); + ("tokens", FormatTokenCount(view.TokenCount))); + // BarGraph stack — one horizontal bar per lane, sized against the heaviest lane's tokens. + // Smooth "cool" gradient (blue → cyan) for magnitude. Lets the eye see which lanes carry + // the collection's weight without reading numbers. Mirrors the Analysis page's + // "tokens by skill" chart so the visual vocabulary is consistent across the shell. if (view.Lanes.Count > 0) { - pane.AddControl(BuildBulletPanel("lanes", AccentTurquoise, - view.Lanes.Select(lane => $"[grey50]·[/] [grey]{Escape(lane.Lane)}[/] [grey50]({lane.InstalledCount}/{lane.Skills.Count} skills, {FormatTokenCount(lane.TokenCount)} tokens)[/]").ToArray())); + var maxLaneTokens = view.Lanes.Max(l => l.TokenCount); + var laneChart = new ScrollablePanelControl + { + ShowScrollbar = false, + EnableMouseWheel = false, + }; + foreach (var lane in view.Lanes) + { + laneChart.AddControl(Controls.BarGraph() + .WithLabel(lane.Lane) + .WithLabelWidth(20) + .WithValue(lane.TokenCount) + .WithMaxValue(maxLaneTokens == 0 ? 1 : maxLaneTokens) + .WithValueFormat("N0") + .ShowValue(true) + .WithSmoothGradient("cool") + .Build()); + } + AddSectionHeader(pane, "tokens by lane", AccentDeepSkyBlue); + pane.AddControl(laneChart); } + // Lanes table — sortable, columns match the lane's logical dimensions. + if (view.Lanes.Count > 0) + { + var lanesTable = ApplyStyledTableRuntime(BuildStyledTable("Lanes", AccentTurquoise) + .AddColumn("Lane") + .AddColumn("Skills", TextJustification.Right) + .AddColumn("Installed", TextJustification.Right) + .AddColumn("Tokens", TextJustification.Right) + .Build()); + foreach (var lane in view.Lanes) + { + lanesTable.AddRow(new TableRow( + lane.Lane, + lane.Skills.Count.ToString(), + $"{lane.InstalledCount}/{lane.Skills.Count}", + FormatTokenCount(lane.TokenCount)) + { + Tag = lane, + }); + } + pane.AddControl(lanesTable); + } + + // Two-stage inline install in a Toolbar — first click arms with a warning toast, + // second click commits. Same UX as the original PR #735 implementation, now living in + // the same ToolbarControl primitive every other page's bulk action uses. var armed = _collectionInstallArmed; var label = armed ? $"Click again to install all {view.SkillCount} skill(s)" : $"Install collection ({view.SkillCount} skill(s))"; - pane.AddControl(Controls.Button(label) - .OnClick((_, _) => + var installToolbar = BuildPageToolbar( + (label, view.SkillCount > 0, () => { if (!_collectionInstallArmed) { @@ -254,7 +305,8 @@ private void BuildCollectionDetail(ScrollablePanelControl pane, CollectionCatalo ToastResult(summary, $"Could not install collection {view.Collection}", summary is null ? string.Empty : $"{view.Collection}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped"); _collectionInstallArmed = false; if (_ws is not null && _activePanel is not null) BuildCollectionsPage(_ws, _activePanel); - }).Build()); + })); + if (installToolbar is not null) pane.AddControl(installToolbar); } // ------------------------------------------------------------------------- @@ -274,9 +326,9 @@ private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl pan var filtered = packages.Where(p => MatchesFilter(p.Name, p.Title)).ToArray(); - panel.AddControl(BuildIdentityStrip(title, AccentDeepSkyBlue, + AddIdentityStrip(panel, title, AccentDeepSkyBlue, (primaryOnly ? "bundles" : "packages", string.IsNullOrEmpty(_searchFilter) ? packages.Length.ToString() : $"{filtered.Length}/{packages.Length}"), - ("skills covered", skillCatalog.Skills.Count.ToString()))); + ("skills covered", skillCatalog.Skills.Count.ToString())); AddSearchChip(panel); if (packages.Length == 0) @@ -290,16 +342,12 @@ private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl pan return; } - var table = Controls.Table() - .WithTitle($"{(primaryOnly ? "Bundles" : "Packages")} (Enter for details)") + var table = BuildStyledTable($"{(primaryOnly ? "Bundles" : "Packages")} (Enter for details)", AccentDeepSkyBlue) .AddColumn("Bundle") .AddColumn("Title") .AddColumn("Skills", TextJustification.Right) - .AddColumn("Tokens", TextJustification.Right) - .WithSorting() - .Rounded() - .WithBorderColor(AccentDeepSkyBlue); - var builtTable = table.Build(); + .AddColumn("Tokens", TextJustification.Right); + var builtTable = ApplyStyledTableRuntime(table.Build()); foreach (var package in filtered) { var tokenCount = package.Skills.Sum(name => skillTokens.TryGetValue(name, out var value) ? value : 0); @@ -355,9 +403,9 @@ private void BuildPackagesPage(ConsoleWindowSystem ws, ScrollablePanelControl pa var signals = SafeGet(BuildPackageSignals, Array.Empty()); var filtered = signals.Where(s => MatchesFilter(s.Signal, s.Skill.Name, s.Skill.Stack, s.Skill.Lane)).ToArray(); - panel.AddControl(BuildIdentityStrip("package signals", AccentTurquoise, + AddIdentityStrip(panel, "package signals", AccentTurquoise, ("signals", string.IsNullOrEmpty(_searchFilter) ? signals.Count.ToString() : $"{filtered.Length}/{signals.Count}"), - ("skills covered", signals.Select(s => s.Skill.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count().ToString()))); + ("skills covered", signals.Select(s => s.Skill.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count().ToString())); AddSearchChip(panel); if (signals.Count == 0) @@ -371,18 +419,14 @@ private void BuildPackagesPage(ConsoleWindowSystem ws, ScrollablePanelControl pa return; } - var table = Controls.Table() - .WithTitle("Package signals (Enter to inspect linked skill)") + var table = BuildStyledTable("Package signals (Enter to inspect linked skill)", AccentTurquoise) .AddColumn("Signal") .AddColumn("Kind") .AddColumn("Skill") .AddColumn("Collection") .AddColumn("Lane") - .AddColumn("Tokens", TextJustification.Right) - .WithSorting() - .Rounded() - .WithBorderColor(AccentTurquoise); - var builtTable = table.Build(); + .AddColumn("Tokens", TextJustification.Right); + var builtTable = ApplyStyledTableRuntime(table.Build()); foreach (var signal in filtered) { builtTable.AddRow(new TableRow( @@ -425,10 +469,10 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane // platform + target live in the top StatusBar; surface "unresolved" target here as a // first-class fact because the agent layout has a separate resolver from the skill one. - panel.AddControl(BuildIdentityStrip("orchestration agents", AccentMediumPurple, + AddIdentityStrip(panel, "orchestration agents", AccentMediumPurple, ("agents", string.IsNullOrEmpty(_searchFilter) ? agentCatalog.Agents.Count.ToString() : $"{filteredAgents.Length}/{agentCatalog.Agents.Count}"), ("installed", layout is null ? "[grey]-[/]" : $"{installed.Count}/{agentCatalog.Agents.Count}"), - ("target", layout is null ? $"[red]{Escape(layoutError ?? "unresolved")}[/]" : string.Empty))); + ("target", layout is null ? $"[red]{Escape(layoutError ?? "unresolved")}[/]" : string.Empty)); AddSearchChip(panel); if (agentCatalog.Agents.Count == 0) @@ -460,16 +504,12 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane })); if (agentsToolbar is not null) panel.AddControl(agentsToolbar); - var table = Controls.Table() - .WithTitle("Agents (Enter for details)") + var table = BuildStyledTable("Agents (Enter for details)", AccentMediumPurple) .AddColumn("Status", TextJustification.Center, width: 8) .AddColumn("Agent") .AddColumn("Description") - .AddColumn("Skills", TextJustification.Right) - .WithSorting() - .Rounded() - .WithBorderColor(AccentMediumPurple); - var builtTable = table.Build(); + .AddColumn("Skills", TextJustification.Right); + var builtTable = ApplyStyledTableRuntime(table.Build()); foreach (var agent in filteredAgents) { var isInstalled = installed.Any(i => string.Equals(i.Agent.Name, agent.Name, StringComparison.OrdinalIgnoreCase)); diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs index 1eed1f9..6ff0c3b 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs @@ -40,12 +40,12 @@ private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel) var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); var outdated = installed.Count(record => !record.IsCurrent); - panel.AddControl(BuildIdentityStrip("session", AccentDeepSkyBlue, + AddIdentityStrip(panel, "session", AccentDeepSkyBlue, ("catalog", $"{Escape(skillCatalog.SourceLabel)} [grey50]({Escape(skillCatalog.CatalogVersion)})[/]"), ("platform", Escape(Session.Agent.ToString())), ("scope", Escape(Session.Scope.ToString())), ("project", Escape(CompactPath(Session.ProjectDirectory ?? Environment.CurrentDirectory))), - ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"))); + ("target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]")); // catalog telemetry — five native metric cards laid out by HorizontalGrid (responsive flex). var installedAccent = installed.Count > 0 ? AccentGreen : AccentGrey; diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs index 089d702..3d9049d 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs @@ -507,6 +507,69 @@ private static IWindowControl BuildIdentityStrip(string title, Color accent, par return new MarkupControl(new List { line }); } + /// + /// Adds an identity strip plus a horizontal rule beneath it. The rule visually separates the + /// page header from the content below (table, graph, form) the same way the modal toolbar's + /// AboveLine separates verbs from content. Rule color follows the page accent so the strip + /// and rule read as a single composite header. Use this in page builders instead of calling + /// `panel.AddControl(BuildIdentityStrip(...))` directly. + /// + private static void AddIdentityStrip(ScrollablePanelControl panel, string title, Color accent, params (string Label, string Value)[] facts) + { + panel.AddControl(BuildIdentityStrip(title, accent, facts)); + panel.AddControl(Controls.RuleBuilder() + .WithColor(accent) + .WithBorderStyle(BorderStyle.Single) + .Build()); + } + + /// + /// In-page section header: a blank spacer line followed by a titled rule. Replaces the + /// previous pattern of using `BuildSectionPanel(title, "", accent)` — a full rounded + /// PanelControl with an empty body — just to display a heading. Two rows instead of three, + /// and the heading reads as a rule with a caption rather than as an empty panel. The empty + /// line above gives the title visual breathing room and rhythm between sections. + /// Use between content blocks (e.g. above each chart on the Analysis page). + /// + private static void AddSectionHeader(ScrollablePanelControl panel, string title, Color accent) + { + // Empty spacer line above — a single-row blank MarkupControl gives the title rule room + // without needing margins. Cheap, predictable rhythm. + panel.AddControl(new MarkupControl(new List { string.Empty })); + panel.AddControl(Controls.RuleBuilder() + .WithTitle(title) + .TitleLeft() + .WithColor(accent) + .WithBorderStyle(BorderStyle.Single) + .Build()); + } + + /// + /// Standard sortable rounded table with a left-aligned title and the accent border color. + /// TableControl defaults the title to centered; left-aligned reads better against the + /// left-aligned identity strip above and is consistent across the polished shell. Use this + /// instead of `Controls.Table().WithTitle(...).WithSorting().Rounded().WithBorderColor(...)` + /// boilerplate. + /// + private static TableControlBuilder BuildStyledTable(string title, Color accent) => Controls.Table() + .WithTitle(title, TextJustification.Left) + .WithSorting() + .Rounded() + .WithBorderColor(accent) + .StretchHorizontal(); + + /// + /// Configures a built TableControl with the polish-PR's standard runtime properties: + /// `TruncationFade = true` makes truncated cell text fade-to-background over the last 4 + /// columns instead of clipping or showing an ASCII ellipsis. There's no builder method for + /// this property, so we set it after Build. Call once per table right after the Build site. + /// + private static TableControl ApplyStyledTableRuntime(TableControl table) + { + table.TruncationFade = true; + return table; + } + /// /// Builds a page-level action toolbar — sits between the identity strip and the data table. /// Each button is a bulk action (per-row actions stay in the modal). Buttons whose diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs index 0f09dd5..d0c8494 100644 --- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs +++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs @@ -37,10 +37,10 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p var filtered = installed.Where(r => MatchesFilter(r.Skill.Name, r.Skill.Stack, r.Skill.Lane)).ToArray(); - panel.AddControl(BuildIdentityStrip("installed skills", AccentGreen, + AddIdentityStrip(panel, "installed skills", AccentGreen, ("installed", string.IsNullOrEmpty(_searchFilter) ? installed.Length.ToString() : $"{filtered.Length}/{installed.Length}"), ("outdated", outdated.Length == 0 ? "[green]0[/]" : $"[yellow]{outdated.Length}[/]"), - ("tokens", FormatTokenCount(installed.Sum(record => record.Skill.TokenCount))))); + ("tokens", FormatTokenCount(installed.Sum(record => record.Skill.TokenCount)))); AddSearchChip(panel); if (installed.Length == 0) @@ -77,18 +77,14 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p // Real sortable TableControl — columns can be sorted by clicking the header. Per-row // foreground color flags outdated rows yellow without needing markup escaping per cell. - var table = Controls.Table() - .WithTitle("Installed skills (Enter for details)") + var table = BuildStyledTable("Installed skills (Enter for details)", AccentGreen) .AddColumn("Status", TextJustification.Center, width: 8) .AddColumn("Skill") .AddColumn("Collection") .AddColumn("Lane") .AddColumn("Installed", TextJustification.Right) .AddColumn("Latest", TextJustification.Right) - .AddColumn("Tokens", TextJustification.Right) - .WithSorting() - .Rounded() - .WithBorderColor(AccentGreen); + .AddColumn("Tokens", TextJustification.Right); foreach (var record in filtered) { var row = new TableRow( @@ -114,7 +110,7 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p ShowInstalledSkillModal(ws, panel, filtered[idx]); } }); - panel.AddControl(table.Build()); + panel.AddControl(ApplyStyledTableRuntime(table.Build())); // Bulk actions live in the page toolbar at the top — no bottom-of-page Button stack. } @@ -185,10 +181,10 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan var med = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Medium); var low = scan.Recommendations.Count(r => r.Confidence == RecommendationConfidence.Low); - panel.AddControl(BuildIdentityStrip("project scan", AccentDeepSkyBlue, + AddIdentityStrip(panel, "project scan", AccentDeepSkyBlue, ("scanned", $"{scan.ProjectFiles.Count} project file(s)"), ("frameworks", scan.TargetFrameworks.Count == 0 ? "[grey50]unknown[/]" : Escape(string.Join(", ", scan.TargetFrameworks))), - ("recommendations", scan.Recommendations.Count.ToString()))); + ("recommendations", scan.Recommendations.Count.ToString())); // Confidence trio — 3 horizontal BarGraphs (high green, med yellow, low grey) so the // user can see the shape of the recommendation set without reading numbers. Confidence @@ -205,7 +201,7 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan confidencePanel.AddControl(Controls.BarGraph().WithLabel("high").WithLabelWidth(8).WithValue(high).WithMaxValue(maxConfidence).WithValueFormat("0").ShowValue(true).WithFilledColor(AccentGreen).Build()); confidencePanel.AddControl(Controls.BarGraph().WithLabel("medium").WithLabelWidth(8).WithValue(med).WithMaxValue(maxConfidence).WithValueFormat("0").ShowValue(true).WithFilledColor(AccentYellow).Build()); confidencePanel.AddControl(Controls.BarGraph().WithLabel("low").WithLabelWidth(8).WithValue(low).WithMaxValue(maxConfidence).WithValueFormat("0").ShowValue(true).WithFilledColor(AccentGrey).Build()); - panel.AddControl(BuildSectionPanel("confidence", string.Empty, AccentDeepSkyBlue)); + AddSectionHeader(panel, "confidence", AccentDeepSkyBlue); panel.AddControl(confidencePanel); } @@ -250,16 +246,12 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan // Confidence cell renders as the same ●●● marker as the legacy list so the visual // grammar is preserved; the column itself is sortable, and the default sort // (Confidence desc) is applied via the row insertion order. - var table = Controls.Table() - .WithTitle("Recommended skills (Enter to install)") + var table = BuildStyledTable("Recommended skills (Enter to install)", AccentDeepSkyBlue) .AddColumn("Confidence", TextJustification.Center, width: 12) .AddColumn("Status", width: 12) .AddColumn("Skill") - .AddColumn("Reasons") - .WithSorting() - .Rounded() - .WithBorderColor(AccentDeepSkyBlue); - var builtTable = table.Build(); + .AddColumn("Reasons"); + var builtTable = ApplyStyledTableRuntime(table.Build()); foreach (var recommendation in scan.Recommendations .OrderByDescending(r => r.Confidence) .ThenBy(r => r.Skill.Name, StringComparer.Ordinal)) @@ -315,26 +307,22 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa var signals = SafeGet(BuildPackageSignals, Array.Empty()); var heaviest = skillCatalog.Skills.OrderByDescending(skill => skill.TokenCount).Take(12).ToArray(); - panel.AddControl(BuildIdentityStrip("catalog analysis", AccentDeepSkyBlue, + AddIdentityStrip(panel, "catalog analysis", AccentDeepSkyBlue, ("collections", views.Length.ToString()), ("skills", skillCatalog.Skills.Count.ToString()), ("total tokens", FormatTokenCount(skillCatalog.Skills.Sum(skill => skill.TokenCount))), - ("package signals", signals.Count.ToString()))); + ("package signals", signals.Count.ToString())); - var collectionCards = views.Take(12).Select(view => BuildBulletPanel( - view.Collection, AccentDeepSkyBlue, - $"[grey50]skills[/] {view.SkillCount} [grey50]installed[/] {view.InstalledCount} [grey50]tokens[/] {FormatTokenCount(view.TokenCount)}")).ToList(); - panel.AddControl(BuildCardGrid(collectionCards, maxColumns: 3)); + // The previous collection-card grid (12 BuildBulletPanels in HorizontalGrid columns) + // was dropped — the same data is shown in the "skills per collection (top 8)" BarGraph + // section further down, so the grid was redundant and crowded the page with rounded + // panels masquerading as content cards. - var heavyTable = Controls.Table() - .WithTitle("Heaviest skills (Enter for details)") + var heavyTable = BuildStyledTable("Heaviest skills (Enter for details)", AccentDeepSkyBlue) .AddColumn("Skill") .AddColumn("Collection") .AddColumn("Lane") - .AddColumn("Tokens", TextJustification.Right) - .WithSorting() - .Rounded() - .WithBorderColor(AccentDeepSkyBlue); + .AddColumn("Tokens", TextJustification.Right); foreach (var skill in heaviest) { heavyTable.AddRow(new TableRow(ToAlias(skill.Name), skill.Stack, skill.Lane, FormatTokenCount(skill.TokenCount)) { Tag = skill }); @@ -346,7 +334,7 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa ShowSkillDetailModal(ws, panel, heaviest[idx]); } }); - panel.AddControl(heavyTable.Build()); + panel.AddControl(ApplyStyledTableRuntime(heavyTable.Build())); // Native bar charts: skills sorted by tokens (heaviest 12), then collections sorted by // skill count (top 8). Each bar uses the standard threshold gradient so the eye picks @@ -363,7 +351,7 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa { chart1.AddControl(BuildSkillTokenBar(skill, maxTokens)); } - panel.AddControl(BuildSectionPanel("tokens by skill (top 12)", string.Empty, AccentDeepSkyBlue)); + AddSectionHeader(panel, "tokens by skill (top 12)", AccentDeepSkyBlue); panel.AddControl(chart1); } @@ -380,7 +368,7 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa { chart2.AddControl(BuildCollectionCountBar(view, maxCount)); } - panel.AddControl(BuildSectionPanel("skills per collection (top 8)", string.Empty, AccentTurquoise)); + AddSectionHeader(panel, "skills per collection (top 8)", AccentTurquoise); panel.AddControl(chart2); } @@ -406,16 +394,13 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa .AddSeries("tokens", AccentDeepSkyBlue, "cool") .WithData("tokens", sortedTokens) .Build(); - panel.AddControl(BuildSectionPanel("token distribution (long tail)", string.Empty, AccentDeepSkyBlue)); + AddSectionHeader(panel, "token distribution (long tail)", AccentDeepSkyBlue); panel.AddControl(distribution); } - if (signals.Count > 0) - { - var signalLines = signals.Take(18).Select(signal => - $"[grey]{Escape(signal.Signal)}[/] [grey50]({Escape(signal.Kind)})[/] [grey50]→[/] {Escape(ToAlias(signal.Skill.Name))}").ToArray(); - panel.AddControl(BuildBulletPanel("package signals", AccentTurquoise, signalLines)); - } + // The previous bottom-of-page bullet list of package signals was dropped — + // the count is already in the identity strip and per-signal detail belongs on the + // dedicated Packages page (which is a sortable Table of the same data). } /// @@ -462,8 +447,8 @@ private void BuildRemoveAllPage(ConsoleWindowSystem ws, ScrollablePanelControl p var installer = new SkillInstaller(skillCatalog); var installed = SafeGet(() => installer.GetInstalledSkills(layout), Array.Empty()); - panel.AddControl(BuildIdentityStrip("remove all installed skills", new Color(200, 60, 60), - ("installed", installed.Count.ToString()))); + AddIdentityStrip(panel, "remove all installed skills", new Color(200, 60, 60), + ("installed", installed.Count.ToString())); if (installed.Count == 0) { @@ -489,8 +474,8 @@ private void BuildUpdateAllPage(ConsoleWindowSystem ws, ScrollablePanelControl p .Where(record => !record.IsCurrent) .ToArray(); - panel.AddControl(BuildIdentityStrip("update all outdated skills", AccentYellow, - ("outdated", outdated.Length.ToString()))); + AddIdentityStrip(panel, "update all outdated skills", AccentYellow, + ("outdated", outdated.Length.ToString())); if (outdated.Length == 0) { @@ -524,10 +509,10 @@ private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl pa // Settings is a form, so the strip carries an at-a-glance summary of the install targets // (the StatusBar already carries project/scope/platform; this adds skill+agent targets // because those are the form's subject and aren't surfaced anywhere else). - panel.AddControl(BuildIdentityStrip("workspace", AccentDeepSkyBlue, + AddIdentityStrip(panel, "workspace", AccentDeepSkyBlue, ("skill target", $"[grey50]{Escape(CompactPath(layout.PrimaryRoot.FullName))}[/]"), ("agent target", agentStatus.Layout is null ? $"[red]{Escape(agentStatus.Summary)}[/]" : $"[grey50]{Escape(CompactPath(agentStatus.Layout.PrimaryRoot.FullName))}[/]"), - ("build", ToolVersionInfo.IsDevelopmentBuild ? "[grey50]local development[/]" : "[green]published[/]"))); + ("build", ToolVersionInfo.IsDevelopmentBuild ? "[grey50]local development[/]" : "[green]published[/]")); // Inline form: native dropdowns (change-on-pick, no modal) for Platform/Scope, // a plain Button for catalog refresh. SelectedIndexChanged fires only on user From 559cf3df60f24f3e69231b1c9f38496310fd736d Mon Sep 17 00:00:00 2001 From: Nikolaos Protopapas Date: Tue, 26 May 2026 01:29:15 +0300 Subject: [PATCH 9/9] Resync external catalog to skill-validator nightly @198e58c Pulls the latest upstream skill-validator nightly into external-sources/upstreams/dotnet-skills and reimports the catalog. CI's `Verify imported external sources are committed` check on this branch was failing because the external nightly mirror had advanced since the branch was cut. This commit catches up: - Adds the new dotnet-blazor plugin and its 6 skills + author/collect/ navigate/render/state/style references - Adds the new dotnet-aspnet/convert-blazor-server-to-webapp skill - Refreshes msbuild-code-review agent + extension-points, msbuild-antipatterns skills - Refreshes dotnet-test migrate-vstest-to-mtp / run-tests / test-anti-patterns / test-smell-detection skills Generated by running `bash scripts/sync_external_catalog_sources.sh` which runs `vendir sync` + `python3 scripts/import_external_catalog_sources.py`. No source code changes; this is purely a regenerated catalog snapshot sitting alongside the TUI polish so CI passes on this branch. --- .../convert-blazor-server-to-webapp/SKILL.md | 295 ++++++++++++ .../manifest.json | 5 + .../Official-DotNet-Blazor/manifest.json | 9 + .../skills/author-component/SKILL.md | 65 +++ .../skills/author-component/manifest.json | 5 + .../references/async-programming-rules.md | 256 +++++++++++ .../references/breaking-down-components.md | 108 +++++ .../references/component-disposal.md | 100 ++++ .../skills/collect-user-input/SKILL.md | 426 ++++++++++++++++++ .../skills/collect-user-input/manifest.json | 5 + .../skills/configure-auth/SKILL.md | 206 +++++++++ .../skills/configure-auth/manifest.json | 5 + .../skills/coordinate-components/SKILL.md | 235 ++++++++++ .../coordinate-components/manifest.json | 5 + .../skills/create-blazor-project/SKILL.md | 293 ++++++++++++ .../assets/agents-md/auto-global.md | 47 ++ .../assets/agents-md/auto-per-page.md | 48 ++ .../assets/agents-md/server-global.md | 31 ++ .../assets/agents-md/server-per-page.md | 31 ++ .../assets/agents-md/ssr-none.md | 33 ++ .../assets/agents-md/webassembly-global.md | 45 ++ .../assets/agents-md/webassembly-per-page.md | 46 ++ .../create-blazor-project/manifest.json | 5 + .../skills/fetch-and-send-data/SKILL.md | 305 +++++++++++++ .../skills/fetch-and-send-data/manifest.json | 5 + .../skills/plan-ui-change/SKILL.md | 152 +++++++ .../skills/plan-ui-change/manifest.json | 5 + .../skills/support-prerendering/SKILL.md | 216 +++++++++ .../skills/support-prerendering/manifest.json | 5 + .../skills/use-js-interop/SKILL.md | 319 +++++++++++++ .../skills/use-js-interop/manifest.json | 5 + .../skills/migrate-vstest-to-mtp/SKILL.md | 10 +- .../skills/run-tests/SKILL.md | 28 +- .../skills/test-anti-patterns/SKILL.md | 27 +- .../skills/test-smell-detection/SKILL.md | 18 +- .../agents/msbuild-code-review/AGENT.md | 13 +- .../skills/extension-points/SKILL.md | 58 ++- .../skills/msbuild-antipatterns/SKILL.md | 39 +- .../convert-blazor-server-to-webapp/SKILL.md | 295 ++++++++++++ .../dotnet-skills/dotnet-blazor/plugin.json | 6 + .../skills/author-component/SKILL.md | 65 +++ .../references/async-programming-rules.md | 256 +++++++++++ .../references/breaking-down-components.md | 108 +++++ .../references/component-disposal.md | 100 ++++ .../skills/collect-user-input/SKILL.md | 426 ++++++++++++++++++ .../skills/configure-auth/SKILL.md | 206 +++++++++ .../skills/coordinate-components/SKILL.md | 235 ++++++++++ .../skills/create-blazor-project/SKILL.md | 293 ++++++++++++ .../assets/agents-md/auto-global.md | 47 ++ .../assets/agents-md/auto-per-page.md | 48 ++ .../assets/agents-md/server-global.md | 31 ++ .../assets/agents-md/server-per-page.md | 31 ++ .../assets/agents-md/ssr-none.md | 33 ++ .../assets/agents-md/webassembly-global.md | 45 ++ .../assets/agents-md/webassembly-per-page.md | 46 ++ .../skills/fetch-and-send-data/SKILL.md | 305 +++++++++++++ .../skills/plan-ui-change/SKILL.md | 152 +++++++ .../skills/support-prerendering/SKILL.md | 216 +++++++++ .../skills/use-js-interop/SKILL.md | 319 +++++++++++++ .../agents/msbuild-code-review.agent.md | 13 +- .../skills/extension-points/SKILL.md | 58 ++- .../skills/msbuild-antipatterns/SKILL.md | 39 +- .../skills/migrate-vstest-to-mtp/SKILL.md | 10 +- .../dotnet-test/skills/run-tests/SKILL.md | 28 +- .../skills/test-anti-patterns/SKILL.md | 27 +- .../skills/test-smell-detection/SKILL.md | 18 +- external-sources/vendir.lock.yml | 6 +- 67 files changed, 6878 insertions(+), 93 deletions(-) create mode 100644 catalog/Frameworks/Official-DotNet-ASPNet/skills/convert-blazor-server-to-webapp/SKILL.md create mode 100644 catalog/Frameworks/Official-DotNet-ASPNet/skills/convert-blazor-server-to-webapp/manifest.json create mode 100644 catalog/Platform/Official-DotNet-Blazor/manifest.json create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/author-component/SKILL.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/author-component/manifest.json create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/async-programming-rules.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/breaking-down-components.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/component-disposal.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/collect-user-input/SKILL.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/collect-user-input/manifest.json create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/configure-auth/SKILL.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/configure-auth/manifest.json create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/coordinate-components/SKILL.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/coordinate-components/manifest.json create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/create-blazor-project/SKILL.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/create-blazor-project/assets/agents-md/auto-global.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/create-blazor-project/assets/agents-md/auto-per-page.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/create-blazor-project/assets/agents-md/server-global.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/create-blazor-project/assets/agents-md/server-per-page.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/create-blazor-project/assets/agents-md/ssr-none.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/create-blazor-project/assets/agents-md/webassembly-global.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/create-blazor-project/assets/agents-md/webassembly-per-page.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/create-blazor-project/manifest.json create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/fetch-and-send-data/SKILL.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/fetch-and-send-data/manifest.json create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/plan-ui-change/SKILL.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/plan-ui-change/manifest.json create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/support-prerendering/SKILL.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/support-prerendering/manifest.json create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/use-js-interop/SKILL.md create mode 100644 catalog/Platform/Official-DotNet-Blazor/skills/use-js-interop/manifest.json create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-aspnet/skills/convert-blazor-server-to-webapp/SKILL.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/plugin.json create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/author-component/SKILL.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/author-component/references/async-programming-rules.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/author-component/references/breaking-down-components.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/author-component/references/component-disposal.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/collect-user-input/SKILL.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/configure-auth/SKILL.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/coordinate-components/SKILL.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/create-blazor-project/SKILL.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/create-blazor-project/assets/agents-md/auto-global.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/create-blazor-project/assets/agents-md/auto-per-page.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/create-blazor-project/assets/agents-md/server-global.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/create-blazor-project/assets/agents-md/server-per-page.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/create-blazor-project/assets/agents-md/ssr-none.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/create-blazor-project/assets/agents-md/webassembly-global.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/create-blazor-project/assets/agents-md/webassembly-per-page.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/fetch-and-send-data/SKILL.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/plan-ui-change/SKILL.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/support-prerendering/SKILL.md create mode 100644 external-sources/upstreams/dotnet-skills/dotnet-blazor/skills/use-js-interop/SKILL.md diff --git a/catalog/Frameworks/Official-DotNet-ASPNet/skills/convert-blazor-server-to-webapp/SKILL.md b/catalog/Frameworks/Official-DotNet-ASPNet/skills/convert-blazor-server-to-webapp/SKILL.md new file mode 100644 index 0000000..819ed43 --- /dev/null +++ b/catalog/Frameworks/Official-DotNet-ASPNet/skills/convert-blazor-server-to-webapp/SKILL.md @@ -0,0 +1,295 @@ +--- +name: convert-blazor-server-to-webapp +license: MIT +description: > + Guides conversion of a pre-.NET 8 Blazor Server app into a .NET 8+ Blazor Web App. + USE FOR: migrating apps that use AddServerSideBlazor and MapBlazorHub to the + AddRazorComponents/MapRazorComponents model, converting _Host.cshtml to an App.razor + root component, replacing blazor.server.js with blazor.web.js, migrating + CascadingAuthenticationState to a service, adopting new Blazor Web App features + like enhanced navigation and streaming rendering. + DO NOT USE FOR: apps that are already Blazor Web Apps (already use AddRazorComponents + and MapRazorComponents), Blazor WebAssembly or hosted Blazor WebAssembly apps + (different migration path), apps that should stay on the Blazor Server hosting + model without converting, or apps still targeting .NET Framework. +--- + +# Convert Blazor Server App to Blazor Web App + +This skill helps an agent convert a pre-.NET 8 Blazor Server app into a .NET 8+ Blazor Web App. The old hosting model uses `AddServerSideBlazor`/`MapBlazorHub` with a `_Host.cshtml` Razor Page as the entry point. The new Blazor Web App model uses `AddRazorComponents`/`MapRazorComponents` with an `App.razor` root component, enabling per-component render modes, enhanced navigation, streaming rendering, and other .NET 8+ features. The converted app uses `InteractiveServer` render mode to preserve existing interactive behavior. + +## When to Use + +- Migrating a Blazor Server app from .NET 6 or .NET 7 to .NET 8+ +- App currently uses `AddServerSideBlazor()` and `MapBlazorHub()` in `Program.cs` (or `Startup.cs`) +- App uses `Pages/_Host.cshtml` (or `_Host.razor`) as the host page with Component Tag Helpers +- Want to adopt new Blazor Web App features while keeping interactive server rendering + +## When Not to Use + +- **The app already uses `AddRazorComponents` and `MapRazorComponents`.** It is already a Blazor Web App — no conversion is needed. Stop here and tell the user the app is already using the Blazor Web App model. +- Blazor WebAssembly or hosted Blazor WebAssembly app — these have a different migration path +- The app should stay on the legacy Blazor Server hosting model (just update TFM and packages) +- The app targets .NET Framework — it must be migrated to .NET first + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Blazor Server project | Yes | The `.csproj` and source files of the Blazor Server app | +| Target framework | Yes | .NET 8 or later (e.g., `net8.0`, `net9.0`, `net10.0`) | +| `Program.cs` or `Startup.cs` | Yes | The app's service and middleware configuration | +| `_Host.cshtml` location | Recommended | Usually `Pages/_Host.cshtml`; may be `_Host.razor` in some projects | + +## Workflow + +> **Commit strategy:** Commit after each logical step so the migration is reviewable and bisectable. + +### Step 1: Update the project file + +Update the `.csproj` file: + +1. Change the Target Framework Moniker (TFM) to the target version: + ```xml + net8.0 + ``` +2. Update all `Microsoft.AspNetCore.*`, `Microsoft.EntityFrameworkCore.*`, `Microsoft.Extensions.*`, and `System.Net.Http.Json` package references to the matching version. + +For non-Blazor project file changes (nullable reference types, implicit usings, HTTP/3 support, etc.), see the [general ASP.NET Core migration guide](https://learn.microsoft.com/aspnet/core/migration/70-to-80). + +### Step 2: Create `Routes.razor` from `App.razor` + +The old `App.razor` contains the `` component. This content moves to a new `Routes.razor` file so that `App.razor` can become the root HTML document component. + +1. Create a new file `Routes.razor` in the project root. +2. Move the entire content of `App.razor` into `Routes.razor`. +3. If the content is wrapped in ``, remove that wrapper (it will be replaced by a service in Step 5). +4. Leave `App.razor` empty for the next step. + +The resulting `Routes.razor` should look similar to: + +```razor + + + + + + + +

Sorry, there's nothing at this address.

+
+
+
+``` + +If the app uses `` instead of ``, keep it — it works the same way in Blazor Web Apps. + +### Step 3: Convert `_Host.cshtml` to `App.razor` + +Move the HTML shell from `Pages/_Host.cshtml` into the now-empty `App.razor` and transform it from a Razor Page into a Razor component: + +1. **Remove Razor Page directives** — delete `@page "/"`, `@using Microsoft.AspNetCore.Components.Web`, `@namespace`, and `@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers`. + +2. **Add component injection** — if using environment-conditional error UI, add: + ```razor + @inject IHostEnvironment Env + ``` + +3. **Fix the base tag** — replace `` with ``. + +4. **Replace HeadOutlet Component Tag Helper** — replace: + ```html + + ``` + with: + ```razor + + ``` + +5. **Replace App Component Tag Helper with Routes** — replace: + ```html + + ``` + with: + ```razor + + ``` + +6. **Replace Environment Tag Helpers** — replace: + ```html + + An error has occurred. This application may no longer respond until reloaded. + + + An unhandled exception has occurred. See browser dev tools for details. + + ``` + with: + ```razor + @if (Env.IsDevelopment()) + { + + An unhandled exception has occurred. See browser dev tools for details. + + } + else + { + + An error has occurred. This app may no longer respond until reloaded. + + } + ``` + +7. **Update the Blazor script** — replace: + ```html + + ``` + with: + ```html + + ``` + +8. **Add render mode import** — add to `_Imports.razor`: + ```razor + @using static Microsoft.AspNetCore.Components.Web.RenderMode + ``` + +9. **Delete `Pages/_Host.cshtml`** (and `Pages/_Host.cshtml.cs` if it exists). + +**Prerendering note:** If the original app used `render-mode="Server"` (not `"ServerPrerendered"`), prerendering was disabled. Preserve this by using `new InteractiveServerRenderMode(prerender: false)` instead of `InteractiveServer` for both `HeadOutlet` and `Routes`. + +### Step 4: Update `Program.cs` + +Make the following changes to `Program.cs` (or `Startup.cs` if the app uses the older hosting pattern): + +1. **Replace Blazor Server services** — replace: + ```csharp + builder.Services.AddServerSideBlazor(); + ``` + with: + ```csharp + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + ``` + + If `AddServerSideBlazor` had options configured (e.g., circuit options, hub options, detailed errors), migrate them to `AddInteractiveServerComponents`: + ```csharp + // Old: + builder.Services.AddServerSideBlazor(options => + { + options.DetailedErrors = true; + options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(10); + }); + + // New: + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(options => + { + options.DetailedErrors = true; + options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(10); + }); + ``` + +2. **Replace Blazor endpoint mapping** — replace: + ```csharp + app.MapBlazorHub(); + ``` + with: + ```csharp + app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + ``` + + Ensure there is a `using` statement for the project's root namespace so that `App` resolves to the `App.razor` component. + +3. **Remove the fallback route** — delete: + ```csharp + app.MapFallbackToPage("/_Host"); + ``` + +4. **Remove explicit routing middleware** — delete if present: + ```csharp + app.UseRouting(); + ``` + Endpoint routing is the default and explicit `UseRouting()` is no longer needed. + +5. **Add antiforgery middleware** — add after `UseAuthentication`/`UseAuthorization` if present: + ```csharp + app.UseAntiforgery(); + ``` + `AddRazorComponents` registers antiforgery services automatically, but the middleware must be explicitly added to the pipeline. Without it, form POST requests fail with 400 errors. + +### Step 5: Migrate `CascadingAuthenticationState` (if present) + +If the app used `` to wrap the router: + +1. Remove the `` component wrapper (already done in Step 2 if following this workflow). +2. Add the cascading authentication state service in `Program.cs`: + ```csharp + builder.Services.AddCascadingAuthenticationState(); + ``` + +The component wrapper approach does not work across render mode boundaries in Blazor Web Apps. The service-based approach provides `Task` as a cascading value to all components regardless of render mode. + +### Step 6: Recommended improvements (optional) + +These are optional modernization improvements — not required for the conversion to work. If you suggest any of these, state explicitly that they are optional. + +- **Replace `UseStaticFiles` with `MapStaticAssets`** (.NET 9+): `app.MapStaticAssets()` provides optimized static file serving with fingerprinting, pre-compression, and content-based ETags. See [MapStaticAssets documentation](https://learn.microsoft.com/aspnet/core/fundamentals/static-files#mapstaticassets). +- **Add `@attribute [StreamRendering]`** to pages with async data loading (`OnInitializedAsync`) for improved perceived performance. The page renders its initial synchronous content immediately and re-renders when async data arrives. +- **Update CSS isolation bundle reference** if the `` tag referenced a `_Host` assembly name; ensure it matches the project's actual assembly name: ``. +- For other non-Blazor improvements (minimal hosting, HTTP/3, output caching, etc.), see the [general ASP.NET Core migration guide](https://learn.microsoft.com/aspnet/core/migration/70-to-80). + +### Step 7: Verify the migration + +1. Build the project targeting the new framework. Confirm no compile errors. +2. Search for remaining references to removed APIs: + - `AddServerSideBlazor` + - `MapBlazorHub` + - `MapFallbackToPage` + - `blazor.server.js` + - `_Host.cshtml` +3. Run the app and verify: + - Pages load and render correctly + - Interactive features work (forms, event handlers, SignalR circuits) + - Navigation between pages works + - Authentication and authorization flows work if present +4. Run existing tests. + +## Validation + +- [ ] No references to `AddServerSideBlazor` remain +- [ ] No references to `MapBlazorHub` remain +- [ ] No references to `MapFallbackToPage("/_Host")` remain +- [ ] No references to `blazor.server.js` remain +- [ ] `Pages/_Host.cshtml` has been deleted +- [ ] `App.razor` serves as the root component with a full HTML document structure +- [ ] `Routes.razor` contains the `` configuration +- [ ] `Program.cs` uses `AddRazorComponents().AddInteractiveServerComponents()` +- [ ] `Program.cs` uses `MapRazorComponents().AddInteractiveServerRenderMode()` +- [ ] `app.UseAntiforgery()` is present in the middleware pipeline +- [ ] If the app used ``, it has been replaced with `AddCascadingAuthenticationState()` service registration +- [ ] App builds and runs successfully on the target framework + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Missing `UseAntiforgery()` middleware | `AddRazorComponents` registers antiforgery services, but the middleware must be explicitly added. Place `app.UseAntiforgery()` after `UseAuthentication`/`UseAuthorization`. Without it, form POST requests fail with 400 errors. | +| Forgetting to replace `blazor.server.js` with `blazor.web.js` | The old script does not work with the Blazor Web App model. Replace all references to `_framework/blazor.server.js` with `_framework/blazor.web.js`. | +| Not removing `` wrapper | The component wrapper does not work across render mode boundaries in Blazor Web Apps. Use `builder.Services.AddCascadingAuthenticationState()` instead. | +| Leaving `app.UseRouting()` in the pipeline | Explicit `UseRouting()` is no longer needed and can interfere with endpoint routing. Remove it unless other middleware specifically requires it. | +| Using `InteractiveServer` when prerendering was disabled | If the original app used `render-mode="Server"` (not `"ServerPrerendered"`), use `new InteractiveServerRenderMode(prerender: false)` to preserve the same behavior. Using `InteractiveServer` enables prerendering which can cause unexpected issues with components that depend on JS interop during initialization. | +| Not migrating `AddServerSideBlazor` circuit options | If circuit options, hub options, or detailed error settings were configured, migrate them to `AddInteractiveServerComponents(options => { ... })`. Otherwise those settings are silently lost. | +| `UseAntiforgery()` placed before authentication middleware | The antiforgery middleware must be placed after `UseAuthentication` and `UseAuthorization`. Placing it before causes antiforgery validation to run before the user identity is established. | +| CSS isolation bundle link has wrong assembly name | If the `` tag referenced the old project name, update it to match the current assembly name. | + +## More Info + +- [Convert a Blazor Server app into a Blazor Web App](https://learn.microsoft.com/aspnet/core/migration/70-to-80#convert-a-blazor-server-app-into-a-blazor-web-app) — the official step-by-step migration guide +- [ASP.NET Core Blazor render modes](https://learn.microsoft.com/aspnet/core/blazor/components/render-modes) — understanding InteractiveServer, InteractiveWebAssembly, and InteractiveAuto +- [Migrate CascadingAuthenticationState to services](https://learn.microsoft.com/aspnet/core/migration/70-to-80#migrate-the-cascadingauthenticationstate-component-to-cascading-authentication-state-services) — replacing the component wrapper with a service +- [MapStaticAssets](https://learn.microsoft.com/aspnet/core/fundamentals/static-files#mapstaticassets) — optimized static file serving in .NET 9+ +- [Migrate from ASP.NET Core 7.0 to 8.0](https://learn.microsoft.com/aspnet/core/migration/70-to-80) — general migration guide for all ASP.NET Core changes +- [Stream rendering with Blazor](https://learn.microsoft.com/aspnet/core/blazor/components/render-modes#streaming-rendering) — `@attribute [StreamRendering]` for async data loading +- [Cascading values and render mode boundaries](https://learn.microsoft.com/aspnet/core/blazor/components/cascading-values-and-parameters#cascading-valuesparameters-and-render-mode-boundaries) — why cascading parameters do not cross render mode boundaries diff --git a/catalog/Frameworks/Official-DotNet-ASPNet/skills/convert-blazor-server-to-webapp/manifest.json b/catalog/Frameworks/Official-DotNet-ASPNet/skills/convert-blazor-server-to-webapp/manifest.json new file mode 100644 index 0000000..65c9d92 --- /dev/null +++ b/catalog/Frameworks/Official-DotNet-ASPNet/skills/convert-blazor-server-to-webapp/manifest.json @@ -0,0 +1,5 @@ +{ + "version": "0.1.0", + "category": "Web", + "compatibility": "Requires an ASP.NET Core project or solution." +} diff --git a/catalog/Platform/Official-DotNet-Blazor/manifest.json b/catalog/Platform/Official-DotNet-Blazor/manifest.json new file mode 100644 index 0000000..6dd55b8 --- /dev/null +++ b/catalog/Platform/Official-DotNet-Blazor/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "dotnet-blazor", + "title": "Official .NET skills: dotnet-blazor", + "description": "Skills for Blazor development: component authoring, interactivity, and web application patterns.", + "links": { + "repository": "https://github.com/dotnet/skills", + "docs": "https://github.com/dotnet/skills/tree/main/plugins/dotnet-blazor" + } +} diff --git a/catalog/Platform/Official-DotNet-Blazor/skills/author-component/SKILL.md b/catalog/Platform/Official-DotNet-Blazor/skills/author-component/SKILL.md new file mode 100644 index 0000000..58343e5 --- /dev/null +++ b/catalog/Platform/Official-DotNet-Blazor/skills/author-component/SKILL.md @@ -0,0 +1,65 @@ +--- +license: MIT +name: author-component +description: > + Create or review Blazor components (.razor files) with correct architecture. + USE FOR: writing new Blazor components that do NOT involve JavaScript interop, + implementing parameters and EventCallback, RenderFragment slots, component + lifecycle (OnInitializedAsync, OnParametersSet), async patterns, IAsyncDisposable, + CancellationToken, CSS isolation, code-behind. + DO NOT USE FOR: creating new projects (use create-blazor-project), JavaScript + interop or calling browser APIs from Blazor (use use-js-interop), forms and + validation (use collect-user-input), prerendering issues (use support-prerendering), + HTTP data fetching patterns (use fetch-and-send-data), coordinating state between + unrelated components (use coordinate-components). +--- + +# Author Blazor Component + +## Core Rules + +- Data flows **down** via `[Parameter]`. Events flow **up** via `EventCallback` (never `Action`/`Func`). +- Never mutate `[Parameter]` properties. Copy to a private field in `OnParametersSet`. +- Use `[Parameter] public T Prop { get; set; }` — never `required` or `init` (causes BL0007). +- Use `[EditorRequired]` for required parameters. +- Handle all states: loading, empty, loaded, error — each with `@if`/`@else`. +- Use `@key` on repeated elements in loops for efficient diffing. +- Use `IReadOnlyList` (not `IEnumerable`) for collection parameters. + +## RenderFragment & Generics + +```csharp +[Parameter] public RenderFragment? ChildContent { get; set; } +[Parameter] public RenderFragment? RowTemplate { get; set; } // generic template +``` + +Use `@typeparam TItem` for generic components. + +## File Patterns + +- **Single-file:** `.razor` with `@code` block when logic < ~50 lines. +- **Code-behind:** `.razor` + `.razor.cs` with `partial class` when logic > ~50 lines. + +## Disposal + +Implement `IAsyncDisposable` (not `IDisposable`) when the component owns subscriptions, timers, or CTS. +In `DisposeAsync`: unsubscribe (`-=`), cancel CTS, dispose resources. Never call `StateHasChanged`. + +## Async Patterns + +- `await` every async operation. Never use `.Result`, `.Wait()`, `Task.Run`, `ContinueWith`, `Thread.Start`. +- **Debounce:** `Task.Delay` + `CancellationTokenSource`. Cancel old CTS, create new, await delay, do work. Never use `System.Threading.Timer` or `System.Timers.Timer`. +- **Polling:** Loop in `OnInitializedAsync` with `await Task.Delay(interval, token)` — stays on sync context. +- **External events** (`Action`): Use `async void` handler + `await InvokeAsync(() => { state++; StateHasChanged(); })` + `catch` → `DispatchExceptionAsync`. Never `_ = InvokeAsync(...)`. +- Cancel CTS in `DisposeAsync`. Don't catch `ObjectDisposedException` — use CTS cancellation. + +## Don'ts + +- `required`/`init` on `[Parameter]` — runtime failure +- Mutate `[Parameter]` — copy to private field in `OnParametersSet` +- `Action`/`Func` for events — use `EventCallback` +- `Task.Run`/`.Result`/`.Wait()`/Timer for debounce — deadlock or thread-pool escape +- Inline `style` attributes — use CSS classes or `data-*` attributes +- `catch { throw; }` — use `when` guard or let exceptions propagate +- Gold-plating: ARIA, wrapper divs, accessibility features not requested +- `_ = InvokeAsync(...)` — swallows exceptions; use `async void` + `DispatchExceptionAsync` diff --git a/catalog/Platform/Official-DotNet-Blazor/skills/author-component/manifest.json b/catalog/Platform/Official-DotNet-Blazor/skills/author-component/manifest.json new file mode 100644 index 0000000..6197565 --- /dev/null +++ b/catalog/Platform/Official-DotNet-Blazor/skills/author-component/manifest.json @@ -0,0 +1,5 @@ +{ + "version": "0.1.0", + "category": "Core", + "compatibility": "Requires a .NET repository or solution." +} diff --git a/catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/async-programming-rules.md b/catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/async-programming-rules.md new file mode 100644 index 0000000..a332be9 --- /dev/null +++ b/catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/async-programming-rules.md @@ -0,0 +1,256 @@ +# Async Programming Rules + +Blazor's sync context guarantees single-threaded component execution. All rules below follow from this. + +## Await every Task + +`await` every `Task` by default — discarded tasks silently lose exceptions. The only exception: fire-and-forget where the called method wraps its body in `try/catch` and routes errors via `DispatchExceptionAsync` (see Fire-and-Forget section below). + +```csharp +// DO +private async Task LoadData() +{ + items = await Http.GetFromJsonAsync>("api/items"); +} + +// DON'T — fire-and-forget hides exceptions +private void LoadData() +{ + _ = Http.GetFromJsonAsync>("api/items"); +} +``` + +## Forbidden Primitives + +These deadlock or escape the sync context. Never use in components: + +| Forbidden | Why | +|-----------|-----| +| `Thread.Start` / `new Thread` | Escapes sync context | +| `Task.Run` | Offloads to thread-pool; `StateHasChanged` throws | +| `.Result` / `.Wait()` | Deadlocks sync context | +| `Task.ContinueWith` | Continuation runs outside sync context | +| `Channel`, `BlockingCollection`, concurrent collections | Unnecessary — single-threaded access guaranteed | + +```csharp +// DON'T — Task.Run escapes sync context +_ = Task.Run(async () => { + var result = await OrderService.SubmitAsync(order); + StateHasChanged(); // InvalidOperationException! +}); + +// DO — stay on sync context +private async Task ProcessOrder() +{ + var result = await OrderService.SubmitAsync(order); + message = result.Message; +} +``` + +## StateHasChanged + +Framework auto-renders after lifecycle methods and event handlers complete. Don't call `StateHasChanged` routinely. + +**Call only for:** + +1. **Intermediate updates** between multiple awaits: +```csharp +private async Task ProcessSteps() +{ + status = "Step 1..."; + await Step1Async(); + status = "Step 2..."; + StateHasChanged(); // intermediate update + await Step2Async(); +} +``` + +2. **External events** (timer, C# event, WebSocket) via `InvokeAsync`: +```csharp +private async void OnExternalEvent(object? sender, EventArgs e) +{ + try + { + await InvokeAsync(() => { count++; StateHasChanged(); }); + } + catch (Exception ex) + { + await DispatchExceptionAsync(ex); + } +} +``` + +`InvokeAsync` marshals onto the sync context. `StateHasChanged` from a raw thread throws `InvalidOperationException`. Use `async void` for external event handlers — it's the only place `async void` is appropriate in Blazor. Always `await InvokeAsync` and route errors via `DispatchExceptionAsync`. + +## Fire-and-Forget + +Route errors via `DispatchExceptionAsync` (activates error boundaries, logs like lifecycle exceptions): + +```csharp +private void SendReport() => _ = SendReportCore(); + +private async Task SendReportCore() +{ + try { await ReportSender.SendAsync(); } + catch (Exception ex) { await DispatchExceptionAsync(ex); } +} +``` + +## Alternatives to Forbidden Primitives + +**Instead of `Task.Run`** — use `await` directly or `Task.Yield`: + +```csharp +// Yield to let renderer paint, then continue on sync context +private async Task StartLongOperation() +{ + status = "Starting..."; + await Task.Yield(); + await LongOperationService.RunAsync(); + status = "Done!"; +} +``` + +**Chunked CPU work** — break with `Task.Yield` so UI stays responsive: + +```csharp +private async Task ProcessLargeList() +{ + for (var i = 0; i < items.Count; i++) + { + ProcessItem(items[i]); + if (i % 100 == 0) + { + StateHasChanged(); + await Task.Yield(); + } + } +} +``` + +**Indivisible long ops** — `Task.WhenAny` + `Task.Delay` for progress: + +```csharp +private async Task RunLongQuery() +{ + var queryTask = DatabaseService.RunExpensiveQueryAsync(); + while (queryTask != await Task.WhenAny(queryTask, Task.Delay(1000))) + { + status = "Still working..."; + StateHasChanged(); + } + result = await queryTask; +} +``` + +### Instead of `.Result` / `.Wait()` — use `await` + +```csharp +// Wrong — blocks the sync context, deadlocks the circuit +private void Load() +{ + var data = Http.GetFromJsonAsync>("api/items").Result; +} + +// Correct — use async all the way through +private async Task Load() +{ + var data = await Http.GetFromJsonAsync>("api/items"); +} +``` + +When the calling context is synchronous and cannot be changed to `async` (e.g., an interface method that returns `void`), use fire-and-forget with error handling: + +```csharp +private void Load() +{ + _ = LoadAsync(); +} + +private async Task LoadAsync() +{ + try + { + data = await Http.GetFromJsonAsync>("api/items"); + StateHasChanged(); + } + catch (Exception ex) + { + await DispatchExceptionAsync(ex); + } +} +``` + +`StateHasChanged` is required here because the framework does not know about the fire-and-forget task, so it will not trigger a re-render when it completes. + +### Instead of `ConcurrentDictionary` / `Channel` — use plain collections + +Because the synchronization context guarantees single-threaded access within a circuit, regular `Dictionary`, `List`, and `Queue` are safe. Concurrent collections add overhead with no benefit: + +```csharp +// Wrong — unnecessary overhead, hides the threading model +private readonly ConcurrentDictionary cache = new(); + +// Correct — the sync context already prevents concurrent access +private readonly Dictionary cache = []; +``` + +### Instead of `Task.ContinueWith` — use `await` with code after it + +```csharp +// Wrong — continuation may run on a thread-pool thread +private void Start() +{ + _ = Http.GetFromJsonAsync>("api/items") + .ContinueWith(t => + { + items = t.Result; + StateHasChanged(); // InvalidOperationException! + }); +} + +// Correct — straightforward async/await +private async Task Start() +{ + items = await Http.GetFromJsonAsync>("api/items"); +} +``` + +## Cancelling async work with CancellationToken + +Components that start long-running async operations (HTTP calls, database queries, streaming) should cancel that work when the component is disposed — typically when the user navigates away. + +Use a `CancellationTokenSource` that is cancelled in `DisposeAsync`: + +```razor +@implements IAsyncDisposable +@inject HttpClient Http + +

@status

+ +@code { + private string status = "Loading..."; + private CancellationTokenSource cts = new(); + + protected override async Task OnInitializedAsync() + { + try + { + var data = await Http.GetFromJsonAsync>( + "api/items", cts.Token); + status = $"Loaded {data?.Count} items."; + } + catch (OperationCanceledException) + { + // Component was disposed while loading — expected, nothing to do. + } + } + + public ValueTask DisposeAsync() + { + cts.Cancel(); + cts.Dispose(); + return ValueTask.CompletedTask; + } +} +``` diff --git a/catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/breaking-down-components.md b/catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/breaking-down-components.md new file mode 100644 index 0000000..9196425 --- /dev/null +++ b/catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/breaking-down-components.md @@ -0,0 +1,108 @@ +# Breaking Down Components + +## Sibling Decomposition + +When a component has two independent blocks (no shared state/handlers), extract each as a sibling. + +```razor + +
+

@Title

+ +
+@code { + [Parameter, EditorRequired] public string Title { get; set; } = ""; + [Parameter] public EventCallback OnPin { get; set; } +} +``` + +```razor + +
+

@Description

+ +
+@code { + [Parameter, EditorRequired] public string Description { get; set; } = ""; + [Parameter] public EventCallback OnExpand { get; set; } +} +``` + +```razor + +
+ + +
+``` + +## List-Item Extraction + +Extract complex item templates into their own component. Use `@key` for efficient diffing. + +```razor + +
  • + + @Task.Title + +
  • +@code { + [Parameter, EditorRequired] public TaskModel Task { get; set; } = default!; + [Parameter] public EventCallback OnToggle { get; set; } + [Parameter] public EventCallback OnDelete { get; set; } +} +``` + +```razor + +
      + @foreach (var task in Tasks) + { + + } +
    +``` + +## Cascading Context + +Avoid parameter drilling through intermediate components. Cascade a context object or cascade the parent itself. + +```razor + + + + +
    @ActiveTab?.ChildContent
    + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + public ITab? ActiveTab { get; private set; } + + public void AddTab(ITab tab) { if (ActiveTab is null) SetActiveTab(tab); } + public void SetActiveTab(ITab tab) + { + if (ActiveTab != tab) { ActiveTab = tab; StateHasChanged(); } + } +} +``` + +```razor + +@implements ITab +
  • + @Title +
  • +@code { + [CascadingParameter] private TabSet? ContainerTabSet { get; set; } + [Parameter] public string? Title { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + protected override void OnInitialized() => ContainerTabSet?.AddTab(this); +} +``` + +- Mark `IsFixed="true"` when the cascaded reference never changes — avoids unnecessary re-renders. +- For app-wide values (theme, auth), register via DI: `builder.Services.AddCascadingValue(sp => new ThemeInfo { ... });` diff --git a/catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/component-disposal.md b/catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/component-disposal.md new file mode 100644 index 0000000..58c82a0 --- /dev/null +++ b/catalog/Platform/Official-DotNet-Blazor/skills/author-component/references/component-disposal.md @@ -0,0 +1,100 @@ +# Component Disposal + +Always use `IAsyncDisposable` (not `IDisposable`). Returns `ValueTask` — works for sync and async cleanup. + +## When to Implement + +Implement when component owns: event subscriptions, timers, `CancellationTokenSource`, or JS interop references (`IJSObjectReference`, `DotNetObjectReference`). Otherwise skip disposal. + +## Pattern — Sync Cleanup + +```razor +@implements IAsyncDisposable +@inject NavigationManager Navigation + +@code { + protected override void OnInitialized() + => Navigation.LocationChanged += HandleLocationChanged; + + private void HandleLocationChanged(object? sender, LocationChangedEventArgs e) { } + + public ValueTask DisposeAsync() + { + Navigation.LocationChanged -= HandleLocationChanged; + return ValueTask.CompletedTask; + } +} +``` + +## Pattern — JS Interop Cleanup + +```razor +@implements IAsyncDisposable +@inject IJSRuntime JS + +@code { + private IJSObjectReference? module; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + module = await JS.InvokeAsync("import", "./js/myModule.js"); + } + + public async ValueTask DisposeAsync() + { + if (module is not null) + { + try { await module.DisposeAsync(); } + catch (JSDisconnectedException) { } // Circuit already gone + } + } +} +``` + +## Anti-pattern — Timer (Don't) + +Prefer `Task.Delay` polling loops (see SKILL.md). If you must use a timer, use `async void` to avoid discarding the `InvokeAsync` task: + +```razor +@using System.Timers +@implements IAsyncDisposable + +@code { + private Timer? timer; + + protected override void OnInitialized() + { + timer = new Timer(1000); + timer.Elapsed += OnTimerElapsed; + timer.Start(); + } + + private async void OnTimerElapsed(object? sender, ElapsedEventArgs e) + { + try + { + await InvokeAsync(() => { count++; StateHasChanged(); }); + } + catch (Exception ex) + { + await DispatchExceptionAsync(ex); + } + } + + public ValueTask DisposeAsync() + { + timer?.Dispose(); + return ValueTask.CompletedTask; + } +} +``` + +`Timer.Elapsed` fires on thread-pool thread. `async void` is the only correct handler signature — it awaits `InvokeAsync` and routes errors via `DispatchExceptionAsync`. + +## Rules + +- **Don't** call `StateHasChanged` in `DisposeAsync` — renderer is tearing down. +- **Do** null-check fields created in lifecycle methods — `DisposeAsync` may run before `OnInitializedAsync` completes. +- **Do** catch `JSDisconnectedException` when disposing JS refs — circuit may be gone. +- **Do** unsubscribe all event handlers (`-=`) — subscriptions on long-lived objects leak the component. diff --git a/catalog/Platform/Official-DotNet-Blazor/skills/collect-user-input/SKILL.md b/catalog/Platform/Official-DotNet-Blazor/skills/collect-user-input/SKILL.md new file mode 100644 index 0000000..be59710 --- /dev/null +++ b/catalog/Platform/Official-DotNet-Blazor/skills/collect-user-input/SKILL.md @@ -0,0 +1,426 @@ +--- +license: MIT +name: collect-user-input +description: Build forms, validate data, and react to user input in Blazor. USE FOR adding forms, search boxes, filter panels, inline editing, data-entry UI, file uploads, validation (annotations or custom), handling form submissions, and binding input controls. Covers EditForm, built-in input components, DataAnnotationsValidator, custom validation, SSR form patterns (SupplyParameterFromForm, FormName, AntiforgeryToken, Enhance), and @bind for simple interactive controls. DO NOT USE for project scaffolding (see create-blazor-project) or prerendering issues (see support-prerendering). +--- + +# Collect User Input + +## Step 1 — Read the Project's AGENTS.md + +Check `AGENTS.md` for **Interactivity Mode** and **Interactivity Scope**. This determines which form patterns apply: + +| Mode | Form mechanism | +|------|---------------| +| None (Static SSR) | `EditForm` with `FormName` + `[SupplyParameterFromForm]`. No `@bind`, no `@onchange`. | +| Server | `EditForm` with `@bind-Value`. Full interactivity — real-time validation, dynamic UI. | +| WebAssembly | Same as Server, but validators needing server data must call APIs. | +| Auto | Same as WebAssembly — code must work in both browser and server. | + +| Scope | Impact | +|-------|--------| +| Global | All forms are interactive. `FormName` only needed when explicitly opting a page to static SSR. | +| Per-page | Forms in static pages use `FormName` + `[SupplyParameterFromForm]`. Forms in `@rendermode` pages use `@bind-Value`. | + +## EditForm Setup + +`EditForm` requires **either** `Model` or `EditContext` — never both. + +### Model-based (default) + +```razor + + + + + + + + + +@code { + [SupplyParameterFromForm] + private EmployeeModel? Employee { get; set; } + + protected override void OnInitialized() => Employee ??= new(); + + private async Task HandleSubmit() + { + // Save Employee + } +} +``` + +This single pattern works in **both** SSR and interactive modes: +- In SSR: `FormName` identifies the form, `[SupplyParameterFromForm]` binds POST data, `??=` initializes on GET. +- In interactive: `@bind-Value` provides two-way binding, `[SupplyParameterFromForm]` is ignored, `FormName` is harmless. + +### EditContext-based (advanced) + +Use when you need programmatic field tracking, dynamic validation rules, or manual `EditContext.Validate()` calls: + +```csharp +private EditContext? editContext; +private EmployeeModel model = new(); + +protected override void OnInitialized() +{ + editContext = new EditContext(model); +} +``` + +```razor + +``` + +## Submit Handlers + +| Handler | Fires when | Use when | +|---------|-----------|----------| +| `OnValidSubmit` | Validation passes | Standard forms with `DataAnnotationsValidator` | +| `OnInvalidSubmit` | Validation fails | Need custom handling for invalid state | +| `OnSubmit` | Always — validation is manual | Using `EditContext.Validate()` yourself | + +`OnSubmit` cannot combine with `OnValidSubmit`/`OnInvalidSubmit`. + +## Built-in Input Components + +| Component | Binds to | Notes | +|-----------|----------|-------| +| `InputText` | `string` | Renders `` | +| `InputTextArea` | `string` | Renders `