Skip to content
Open
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
2 changes: 1 addition & 1 deletion cli/ManagedCode.Agents/ManagedCode.Agents.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
<PackageReference Include="Microsoft.ML.Tokenizers" Version="1.0.1" />
<PackageReference Include="Microsoft.ML.Tokenizers.Data.O200kBase" Version="1.0.1" />
<PackageReference Include="NuGet.Versioning" Version="7.3.0" />
<PackageReference Include="SharpConsoleUI" Version="2.4.61" />
<PackageReference Include="SharpConsoleUI" Version="2.5.0" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
<PackageReference Include="Microsoft.ML.Tokenizers" Version="1.0.1" />
<PackageReference Include="Microsoft.ML.Tokenizers.Data.O200kBase" Version="1.0.1" />
<PackageReference Include="NuGet.Versioning" Version="7.3.0" />
<PackageReference Include="SharpConsoleUI" Version="2.4.61" />
<PackageReference Include="SharpConsoleUI" Version="2.5.0" />
</ItemGroup>

<ItemGroup>
Expand Down
512 changes: 324 additions & 188 deletions cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Catalog.cs

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Home.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[/]");
}
}
712 changes: 553 additions & 159 deletions cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Shell.cs

Large diffs are not rendered by default.

355 changes: 286 additions & 69 deletions cli/ManagedCode.DotnetSkills/InteractiveConsoleApp.Workspace.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
<PackageReference Include="Microsoft.ML.Tokenizers" Version="1.0.1" />
<PackageReference Include="Microsoft.ML.Tokenizers.Data.O200kBase" Version="1.0.1" />
<PackageReference Include="NuGet.Versioning" Version="7.3.0" />
<PackageReference Include="SharpConsoleUI" Version="2.4.61" />
<PackageReference Include="SharpConsoleUI" Version="2.5.0" />
<PackageReference Include="Spectre.Console" Version="0.55.0" />
<PackageReference Include="Spectre.Console.Ansi" Version="0.55.0" />
</ItemGroup>
Expand Down
65 changes: 65 additions & 0 deletions cli/ManagedCode.DotnetSkills/Runtime/CommandRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
namespace ManagedCode.DotnetSkills.Runtime;

/// <summary>
/// Structured key binding (carried for future direct dispatch; not wired to global keys yet).
/// </summary>
public record KeyBinding(ConsoleKey Key, ConsoleModifiers Modifiers = 0);

/// <summary>
/// A single command-palette command: a labelled, categorised action with an optional keybinding hint.
/// </summary>
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;
}

/// <summary>
/// Holds the command-palette commands and provides search + key lookup. Commands are registered once
/// at shell startup; the palette renders and filters them.
/// </summary>
public sealed class CommandRegistry
{
private readonly List<SkillCommand> _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<SkillCommand> All => _commands;

/// <summary>Look up a command by key combo, or null if none is bound.</summary>
public SkillCommand? FindByKey(ConsoleKey key, ConsoleModifiers modifiers) =>
_keyMap.TryGetValue((key, modifiers), out var cmd) ? cmd : null;

/// <summary>
/// Filters by case-insensitive substring on label/category/keybinding, then orders by
/// label-prefix-match first, then by descending priority.
/// </summary>
public List<SkillCommand> 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();
}
}
14 changes: 14 additions & 0 deletions cli/ManagedCode.DotnetSkills/Runtime/PaletteEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace ManagedCode.DotnetSkills;

/// <summary>
/// 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
/// <see cref="Runtime.SkillCommand"/>, which is a curated verb in commands mode.
/// </summary>
internal sealed record PaletteEntry(
string IconLabel,
string AccentMarkup,
string Label,
string Detail,
string SearchHaystack,
Action Activate);
197 changes: 197 additions & 0 deletions cli/ManagedCode.DotnetSkills/UI/CommandPalettePortal.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Command palette as a portal overlay (not a modal window). Commands-first: the default view lists
/// curated <see cref="SkillCommand"/>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.
/// </summary>
internal sealed class CommandPalettePortal : PortalContentContainer
{
private const int PaletteMaxWidth = 85;
private const int PaletteMaxHeight = 22;

private readonly CommandRegistry _registry;
private readonly IReadOnlyList<PaletteEntry> _content;
private readonly PromptControl _searchInput;
private readonly ListControl _list;
private readonly MarkupControl _statusText;

public event EventHandler<SkillCommand?>? CommandChosen;
public event EventHandler<PaletteEntry?>? ContentChosen;

public CommandPalettePortal(CommandRegistry registry, IReadOnlyList<PaletteEntry> 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<string> { $"[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<string> { 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);
}
}
Loading