diff --git a/src/SkillView.Core/Gh/GhSkillInstallService.cs b/src/SkillView.Core/Gh/GhSkillInstallService.cs index 301f59a..4aff737 100644 --- a/src/SkillView.Core/Gh/GhSkillInstallService.cs +++ b/src/SkillView.Core/Gh/GhSkillInstallService.cs @@ -137,7 +137,7 @@ internal static ImmutableArray ParseRepoSkillListing(string? stdout) } var tab = line.IndexOf('\t'); - var name = (tab >= 0 ? line[..tab] : line).Trim(); + var name = NormalizeSkillName((tab >= 0 ? line[..tab] : line).Trim()); var description = tab >= 0 ? line[(tab + 1)..].Trim() : string.Empty; if (name.Length == 0) { @@ -162,6 +162,37 @@ internal static ImmutableArray ParseRepoSkillListing(string? stdout) return builder.ToImmutable(); } + /// gh prefixes each listed skill with its namespace in brackets, e.g. + /// `[root] code-review` or `[monalisa] code-review`. The bracket tag is + /// display decoration, not part of the installable argument: root-namespace + /// skills install by bare name, others as `namespace/name`. Strip the tag so + /// the picker shows clean names and per-name (subset) installs actually + /// resolve. Input without a leading `[ns] ` tag is returned unchanged. + internal static string NormalizeSkillName(string raw) + { + if (raw.Length < 2 || raw[0] != '[') + { + return raw; + } + + var close = raw.IndexOf(']'); + if (close < 1) + { + return raw; + } + + var ns = raw[1..close].Trim(); + var rest = raw[(close + 1)..].Trim(); + if (rest.Length == 0) + { + return raw; + } + + return ns.Length == 0 || ns.Equals("root", StringComparison.OrdinalIgnoreCase) + ? rest + : $"{ns}/{rest}"; + } + /// How to install a chosen subset of a repo's discovered skills. When the /// user keeps every discovered skill checked, a single `--all` install is /// cheaper and matches the existing install-all path; otherwise each diff --git a/src/SkillView.Core/Ui/RepoSkillPickerModal.cs b/src/SkillView.Core/Ui/RepoSkillPickerModal.cs index 4095f40..df18e81 100644 --- a/src/SkillView.Core/Ui/RepoSkillPickerModal.cs +++ b/src/SkillView.Core/Ui/RepoSkillPickerModal.cs @@ -88,23 +88,30 @@ internal Result Show() listFrame.ViewportSettings |= ViewportSettingsFlags.HasVerticalScrollBar; var skillBoxes = new CheckBox[_skills.Length]; + var contentWidth = 20; for (var i = 0; i < _skills.Length; i++) { var skill = _skills[i]; - var label = string.IsNullOrEmpty(skill.Description) - ? skill.Name - : $"{skill.Name} — {skill.Description}"; + // Keep labels bounded so the (non-horizontally-scrolling) content + // region stays readable; the full description lives in the repo. + var desc = skill.Description.Length > 70 + ? skill.Description[..70].TrimEnd() + "…" + : skill.Description; + var label = string.IsNullOrEmpty(desc) ? skill.Name : $"{skill.Name} — {desc}"; + contentWidth = Math.Max(contentWidth, label.Length + 4); var box = new CheckBox { X = 0, Y = i, - Width = Dim.Fill(), Text = label, Value = CheckState.Checked, }; skillBoxes[i] = box; listFrame.Add(box); } - listFrame.SetContentSize(new Size(1, Math.Max(_skills.Length, 1))); + // Content size drives the vertical scrollbar; the width must be wide + // enough for the labels (a width of 1 would clip every label to the + // checkbox glyph). Height is the row count so all skills scroll. + listFrame.SetContentSize(new Size(contentWidth, Math.Max(_skills.Length, 1))); var scopeLabel = new Label { X = 1, Y = Pos.AnchorEnd(9), Text = "Scope:" }; var scopeSelector = new OptionSelector @@ -193,6 +200,12 @@ void RefreshValidity() RefreshValidity(); }; customPathField.TextChanged += (_, _) => RefreshValidity(); + // Update the "N/M selected" count + Install-enabled state when a row is + // toggled with Space (the CheckBox change event is ValueChanged here). + foreach (var box in skillBoxes) + { + box.ValueChanged += (_, _) => RefreshValidity(); + } void SetAll(CheckState state) { diff --git a/src/SkillView.Core/Ui/SkillViewApp.cs b/src/SkillView.Core/Ui/SkillViewApp.cs index 5ae200c..5d08a06 100644 --- a/src/SkillView.Core/Ui/SkillViewApp.cs +++ b/src/SkillView.Core/Ui/SkillViewApp.cs @@ -498,6 +498,18 @@ private bool OnWindowShortcut(Key key) return false; } + // Esc at the root of a primary tab would otherwise fall through to + // Terminal.Gui's default quit-on-Esc and exit the app with no + // confirmation. Swallow it and hint how to quit instead. Modals, + // Doctor, Updates, and the search field all handle Esc themselves + // (setting Handled before this runs), so this only fires at the + // top-level list where Esc previously meant "lose your session". + if (key.KeyCode == KeyCode.Esc) + { + SetStatus("Press q to quit"); + return true; + } + var rune = key.AsRune; if (rune.Value == '/') { @@ -603,6 +615,8 @@ private void ActivateTab(SkillViewTab tab) { _installedTab.Visible = true; _ = _installedTab.LoadAsync(); + var installed = _installedTab; + Invoke(() => installed.FocusList()); } break; case SkillViewTab.Changes: @@ -806,6 +820,12 @@ private void ProbeGhAsync() _activeTab = SkillViewTab.Installed; _tabBar?.SetActiveTab(SkillViewTab.Installed); _installedTab.LoadSeeded(snapshot); + // Defer to the next loop tick so the list focus wins + // over Terminal.Gui's post-reflow default that would + // otherwise land on the filter field and swallow the + // advertised global hotkeys (?, d, x). + var tab = _installedTab; + Invoke(() => tab.FocusList()); } } }); diff --git a/src/SkillView.Core/Ui/SkillViewWorkflowCoordinator.cs b/src/SkillView.Core/Ui/SkillViewWorkflowCoordinator.cs index 7672822..d086a01 100644 --- a/src/SkillView.Core/Ui/SkillViewWorkflowCoordinator.cs +++ b/src/SkillView.Core/Ui/SkillViewWorkflowCoordinator.cs @@ -230,6 +230,12 @@ public void OpenRepoDiscoveryDialog(InstallRequest request) $"install failed — {TuiHelpers.ErrorSnippet(result.FirstError)}".TrimEnd(), TuiHelpers.NotificationLevel.Error); } + else + { + // Cancelled (or nothing selected): clear the lingering + // "discovering skills in …" busy status the spinner left. + _setStatus($"{request.Repo}: no skills installed"); + } }); }, "discover"); } diff --git a/src/SkillView.Core/Ui/Tabs/InstalledTabView.cs b/src/SkillView.Core/Ui/Tabs/InstalledTabView.cs index 4ec1649..f39511d 100644 --- a/src/SkillView.Core/Ui/Tabs/InstalledTabView.cs +++ b/src/SkillView.Core/Ui/Tabs/InstalledTabView.cs @@ -141,6 +141,18 @@ internal InstalledTabView( }; _filterField.TextChanged += (_, _) => RefreshAll(); + _filterField.KeyDown += (_, key) => + { + // Esc on a non-empty filter clears it (the list is live-filtered, so + // stray text otherwise leaves it stuck at "(no matches)"); the + // TextChanged handler then restores the full list. An Esc on an + // already-empty filter is left to the normal "Back" shortcut. + if (key.KeyCode == KeyCode.Esc && _filterField.Text.Length > 0) + { + key.Handled = true; + _filterField.Text = string.Empty; + } + }; _table.ValueChanged += (_, _) => { var row = _table.GetSelectedRow(); @@ -455,6 +467,15 @@ private void QueueDeferredWidthStabilization(int remainingPasses) }); } + /// Force focus onto the skill list. Used after the tab is activated so the + /// browse-first list (not the filter text field) holds focus — otherwise + /// global hotkeys like `?`, `d`, and `x` get typed into the filter. + internal void FocusList() + { + _lastFocusedTarget = FocusTarget.Table; + _table.SetFocus(); + } + private void RestoreFocus() { switch (_lastFocusedTarget) diff --git a/tests/SkillView.Tests/Gh/GhSkillInstallServiceTests.cs b/tests/SkillView.Tests/Gh/GhSkillInstallServiceTests.cs index b08cfd2..bbfb043 100644 --- a/tests/SkillView.Tests/Gh/GhSkillInstallServiceTests.cs +++ b/tests/SkillView.Tests/Gh/GhSkillInstallServiceTests.cs @@ -173,6 +173,30 @@ public void ParseRepoSkillListing_ToleratesBlankLinesNameOnlyHeaderAndCrlf() Assert.Equal("code-review", skills[1].Name); } + [Theory] + [InlineData("[root] code-review", "code-review")] // root namespace → bare name + [InlineData("[monalisa] code-review", "monalisa/code-review")] // other namespace → ns/name + [InlineData("[ROOT] x", "x")] // case-insensitive root + [InlineData("plain-name", "plain-name")] // no tag → unchanged + [InlineData("[root] a/b", "a/b")] // already-pathed name kept + [InlineData("[unterminated name", "[unterminated name")] // malformed → unchanged + [InlineData("[root] ", "[root] ")] // empty rest → unchanged + public void NormalizeSkillName_StripsNamespaceTag(string raw, string expected) + { + Assert.Equal(expected, GhSkillInstallService.NormalizeSkillName(raw)); + } + + [Fact] + public void ParseRepoSkillListing_StripsRootNamespaceFromNames() + { + // gh emits "[root] namedesc"; the picker/install must use bare name. + var stdout = "[root] code-review\tReviews PRs\n[root] git-commit\t\n"; + var skills = GhSkillInstallService.ParseRepoSkillListing(stdout); + + Assert.Equal(new[] { "code-review", "git-commit" }, skills.Select(s => s.Name)); + Assert.Equal("Reviews PRs", skills[0].Description); + } + [Fact] public void ParseRepoSkillListing_DeduplicatesByNameAndHandlesEmpty() {