Skip to content

Commit 1f9776b

Browse files
committed
Add /tools command to show available tools in a TUI dialog
Assisted-By: docker-agent
1 parent 84d3fc2 commit 1f9776b

6 files changed

Lines changed: 186 additions & 0 deletions

File tree

pkg/app/app.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ func (a *App) SendFirstMessage() tea.Cmd {
179179
return tea.Sequence(cmds...)
180180
}
181181

182+
// CurrentAgentTools returns the tools available to the current agent.
183+
func (a *App) CurrentAgentTools(ctx context.Context) ([]tools.Tool, error) {
184+
return a.runtime.CurrentAgentTools(ctx)
185+
}
186+
182187
// CurrentAgentCommands returns the commands for the active agent
183188
func (a *App) CurrentAgentCommands(ctx context.Context) types.Commands {
184189
return a.runtime.CurrentAgentInfo(ctx).Commands

pkg/tui/commands/commands.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,16 @@ func builtInSessionCommands() []Item {
209209
},
210210
},
211211

212+
{
213+
ID: "session.tools",
214+
Label: "Tools",
215+
SlashCommand: "/tools",
216+
Description: "Show all tools available to the current agent",
217+
Category: "Session",
218+
Execute: func(string) tea.Cmd {
219+
return core.CmdHandler(messages.ShowToolsDialogMsg{})
220+
},
221+
},
212222
{
213223
ID: "session.title",
214224
Label: "Title",

pkg/tui/dialog/tools.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package dialog
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
"strings"
7+
8+
"charm.land/bubbles/v2/key"
9+
tea "charm.land/bubbletea/v2"
10+
"charm.land/lipgloss/v2"
11+
12+
"github.com/docker/docker-agent/pkg/tools"
13+
"github.com/docker/docker-agent/pkg/tui/components/scrollview"
14+
"github.com/docker/docker-agent/pkg/tui/components/toolcommon"
15+
"github.com/docker/docker-agent/pkg/tui/core"
16+
"github.com/docker/docker-agent/pkg/tui/core/layout"
17+
"github.com/docker/docker-agent/pkg/tui/styles"
18+
)
19+
20+
// toolsDialog displays all tools available to the current agent.
21+
type toolsDialog struct {
22+
BaseDialog
23+
24+
tools []tools.Tool
25+
closeKey key.Binding
26+
scrollview *scrollview.Model
27+
}
28+
29+
// NewToolsDialog creates a new dialog showing all available tools.
30+
func NewToolsDialog(toolList []tools.Tool) Dialog {
31+
// Sort tools by category then name
32+
sorted := make([]tools.Tool, len(toolList))
33+
copy(sorted, toolList)
34+
slices.SortFunc(sorted, func(a, b tools.Tool) int {
35+
if c := strings.Compare(strings.ToLower(a.Category), strings.ToLower(b.Category)); c != 0 {
36+
return c
37+
}
38+
return strings.Compare(strings.ToLower(a.DisplayName()), strings.ToLower(b.DisplayName()))
39+
})
40+
41+
return &toolsDialog{
42+
tools: sorted,
43+
scrollview: scrollview.New(
44+
scrollview.WithKeyMap(scrollview.ReadOnlyScrollKeyMap()),
45+
scrollview.WithReserveScrollbarSpace(true),
46+
),
47+
closeKey: key.NewBinding(key.WithKeys("esc", "enter", "q"), key.WithHelp("Esc", "close")),
48+
}
49+
}
50+
51+
func (d *toolsDialog) Init() tea.Cmd {
52+
return nil
53+
}
54+
55+
func (d *toolsDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
56+
if handled, cmd := d.scrollview.Update(msg); handled {
57+
return d, cmd
58+
}
59+
60+
switch msg := msg.(type) {
61+
case tea.WindowSizeMsg:
62+
cmd := d.SetSize(msg.Width, msg.Height)
63+
return d, cmd
64+
65+
case tea.KeyPressMsg:
66+
if key.Matches(msg, d.closeKey) {
67+
return d, core.CmdHandler(CloseDialogMsg{})
68+
}
69+
}
70+
return d, nil
71+
}
72+
73+
func (d *toolsDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) {
74+
dialogWidth = d.ComputeDialogWidth(70, 50, 80)
75+
maxHeight = min(d.Height()*80/100, 40)
76+
contentWidth = d.ContentWidth(dialogWidth, 2) - d.scrollview.ReservedCols()
77+
return dialogWidth, maxHeight, contentWidth
78+
}
79+
80+
func (d *toolsDialog) Position() (row, col int) {
81+
dialogWidth, maxHeight, _ := d.dialogSize()
82+
return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight)
83+
}
84+
85+
func (d *toolsDialog) View() string {
86+
dialogWidth, maxHeight, contentWidth := d.dialogSize()
87+
content := d.renderContent(contentWidth, maxHeight)
88+
return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Render(content)
89+
}
90+
91+
func (d *toolsDialog) renderContent(contentWidth, maxHeight int) string {
92+
title := fmt.Sprintf("Tools (%d)", len(d.tools))
93+
lines := []string{
94+
RenderTitle(title, contentWidth, styles.DialogTitleStyle),
95+
RenderSeparator(contentWidth),
96+
"",
97+
}
98+
99+
if len(d.tools) == 0 {
100+
lines = append(lines, styles.MutedStyle.Render("No tools available."), "")
101+
} else {
102+
var lastCategory string
103+
for i := range d.tools {
104+
t := &d.tools[i]
105+
cat := t.Category
106+
if cat == "" {
107+
cat = "Other"
108+
}
109+
if cat != lastCategory {
110+
if lastCategory != "" {
111+
lines = append(lines, "")
112+
}
113+
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(styles.TextSecondary).Render(cat))
114+
lastCategory = cat
115+
}
116+
117+
name := lipgloss.NewStyle().Foreground(styles.Highlight).Render(" " + t.DisplayName())
118+
if t.Description != "" {
119+
separator := " • "
120+
separatorWidth := lipgloss.Width(separator)
121+
nameWidth := lipgloss.Width(name)
122+
availableWidth := contentWidth - nameWidth - separatorWidth
123+
if availableWidth > 0 {
124+
desc := toolcommon.TruncateText(t.Description, availableWidth)
125+
name += styles.MutedStyle.Render(separator + desc)
126+
}
127+
}
128+
lines = append(lines, name)
129+
}
130+
lines = append(lines, "")
131+
}
132+
133+
return d.applyScrolling(lines, contentWidth, maxHeight)
134+
}
135+
136+
func (d *toolsDialog) applyScrolling(allLines []string, contentWidth, maxHeight int) string {
137+
const headerLines = 3 // title + separator + space
138+
const footerLines = 2 // space + help
139+
140+
visibleLines := max(1, maxHeight-headerLines-footerLines-4)
141+
contentLines := allLines[headerLines:]
142+
143+
regionWidth := contentWidth + d.scrollview.ReservedCols()
144+
d.scrollview.SetSize(regionWidth, visibleLines)
145+
146+
dialogRow, dialogCol := d.Position()
147+
d.scrollview.SetPosition(dialogCol+3, dialogRow+2+headerLines)
148+
149+
d.scrollview.SetContent(contentLines, len(contentLines))
150+
151+
scrollableContent := d.scrollview.View()
152+
parts := append(allLines[:headerLines], scrollableContent)
153+
parts = append(parts, "", RenderHelpKeys(regionWidth, "↑↓", "scroll", "Esc", "close"))
154+
return lipgloss.JoinVertical(lipgloss.Left, parts...)
155+
}

pkg/tui/handlers.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,16 @@ func (m *appModel) handleShowPermissionsDialog() (tea.Model, tea.Cmd) {
311311
})
312312
}
313313

314+
func (m *appModel) handleShowToolsDialog() (tea.Model, tea.Cmd) {
315+
agentTools, err := m.application.CurrentAgentTools(context.Background())
316+
if err != nil {
317+
return m, notification.ErrorCmd(fmt.Sprintf("Failed to load tools: %v", err))
318+
}
319+
return m, core.CmdHandler(dialog.OpenDialogMsg{
320+
Model: dialog.NewToolsDialog(agentTools),
321+
})
322+
}
323+
314324
// --- MCP prompts ---
315325

316326
func (m *appModel) handleShowMCPPromptInput(promptName string, promptInfo any) (tea.Model, tea.Cmd) {

pkg/tui/messages/toggle.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,7 @@ type (
2121

2222
// ShowPermissionsDialogMsg shows the permissions dialog.
2323
ShowPermissionsDialogMsg struct{}
24+
25+
// ShowToolsDialogMsg shows the tools dialog.
26+
ShowToolsDialogMsg struct{}
2427
)

pkg/tui/tui.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,9 @@ func (m *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
806806
case messages.ShowPermissionsDialogMsg:
807807
return m.handleShowPermissionsDialog()
808808

809+
case messages.ShowToolsDialogMsg:
810+
return m.handleShowToolsDialog()
811+
809812
case messages.AgentCommandMsg:
810813
return m.handleAgentCommand(msg.Command)
811814

0 commit comments

Comments
 (0)