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); + } +}