Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/SkillView.Core/Gh/GhSkillInstallService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ internal static ImmutableArray<RepoSkill> 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)
{
Expand All @@ -162,6 +162,37 @@ internal static ImmutableArray<RepoSkill> 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
Expand Down
23 changes: 18 additions & 5 deletions src/SkillView.Core/Ui/RepoSkillPickerModal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
{
Expand Down
20 changes: 20 additions & 0 deletions src/SkillView.Core/Ui/SkillViewApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 == '/')
{
Expand Down Expand Up @@ -603,6 +615,8 @@ private void ActivateTab(SkillViewTab tab)
{
_installedTab.Visible = true;
_ = _installedTab.LoadAsync();
var installed = _installedTab;
Invoke(() => installed.FocusList());
}
break;
case SkillViewTab.Changes:
Expand Down Expand Up @@ -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());
}
}
});
Expand Down
6 changes: 6 additions & 0 deletions src/SkillView.Core/Ui/SkillViewWorkflowCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
21 changes: 21 additions & 0 deletions src/SkillView.Core/Ui/Tabs/InstalledTabView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions tests/SkillView.Tests/Gh/GhSkillInstallServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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] name<TAB>desc"; 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()
{
Expand Down
Loading