diff --git a/cli/ManagedCode.Agents/ManagedCode.Agents.csproj b/cli/ManagedCode.Agents/ManagedCode.Agents.csproj
index 9f87349..4d61aa5 100644
--- a/cli/ManagedCode.Agents/ManagedCode.Agents.csproj
+++ b/cli/ManagedCode.Agents/ManagedCode.Agents.csproj
@@ -50,7 +50,7 @@
-
+
diff --git a/cli/ManagedCode.DotnetAgents/ManagedCode.DotnetAgents.csproj b/cli/ManagedCode.DotnetAgents/ManagedCode.DotnetAgents.csproj
index 54e54a7..6a9a74f 100644
--- a/cli/ManagedCode.DotnetAgents/ManagedCode.DotnetAgents.csproj
+++ b/cli/ManagedCode.DotnetAgents/ManagedCode.DotnetAgents.csproj
@@ -50,7 +50,7 @@
-
+
diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs
index 35f4346..dc9ee15 100644
--- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs
+++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs
@@ -40,19 +40,19 @@ private void BuildSkillBrowserPage(ConsoleWindowSystem ws, ScrollablePanelContro
var filtered = available.Where(s => MatchesFilter(s.Name, s.Stack, s.Lane)).ToArray();
- AddIdentityStrip(panel, "skill browser", AccentTurquoise,
+ AddIdentityStrip(panel, "skill browser", AccentTurquoise, "Enter ▸ details",
("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));
+ AddEmptyState(panel, "Every catalog skill is already installed in this target.");
return;
}
if (filtered.Length == 0)
{
- panel.AddControl(BuildNotePanel("available", $"[grey50]No skills match “{Escape(_searchFilter)}”.[/]", AccentYellow));
+ AddEmptyState(panel, $"No skills match “{Escape(_searchFilter)}”.");
return;
}
@@ -60,12 +60,12 @@ 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 = BuildStyledTable("Available skills (Enter for details)", AccentTurquoise)
+ var table = BuildStyledTableBorderless(AccentTurquoise)
.AddColumn("Collection")
.AddColumn("Lane")
.AddColumn("Skill")
- .AddColumn("Version", TextJustification.Right)
- .AddColumn("Tokens", TextJustification.Right);
+ .AddColumn("Version", TextJustification.Right, width: 9)
+ .AddColumn("Tokens", TextJustification.Right, width: 9);
var builtTable = ApplyStyledTableRuntime(table.Build());
foreach (var skill in filtered)
{
@@ -101,34 +101,173 @@ private void ShowSkillDetailModal(ConsoleWindowSystem ws, ScrollablePanelControl
("lane", Escape(skill.Lane)),
("version", Escape(skill.Version)),
("tokens", FormatTokenCount(skill.TokenCount))),
- BuildNotePanel("summary", Escape(skill.Description), AccentDeepSkyBlue),
- BuildNotePanel("preview", Escape(LoadSkillPreview(skill)), AccentGrey),
+ BuildModalBlock("summary", Escape(skill.Description)),
+ BuildModalBlock("preview", Escape(LoadSkillPreview(skill))),
};
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);
+ var layout = ResolveSkillLayout();
+ RunOperationQueued(
+ $"Installing {ToAlias(skill.Name)}",
+ work: () => new SkillInstaller(skillCatalog).Install(new[] { skill }, layout, force: false),
+ onComplete: summary =>
+ {
+ 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);
+ var layout = ResolveSkillLayout();
+ RunOperationQueued(
+ $"Reinstalling {ToAlias(skill.Name)}",
+ work: () => new SkillInstaller(skillCatalog).Install(new[] { skill }, layout, force: true),
+ onComplete: summary =>
+ {
+ 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 enum CollectionRowKind { Collection, Lane, Skill }
+
+ // Identity carried in each grouped-table TableRow.Tag so RowActivated/selection can branch on row
+ // kind and recover data without indexing into a list (row count changes on every expand/collapse).
+ private sealed record CollectionRowTag(
+ CollectionRowKind Kind,
+ CollectionCatalogView Collection,
+ CollectionLaneView? Lane = null,
+ SkillEntry? Skill = null);
+
+ // Fixed width of the in-cell weight bar so the columns after it stay aligned across all rows.
+ private const int CollectionBarCellWidth = 16;
+
+ // The "cool" blue→cyan gradient — the same one the Analysis token chart uses
+ // (ColorGradient.Predefined["cool"] = Blue → Cyan1). Collection weight bars sweep this gradient
+ // ALONG their length, so a glance reads the same way as the Analysis bars.
+ private static readonly ColorGradient WeightBarGradient = ColorGradient.FromColors(Color.Blue, Color.Cyan1);
+
+ // Builds an in-cell horizontal weight bar as markup: a run of █ that sweeps the cool gradient
+ // along its length (dim blue at the start → bright cyan at the end), exactly like the Analysis
+ // BarGraphs; sized to value/parentMax and padded with ░ to cellWidth so the columns after it stay
+ // aligned. A long (heavy) bar shows the full blue→cyan sweep; a short bar shows only its start.
+ // parentMax <= 0 → empty track.
+ private static string WeightBarMarkup(int value, int parentMax, int cellWidth = CollectionBarCellWidth)
+ {
+ if (cellWidth <= 0) return string.Empty;
+ int filled = 0;
+ if (parentMax > 0 && value > 0)
+ {
+ double ratio = System.Math.Clamp((double)value / parentMax, 0.0, 1.0);
+ filled = (int)System.Math.Round(ratio * cellWidth);
+ filled = System.Math.Clamp(filled, 1, cellWidth); // any nonzero value shows at least 1 cell
+ }
+ int empty = cellWidth - filled;
+
+ // Per-cell gradient along the bar: cell i maps to Interpolate(i/(filled-1)) so the run runs
+ // the full Blue→Cyan sweep regardless of length. A 1-cell bar uses the gradient's start.
+ var sb = new System.Text.StringBuilder();
+ for (int i = 0; i < filled; i++)
+ {
+ double t = filled > 1 ? (double)i / (filled - 1) : 0.0;
+ var c = WeightBarGradient.Interpolate(t);
+ sb.Append($"[#{c.R:X2}{c.G:X2}{c.B:X2}]█[/]");
+ }
+ if (empty > 0)
+ sb.Append($"[grey30]{new string('░', empty)}[/]");
+ return sb.ToString();
+ }
+
+ // Rebuilds the grouped table's visible rows from _expandedCollections. Collapsed collection =>
+ // one band row; expanded => band row + each lane's sub-header row + that lane's skill rows.
+ // Restores the selection by tag identity (NOT index) because the row count changes on every toggle.
+ private void RebuildCollectionRows(TableControl table, IReadOnlyList views)
+ {
+ // Capture what is selected so we can re-select the equivalent row after the rebuild.
+ var prior = table.SelectedRow?.Tag as CollectionRowTag;
+
+ table.ClearRows();
+
+ int maxCollectionTokens = views.Count == 0 ? 0 : views.Max(v => v.TokenCount);
+
+ foreach (var view in views)
+ {
+ bool expanded = _expandedCollections.Contains(view.Collection);
+ string caret = expanded ? "▾" : "▸";
+
+ var bandRow = new TableRow(
+ $"{caret} {Escape(view.Collection)}",
+ $"{view.InstalledCount}/{view.SkillCount}",
+ WeightBarMarkup(view.TokenCount, maxCollectionTokens),
+ FormatTokenCount(view.TokenCount))
+ {
+ Tag = new CollectionRowTag(CollectionRowKind.Collection, view),
+ BackgroundColor = CollectionBandBackground,
+ };
+ table.AddRow(bandRow);
+
+ if (!expanded) continue;
+
+ foreach (var lane in view.Lanes)
+ {
+ var laneRow = new TableRow(
+ $" {Escape(lane.Lane)}",
+ $"{lane.InstalledCount}/{lane.Skills.Count}",
+ string.Empty, // lanes are grouping headers — no bar
+ FormatTokenCount(lane.TokenCount))
+ {
+ Tag = new CollectionRowTag(CollectionRowKind.Lane, view, lane),
+ ForegroundColor = AccentGrey,
+ };
+ table.AddRow(laneRow);
+
+ foreach (var skill in lane.Skills)
+ {
+ var skillRow = new TableRow(
+ $" {Escape(ToAlias(skill.Name))}",
+ "·",
+ string.Empty, // bars are a collection-level comparison only; exact Tokens are shown
+ FormatTokenCount(skill.TokenCount))
+ {
+ Tag = new CollectionRowTag(CollectionRowKind.Skill, view, lane, skill),
+ };
+ table.AddRow(skillRow);
+ }
+ }
+ }
+
+ RestoreCollectionSelection(table, prior);
+ }
+
+ // Re-selects the row matching the previously-selected tag after a rebuild. Collection bands match
+ // by collection name; skills match by collection+skill name; lanes by collection+lane name.
+ private static void RestoreCollectionSelection(TableControl table, CollectionRowTag? prior)
+ {
+ if (prior is null) return;
+ var rows = table.Rows;
+ for (int i = 0; i < rows.Count; i++)
+ {
+ if (rows[i].Tag is not CollectionRowTag tag) continue;
+ bool match = tag.Kind == prior.Kind
+ && string.Equals(tag.Collection.Collection, prior.Collection.Collection, System.StringComparison.OrdinalIgnoreCase)
+ && string.Equals(tag.Lane?.Lane, prior.Lane?.Lane, System.StringComparison.OrdinalIgnoreCase)
+ && string.Equals(tag.Skill?.Name, prior.Skill?.Name, System.StringComparison.OrdinalIgnoreCase);
+ if (match) { table.SelectedRowIndex = i; return; }
+ }
+ }
+
private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl panel)
{
panel.ClearContents();
@@ -150,163 +289,134 @@ private void BuildCollectionsPage(ConsoleWindowSystem ws, ScrollablePanelControl
if (views.Length == 0)
{
- panel.AddControl(BuildNotePanel("collections", "[grey50]No collections in this catalog version.[/]", AccentDeepSkyBlue));
+ AddEmptyState(panel, "No collections in this catalog version.");
return;
}
if (filtered.Length == 0)
{
- panel.AddControl(BuildNotePanel("collections", $"[grey50]No collections match “{Escape(_searchFilter)}”.[/]", AccentYellow));
+ AddEmptyState(panel, $"No collections match “{Escape(_searchFilter)}”.");
return;
}
- // 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)))
- {
- _selectedCollection = filtered[0];
- _collectionInstallArmed = false;
- }
-
- // 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,
- VerticalScrollMode = ScrollMode.Scroll,
- EnableMouseWheel = true,
- };
-
- // 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)
+ // Single borderless grouped table — Collection bands collapse/expand to reveal Lane sub-headers
+ // and Skill rows. Matches the Phase-1 grammar of every other page (no splitter, no nested frame).
+ var table = ApplyStyledTableRuntime(BuildStyledTableBorderless(AccentDeepSkyBlue)
+ .AddColumn("Name")
+ .AddColumn("Installed", TextJustification.Right, width: 9)
+ .AddColumn("Weight", TextJustification.Left, width: CollectionBarCellWidth)
+ .AddColumn("Tokens", TextJustification.Right, width: 9)
.Build());
- foreach (var view in filtered)
+
+ void ToggleCollection(CollectionCatalogView collection)
{
- leftTable.AddRow(new TableRow(view.Collection, $"{view.InstalledCount}/{view.SkillCount}")
- {
- Tag = view,
- });
+ if (!_expandedCollections.Remove(collection.Collection))
+ _expandedCollections.Add(collection.Collection);
+ RebuildCollectionRows(table, filtered);
}
- leftTable.SelectedRowItemChanged += (_, row) =>
- {
- if (row?.Tag is CollectionCatalogView v && !ReferenceEquals(v, _selectedCollection))
- {
- _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. 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();
- AddIdentityStrip(pane, view.Collection, AccentDeepSkyBlue,
- ("lanes", view.Lanes.Count.ToString()),
- ("skills", $"{view.InstalledCount}/{view.SkillCount}"),
- ("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)
+ // Keyboard Enter / double-click on a row: bands toggle, skills open detail, lanes do nothing.
+ table.RowActivated += (_, _) =>
{
- var maxLaneTokens = view.Lanes.Max(l => l.TokenCount);
- var laneChart = new ScrollablePanelControl
- {
- ShowScrollbar = false,
- EnableMouseWheel = false,
- };
- foreach (var lane in view.Lanes)
+ if (table.SelectedRow?.Tag is not CollectionRowTag tag) return;
+ switch (tag.Kind)
{
- laneChart.AddControl(Controls.BarGraph()
- .WithLabel(lane.Lane)
- .WithLabelWidth(20)
- .WithValue(lane.TokenCount)
- .WithMaxValue(maxLaneTokens == 0 ? 1 : maxLaneTokens)
- .WithValueFormat("N0")
- .ShowValue(true)
- .WithSmoothGradient("cool")
- .Build());
+ case CollectionRowKind.Collection:
+ ToggleCollection(tag.Collection);
+ break;
+ case CollectionRowKind.Skill:
+ if (tag.Skill is not null) ShowSkillDetailModal(ws, panel, tag.Skill);
+ break;
+ case CollectionRowKind.Lane:
+ break; // lanes are non-interactive sub-headers
}
- AddSectionHeader(pane, "tokens by lane", AccentDeepSkyBlue);
- pane.AddControl(laneChart);
- }
+ };
- // Lanes table — sortable, columns match the lane's logical dimensions.
- if (view.Lanes.Count > 0)
+ // Single-click on the caret glyph (the first cell of the Name column) toggles a collection band.
+ // MouseClick fires AFTER the table has already selected the clicked row, so SelectedRow is the
+ // clicked band; we only need to confirm the click landed on the arrow (table-relative X 0..1).
+ table.MouseClick += (_, args) =>
{
- 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);
- }
+ if (args.Position.X > 1) return; // only the caret column, not the whole band
+ if (table.SelectedRow?.Tag is CollectionRowTag tag && tag.Kind == CollectionRowKind.Collection)
+ ToggleCollection(tag.Collection);
+ };
- // 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))";
- var installToolbar = BuildPageToolbar(
- (label, view.SkillCount > 0, () =>
+ panel.AddControl(table);
+ RebuildCollectionRows(table, filtered);
+
+ // Resolves the collection the install action targets = the collection owning the highlighted
+ // row (band, lane, or skill), falling back to the first when nothing is selected.
+ CollectionCatalogView InstallTarget()
+ => (table.SelectedRow?.Tag as CollectionRowTag)?.Collection ?? filtered[0];
+
+ // Caption names the highlighted collection (explicit install target) AND reflects the two-stage
+ // arm state.
+ string InstallLabel(CollectionCatalogView t)
+ => _collectionInstallArmed
+ ? $"Click again to install “{t.Collection}” ({t.SkillCount} skill(s))"
+ : $"Install “{t.Collection}” ({t.SkillCount} skill(s))";
+
+ // Build the install button explicitly so we can keep a reference and update its caption live as
+ // the selection moves between collections (the toolbar is built once and never rebuilt on a
+ // mere selection change). Styling mirrors BuildPageToolbar (below-line, spacing 1).
+ var installButton = Controls.Button(InstallLabel(InstallTarget()))
+ .OnClick((_, _) =>
{
+ var current = InstallTarget();
if (!_collectionInstallArmed)
{
_collectionInstallArmed = true;
- Toast($"Click again to confirm installing {view.SkillCount} skill(s)", NotificationSeverity.Warning);
- BuildCollectionDetail(pane, view);
+ Toast($"Click again to confirm installing {current.SkillCount} skill(s)", NotificationSeverity.Warning);
+ BuildCollectionsPage(ws, panel);
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);
- }));
- if (installToolbar is not null) pane.AddControl(installToolbar);
+ var layout = ResolveSkillLayout();
+ RunOperationQueued(
+ $"Installing {current.Collection} ({current.SkillCount} skills)",
+ work: () =>
+ {
+ var skills = new SkillInstaller(skillCatalog).SelectSkillsFromCollections(new[] { current.Collection });
+ return skills.Count == 0 ? null : new SkillInstaller(skillCatalog).Install(skills, layout, force: false);
+ },
+ onComplete: summary =>
+ {
+ ToastResult(summary, $"Could not install collection {current.Collection}", summary is null ? string.Empty : $"{current.Collection}: {summary.InstalledCount} written, {summary.SkippedExisting.Count} skipped");
+ BuildCollectionsPage(ws, panel);
+ });
+ })
+ .Build();
+ installButton.IsEnabled = InstallTarget().SkillCount > 0;
+
+ // Keep the install caption (and enabled state) in sync as the cursor moves between collections.
+ // Use the ROW PASSED BY THE EVENT, not table.SelectedRow — the property may not be updated yet
+ // when the event fires, which would leave the caption one row behind.
+ //
+ // The update is deferred via EnqueueOnUIThread: this handler fires synchronously from inside
+ // input dispatch (mouse click → RebuildCollectionRows → SetSelectedRow → this event). Mutating
+ // the button + repainting from here re-enters the renderer while the window content lock is held
+ // and deadlocks the UI thread (watchdog stall: "blocked in Click / TableControl",
+ // Window.EnsureContentReady → Monitor.Enter — do NOT call ws.ForceRender here). Running the
+ // update on the next UI-loop drain applies it outside the dispatch stack, so it's both
+ // deadlock-free AND lands on the very next frame (no one-selection lag).
+ table.SelectedRowItemChanged += (_, row) =>
+ {
+ var t = (row?.Tag as CollectionRowTag)?.Collection ?? InstallTarget();
+ ws.EnqueueOnUIThread(() =>
+ {
+ installButton.Text = InstallLabel(t);
+ installButton.IsEnabled = t.SkillCount > 0;
+ });
+ };
+
+ // Above-line (not below): the rule separates the table from the install button, so the order
+ // reads table → ruler → button rather than button → ruler → empty space.
+ var installToolbar = Controls.Toolbar()
+ .WithSpacing(1)
+ .WithAboveLine(true)
+ .AddButton(installButton)
+ .Build();
+ panel.AddControl(installToolbar);
}
// -------------------------------------------------------------------------
@@ -326,27 +436,27 @@ private void BuildBundlesPage(ConsoleWindowSystem ws, ScrollablePanelControl pan
var filtered = packages.Where(p => MatchesFilter(p.Name, p.Title)).ToArray();
- AddIdentityStrip(panel, title, AccentDeepSkyBlue,
+ AddIdentityStrip(panel, title, AccentDeepSkyBlue, "Enter ▸ details",
(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));
+ AddEmptyState(panel, "Nothing available in this catalog version.");
return;
}
if (filtered.Length == 0)
{
- panel.AddControl(BuildNotePanel(title, $"[grey50]No bundles match “{Escape(_searchFilter)}”.[/]", AccentYellow));
+ AddEmptyState(panel, $"No bundles match “{Escape(_searchFilter)}”.");
return;
}
- var table = BuildStyledTable($"{(primaryOnly ? "Bundles" : "Packages")} (Enter for details)", AccentDeepSkyBlue)
+ var table = BuildStyledTableBorderless(AccentDeepSkyBlue)
.AddColumn("Bundle")
.AddColumn("Title")
- .AddColumn("Skills", TextJustification.Right)
- .AddColumn("Tokens", TextJustification.Right);
+ .AddColumn("Skills", TextJustification.Right, width: 8)
+ .AddColumn("Tokens", TextJustification.Right, width: 9);
var builtTable = ApplyStyledTableRuntime(table.Build());
foreach (var package in filtered)
{
@@ -379,16 +489,25 @@ private void ShowBundleModal(ConsoleWindowSystem ws, ScrollablePanelControl owne
("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),
+ BuildModalBlock("summary", Escape(package.Description)),
};
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);
+ var layout = ResolveSkillLayout();
+ RunOperationQueued(
+ $"Installing {package.Name}",
+ work: () =>
+ {
+ var skills = new SkillInstaller(skillCatalog).SelectSkillsFromPackages(new[] { package.Name });
+ return skills.Count == 0 ? null : new SkillInstaller(skillCatalog).Install(skills, layout, force: false);
+ },
+ onComplete: summary =>
+ {
+ 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);
+ });
}));
}
@@ -403,29 +522,29 @@ 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();
- AddIdentityStrip(panel, "package signals", AccentTurquoise,
+ AddIdentityStrip(panel, "package signals", AccentTurquoise, "Enter ▸ inspect linked skill",
("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));
+ AddEmptyState(panel, "No NuGet package or prefix signals are present in this catalog version.");
return;
}
if (filtered.Length == 0)
{
- panel.AddControl(BuildNotePanel("packages", $"[grey50]No signals match “{Escape(_searchFilter)}”.[/]", AccentYellow));
+ AddEmptyState(panel, $"No signals match “{Escape(_searchFilter)}”.");
return;
}
- var table = BuildStyledTable("Package signals (Enter to inspect linked skill)", AccentTurquoise)
+ var table = BuildStyledTableBorderless(AccentTurquoise)
.AddColumn("Signal")
.AddColumn("Kind")
.AddColumn("Skill")
.AddColumn("Collection")
.AddColumn("Lane")
- .AddColumn("Tokens", TextJustification.Right);
+ .AddColumn("Tokens", TextJustification.Right, width: 9);
var builtTable = ApplyStyledTableRuntime(table.Build());
foreach (var signal in filtered)
{
@@ -469,20 +588,20 @@ 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.
- AddIdentityStrip(panel, "orchestration agents", AccentMediumPurple,
+ AddIdentityStrip(panel, "orchestration agents", AccentMediumPurple, "Enter ▸ details",
("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 ? $"[#d70000]{Escape(layoutError ?? "unresolved")}[/]" : string.Empty));
AddSearchChip(panel);
if (agentCatalog.Agents.Count == 0)
{
- panel.AddControl(BuildNotePanel("agents", "[grey50]No agents available in the catalog.[/]", AccentDeepSkyBlue));
+ AddEmptyState(panel, "No agents available in the catalog.");
return;
}
if (filteredAgents.Length == 0)
{
- panel.AddControl(BuildNotePanel("agents", $"[grey50]No agents match “{Escape(_searchFilter)}”.[/]", AccentYellow));
+ AddEmptyState(panel, $"No agents match “{Escape(_searchFilter)}”.");
return;
}
@@ -498,17 +617,24 @@ private void BuildAgentsPage(ConsoleWindowSystem ws, ScrollablePanelControl pane
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);
+ RunOperationQueued(
+ $"Installing all agents across {detected.Count} platform(s)",
+ work: () => new AgentInstaller(agentCatalog).InstallToMultiple(agentCatalog.Agents, detected, force: false),
+ onComplete: summary2 =>
+ {
+ 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 = BuildStyledTable("Agents (Enter for details)", AccentMediumPurple)
- .AddColumn("Status", TextJustification.Center, width: 8)
- .AddColumn("Agent")
+ // Status fits "○ available"/"✓ installed" (~11 cols); Agent gets a fixed width so it isn't
+ // collapsed by the stretch Description column (two stretch columns starve each other).
+ var table = BuildStyledTableBorderless(AccentMediumPurple)
+ .AddColumn("Status", TextJustification.Left, width: 12)
+ .AddColumn("Agent", TextJustification.Left, width: 28)
.AddColumn("Description")
- .AddColumn("Skills", TextJustification.Right);
+ .AddColumn("Skills", TextJustification.Right, width: 8);
var builtTable = ApplyStyledTableRuntime(table.Build());
foreach (var agent in filteredAgents)
{
@@ -533,7 +659,7 @@ 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));
+ AddInlineNote(panel, "No native agent directory resolved. Set the platform on the Settings page, or create one of .codex/.claude/.github/.gemini/.junie.", NoteSeverity.Warning);
}
// Bulk install lives in the page toolbar at the top.
}
@@ -546,7 +672,7 @@ private void ShowAgentModal(ConsoleWindowSystem ws, ScrollablePanelControl owner
("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),
+ BuildModalBlock("summary", Escape(agent.Description)),
};
var buttons = new List<(string, Action)>();
@@ -555,15 +681,25 @@ private void ShowAgentModal(ConsoleWindowSystem ws, ScrollablePanelControl owner
{
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);
+ RunOperationQueued(
+ $"Installing agent {ToAlias(agent.Name)}",
+ work: () => new AgentInstaller(agentCatalog).Install(new[] { agent }, layout, force: false),
+ onComplete: summary =>
+ {
+ 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);
+ RunOperationQueued(
+ $"Removing agent {ToAlias(agent.Name)}",
+ work: () => new AgentInstaller(agentCatalog).Remove(new[] { agent }, layout),
+ onComplete: summary =>
+ {
+ ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {ToAlias(agent.Name)} ({summary.RemovedCount} file(s))");
+ BuildAgentsPage(ws, owner);
+ });
}));
}
diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs
index 6ff0c3b..e0ad474 100644
--- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs
+++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs
@@ -26,7 +26,7 @@ private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel)
if (_currentPage != null)
{
_searchFilter = string.Empty;
- _selectedCollection = null;
+ _expandedCollections.Clear();
_collectionInstallArmed = false;
}
_activePanel = panel;
@@ -67,19 +67,19 @@ private void BuildHomePage(ConsoleWindowSystem ws, ScrollablePanelControl panel)
: 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,
+ AddInfoBlock(panel, "tool update",
"[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));
+ freshness);
}
- panel.AddControl(BuildBulletPanel("quick start", AccentDeepSkyBlue,
+ AddInfoBlock(panel, "quick start",
"[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[/]"));
+ "[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 b72c062..364fe22 100644
--- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs
+++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs
@@ -20,11 +20,13 @@
using ManagedCode.DotnetSkills.Runtime;
using SharpConsoleUI;
using SharpConsoleUI.Builders;
+using SharpConsoleUI.Configuration;
using SharpConsoleUI.Controls;
using SharpConsoleUI.Core;
using SharpConsoleUI.Drivers;
using SharpConsoleUI.Helpers;
using SharpConsoleUI.Layout;
+using SharpConsoleUI.Parsing;
using SharpConsoleUI.Rendering;
using SharpConsoleUI.Themes;
@@ -53,6 +55,22 @@ 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
+
+ // Dimmed vertical column separator for borderless page tables. Deliberately a low-contrast
+ // desaturated grey-blue — NOT an accent — so the hairlines read as quiet column structure
+ // without competing with the turquoise/blue column headers above them.
+ private static readonly Color GridLineColor = new(64, 76, 92);
+
+ // Faint accent-tinted fill behind a collection band row in the grouped Collections table — a
+ // darkened blend of the page accent toward the dark bg, so bands read as group headers without
+ // shouting. Quiet, like GridLineColor.
+ private static readonly Color CollectionBandBackground = new(22, 40, 54);
+
+ // Elevated modal surface: a lighter version of the page's dark blue gradient. In a dark UI an
+ // "above" surface reads as lighter, so modals get this raised gradient (vs the page's
+ // (25,32,52)->(7,7,13)) to feel floated rather than blended into the page.
+ private static readonly ColorGradient ElevatedModalGradient =
+ ColorGradient.FromColors(new Color(58, 70, 104), new Color(34, 42, 66));
// 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
@@ -63,6 +81,13 @@ internal sealed partial class InteractiveConsoleApp
private ConsoleWindowSystem? _ws;
private ScrollablePanelControl? _activePanel;
private HomeAction? _currentPage;
+ // The nav rail. Held so NavigateTo (palette / Home cards / toolbar buttons) can sync the rail's
+ // visual selection to the target page instead of leaving it stuck on the previously-clicked item.
+ private NavigationView? _nav;
+ // The command-center window — held so the command palette can host its portal overlay.
+ private Window? _mainWindow;
+ private CommandPalettePortal? _palettePortal;
+ private LayoutNode? _palettePortalNode;
private StatusBarControl? _statusBar;
private StatusBarItem? _clockItem;
private StatusBarItem? _statusMessage;
@@ -79,13 +104,22 @@ internal sealed partial class InteractiveConsoleApp
// List-page filter active across rebuilds; cleared on page switch. Bound to the `/` overlay.
private string _searchFilter = string.Empty;
- // Currently selected collection in the master-detail Collections page (Commit 4).
- private CollectionCatalogView? _selectedCollection;
-
// First-click arms the inline two-stage install button on Collections detail (Commit 4);
// second click commits. Cleared every time the selected collection changes.
private bool _collectionInstallArmed;
+ // Keys (collection name, case-insensitive) of collections currently expanded in the grouped
+ // Collections table. Empty = all collapsed (the default at-a-glance view).
+ private readonly HashSet _expandedCollections = new(StringComparer.OrdinalIgnoreCase);
+
+ // ===== Off-thread operation queue (installs/removes) — UI-thread-only access by construction =====
+ private readonly Queue _operationQueue = new();
+ private bool _operationInProgress;
+ private SpinnerTextAnimator? _operationSpinner;
+
+ // True while the quit-confirm dialog is open, so repeated Ctrl+Q/Esc don't stack modals.
+ private bool _quitConfirmOpen;
+
private static readonly Color[] SectionPalette =
{
new(120, 180, 255),
@@ -120,7 +154,13 @@ public async Task RunAsync()
try
{
- var windowSystem = new ConsoleWindowSystem(new NetConsoleDriver(RenderMode.Buffer), BuildTheme());
+ var windowSystem = new ConsoleWindowSystem(
+ new NetConsoleDriver(RenderMode.Buffer),
+ BuildTheme(),
+ options: new ConsoleWindowSystemOptions(ExitKey: null));
+ // We own Ctrl+Q (ExitKey disabled above) so it raises the graceful confirm dialog instead
+ // of the framework's instant shutdown.
+ windowSystem.RegisterGlobalShortcut(ConsoleModifiers.Control, ConsoleKey.Q, RequestExit);
// Top/bottom system panels are both replaced by interactive StatusBarControl instances —
// the top one carries live session identity (project, scope, version), the bottom one
// carries shortcuts + toast slot.
@@ -145,19 +185,25 @@ private void CreateCommandCenter(ConsoleWindowSystem ws)
var installedCount = SafeCount(GetInstalledSkillCount);
var outdatedCount = SafeCount(GetOutdatedSkillCount);
+ // Rail surfaces only. Exit is a shell hotkey, not a rail destination. Update-all /
+ // Remove-all are consolidated into the Installed page (its toolbar + U / Delete
+ // shortcuts + per-row modal), so they're hidden from the rail here — their manifest
+ // surfaces, HomeAction values, page builders, and CLI verbs all stay intact. This
+ // filter is on the SharpConsoleUI call site only; GetHomeActions and the classic
+ // fallback menu are untouched.
var actions = GetHomeActions(installedCount, outdatedCount)
- .Where(action => action.Action != HomeAction.Exit)
+ .Where(action => action.Action is not (HomeAction.Exit or HomeAction.UpdateAll or HomeAction.RemoveAll))
.ToArray();
var nav = Controls.NavigationView()
.WithNavWidth(30)
.WithPaneHeader("[bold rgb(120,180,255)] ◆ dotnet skills[/]")
.WithPaneDisplayMode(NavigationViewDisplayMode.Auto)
- // 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)
+ // Show full pane labels at ≥120 cols. On 90–120-col terminals the rail goes
+ // Compact (icon-only) — and because every surface now has a DISTINCT geometric
+ // icon (see IconFor), compact mode stays readable rather than a column of
+ // identical glyphs. Below 90 cols the rail collapses to Minimal (summon on hotkey).
+ .WithExpandedThreshold(120)
.WithCompactThreshold(90)
.WithContentBorder(BorderStyle.Rounded)
.WithContentBorderColor(new Color(70, 100, 150))
@@ -176,7 +222,7 @@ private void CreateCommandCenter(ConsoleWindowSystem ws)
{
var captured = action;
header.AddItem(
- new NavigationItem(captured.Label, icon: "›", subtitle: captured.Summary) { Tag = captured.Action },
+ new NavigationItem(captured.Label, icon: IconFor(captured.Action), subtitle: captured.Summary) { Tag = captured.Action },
panel => BuildActionPage(ws, panel, captured.Action));
}
});
@@ -187,6 +233,7 @@ private void CreateCommandCenter(ConsoleWindowSystem ws)
.WithAlignment(HorizontalAlignment.Stretch)
.WithVerticalAlignment(VerticalAlignment.Fill)
.Build();
+ _nav = navView;
_topStatusBar = new StatusBarControl(stickyBottom: false)
{
@@ -215,7 +262,7 @@ private void CreateCommandCenter(ConsoleWindowSystem ws)
// Background gradient (cxpost / cxfiles house style — cool dark blue top to near-black bottom).
var backgroundGradient = ColorGradient.FromColors(new Color(25, 32, 52), new Color(7, 7, 13));
- new WindowBuilder(ws)
+ _mainWindow = new WindowBuilder(ws)
.WithTitle("dotnet skills — command center")
.HideTitle()
.Maximized()
@@ -235,10 +282,32 @@ private void CreateCommandCenter(ConsoleWindowSystem ws)
.AddControl(_statusBar)
.BuildAndShow();
+ // Preview-key hook: lets the command palette portal swallow all keys before they reach the page.
+ _mainWindow.PreviewKeyPressed += (_, e) => HandlePreviewKey(e);
+
RebuildStatusBar(null);
RebuildTopStatusBar();
}
+ ///
+ /// Distinct geometric (1-cell, terminal-safe) rail icon per surface. Distinct icons keep the
+ /// rail readable in Compact (icon-only) mode — otherwise every section item shows the same glyph.
+ ///
+ private static string IconFor(HomeAction action) => action switch
+ {
+ HomeAction.BrowseSkills => "◇",
+ HomeAction.BrowseCollections => "⊞",
+ HomeAction.BrowseBundles => "▣",
+ HomeAction.BrowsePackages => "⬡",
+ HomeAction.BrowseAgents => "⊕",
+ HomeAction.ManageInstalled => "▤",
+ HomeAction.SyncProject => "⌖",
+ HomeAction.Analysis => "∿",
+ HomeAction.Workspace => "⚙", // Settings
+ HomeAction.About => "ⓘ",
+ _ => "›",
+ };
+
///
/// Repopulates the top status bar with current session identity. Called on initial build
/// and from session-change event subscriptions in BuildActionPage/BuildHomePage.
@@ -262,13 +331,33 @@ private void RebuildTopStatusBar()
}
///
- /// Navigates to a HomeAction page without going through the NavigationView rail — used by
- /// the clickable Home metric cards and the command palette. The rail's visual selection
- /// state won't follow, but the content panel rebuilds and status bars update.
+ /// Navigates to a HomeAction page from outside the rail (Home metric cards, command palette,
+ /// page toolbar buttons). Routes through the rail's selection so the rail highlight follows the
+ /// content: selecting the matching item rebuilds the page via the rail's own factory and fires
+ /// SelectedItemChanged (which updates the status bar). Falls back to a direct build if no rail
+ /// item carries the action (e.g. Home, or an action not present in the rail).
///
private void NavigateTo(HomeAction action)
{
if (_ws is null || _activePanel is null) return;
+
+ if (_nav is not null)
+ {
+ var items = _nav.Items;
+ for (int i = 0; i < items.Count; i++)
+ {
+ if (items[i].Tag is HomeAction tagged && tagged == action)
+ {
+ // Setting SelectedIndex applies the rail highlight AND invokes the item's page
+ // factory (→ BuildActionPage) + fires SelectedItemChanged (→ RebuildStatusBar),
+ // so content, rail, and status bar all stay in sync through one path.
+ _nav.SelectedIndex = i;
+ return;
+ }
+ }
+ }
+
+ // Fallback: no matching rail item — build directly (rail selection won't follow).
BuildActionPage(_ws, _activePanel, action);
RebuildStatusBar(action);
}
@@ -299,11 +388,23 @@ private void AttachSessionEvents()
};
}
+ // Fires BEFORE any control sees the key (Window.PreviewKeyPressed). While the command palette portal
+ // is open it captures ALL keys here and marks them handled, so nothing leaks to the focused page/rail
+ // (OnKeyPressed runs too late — the focused control would already have processed the key).
+ private void HandlePreviewKey(KeyPressedEventArgs e)
+ {
+ if (_palettePortal != null)
+ {
+ _palettePortal.ProcessKey(e.KeyInfo);
+ e.Handled = true;
+ }
+ }
+
private void HandleGlobalKey(KeyPressedEventArgs e)
{
var key = e.KeyInfo;
- // Esc clears an active search filter first, then ends the session.
+ // Esc clears an active search filter first, then raises the graceful quit dialog.
if (key.Key == ConsoleKey.Escape)
{
if (!string.IsNullOrEmpty(_searchFilter))
@@ -313,7 +414,7 @@ private void HandleGlobalKey(KeyPressedEventArgs e)
e.Handled = true;
return;
}
- _ws?.Shutdown(0);
+ RequestExit();
e.Handled = true;
return;
}
@@ -368,12 +469,12 @@ HomeAction.BrowsePackages or
private void BuildActionPage(ConsoleWindowSystem ws, ScrollablePanelControl panel, HomeAction action)
{
- // Page-switch clears transient filters (search + Collections detail selection) so each
+ // Page-switch clears transient filters (search + Collections expansion/arm state) so each
// page lands in a clean state. Use NavigateTo if you need to preserve filter context.
if (_currentPage != action)
{
_searchFilter = string.Empty;
- _selectedCollection = null;
+ _expandedCollections.Clear();
_collectionInstallArmed = false;
}
_activePanel = panel;
@@ -396,7 +497,7 @@ private void BuildActionPage(ConsoleWindowSystem ws, ScrollablePanelControl pane
case HomeAction.About: BuildAboutPage(panel); break;
default:
panel.ClearContents();
- panel.AddControl(BuildNotePanel(action.ToString(), "[grey50]Not available in this surface.[/]", AccentGrey));
+ AddEmptyState(panel, "Not available in this surface.");
break;
}
}
@@ -406,20 +507,6 @@ private void BuildActionPage(ConsoleWindowSystem ws, ScrollablePanelControl pane
// Native control helpers — every page and modal renders through these.
// -------------------------------------------------------------------------
- ///
- /// A native PanelControl with rounded border, themed header, and accent border color —
- /// the visual equivalent of BuildRichShellPanel but drawn directly into the cell buffer
- /// so its border aligns with the surrounding window chrome.
- ///
- private static PanelControl BuildSectionPanel(string title, string body, Color accent) => Controls.Panel()
- .WithHeader($"[bold]{Escape(title)}[/]")
- .WithBorderStyle(BorderStyle.Rounded)
- .WithBorderColor(accent)
- .WithPadding(1, 0, 1, 0)
- .WithContent(body)
- .WithAlignment(HorizontalAlignment.Stretch)
- .Build();
-
///
/// A native metric card: three stacked lines (title accent, value bold, detail grey) inside
/// a rounded PanelControl with an accent border. Used in HorizontalGrid columns.
@@ -466,26 +553,16 @@ private static string FormatRow(string label, string value)
private static PanelControl BuildPropertyPanel(string title, Color accent, params (string Label, string Value)[] rows)
{
var body = string.Join("\n", rows.Select(r => FormatRow(r.Label, r.Value)));
- return BuildSectionPanel(title, body, accent);
+ return Controls.Panel()
+ .WithHeader($"[bold]{Escape(title)}[/]")
+ .WithBorderStyle(BorderStyle.Rounded)
+ .WithBorderColor(accent)
+ .WithPadding(1, 0, 1, 0)
+ .WithContent(body)
+ .WithAlignment(HorizontalAlignment.Stretch)
+ .Build();
}
- ///
- /// A native section panel containing a single markup line — used for empty-state notes and
- /// short status messages.
- ///
- private static PanelControl BuildNotePanel(string title, string markup, Color accent)
- => BuildSectionPanel(title, markup, accent);
-
- ///
- /// A native section panel whose body is a vertical stack of markup lines — used for
- /// "quick start", "surface map", and similar bullet-list cards. Lines are joined with \n
- /// so PanelControl wraps each independently.
- ///
- private static PanelControl BuildBulletPanel(string title, Color accent, params string[] lines)
- {
- var body = string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l)));
- return BuildSectionPanel(title, body, accent);
- }
///
/// One-line identity strip used as a page header. Renders as a single MarkupControl with the
@@ -495,9 +572,25 @@ private static PanelControl BuildBulletPanel(string title, Color accent, params
/// labels are dimmed by this helper.
///
private static IWindowControl BuildIdentityStrip(string title, Color accent, params (string Label, string Value)[] facts)
+ => BuildIdentityStrip(title, accent, hint: null, facts);
+
+ ///
+ /// Identity strip with an optional affordance (e.g. "Enter ▸ details")
+ /// rendered as a quiet bracketed chip immediately after the title. Pages whose primary table no
+ /// longer carries its own title line pass the affordance here instead, so the page has a single
+ /// header (the column row) rather than a redundant turquoise title duplicating the strip.
+ ///
+ private static IWindowControl BuildIdentityStrip(string title, Color accent, string? hint, 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)}[/]" };
+ var head = $"[bold {hex}]{Escape(title)}[/]";
+ if (!string.IsNullOrEmpty(hint))
+ {
+ // Dim bracketed chip — subordinate to the title, signals the row action without
+ // competing with the accent-colored column header below.
+ head += $" [grey50]❨[/][grey62]{Escape(hint)}[/][grey50]❩[/]";
+ }
+ var parts = new List { head };
foreach (var (label, value) in facts)
{
if (string.IsNullOrEmpty(value)) continue;
@@ -515,8 +608,17 @@ private static IWindowControl BuildIdentityStrip(string title, Color accent, par
/// `panel.AddControl(BuildIdentityStrip(...))` directly.
///
private static void AddIdentityStrip(ScrollablePanelControl panel, string title, Color accent, params (string Label, string Value)[] facts)
+ => AddIdentityStrip(panel, title, accent, hint: null, facts);
+
+ ///
+ /// Identity-strip variant carrying an affordance chip (see
+ /// ). Pages
+ /// whose primary table dropped its own title use this so the row action ("Enter ▸ details") lives
+ /// in the header strip rather than as a second turquoise line above the columns.
+ ///
+ private static void AddIdentityStrip(ScrollablePanelControl panel, string title, Color accent, string? hint, params (string Label, string Value)[] facts)
{
- panel.AddControl(BuildIdentityStrip(title, accent, facts));
+ panel.AddControl(BuildIdentityStrip(title, accent, hint, facts));
panel.AddControl(Controls.RuleBuilder()
.WithColor(accent)
.WithBorderStyle(BorderStyle.Single)
@@ -544,6 +646,60 @@ private static void AddSectionHeader(ScrollablePanelControl panel, string title,
.Build());
}
+ // ===== Quiet text-block vocabulary (replaces the rounded accent-box panels) =====
+
+ internal enum NoteSeverity { Info, Warning, Error, Success }
+
+ /// Quiet empty-state: a spacer + a centered dim line. No box. For passive
+ /// "no results / nothing available" states (the page identity strip gives context).
+ private static void AddEmptyState(ScrollablePanelControl panel, string message)
+ {
+ panel.AddControl(new MarkupControl(new List { string.Empty }));
+ panel.AddControl(Controls.Markup()
+ .AddLine($"[grey50]{message}[/]")
+ .Centered()
+ .Build());
+ }
+
+ /// Inline severity note: a single wrapped line with a leading glyph + color, NO box.
+ /// The glyph/color carries severity. For actionable notes (warnings, errors, status).
+ private static void AddInlineNote(ScrollablePanelControl panel, string message, NoteSeverity severity)
+ {
+ // Muted accent hexes (not the raw [yellow]/[red] markup tags, which resolve to the
+ // max-bright #FFFF00 / #FF0000 and "scream" against the dark shell). These match the
+ // app's AccentYellow / AccentGreen palette so a passive note stays warm, not loud.
+ var (glyph, open) = severity switch
+ {
+ NoteSeverity.Warning => ("⚠", "[#d7af00]"), // AccentYellow — muted amber
+ NoteSeverity.Error => ("✕", "[#d70000]"), // softened red
+ NoteSeverity.Success => ("✓", "[#00af00]"), // AccentGreen
+ _ => ("ℹ", "[grey62]"),
+ };
+ panel.AddControl(new MarkupControl(new List { $"{open}{glyph} {message}[/]" }));
+ }
+
+ /// Form section header: a titled rule (via AddSectionHeader) + a dim caption line. No box.
+ /// The caller adds the fields after.
+ private static void AddFormSection(ScrollablePanelControl panel, string title, string caption, Color accent)
+ {
+ AddSectionHeader(panel, title, accent);
+ panel.AddControl(new MarkupControl(new List { $"[grey50]{caption}[/]" }));
+ }
+
+ /// Info block: a bold caption + dim body lines, no border. For bullet-list info
+ /// ("quick start", "tool update", "surface map", "notes").
+ private static void AddInfoBlock(ScrollablePanelControl panel, string title, params string[] lines)
+ {
+ var rows = new List { $"[bold]{Escape(title)}[/]" };
+ rows.AddRange(lines.Where(l => !string.IsNullOrWhiteSpace(l)));
+ panel.AddControl(new MarkupControl(rows));
+ }
+
+ /// De-emphasized titled block for use INSIDE a detail modal (which already has a frame).
+ /// A bold caption + body, no accent box.
+ private static IWindowControl BuildModalBlock(string title, string body)
+ => new MarkupControl(new List { $"[bold]{Escape(title)}[/]", body });
+
///
/// 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
@@ -558,6 +714,42 @@ private static TableControlBuilder BuildStyledTable(string title, Color accent)
.WithBorderColor(accent)
.StretchHorizontal();
+ ///
+ /// Borderless variant of for a page's PRIMARY table — the one that
+ /// sits directly inside the NavigationView content area, which already supplies a rounded frame.
+ /// Dropping the table's own border removes the redundant inner frame (one frame, not two). Tables
+ /// shown inside a modal (which has its own border) keep the rounded .
+ ///
+ private static TableControlBuilder BuildStyledTableBorderless(string title, Color accent)
+ {
+ var builder = BuildStyledTableBorderless(accent);
+ if (!string.IsNullOrEmpty(title))
+ builder.WithTitle(title, TextJustification.Left);
+ return builder;
+ }
+
+ ///
+ /// Titleless borderless page table. The page's identity strip already names the section (and
+ /// carries the row-action affordance as a chip), so the table needs no title of its own — a
+ /// turquoise title line directly above the same-colored column header read as a duplicate header.
+ /// Use this for Skills/Bundles/Packages/Agents; the column row is the only header.
+ ///
+ private static TableControlBuilder BuildStyledTableBorderless(Color accent) => Controls.Table()
+ .WithSorting()
+ .NoBorder()
+ .WithBorderColor(accent)
+ // Dim hairline between columns — borders are off, so this is the only column structure.
+ // A desaturated grey keeps it quiet (see GridLineColor); padded:true gives the glyph a
+ // space on each side so it doesn't sit flush against the cell text.
+ .WithColumnSeparator('│', GridLineColor, padded: true)
+ // Keep the right-aligned final column (e.g. Tokens) off the scrollbar with a 1-cell gutter.
+ .ScrollbarGutter()
+ .StretchHorizontal()
+ // Fill the page's content height so the table viewport (rows + scrollbar) uses the available
+ // space instead of leaving a large void below. The page table is always the primary content,
+ // so it should own the vertical room.
+ .WithVerticalAlignment(VerticalAlignment.Fill);
+
///
/// 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
@@ -658,14 +850,20 @@ void Close()
}
}
- // 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.
+ // Separator between the modal's data/property panels and the action toolbar. The
+ // toolbar's own AboveLine only spans the centered button cluster (the toolbar measures to
+ // its content width), so it reads as an underline of the buttons rather than a section
+ // divider. Instead, a standalone edge-to-edge Rule spans the full modal width, and the
+ // toolbar itself is borderless so the buttons sit centered beneath it.
+ var toolbarRule = Controls.RuleBuilder()
+ .WithColor(new Color(70, 88, 116))
+ .WithBorderStyle(BorderStyle.Single)
+ .Build();
+ toolbarRule.StickyPosition = StickyPosition.Bottom;
+
var toolbar = Controls.Toolbar()
.WithSpacing(2)
- .WithAlignment(HorizontalAlignment.Center)
- .WithAboveLine(true)
- .WithAboveLineColor(new Color(70, 88, 116));
+ .WithAlignment(HorizontalAlignment.Center);
foreach (var (label, onClick) in buttons)
{
var captured = onClick;
@@ -678,6 +876,7 @@ void Close()
.WithSize(width, height)
.Centered()
.AsModal()
+ .WithBackgroundGradient(ElevatedModalGradient, GradientDirection.Vertical)
.Minimizable(false)
.Maximizable(false)
.WithBorderStyle(BorderStyle.Rounded)
@@ -691,19 +890,192 @@ void Close()
}
})
.AddControl(body)
+ .AddControl(toolbarRule)
.AddControl(toolbar.StickyBottom().Build())
.BuildAndShow();
}
- private void ConfirmModal(ConsoleWindowSystem ws, string title, string message, Action onConfirm)
+ // Single exit funnel for Ctrl+Q, Escape, and the "Quit" status action. Always confirms; the message
+ // escalates when a background operation is in progress or queued (quitting then drops the queue).
+ private void RequestExit()
{
- var content = new IWindowControl[]
+ if (_quitConfirmOpen || _ws is null) return;
+ _quitConfirmOpen = true;
+
+ bool busy = _operationInProgress || _operationQueue.Count > 0;
+ int queued = _operationQueue.Count;
+ string body = busy
+ ? $"[yellow]⚠ An operation is still running{(queued > 0 ? $" (+{queued} queued)" : string.Empty)}. Quitting now drops queued operations.[/]"
+ : "Exit the catalog browser?";
+ string confirmLabel = busy ? "Quit anyway" : "Quit";
+
+ ShowExitConfirm("Quit dotnet skills?", body, confirmLabel,
+ onConfirm: () => { _quitConfirmOpen = false; _ws?.Shutdown(0); },
+ onCancel: () => { _quitConfirmOpen = false; });
+ }
+
+ // Compact confirm dialog: a small modal sized to its message, with the message as plain markup (no
+ // boxed panel) and a centered [confirmLabel] / [Cancel] toolbar. Escape or Cancel runs onCancel;
+ // the confirm button runs onConfirm. Self-sized (vs the large content-modal ShowModalNative) so a
+ // one-line question doesn't open a near-fullscreen window.
+ private void ShowExitConfirm(string title, string bodyMarkup, string confirmLabel, Action onConfirm, Action onCancel)
+ {
+ var ws = _ws;
+ if (ws is null) return;
+
+ Window? modal = null;
+ bool closed = false;
+ void Close(Action after)
{
- BuildNotePanel("confirm", $"[yellow]{Escape(message)}[/]", AccentYellow),
- };
- ShowModalNative(ws, title, content, ("Yes, proceed", onConfirm));
+ if (closed) return;
+ closed = true;
+ if (modal is not null) ws.CloseWindow(modal);
+ after();
+ }
+
+ var toolbar = Controls.Toolbar()
+ .WithSpacing(2)
+ .WithAlignment(HorizontalAlignment.Center);
+ toolbar.AddButton(confirmLabel, (_, _) => Close(onConfirm));
+ toolbar.AddButton("Cancel", (_, _) => Close(onCancel));
+
+ // Width fits the longer of the message / title / buttons, within a tidy small range.
+ int msgWidth = MarkupParser.StripLength(bodyMarkup);
+ int btnWidth = confirmLabel.Length + "Cancel".Length + 8;
+ int width = Math.Clamp(Math.Max(Math.Max(msgWidth, title.Length), btnWidth) + 8, 40, 72);
+
+ modal = new WindowBuilder(ws)
+ .WithTitle(title)
+ .WithSize(width, 8)
+ .Centered()
+ .AsModal()
+ .WithBackgroundGradient(ElevatedModalGradient, GradientDirection.Vertical)
+ .Minimizable(false)
+ .Maximizable(false)
+ .WithBorderStyle(BorderStyle.Rounded)
+ .WithBorderColor(new Color(90, 110, 142))
+ .OnKeyPressed((_, e) =>
+ {
+ if (e.KeyInfo.Key == ConsoleKey.Escape)
+ {
+ Close(onCancel);
+ e.Handled = true;
+ }
+ })
+ .AddControl(new MarkupControl(new List { string.Empty, " " + bodyMarkup }))
+ .AddControl(toolbar.StickyBottom().Build())
+ .BuildAndShow();
}
+ ///
+ /// Danger-styled confirmation dialog for destructive actions. Red (Danger-role) border, a ⚠
+ /// title prefix, the message, an optional "Affected" list (capped at 8 + "+K more"), and a
+ /// neutral Cancel (focused by default — reflexive Enter cancels) beside a Danger-red confirm.
+ /// Esc cancels. No auto "Close" button.
+ ///
+ private void ConfirmDangerModal(ConsoleWindowSystem ws, string title, string message, string confirmLabel, Action onConfirm, IReadOnlyList? affectedItems = null)
+ {
+ const int AffectedListCap = 8;
+ const int ConfirmWidth = 58; // compact, content-width — not the full info-modal clamp
+
+ Window? modal = null;
+
+ // Danger-role red for the border (the contrast-checked Border derivative, not the fill).
+ Color dangerColor = ColorRoleResolver.Resolve(ColorRole.Danger, ws.Theme).Border;
+
+ // Content-sized: a confirm is a question, not a document. Estimate the body line count from
+ // the message (wrapped to the inner width) + the optional Affected block, then add chrome
+ // (border 2 + title 1 + rule 1 + button row 1 + a little breathing room). Clamp to the screen.
+ int termW = SafeConsole(() => Console.WindowWidth, 120);
+ int termH = SafeConsole(() => Console.WindowHeight, 32);
+ int width = Math.Min(ConfirmWidth, Math.Max(40, termW - 8));
+ int innerW = Math.Max(10, width - 4 - 4); // border (2) + body padding (2 each side)
+ int msgLines = message.Split('\n').Sum(l => Math.Max(1, (int)Math.Ceiling((double)MarkupParser.StripLength(l) / innerW)));
+ int affectedLines = affectedItems is { Count: > 0 }
+ ? 2 + Math.Min(affectedItems.Count, AffectedListCap) + (affectedItems.Count > AffectedListCap ? 1 : 0)
+ : 0;
+ // chrome: border 2 + title 1 + rule 1 + button row 1 + body padding 2 (top+bottom) = 7
+ int height = Math.Clamp(msgLines + affectedLines + 7, 9, Math.Max(9, termH - 4));
+
+ // Per-control padding on the body panel only — the message/Affected list breathe off the
+ // border, while the sticky rule + centered toolbar below stay full-width.
+ var body = Controls.ScrollablePanel().WithPadding(2, 1, 2, 1).Build();
+ body.AddControl(new MarkupControl(new List { $"[grey85]{Escape(message)}[/]" }));
+
+ if (affectedItems is { Count: > 0 })
+ {
+ var lines = new List { string.Empty, "[grey50]Affected[/]" };
+ foreach (var name in affectedItems.Take(AffectedListCap))
+ lines.Add($" [grey70]•[/] {Escape(name)}");
+ if (affectedItems.Count > AffectedListCap)
+ lines.Add($"[grey50] +{affectedItems.Count - AffectedListCap} more[/]");
+ body.AddControl(new MarkupControl(lines));
+ }
+
+ void Close()
+ {
+ if (modal is not null) ws.CloseWindow(modal);
+ }
+
+ var toolbarRule = Controls.RuleBuilder()
+ .WithColor(new Color(70, 88, 116))
+ .WithBorderStyle(BorderStyle.Single)
+ .Build();
+ toolbarRule.StickyPosition = StickyPosition.Bottom;
+
+ // Cancel FIRST so the toolbar's initial focus (first focusable) lands on it; we also set it
+ // explicitly after show as a belt-and-suspenders safe default.
+ var cancelButton = new ButtonBuilder()
+ .WithText("Cancel")
+ .OnClick((_, _) => Close())
+ .Build();
+ var confirmButton = new ButtonBuilder()
+ .WithText(confirmLabel)
+ .WithColorRole(ColorRole.Danger)
+ .OnClick((_, _) => { Close(); onConfirm(); })
+ .Build();
+
+ var toolbar = Controls.Toolbar()
+ .WithSpacing(2)
+ .WithAlignment(HorizontalAlignment.Center)
+ .AddButton(cancelButton)
+ .AddButton(confirmButton);
+
+ modal = new WindowBuilder(ws)
+ .WithTitle($"⚠ {title}")
+ .WithSize(width, height)
+ .Centered()
+ .AsModal()
+ // Flat solid surface (not the elevated gradient used by info/detail modals) so a confirm
+ // reads as a focused decision, not a document. The red border carries the danger signal.
+ .WithBackgroundColor(ws.Theme.WindowBackgroundColor)
+ .Minimizable(false)
+ .Maximizable(false)
+ .WithBorderStyle(BorderStyle.Rounded)
+ .WithBorderColor(dangerColor)
+ .OnKeyPressed((_, e) =>
+ {
+ if (e.KeyInfo.Key == ConsoleKey.Escape)
+ {
+ Close();
+ e.Handled = true;
+ }
+ })
+ .AddControl(body)
+ .AddControl(toolbarRule)
+ .AddControl(toolbar.StickyBottom().Build())
+ .BuildAndShow();
+
+ // Default focus on Cancel (safe — a reflexive Enter cancels rather than destroys).
+ modal?.FocusManager.SetFocus(cancelButton, FocusReason.Programmatic);
+ }
+
+ // Backward-compatible wrapper: every existing caller is a destructive remove, so it routes to
+ // the danger dialog with a "Remove" confirm label and no affected-item list. Callers wanting the
+ // "Affected" list call ConfirmDangerModal directly (see RemoveCheckedSkills).
+ private void ConfirmModal(ConsoleWindowSystem ws, string title, string message, Action onConfirm)
+ => ConfirmDangerModal(ws, title, message, confirmLabel: "Remove", onConfirm);
+
private void ChooseEnumModal(ConsoleWindowSystem ws, string title, TEnum[] values, TEnum current, Action onPicked)
where TEnum : struct, Enum
{
@@ -739,6 +1111,7 @@ void Close()
.WithSize(Math.Clamp(values.Length == 0 ? 40 : values.Max(v => v.ToString().Length) + 24, 40, 70), Math.Min(values.Length + 8, 18))
.Centered()
.AsModal()
+ .WithBackgroundGradient(ElevatedModalGradient, GradientDirection.Vertical)
.Minimizable(false)
.Maximizable(false)
.WithBorderStyle(BorderStyle.Rounded)
@@ -792,6 +1165,7 @@ void Close()
.WithSize(Math.Clamp(SafeConsole(() => Console.WindowWidth, 100) - 20, 50, 80), 9)
.Centered()
.AsModal()
+ .WithBackgroundGradient(ElevatedModalGradient, GradientDirection.Vertical)
.Minimizable(false)
.Maximizable(false)
.WithBorderStyle(BorderStyle.Rounded)
@@ -810,79 +1184,62 @@ void Close()
}
///
- /// Global command palette (Ctrl+P). Opens a centered modal hosting a PromptControl + a
- /// ListControl pre-populated with every catalog skill, bundle, and agent. Enter on the prompt
- /// filters the list; Enter on the list activates the entry (skill/bundle/agent detail modal).
+ /// Global command palette (Ctrl+P). Opens (or toggles closed) a portal overlay: commands-first by
+ /// default, with a '/' prefix switching to searching skills/bundles/agents. Live-filters per
+ /// keystroke. Replaces the old modal palette.
///
private void ShowCommandPalette(ConsoleWindowSystem ws)
{
- Window? modal = null;
- var allEntries = BuildPaletteEntries();
+ if (_palettePortal != null) { DismissPalette(); return; } // toggle closed
+ if (_mainWindow is null || _nav is null) return;
- void Close()
+ var portal = new CommandPalettePortal(BuildCommandRegistry(), BuildContentEntries(), _mainWindow.Width, _mainWindow.Height)
{
- if (modal is not null) ws.CloseWindow(modal);
- }
-
- var listControl = StyledList(null).MaxVisibleItems(14).WithScrollbarVisibility(ScrollbarVisibility.Auto).Build();
- listControl.ItemActivated += (_, item) =>
- {
- if (item.Tag is PaletteEntry entry)
- {
- Close();
- entry.Activate();
- }
+ Container = _mainWindow
};
- void FillList(string filter)
- {
- listControl.ClearItems();
- var f = filter.Trim();
- var matches = string.IsNullOrEmpty(f)
- ? allEntries
- : allEntries.Where(e => e.SearchHaystack.Contains(f, StringComparison.OrdinalIgnoreCase)).ToArray();
- foreach (var entry in matches.Take(80))
- {
- listControl.AddItem(new ListItem($"[{entry.AccentMarkup}]{entry.IconLabel}[/] {Escape(entry.Label)} [grey50]{Escape(entry.Detail)}[/]") { Tag = entry });
- }
- }
+ _palettePortal = portal;
+ _palettePortalNode = _mainWindow.CreatePortal(_nav, portal);
- FillList(string.Empty);
+ portal.CommandChosen += (_, cmd) => { DismissPalette(); cmd?.Execute(); };
+ portal.ContentChosen += (_, entry) => { DismissPalette(); entry?.Activate(); };
+ portal.DismissRequested += (_, _) => DismissPalette();
+ }
- var prompt = Controls.Prompt(" > ")
- .UnfocusOnEnter(false)
- .OnEntered((_, query) =>
- {
- FillList(query ?? string.Empty);
- })
- .Build();
+ private void DismissPalette()
+ {
+ if (_palettePortalNode is not null && _mainWindow is not null && _nav is not null)
+ _mainWindow.RemovePortal(_nav, _palettePortalNode);
+ _palettePortal = null;
+ _palettePortalNode = null;
+ }
- modal = new WindowBuilder(ws)
- .WithTitle("command palette · Esc to close")
- .WithSize(Math.Clamp(SafeConsole(() => Console.WindowWidth, 120) - 10, 64, 100), 22)
- .Centered()
- .AsModal()
- .Minimizable(false)
- .Maximizable(false)
- .WithBorderStyle(BorderStyle.Rounded)
- .WithBorderColor(AccentDeepSkyBlue)
- .OnKeyPressed((_, e) =>
- {
- if (e.KeyInfo.Key == ConsoleKey.Escape)
- {
- Close();
- e.Handled = true;
- }
- })
- .AddControl(prompt)
- .AddControl(listControl)
- .BuildAndShow();
+ ///
+ /// The curated command set shown in the palette's default (commands) mode. Navigate commands route
+ /// through NavigateTo (which also syncs the rail); actions run their handler.
+ ///
+ private CommandRegistry BuildCommandRegistry()
+ {
+ var r = new CommandRegistry();
+ r.Register(new SkillCommand { Id = "nav.skills", Label = "Skills", Category = "Navigate", Icon = "→", Priority = 80, Execute = () => NavigateTo(HomeAction.BrowseSkills) });
+ r.Register(new SkillCommand { Id = "nav.installed", Label = "Installed", Category = "Navigate", Icon = "→", Priority = 80, Execute = () => NavigateTo(HomeAction.ManageInstalled) });
+ r.Register(new SkillCommand { Id = "nav.collections", Label = "Collections", Category = "Navigate", Icon = "→", Priority = 80, Execute = () => NavigateTo(HomeAction.BrowseCollections) });
+ r.Register(new SkillCommand { Id = "nav.bundles", Label = "Bundles", Category = "Navigate", Icon = "→", Priority = 80, Execute = () => NavigateTo(HomeAction.BrowseBundles) });
+ r.Register(new SkillCommand { Id = "nav.packages", Label = "Packages", Category = "Navigate", Icon = "→", Priority = 80, Execute = () => NavigateTo(HomeAction.BrowsePackages) });
+ r.Register(new SkillCommand { Id = "nav.agents", Label = "Agents", Category = "Navigate", Icon = "→", Priority = 80, Execute = () => NavigateTo(HomeAction.BrowseAgents) });
+ r.Register(new SkillCommand { Id = "nav.project", Label = "Project", Category = "Navigate", Icon = "→", Priority = 80, Execute = () => NavigateTo(HomeAction.SyncProject) });
+ r.Register(new SkillCommand { Id = "nav.analysis", Label = "Analysis", Category = "Navigate", Icon = "→", Priority = 80, Execute = () => NavigateTo(HomeAction.Analysis) });
+ r.Register(new SkillCommand { Id = "nav.about", Label = "About", Category = "Navigate", Icon = "→", Priority = 70, Execute = () => NavigateTo(HomeAction.About) });
+ r.Register(new SkillCommand { Id = "nav.home", Label = "Home", Category = "Navigate", Icon = "◈", Priority = 70, Execute = () => { if (_ws is not null && _activePanel is not null) BuildHomePage(_ws, _activePanel); } });
+ r.Register(new SkillCommand { Id = "act.refresh", Label = "Refresh catalog", Category = "Action", Icon = "↻", Keybinding = "Ctrl+R", Priority = 75, Execute = () => RefreshCatalogFromUi() });
+ r.Register(new SkillCommand { Id = "act.settings", Label = "Settings", Category = "Action", Icon = "⚙", Priority = 60, Execute = () => NavigateTo(HomeAction.Workspace) });
+ return r;
}
///
- /// Builds the union of every searchable entry — skills, bundles, agents, settings actions —
- /// used to populate the command palette. Each entry knows how to activate itself.
+ /// The searchable content (skills, bundles, agents) shown in the palette's '/' content mode. Each
+ /// entry opens its detail modal on activation.
///
- private IReadOnlyList BuildPaletteEntries()
+ private IReadOnlyList BuildContentEntries()
{
var entries = new List();
@@ -921,31 +1278,9 @@ private IReadOnlyList BuildPaletteEntries()
Activate: () => { if (_ws is not null && _activePanel is not null) ShowAgentModal(_ws, _activePanel, a); }));
}
- // Settings actions and page jumps.
- entries.Add(new PaletteEntry("⚙ settings", "deepskyblue1", "Settings", "open workspace settings", "settings platform scope refresh workspace", () => NavigateTo(HomeAction.Workspace)));
- entries.Add(new PaletteEntry("↻ refresh", "deepskyblue1", "Refresh catalog", "pull the latest catalog", "refresh catalog reload pull", () => RefreshCatalogFromUi()));
- entries.Add(new PaletteEntry("◈ home", "deepskyblue1", "Home", "session and telemetry", "home session telemetry", () => { if (_ws is not null && _activePanel is not null) BuildHomePage(_ws, _activePanel); }));
- entries.Add(new PaletteEntry("→ skills", "turquoise2", "Skills", "browse the catalog", "skills browse catalog", () => NavigateTo(HomeAction.BrowseSkills)));
- entries.Add(new PaletteEntry("→ installed","green", "Installed", "manage installed skills", "installed manage update remove", () => NavigateTo(HomeAction.ManageInstalled)));
- entries.Add(new PaletteEntry("→ collections","deepskyblue1","Collections", "browse collections", "collections browse", () => NavigateTo(HomeAction.BrowseCollections)));
- entries.Add(new PaletteEntry("→ bundles", "springgreen3", "Bundles", "focused bundles", "bundles focused", () => NavigateTo(HomeAction.BrowseBundles)));
- entries.Add(new PaletteEntry("→ packages", "turquoise2", "Packages", "NuGet signals", "packages nuget signals", () => NavigateTo(HomeAction.BrowsePackages)));
- entries.Add(new PaletteEntry("→ agents", "mediumpurple2","Agents", "orchestration agents", "agents orchestration", () => NavigateTo(HomeAction.BrowseAgents)));
- entries.Add(new PaletteEntry("→ project", "deepskyblue1", "Project", "scan and install", "project scan recommend", () => NavigateTo(HomeAction.SyncProject)));
- entries.Add(new PaletteEntry("→ analysis", "deepskyblue1", "Analysis", "catalog analysis", "analysis stats heaviest", () => NavigateTo(HomeAction.Analysis)));
- entries.Add(new PaletteEntry("→ about", "grey", "About", "version and surface map", "about version", () => NavigateTo(HomeAction.About)));
-
return entries;
}
- private sealed record PaletteEntry(
- string IconLabel,
- string AccentMarkup,
- string Label,
- string Detail,
- string SearchHaystack,
- Action Activate);
-
private static ITheme BuildTheme() => new ModernGrayTheme
{
ListHoverBackgroundColor = SelectionBg,
@@ -993,7 +1328,7 @@ private void RebuildStatusBar(HomeAction? page)
bar.AddLeft(key, label, action);
}
bar.AddLeft("Ctrl+R", "Refresh", RefreshCatalogFromUi);
- bar.AddLeft("Esc", "Quit", () => _ws?.Shutdown(0));
+ bar.AddLeft("Esc/^Q", "Quit", () => RequestExit());
_statusMessage = bar.AddCenterText(string.Empty);
@@ -1035,7 +1370,7 @@ private void RebuildActivePage()
}
///
- /// Shows a transient notification. Info/Success render only as a sliding card; Warning/Danger
+ /// Shows a transient toast. Info/Success render only as the corner toast card; Warning/Danger
/// also leave a sticky line in the bottom status bar until the next page change so the user
/// has time to read it. Default severity is Info.
///
@@ -1044,7 +1379,7 @@ private void Toast(string message, NotificationSeverity? severity = null)
if (string.IsNullOrEmpty(message)) { ClearStickyStatus(); return; }
var sev = severity ?? NotificationSeverity.Info;
- _ws?.NotificationStateService.ShowNotification(title: string.Empty, message, sev);
+ _ws?.ToastService.Show(message, sev);
if (_statusMessage is not null)
{
@@ -1106,10 +1441,7 @@ private bool MatchesFilter(params string?[] tokens)
private void AddSearchChip(ScrollablePanelControl panel)
{
if (string.IsNullOrWhiteSpace(_searchFilter)) return;
- panel.AddControl(BuildNotePanel(
- "filter",
- $"[yellow]matching “{Escape(_searchFilter)}”[/] [grey50]· press[/] [bold]Esc[/] [grey50]to clear[/]",
- AccentYellow));
+ AddInlineNote(panel, $"matching “{Escape(_searchFilter)}” [grey50]· press[/] [bold]Esc[/] [grey50]to clear[/]", NoteSeverity.Info);
}
private async Task ClockLoopAsync(Window window, CancellationToken cancellationToken)
@@ -1133,6 +1465,65 @@ private async Task ClockLoopAsync(Window window, CancellationToken cancellationT
}
}
+ // Submit an install/remove to run off the UI thread, queued behind any in-flight op. Called FROM
+ // the UI thread. `work` is the synchronous filesystem/catalog operation (runs off-thread); its
+ // exceptions are caught by the runner and toasted (do NOT SafeGet the install call inside work).
+ // `onComplete` runs back on the UI thread with the result (toast + page rebuild).
+ private void RunOperationQueued(string busyLabel, Func work, Action onComplete)
+ {
+ _operationQueue.Enqueue(() => StartOperation(busyLabel, work, onComplete));
+ if (_operationInProgress)
+ Toast($"Queued: {busyLabel}", NotificationSeverity.Info);
+ PumpOperations();
+ }
+
+ // Runs the next queued op if idle. Re-invoked on each op's completion to drain the queue.
+ private void PumpOperations()
+ {
+ if (_operationInProgress || _operationQueue.Count == 0) return;
+ _operationQueue.Dequeue()();
+ }
+
+ private void StartOperation(string busyLabel, Func work, Action onComplete)
+ {
+ _operationInProgress = true;
+ var ws = _ws;
+
+ if (ws is not null && _statusMessage is not null)
+ {
+ _operationSpinner = new SpinnerTextAnimator(ws, SpinnerStyle.Braille,
+ frame => _statusMessage.Label = $"{frame} [grey70]{Escape(busyLabel)}[/]");
+ _operationSpinner.Start();
+ }
+
+ _ = Task.Run(() =>
+ {
+ TResult result = default!;
+ Exception? error = null;
+ try { result = work(); }
+ catch (Exception ex) { error = ex; }
+
+ void Finish()
+ {
+ _operationSpinner?.Stop();
+ _operationSpinner?.Dispose();
+ _operationSpinner = null;
+ ClearStickyStatus();
+
+ if (error is not null)
+ Toast($"{busyLabel} failed: {error.Message}", NotificationSeverity.Danger);
+ else
+ onComplete(result);
+
+ _operationInProgress = false;
+ PumpOperations();
+ }
+
+ if (ws is not null) ws.EnqueueOnUIThread(Finish);
+ else Finish();
+ });
+ }
+
private void RefreshCatalogFromUi()
{
// Invoked from UI handlers (Ctrl+R, refresh button, command palette). The catalog refresh
@@ -1213,11 +1604,14 @@ private void RemoveAllFromUi()
}
ConfirmModal(_ws, "Remove all installed skills?", $"Deletes every catalog skill from {layout.PrimaryRoot.FullName}.", () =>
- {
- var summary = SafeGet(() => new SkillInstaller(skillCatalog).Remove(installed.Select(record => record.Skill).ToArray(), layout), default(SkillRemoveSummary));
- ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)");
- RebuildActivePage();
- });
+ RunOperationQueued(
+ "Removing all installed skills",
+ work: () => new SkillInstaller(skillCatalog).Remove(installed.Select(record => record.Skill).ToArray(), layout),
+ onComplete: summary =>
+ {
+ ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)");
+ RebuildActivePage();
+ }));
}
private void InstallAllRecommendedFromUi()
diff --git a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs
index d0c8494..6c8c9c1 100644
--- a/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs
+++ b/cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs
@@ -45,46 +45,121 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p
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));
+ AddEmptyState(panel, "No catalog skills are installed in this target yet. Visit the Skills page to add some.");
return;
}
if (filtered.Length == 0)
{
- panel.AddControl(BuildNotePanel("installed", $"[grey50]No installed skills match “{Escape(_searchFilter)}”.[/]", AccentYellow));
+ AddEmptyState(panel, $"No installed skills match “{Escape(_searchFilter)}”.");
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}.",
- () =>
+ // screen, not buried under a 16-row scrolling list. The modal-on-Enter flow handles
+ // per-row Update/Reinstall/Remove. The toolbar ALSO hosts a contextual batch segment
+ // (after a separator) that appears only when ≥1 row is checked — built once, hidden,
+ // then mutated reactively from MultiSelectionChanged (no toolbar/page rebuild on toggle).
+
+ TableControl? builtTable = null;
+
+ // Contextual controls hosted after the toolbar separator. ONE button set serves both modes
+ // (role-styled — Warning Update, Danger Remove); the markup hosts only a hint, no links.
+ // The CURRENT TARGET is the checked set when ≥1 row is checked, otherwise the selected
+ // (cursor) row. RefreshSelectionContext (wired after the table is built) sets visibility:
+ // • ≥1 checked → hint "N selected" + Update/Remove + Clear (acts on the checked set)
+ // • 0 checked, a row selected → Update/Remove only (acts on the selected row)
+ // • nothing → all hidden
+ InstalledSkillRecord[] ContextTarget()
+ {
+ if (builtTable is null) return Array.Empty();
+ var checkedRecs = builtTable.GetCheckedRows().Select(r => r.Tag).OfType().ToArray();
+ if (checkedRecs.Length > 0) return checkedRecs;
+ return builtTable.SelectedRow?.Tag is InstalledSkillRecord sel ? new[] { sel } : Array.Empty();
+ }
+
+ var ctxHint = new MarkupControl(new List { string.Empty }) { Visible = false };
+
+ var ctxUpdateBtn = new ButtonBuilder()
+ .WithText("Update")
+ .WithColorRole(ColorRole.Warning)
+ .OnClick((_, _) => { var t = ContextTarget(); if (t.Length > 0) UpdateCheckedSkills(ws, panel, t); })
+ .Build();
+ ctxUpdateBtn.Visible = false;
+
+ var ctxRemoveBtn = new ButtonBuilder()
+ .WithText("Remove")
+ .WithColorRole(ColorRole.Danger)
+ .OnClick((_, _) => { var t = ContextTarget(); if (t.Length > 0) RemoveCheckedSkills(ws, panel, t); })
+ .Build();
+ ctxRemoveBtn.Visible = false;
+
+ var ctxClearBtn = new ButtonBuilder()
+ .WithText("Clear")
+ .OnClick((_, _) => builtTable?.ClearSelection())
+ .Build();
+ ctxClearBtn.Visible = false;
+
+ var installedToolbar = Controls.Toolbar()
+ .WithSpacing(1)
+ .WithBelowLine(true)
+ .WithWrap(true)
+ .AddButton("Browse skills", (_, _) => NavigateTo(HomeAction.BrowseSkills))
+ // Disabled (greyed) when nothing is outdated — preserves the prior BuildPageToolbar
+ // behavior; the runtime guard stays as defense-in-depth.
+ .AddButton(new ButtonBuilder()
+ .WithText($"Update all outdated ({outdated.Length})")
+ .Enabled(outdated.Length > 0)
+ .OnClick((_, _) =>
{
- 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);
+ if (outdated.Length == 0) { Toast("Nothing outdated to update.", NotificationSeverity.Info); return; }
+ RunOperationQueued(
+ $"Updating {outdated.Length} outdated skill(s)",
+ work: () => UpdateSkillRecords(outdated),
+ onComplete: summaryText =>
+ {
+ Toast(summaryText, summaryText.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success);
+ BuildInstalledPage(ws, panel);
+ });
+ }))
+ // Disabled (greyed) when nothing is installed — preserves the prior BuildPageToolbar
+ // behavior (parity with "Update all outdated").
+ .AddButton(new ButtonBuilder()
+ .WithText($"Remove all ({installed.Length})")
+ .Enabled(installed.Length > 0)
+ .OnClick((_, _) => ConfirmModal(ws, "Remove all installed skills?",
+ $"This removes every catalog skill from {layout.PrimaryRoot.FullName}.",
+ () => RunOperationQueued(
+ "Removing all installed skills",
+ work: () => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout),
+ onComplete: summary =>
+ {
+ ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)");
+ BuildInstalledPage(ws, panel);
+ })))
+ .Build())
+ .AddSeparator()
+ .Add(ctxHint)
+ .AddButton(ctxUpdateBtn)
+ .AddButton(ctxRemoveBtn)
+ .AddButton(ctxClearBtn)
+ .Build();
+ 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 = BuildStyledTable("Installed skills (Enter for details)", AccentGreen)
+ // Titleless: the identity strip above already names "installed skills", and a same-accent
+ // title line directly over the same-accent column header read as a duplicate header row
+ // (matches Skills/Bundles/Packages/Agents). Checkbox mode adds a leading check cell;
+ // Spacebar toggles the focused row for batch selection.
+ var table = BuildStyledTableBorderless(AccentGreen)
+ .WithCheckboxMode()
.AddColumn("Status", TextJustification.Center, width: 8)
.AddColumn("Skill")
.AddColumn("Collection")
.AddColumn("Lane")
- .AddColumn("Installed", TextJustification.Right)
- .AddColumn("Latest", TextJustification.Right)
- .AddColumn("Tokens", TextJustification.Right);
+ .AddColumn("Installed", TextJustification.Right, width: 10)
+ .AddColumn("Latest", TextJustification.Right, width: 9)
+ .AddColumn("Tokens", TextJustification.Right, width: 9);
foreach (var record in filtered)
{
var row = new TableRow(
@@ -110,10 +185,107 @@ private void BuildInstalledPage(ConsoleWindowSystem ws, ScrollablePanelControl p
ShowInstalledSkillModal(ws, panel, filtered[idx]);
}
});
- panel.AddControl(ApplyStyledTableRuntime(table.Build()));
+ builtTable = ApplyStyledTableRuntime(table.Build());
+ panel.AddControl(builtTable);
+
+ // Reactive: adapt the contextual controls to the current target. Called from both
+ // MultiSelectionChanged (checkbox) and SelectedRowItemChanged (cursor). The toolbar re-flows
+ // when controls hide/show (it skips !Visible items in layout); Visible/IsEnabled/SetContent
+ // go through reactive invalidation, so no manual rebuild is needed.
+ void RefreshSelectionContext()
+ {
+ var checkedRecords = builtTable!.GetCheckedRows().Select(r => r.Tag).OfType().ToArray();
+ var multi = checkedRecords.Length > 0;
+ var target = multi
+ ? checkedRecords
+ : (builtTable.SelectedRow?.Tag is InstalledSkillRecord sel ? new[] { sel } : Array.Empty());
+
+ if (target.Length == 0)
+ {
+ ctxHint.Visible = false;
+ ctxUpdateBtn.Visible = false;
+ ctxRemoveBtn.Visible = false;
+ ctxClearBtn.Visible = false;
+ return;
+ }
+
+ // Hint shows the count only in multi mode; Clear is multi-only (single mode has no checks).
+ if (multi)
+ {
+ ctxHint.SetContent(new List { $"[#7ab4ff]{checkedRecords.Length} selected[/]" });
+ ctxHint.Visible = true;
+ ctxClearBtn.Visible = true;
+ }
+ else
+ {
+ ctxHint.Visible = false;
+ ctxClearBtn.Visible = false;
+ }
+
+ // Update is enabled only when the target contains an outdated row (it skips current rows).
+ // Apply the Warning role ONLY when enabled — the disabled Warning paints as a muddy dimmed
+ // amber; dropping to Default gives a clean neutral greyed button when there's nothing to update.
+ var canUpdate = target.Any(r => !r.IsCurrent);
+ ctxUpdateBtn.IsEnabled = canUpdate;
+ ctxUpdateBtn.ColorRole = canUpdate ? ColorRole.Warning : ColorRole.Default;
+ ctxUpdateBtn.Visible = true;
+ ctxRemoveBtn.Visible = true;
+ }
+ builtTable.MultiSelectionChanged += (_, _) => RefreshSelectionContext();
+ builtTable.SelectedRowItemChanged += (_, _) => RefreshSelectionContext();
+ RefreshSelectionContext(); // initial: reflect whatever row is selected on first render
// Bulk actions live in the page toolbar at the top — no bottom-of-page Button stack.
}
+ ///
+ /// Batch Update for a checked subset. Acts on OUTDATED rows only (current rows are skipped,
+ /// matching Update-all-outdated semantics); reports the skip count. No-op with a toast if the
+ /// set has nothing outdated (the batch segment hides the Update link before this is reachable,
+ /// but guard anyway). Rebuilds the page on completion (resets checks + re-reads state).
+ ///
+ private void UpdateCheckedSkills(ConsoleWindowSystem ws, ScrollablePanelControl panel, IReadOnlyList checkedRecords)
+ {
+ var outdated = checkedRecords.Where(r => !r.IsCurrent).ToArray();
+ var skipped = checkedRecords.Count - outdated.Length;
+ if (outdated.Length == 0)
+ {
+ Toast("Nothing to update in the selection.", NotificationSeverity.Info);
+ return;
+ }
+ RunOperationQueued(
+ $"Updating {outdated.Length} selected skill(s)",
+ work: () => UpdateSkillRecords(outdated),
+ onComplete: summaryText =>
+ {
+ var failed = summaryText.Contains("failed", StringComparison.OrdinalIgnoreCase);
+ var msg = skipped > 0 ? $"{summaryText}, skipped {skipped} current" : summaryText;
+ Toast(msg, failed ? NotificationSeverity.Danger : NotificationSeverity.Success);
+ BuildInstalledPage(ws, panel);
+ });
+ }
+
+ ///
+ /// Batch Remove for a checked subset. Confirms once with the count (target dir in the body),
+ /// then removes every checked row. Rebuilds the page on completion.
+ ///
+ private void RemoveCheckedSkills(ConsoleWindowSystem ws, ScrollablePanelControl panel, IReadOnlyList checkedRecords)
+ {
+ if (checkedRecords.Count == 0) return;
+ var layout = ResolveSkillLayout();
+ ConfirmDangerModal(ws, $"Remove {checkedRecords.Count} selected skill(s)?",
+ $"Deletes the selected skill directories from {layout.PrimaryRoot.FullName}.",
+ confirmLabel: "Remove",
+ affectedItems: checkedRecords.Select(r => ToAlias(r.Skill.Name)).ToList(),
+ onConfirm: () => RunOperationQueued(
+ $"Removing {checkedRecords.Count} selected skill(s)",
+ work: () => new SkillInstaller(skillCatalog).Remove(checkedRecords.Select(r => r.Skill).ToArray(), layout),
+ onComplete: summary =>
+ {
+ ToastResult(summary, "Remove failed", summary is null ? string.Empty : $"Removed {summary.RemovedCount} skill(s)");
+ BuildInstalledPage(ws, panel);
+ }));
+ }
+
private void ShowInstalledSkillModal(ConsoleWindowSystem ws, ScrollablePanelControl owner, InstalledSkillRecord record)
{
var detail = new IWindowControl[]
@@ -125,7 +297,7 @@ private void ShowInstalledSkillModal(ConsoleWindowSystem ws, ScrollablePanelCont
("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),
+ BuildModalBlock("summary", Escape(record.Skill.Description)),
};
var buttons = new List<(string, Action)>();
@@ -133,22 +305,39 @@ private void ShowInstalledSkillModal(ConsoleWindowSystem ws, ScrollablePanelCont
{
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);
+ RunOperationQueued(
+ $"Updating {ToAlias(record.Skill.Name)}",
+ work: () => UpdateSkillRecords(new[] { record }),
+ onComplete: msg =>
+ {
+ 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);
+ var layout = ResolveSkillLayout();
+ RunOperationQueued(
+ $"Reinstalling {ToAlias(record.Skill.Name)}",
+ work: () => new SkillInstaller(skillCatalog).Install(new[] { record.Skill }, layout, force: true),
+ onComplete: summary =>
+ {
+ 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);
+ var layout = ResolveSkillLayout();
+ RunOperationQueued(
+ $"Removing {ToAlias(record.Skill.Name)}",
+ work: () => new SkillInstaller(skillCatalog).Remove(new[] { record.Skill }, layout),
+ onComplete: summary =>
+ {
+ ToastResult(summary, "Remove failed", $"Removed {ToAlias(record.Skill.Name)}");
+ BuildInstalledPage(ws, owner);
+ });
})));
ShowModalNative(ws, $"Installed · {ToAlias(record.Skill.Name)}", detail, buttons.ToArray());
@@ -169,7 +358,7 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan
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)));
+ AddInlineNote(panel, "Could not scan the project directory.", NoteSeverity.Error);
return;
}
@@ -207,7 +396,7 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan
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));
+ AddEmptyState(panel, "No package or framework signals matched the catalog. Start with the [green]dotnet[/] and [green]modern-csharp[/] skills from the Skills page.");
return;
}
@@ -231,14 +420,23 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan
($"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);
+ RunOperationQueued(
+ $"Installing {installable.Length} recommended skill(s)",
+ work: () =>
+ {
+ var installer2 = new SkillInstaller(skillCatalog);
+ var newSummary = newSkills.Length == 0 ? default : installer2.Install(newSkills, skillLayout, force: false);
+ var updateSummary = outdatedSkills.Length == 0 ? default : installer2.Install(outdatedSkills, skillLayout, force: true);
+ return (newSummary, updateSummary);
+ },
+ onComplete: t =>
+ {
+ var installedCount = (t.newSummary?.InstalledCount ?? 0) + (t.updateSummary?.InstalledCount ?? 0);
+ var skippedCount = (t.newSummary?.SkippedExisting.Count ?? 0) + (t.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);
@@ -246,7 +444,7 @@ 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 = BuildStyledTable("Recommended skills (Enter to install)", AccentDeepSkyBlue)
+ var table = BuildStyledTableBorderless("Recommended skills (Enter to install)", AccentDeepSkyBlue)
.AddColumn("Confidence", TextJustification.Center, width: 12)
.AddColumn("Status", width: 12)
.AddColumn("Skill")
@@ -281,9 +479,15 @@ private void BuildProjectPage(ConsoleWindowSystem ws, ScrollablePanelControl pan
// 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);
+ var layout = ResolveSkillLayout();
+ RunOperationQueued(
+ $"Installing {ToAlias(recommendation.Skill.Name)}",
+ work: () => new SkillInstaller(skillCatalog).Install(new[] { recommendation.Skill }, layout, force: isOutdated),
+ onComplete: summary2 =>
+ {
+ 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(builtTable);
@@ -318,11 +522,11 @@ private void BuildAnalysisPage(ConsoleWindowSystem ws, ScrollablePanelControl pa
// section further down, so the grid was redundant and crowded the page with rounded
// panels masquerading as content cards.
- var heavyTable = BuildStyledTable("Heaviest skills (Enter for details)", AccentDeepSkyBlue)
+ var heavyTable = BuildStyledTableBorderless("Heaviest skills (Enter for details)", AccentDeepSkyBlue)
.AddColumn("Skill")
.AddColumn("Collection")
.AddColumn("Lane")
- .AddColumn("Tokens", TextJustification.Right);
+ .AddColumn("Tokens", TextJustification.Right, width: 9);
foreach (var skill in heaviest)
{
heavyTable.AddRow(new TableRow(ToAlias(skill.Name), skill.Stack, skill.Lane, FormatTokenCount(skill.TokenCount)) { Tag = skill });
@@ -452,17 +656,20 @@ private void BuildRemoveAllPage(ConsoleWindowSystem ws, ScrollablePanelControl p
if (installed.Count == 0)
{
- panel.AddControl(BuildNotePanel("status", "[grey50]Nothing to remove in this target.[/]", AccentDeepSkyBlue));
+ AddEmptyState(panel, "Nothing to remove in this target.");
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());
+ RunOperationQueued(
+ "Removing all installed skills",
+ work: () => new SkillInstaller(skillCatalog).Remove(installed.Select(r => r.Skill).ToArray(), layout),
+ onComplete: summary =>
+ {
+ 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)
@@ -479,20 +686,25 @@ private void BuildUpdateAllPage(ConsoleWindowSystem ws, ScrollablePanelControl p
if (outdated.Length == 0)
{
- panel.AddControl(BuildNotePanel("status", "[green]All installed skills already match the catalog version.[/]", AccentGreen));
+ AddInlineNote(panel, "All installed skills already match the catalog version.", NoteSeverity.Success);
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));
+ AddInfoBlock(panel, "pending updates", 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);
+ RunOperationQueued(
+ $"Updating {outdated.Length} outdated skill(s)",
+ work: () => UpdateSkillRecords(outdated),
+ onComplete: msg =>
+ {
+ Toast(msg, msg.Contains("failed", StringComparison.OrdinalIgnoreCase) ? NotificationSeverity.Danger : NotificationSeverity.Success);
+ BuildUpdateAllPage(ws, panel);
+ });
}).Build());
}
@@ -511,9 +723,14 @@ private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl pa
// because those are the form's subject and aren't surfaced anywhere else).
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))}[/]"),
+ ("agent target", agentStatus.Layout is null ? "[#d70000]unresolved[/]" : $"[grey50]{Escape(CompactPath(agentStatus.Layout.PrimaryRoot.FullName))}[/]"),
("build", ToolVersionInfo.IsDevelopmentBuild ? "[grey50]local development[/]" : "[green]published[/]"));
+ // When the agent target is unresolved, surface the full explanation as a warning note under
+ // the strip rather than overflowing the strip itself.
+ if (agentStatus.Layout is null)
+ AddInlineNote(panel, Escape(agentStatus.Summary), NoteSeverity.Warning);
+
// 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),
@@ -546,11 +763,11 @@ private void BuildSettingsPage(ConsoleWindowSystem ws, ScrollablePanelControl pa
})
.Build();
- panel.AddControl(BuildSectionPanel("install target", "[grey50]Platform and scope control where skills and agents are written. Changes take effect immediately.[/]", AccentDeepSkyBlue));
+ AddFormSection(panel, "install target", "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));
+ AddFormSection(panel, "catalog", "Pull the latest catalog from upstream.", AccentTurquoise);
panel.AddControl(Controls.Button("Refresh catalog now")
.OnClick((_, _) => RefreshCatalogFromUi())
.Build());
@@ -574,17 +791,17 @@ private void BuildAboutPage(ScrollablePanelControl panel)
("skills", skillCatalog.Skills.Count.ToString()),
("agents", agentCatalog.Agents.Count.ToString())));
- panel.AddControl(BuildBulletPanel("surface map", AccentDeepSkyBlue,
+ AddInfoBlock(panel, "surface map",
"[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[/]"));
+ "[grey]Analysis[/] [grey50]collection sizes, heaviest skills, package signals[/]");
- panel.AddControl(BuildBulletPanel("notes", AccentGrey,
+ AddInfoBlock(panel, "notes",
"[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].[/]"));
+ "[grey50]CLI sub-commands (list, install, recommend, …) are unchanged — see[/] [green]dotnet skills help[/][grey50].[/]");
}
}
diff --git a/cli/ManagedCode.DotnetSkills/ManagedCode.DotnetSkills.csproj b/cli/ManagedCode.DotnetSkills/ManagedCode.DotnetSkills.csproj
index 2f5ebda..00c237c 100644
--- a/cli/ManagedCode.DotnetSkills/ManagedCode.DotnetSkills.csproj
+++ b/cli/ManagedCode.DotnetSkills/ManagedCode.DotnetSkills.csproj
@@ -46,7 +46,7 @@
-
+
diff --git a/cli/ManagedCode.DotnetSkills/Runtime/CommandRegistry.cs b/cli/ManagedCode.DotnetSkills/Runtime/CommandRegistry.cs
new file mode 100644
index 0000000..e24b1c2
--- /dev/null
+++ b/cli/ManagedCode.DotnetSkills/Runtime/CommandRegistry.cs
@@ -0,0 +1,65 @@
+namespace ManagedCode.DotnetSkills.Runtime;
+
+///
+/// Structured key binding (carried for future direct dispatch; not wired to global keys yet).
+///
+public record KeyBinding(ConsoleKey Key, ConsoleModifiers Modifiers = 0);
+
+///
+/// A single command-palette command: a labelled, categorised action with an optional keybinding hint.
+///
+public sealed class SkillCommand
+{
+ public string Id { get; init; } = "";
+ public string Label { get; init; } = "";
+ public string Category { get; init; } = "";
+ public string? Keybinding { get; init; }
+ public KeyBinding? KeyCombo { get; init; }
+ public string Icon { get; init; } = " ";
+ public Action Execute { get; init; } = () => { };
+ public int Priority { get; init; } = 50;
+}
+
+///
+/// Holds the command-palette commands and provides search + key lookup. Commands are registered once
+/// at shell startup; the palette renders and filters them.
+///
+public sealed class CommandRegistry
+{
+ private readonly List _commands = new();
+ private readonly Dictionary<(ConsoleKey, ConsoleModifiers), SkillCommand> _keyMap = new();
+
+ public void Register(SkillCommand command)
+ {
+ _commands.Add(command);
+ if (command.KeyCombo != null)
+ _keyMap[(command.KeyCombo.Key, command.KeyCombo.Modifiers)] = command;
+ }
+
+ public IReadOnlyList All => _commands;
+
+ /// Look up a command by key combo, or null if none is bound.
+ public SkillCommand? FindByKey(ConsoleKey key, ConsoleModifiers modifiers) =>
+ _keyMap.TryGetValue((key, modifiers), out var cmd) ? cmd : null;
+
+ ///
+ /// Filters by case-insensitive substring on label/category/keybinding, then orders by
+ /// label-prefix-match first, then by descending priority.
+ ///
+ public List Search(string query)
+ {
+ var filtered = string.IsNullOrWhiteSpace(query)
+ ? _commands.ToList()
+ : _commands.Where(c =>
+ c.Label.Contains(query, StringComparison.OrdinalIgnoreCase) ||
+ c.Category.Contains(query, StringComparison.OrdinalIgnoreCase) ||
+ (c.Keybinding != null && c.Keybinding.Contains(query, StringComparison.OrdinalIgnoreCase))
+ ).ToList();
+
+ return filtered
+ .OrderByDescending(c => !string.IsNullOrWhiteSpace(query)
+ && c.Label.StartsWith(query, StringComparison.OrdinalIgnoreCase) ? 1 : 0)
+ .ThenByDescending(c => c.Priority)
+ .ToList();
+ }
+}
diff --git a/cli/ManagedCode.DotnetSkills/Runtime/PaletteEntry.cs b/cli/ManagedCode.DotnetSkills/Runtime/PaletteEntry.cs
new file mode 100644
index 0000000..af97973
--- /dev/null
+++ b/cli/ManagedCode.DotnetSkills/Runtime/PaletteEntry.cs
@@ -0,0 +1,14 @@
+namespace ManagedCode.DotnetSkills;
+
+///
+/// A searchable content entry in the command palette's content mode (a skill, bundle, or agent).
+/// Carries its own display fields and an activation action (open its detail modal). Distinct from a
+/// , which is a curated verb in commands mode.
+///
+internal sealed record PaletteEntry(
+ string IconLabel,
+ string AccentMarkup,
+ string Label,
+ string Detail,
+ string SearchHaystack,
+ Action Activate);
diff --git a/cli/ManagedCode.DotnetSkills/UI/CommandPalettePortal.cs b/cli/ManagedCode.DotnetSkills/UI/CommandPalettePortal.cs
new file mode 100644
index 0000000..d6127d1
--- /dev/null
+++ b/cli/ManagedCode.DotnetSkills/UI/CommandPalettePortal.cs
@@ -0,0 +1,197 @@
+using ManagedCode.DotnetSkills.Runtime;
+using SharpConsoleUI;
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Controls;
+using SharpConsoleUI.Drawing;
+using SharpConsoleUI.Layout;
+using Rectangle = System.Drawing.Rectangle;
+
+namespace ManagedCode.DotnetSkills;
+
+///
+/// Command palette as a portal overlay (not a modal window). Commands-first: the default view lists
+/// curated s from the registry; typing a leading '/' switches to searching
+/// the content list (skills/bundles/agents). Filters live on every keystroke. Modeled on LazyDotIDE's
+/// CommandPalettePortal.
+///
+internal sealed class CommandPalettePortal : PortalContentContainer
+{
+ private const int PaletteMaxWidth = 85;
+ private const int PaletteMaxHeight = 22;
+
+ private readonly CommandRegistry _registry;
+ private readonly IReadOnlyList _content;
+ private readonly PromptControl _searchInput;
+ private readonly ListControl _list;
+ private readonly MarkupControl _statusText;
+
+ public event EventHandler? CommandChosen;
+ public event EventHandler? ContentChosen;
+
+ public CommandPalettePortal(CommandRegistry registry, IReadOnlyList content, int windowWidth, int windowHeight)
+ {
+ _registry = registry;
+ _content = content;
+
+ DismissOnOutsideClick = true;
+ BorderStyle = BoxChars.Rounded;
+ BorderColor = Color.Grey50;
+ BorderBackgroundColor = Color.Grey15;
+ BackgroundColor = Color.Grey15;
+ ForegroundColor = Color.Grey93;
+
+ _searchInput = Controls.Prompt("> ")
+ .WithAlignment(HorizontalAlignment.Stretch)
+ .WithMargin(1, 0, 1, 0)
+ .Build();
+ AddChild(_searchInput);
+
+ AddChild(Controls.RuleBuilder().WithColor(Color.Grey23).Build());
+
+ _list = Controls.List()
+ .WithAlignment(HorizontalAlignment.Stretch)
+ .WithVerticalAlignment(VerticalAlignment.Fill)
+ .WithColors(Color.Grey93, Color.Grey15)
+ .WithFocusedColors(Color.Grey93, Color.Grey15)
+ .WithHighlightColors(Color.White, Color.Grey35)
+ .WithDoubleClickActivation(true)
+ .WithTitle(string.Empty)
+ .Build();
+ AddChild(_list);
+
+ AddChild(Controls.RuleBuilder().WithColor(Color.Grey23).StickyBottom().Build());
+
+ _statusText = Controls.Markup()
+ .AddLine($"[grey50]{_registry.All.Count} commands[/]")
+ .WithAlignment(HorizontalAlignment.Left)
+ .WithMargin(1, 0, 1, 0)
+ .StickyBottom()
+ .Build();
+ AddChild(_statusText);
+
+ AddChild(Controls.Markup()
+ .AddLine("[grey70]Enter: run • / : search content • Esc: cancel • ↑↓: navigate[/]")
+ .WithAlignment(HorizontalAlignment.Center)
+ .StickyBottom()
+ .Build());
+
+ int w = Math.Min(PaletteMaxWidth, windowWidth - 4);
+ int h = Math.Min(PaletteMaxHeight, windowHeight - 2);
+ int x = (windowWidth - w) / 2;
+ PortalBounds = new Rectangle(x, 1, w, h);
+
+ // total height − border(2) − fixed children (prompt 1 + 2 rules + status 1 + hint 1 = 5)
+ _list.MaxVisibleItems = Math.Max(1, h - 2 - 5);
+
+ UpdateList(string.Empty);
+
+ _searchInput.InputChanged += (_, text) => UpdateList(text);
+ _list.ItemActivated += (_, item) => Activate(item);
+
+ SetFocusOnFirstChild();
+ }
+
+ private void Activate(ListItem? item)
+ {
+ switch (item?.Tag)
+ {
+ case SkillCommand cmd: CommandChosen?.Invoke(this, cmd); break;
+ case PaletteEntry entry: ContentChosen?.Invoke(this, entry); break;
+ }
+ }
+
+ private void UpdateList(string rawText)
+ {
+ _list.ClearItems();
+ bool contentMode = rawText.StartsWith("/", StringComparison.Ordinal);
+
+ if (contentMode)
+ {
+ string q = rawText[1..].Trim();
+ var results = (string.IsNullOrEmpty(q)
+ ? _content
+ : _content.Where(e => e.SearchHaystack.Contains(q, StringComparison.OrdinalIgnoreCase)))
+ .Take(80).ToList();
+ foreach (var e in results)
+ _list.AddItem(new ListItem($"[{e.AccentMarkup}]{e.IconLabel}[/] {e.Label} [grey50]{e.Detail}[/]") { Tag = e });
+ _statusText.SetContent(new List { $"[grey50]{results.Count} result(s) · content[/]" });
+ }
+ else
+ {
+ string q = rawText.Trim();
+ var results = _registry.Search(q);
+ foreach (var c in results)
+ _list.AddItem(new ListItem($"{c.Icon} [grey50]{c.Category,-9}[/] {c.Label} [grey50]{c.Keybinding ?? string.Empty}[/]") { Tag = c });
+ string status = string.IsNullOrWhiteSpace(q)
+ ? $"[grey50]{results.Count} commands · type / to search content[/]"
+ : $"[grey50]{results.Count} of {_registry.All.Count} commands[/]";
+ _statusText.SetContent(new List { status });
+ }
+
+ // Auto-select the first result so Enter activates it without needing a Down first.
+ if (_list.Items.Count > 0)
+ _list.SelectedIndex = 0;
+ }
+
+ // The portal isn't part of the host window's focus tree (the shell forwards keys here manually), so
+ // the usual prompt↔list focus dance via the window FocusManager doesn't apply. Instead the prompt is
+ // always "active" for typing (keys delegate to it via base.ProcessKey), Up/Down move the LIST
+ // selection directly, and Enter activates it. Simple and focus-independent.
+ public new bool ProcessKey(ConsoleKeyInfo key)
+ {
+ switch (key.Key)
+ {
+ case ConsoleKey.Escape:
+ CommandChosen?.Invoke(this, null); // null command → shell dismisses without executing
+ return true;
+
+ case ConsoleKey.Enter:
+ Activate(_list.SelectedItem);
+ return true;
+
+ case ConsoleKey.DownArrow:
+ MoveSelection(+1);
+ return true;
+
+ case ConsoleKey.UpArrow:
+ MoveSelection(-1);
+ return true;
+
+ case ConsoleKey.PageDown:
+ MoveSelection(+(_list.MaxVisibleItems ?? 10));
+ return true;
+
+ case ConsoleKey.PageUp:
+ MoveSelection(-(_list.MaxVisibleItems ?? 10));
+ return true;
+
+ case ConsoleKey.Home:
+ SetSelection(0);
+ return true;
+
+ case ConsoleKey.End:
+ SetSelection(_list.Items.Count - 1);
+ return true;
+ }
+
+ // Everything else (typing, backspace) goes to the focused child — the prompt — which updates
+ // the query and re-filters via InputChanged.
+ base.ProcessKey(key);
+ return true; // swallow all keys while the palette is open
+ }
+
+ private void MoveSelection(int delta)
+ {
+ int count = _list.Items.Count;
+ if (count == 0) return;
+ int cur = _list.SelectedIndex < 0 ? 0 : _list.SelectedIndex;
+ _list.SelectedIndex = Math.Clamp(cur + delta, 0, count - 1);
+ }
+
+ private void SetSelection(int index)
+ {
+ int count = _list.Items.Count;
+ if (count == 0) return;
+ _list.SelectedIndex = Math.Clamp(index, 0, count - 1);
+ }
+}