From ad93b8da1318c876ebbbf382d64b209f3e2ee4c8 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 21 May 2026 18:39:14 +0200 Subject: [PATCH] Add skills dialog to TUI --- pkg/tui/commands/commands.go | 11 +++++ pkg/tui/commands/commands_test.go | 9 ++++ pkg/tui/dialog/skills.go | 78 +++++++++++++++++++++++++++++++ pkg/tui/dialog/skills_test.go | 45 ++++++++++++++++++ pkg/tui/handlers.go | 6 +++ pkg/tui/messages/toggle.go | 4 ++ pkg/tui/tui.go | 3 ++ 7 files changed, 156 insertions(+) create mode 100644 pkg/tui/dialog/skills.go create mode 100644 pkg/tui/dialog/skills_test.go diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index 25002009f..7f1d977c1 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -284,6 +284,17 @@ func builtInSessionCommands() []Item { return core.CmdHandler(messages.ShowToolsDialogMsg{}) }, }, + { + ID: "session.skills", + Label: "Skills", + SlashCommand: "/skills", + Description: "List skills available to the current agent", + Category: "Session", + Immediate: true, + Execute: func(string) tea.Cmd { + return core.CmdHandler(messages.ShowSkillsDialogMsg{}) + }, + }, { ID: "session.toolset.restart", Label: "Restart Toolset", diff --git a/pkg/tui/commands/commands_test.go b/pkg/tui/commands/commands_test.go index 74c751751..a37df1630 100644 --- a/pkg/tui/commands/commands_test.go +++ b/pkg/tui/commands/commands_test.go @@ -116,6 +116,15 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) { assert.True(t, ok) }) + t.Run("skills command", func(t *testing.T) { + t.Parallel() + cmd := parser.Parse("/skills") + require.NotNil(t, cmd) + msg := cmd() + _, ok := msg.(messages.ShowSkillsDialogMsg) + assert.True(t, ok) + }) + t.Run("unknown command returns nil", func(t *testing.T) { t.Parallel() cmd := parser.Parse("/unknown") diff --git a/pkg/tui/dialog/skills.go b/pkg/tui/dialog/skills.go new file mode 100644 index 000000000..58a69086f --- /dev/null +++ b/pkg/tui/dialog/skills.go @@ -0,0 +1,78 @@ +package dialog + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/docker/docker-agent/pkg/skills" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +type skillsDialog struct { + readOnlyScrollDialog + + skills []skills.Skill +} + +// NewSkillsDialog creates the /skills dialog showing every skill exposed +// to the current agent. +func NewSkillsDialog(skillList []skills.Skill) Dialog { + d := &skillsDialog{ + skills: skillList, + } + d.readOnlyScrollDialog = newReadOnlyScrollDialog( + readOnlyScrollDialogSize{widthPercent: 70, minWidth: 60, maxWidth: 100, heightPercent: 80, heightMax: 40}, + d.renderLines, + ) + return d +} + +func (d *skillsDialog) renderLines(contentWidth, _ int) []string { + title := fmt.Sprintf("Skills (%d)", len(d.skills)) + lines := []string{ + RenderTitle(title, contentWidth, styles.DialogTitleStyle), + RenderSeparator(contentWidth), + "", + } + + if len(d.skills) == 0 { + lines = append(lines, " "+styles.MutedStyle.Render("No skills available."), "") + return lines + } + + for i := range d.skills { + lines = append(lines, formatSkill(&d.skills[i], contentWidth)...) + } + + return lines +} + +func formatSkill(s *skills.Skill, contentWidth int) []string { + name := lipgloss.NewStyle().Foreground(styles.Highlight).Render(" " + s.Name) + name += " " + skillSourceBadge(s) + if s.IsFork() { + name += " " + styles.MutedStyle.Render("[fork]") + } + + out := []string{name} + + if desc, _, _ := strings.Cut(s.Description, "\n"); desc != "" { + indent := " " + availableWidth := contentWidth - lipgloss.Width(indent) + if availableWidth > 0 { + out = append(out, indent+styles.MutedStyle.Render(toolcommon.TruncateText(desc, availableWidth))) + } + } + out = append(out, "") + return out +} + +func skillSourceBadge(s *skills.Skill) string { + if s.Local { + return styles.SuccessStyle.Render("[local]") + } + return styles.WarningStyle.Render("[remote]") +} diff --git a/pkg/tui/dialog/skills_test.go b/pkg/tui/dialog/skills_test.go new file mode 100644 index 000000000..f667f264a --- /dev/null +++ b/pkg/tui/dialog/skills_test.go @@ -0,0 +1,45 @@ +package dialog + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/docker/docker-agent/pkg/skills" +) + +func TestNewSkillsDialog_EmptyShowsPlaceholder(t *testing.T) { + t.Parallel() + d := NewSkillsDialog(nil).(*skillsDialog) + out := strings.Join(d.renderLines(80, 24), "\n") + assert.Contains(t, out, "Skills (0)") + assert.Contains(t, out, "No skills available") +} + +func TestNewSkillsDialog_RendersSkills(t *testing.T) { + t.Parallel() + skillList := []skills.Skill{ + { + Name: "commit", + Description: "Commit local changes", + Local: true, + Context: "fork", + }, + { + Name: "poem", + Description: "Prints a poem", + Local: false, + }, + } + d := NewSkillsDialog(skillList).(*skillsDialog) + out := strings.Join(d.renderLines(80, 24), "\n") + assert.Contains(t, out, "Skills (2)") + assert.Contains(t, out, "commit") + assert.Contains(t, out, "Commit local changes") + assert.Contains(t, out, "local") + assert.Contains(t, out, "fork") + assert.Contains(t, out, "poem") + assert.Contains(t, out, "Prints a poem") + assert.Contains(t, out, "remote") +} diff --git a/pkg/tui/handlers.go b/pkg/tui/handlers.go index 1cea67095..90df59def 100644 --- a/pkg/tui/handlers.go +++ b/pkg/tui/handlers.go @@ -446,6 +446,12 @@ func (m *appModel) handleShowToolsDialog() (tea.Model, tea.Cmd) { }) } +func (m *appModel) handleShowSkillsDialog() (tea.Model, tea.Cmd) { + return m, core.CmdHandler(dialog.OpenDialogMsg{ + Model: dialog.NewSkillsDialog(m.application.CurrentAgentSkills()), + }) +} + // handleRestartToolset asks the runtime to restart the named toolset. // The actual call can block for up to ~35s (the supervisor's // reconnect timeout), so we run it inside a tea.Cmd goroutine and diff --git a/pkg/tui/messages/toggle.go b/pkg/tui/messages/toggle.go index 386467b19..8d070e018 100644 --- a/pkg/tui/messages/toggle.go +++ b/pkg/tui/messages/toggle.go @@ -32,6 +32,10 @@ type ( // the tool catalogue grouped by category. ShowToolsDialogMsg struct{} + // ShowSkillsDialogMsg shows the skills dialog: the list of skills + // available to the current agent. + ShowSkillsDialogMsg struct{} + // RestartToolsetMsg asks the runtime to restart the named toolset by // triggering its supervisor's RestartAndWait. RestartToolsetMsg struct{ Name string } diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 6cfa04405..8a4810567 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -879,6 +879,9 @@ func (m *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case messages.ShowToolsDialogMsg: return m.handleShowToolsDialog() + case messages.ShowSkillsDialogMsg: + return m.handleShowSkillsDialog() + case messages.RestartToolsetMsg: return m.handleRestartToolset(msg.Name)