Skip to content

Commit 6878e0b

Browse files
authored
Merge pull request #2209 from dgageot/slash-tools
Add /tools command to show the available tools
2 parents 0c2bf5d + f595b08 commit 6878e0b

10 files changed

Lines changed: 278 additions & 97 deletions

File tree

pkg/app/app.go

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

188+
// CurrentAgentTools returns the tools available to the current agent.
189+
func (a *App) CurrentAgentTools(ctx context.Context) ([]tools.Tool, error) {
190+
return a.runtime.CurrentAgentTools(ctx)
191+
}
192+
188193
// CurrentAgentCommands returns the commands for the active agent
189194
func (a *App) CurrentAgentCommands(ctx context.Context) types.Commands {
190195
return a.runtime.CurrentAgentInfo(ctx).Commands

pkg/tools/builtin/script_shell.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ func (t *ScriptShellTool) Tools(context.Context) ([]tools.Tool, error) {
116116

117117
toolsList = append(toolsList, tools.Tool{
118118
Name: toolName,
119+
Category: "shell",
119120
Description: description,
120121
Parameters: inputSchema,
121122
OutputSchema: tools.MustSchemaFor[string](),

pkg/tools/mcp/mcp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ func (ts *Toolset) Tools(ctx context.Context) ([]tools.Tool, error) {
374374

375375
tool := tools.Tool{
376376
Name: name,
377+
Category: "mcp",
377378
Description: t.Description,
378379
Parameters: t.InputSchema,
379380
OutputSchema: t.OutputSchema,

pkg/tui/commands/commands.go

Lines changed: 10 additions & 11 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",
@@ -317,17 +327,6 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor
317327
// Get session commands and filter based on model capabilities
318328
sessionCommands := builtInSessionCommands()
319329

320-
// Hide /permissions if no permissions are configured
321-
if !application.HasPermissions() {
322-
filtered := make([]Item, 0, len(sessionCommands))
323-
for _, cmd := range sessionCommands {
324-
if cmd.ID != "session.permissions" {
325-
filtered = append(filtered, cmd)
326-
}
327-
}
328-
sessionCommands = filtered
329-
}
330-
331330
categories := []Category{
332331
{
333332
Name: "Session",

pkg/tui/dialog/permissions.go

Lines changed: 9 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,34 @@
11
package dialog
22

33
import (
4-
"charm.land/bubbles/v2/key"
5-
tea "charm.land/bubbletea/v2"
64
"charm.land/lipgloss/v2"
75

86
"github.com/docker/docker-agent/pkg/runtime"
9-
"github.com/docker/docker-agent/pkg/tui/components/scrollview"
10-
"github.com/docker/docker-agent/pkg/tui/core"
11-
"github.com/docker/docker-agent/pkg/tui/core/layout"
127
"github.com/docker/docker-agent/pkg/tui/styles"
138
)
149

1510
// permissionsDialog displays the configured tool permissions (allow/deny patterns).
1611
type permissionsDialog struct {
17-
BaseDialog
12+
readOnlyScrollDialog
1813

1914
permissions *runtime.PermissionsInfo
2015
yoloEnabled bool
21-
closeKey key.Binding
22-
scrollview *scrollview.Model
2316
}
2417

2518
// NewPermissionsDialog creates a new dialog showing tool permission rules.
2619
func NewPermissionsDialog(perms *runtime.PermissionsInfo, yoloEnabled bool) Dialog {
27-
return &permissionsDialog{
20+
d := &permissionsDialog{
2821
permissions: perms,
2922
yoloEnabled: yoloEnabled,
30-
scrollview: scrollview.New(
31-
scrollview.WithKeyMap(scrollview.ReadOnlyScrollKeyMap()),
32-
scrollview.WithReserveScrollbarSpace(true),
33-
),
34-
closeKey: key.NewBinding(key.WithKeys("esc", "enter", "q"), key.WithHelp("Esc", "close")),
3523
}
24+
d.readOnlyScrollDialog = newReadOnlyScrollDialog(
25+
readOnlyScrollDialogSize{widthPercent: 60, minWidth: 40, maxWidth: 70, heightPercent: 70, heightMax: 30},
26+
d.renderLines,
27+
)
28+
return d
3629
}
3730

38-
func (d *permissionsDialog) Init() tea.Cmd {
39-
return nil
40-
}
41-
42-
func (d *permissionsDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
43-
if handled, cmd := d.scrollview.Update(msg); handled {
44-
return d, cmd
45-
}
46-
47-
switch msg := msg.(type) {
48-
case tea.WindowSizeMsg:
49-
cmd := d.SetSize(msg.Width, msg.Height)
50-
return d, cmd
51-
52-
case tea.KeyPressMsg:
53-
if key.Matches(msg, d.closeKey) {
54-
return d, core.CmdHandler(CloseDialogMsg{})
55-
}
56-
}
57-
return d, nil
58-
}
59-
60-
func (d *permissionsDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) {
61-
dialogWidth = d.ComputeDialogWidth(60, 40, 70)
62-
maxHeight = min(d.Height()*70/100, 30)
63-
contentWidth = d.ContentWidth(dialogWidth, 2) - d.scrollview.ReservedCols()
64-
return dialogWidth, maxHeight, contentWidth
65-
}
66-
67-
func (d *permissionsDialog) Position() (row, col int) {
68-
dialogWidth, maxHeight, _ := d.dialogSize()
69-
return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight)
70-
}
71-
72-
func (d *permissionsDialog) View() string {
73-
dialogWidth, maxHeight, contentWidth := d.dialogSize()
74-
content := d.renderContent(contentWidth, maxHeight)
75-
return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Render(content)
76-
}
77-
78-
func (d *permissionsDialog) renderContent(contentWidth, maxHeight int) string {
79-
// Build all lines
31+
func (d *permissionsDialog) renderLines(contentWidth, _ int) []string {
8032
lines := []string{
8133
RenderTitle("Tool Permissions", contentWidth, styles.DialogTitleStyle),
8234
RenderSeparator(contentWidth),
@@ -89,7 +41,6 @@ func (d *permissionsDialog) renderContent(contentWidth, maxHeight int) string {
8941
if d.permissions == nil {
9042
lines = append(lines, styles.MutedStyle.Render("No permission patterns configured."), "")
9143
} else {
92-
// Deny section (checked first during evaluation)
9344
if len(d.permissions.Deny) > 0 {
9445
lines = append(lines, d.renderSectionHeader("Deny", "Always blocked, even with yolo mode"), "")
9546
for _, pattern := range d.permissions.Deny {
@@ -98,7 +49,6 @@ func (d *permissionsDialog) renderContent(contentWidth, maxHeight int) string {
9849
lines = append(lines, "")
9950
}
10051

101-
// Allow section
10252
if len(d.permissions.Allow) > 0 {
10353
lines = append(lines, d.renderSectionHeader("Allow", "Auto-approved without confirmation"), "")
10454
for _, pattern := range d.permissions.Allow {
@@ -107,7 +57,6 @@ func (d *permissionsDialog) renderContent(contentWidth, maxHeight int) string {
10757
lines = append(lines, "")
10858
}
10959

110-
// Ask section
11160
if len(d.permissions.Ask) > 0 {
11261
lines = append(lines, d.renderSectionHeader("Ask", "Always requires confirmation, even for read-only tools"), "")
11362
for _, pattern := range d.permissions.Ask {
@@ -116,14 +65,12 @@ func (d *permissionsDialog) renderContent(contentWidth, maxHeight int) string {
11665
lines = append(lines, "")
11766
}
11867

119-
// If all are empty
12068
if len(d.permissions.Allow) == 0 && len(d.permissions.Ask) == 0 && len(d.permissions.Deny) == 0 {
12169
lines = append(lines, styles.MutedStyle.Render("No permission patterns configured."), "")
12270
}
12371
}
12472

125-
// Apply scrolling
126-
return d.applyScrolling(lines, contentWidth, maxHeight)
73+
return lines
12774
}
12875

12976
func (d *permissionsDialog) renderYoloStatus() string {
@@ -146,7 +93,6 @@ func (d *permissionsDialog) renderSectionHeader(title, description string) strin
14693
}
14794

14895
func (d *permissionsDialog) renderPattern(pattern string, isDeny bool) string {
149-
// Use different colors for deny (red-ish) vs allow (green-ish)
15096
var icon string
15197
var style lipgloss.Style
15298
if isDeny {
@@ -165,26 +111,3 @@ func (d *permissionsDialog) renderAskPattern(pattern string) string {
165111
style := lipgloss.NewStyle().Foreground(styles.TextSecondary)
166112
return style.Render(icon) + " " + lipgloss.NewStyle().Foreground(styles.Highlight).Render(pattern)
167113
}
168-
169-
func (d *permissionsDialog) applyScrolling(allLines []string, contentWidth, maxHeight int) string {
170-
const headerLines = 3 // title + separator + space
171-
const footerLines = 2 // space + help
172-
173-
visibleLines := max(1, maxHeight-headerLines-footerLines-4)
174-
contentLines := allLines[headerLines:]
175-
176-
regionWidth := contentWidth + d.scrollview.ReservedCols()
177-
d.scrollview.SetSize(regionWidth, visibleLines)
178-
179-
// Set scrollview position for mouse hit-testing (auto-computed from dialog position)
180-
// Y offset: border(1) + padding(1) + headerLines(3) = 5
181-
dialogRow, dialogCol := d.Position()
182-
d.scrollview.SetPosition(dialogCol+3, dialogRow+2+headerLines)
183-
184-
d.scrollview.SetContent(contentLines, len(contentLines))
185-
186-
scrollableContent := d.scrollview.View()
187-
parts := append(allLines[:headerLines], scrollableContent)
188-
parts = append(parts, "", RenderHelpKeys(regionWidth, "↑↓", "scroll", "Esc", "close"))
189-
return lipgloss.JoinVertical(lipgloss.Left, parts...)
190-
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package dialog
2+
3+
import (
4+
"strings"
5+
6+
"charm.land/bubbles/v2/key"
7+
tea "charm.land/bubbletea/v2"
8+
"charm.land/lipgloss/v2"
9+
10+
"github.com/docker/docker-agent/pkg/tui/components/scrollview"
11+
"github.com/docker/docker-agent/pkg/tui/core"
12+
"github.com/docker/docker-agent/pkg/tui/core/layout"
13+
"github.com/docker/docker-agent/pkg/tui/styles"
14+
)
15+
16+
// readOnlyScrollDialogSize defines the sizing parameters for a read-only scroll dialog.
17+
type readOnlyScrollDialogSize struct {
18+
widthPercent int
19+
minWidth int
20+
maxWidth int
21+
heightPercent int
22+
heightMax int
23+
}
24+
25+
// contentRenderer renders dialog content lines given the available width and max height.
26+
type contentRenderer func(contentWidth, maxHeight int) []string
27+
28+
// readOnlyScrollDialog is a base for simple read-only dialogs with scrollable content.
29+
// It handles Init, Update (scrollview + close key), Position, View, and scrolling.
30+
// Concrete dialogs embed it and provide a contentRenderer and help key bindings.
31+
type readOnlyScrollDialog struct {
32+
BaseDialog
33+
34+
scrollview *scrollview.Model
35+
closeKey key.Binding
36+
size readOnlyScrollDialogSize
37+
render contentRenderer
38+
helpKeys []string // pairs of [key, description] for the footer
39+
}
40+
41+
// Dialog chrome: border (top+bottom=2) + padding (top+bottom=2).
42+
const dialogChrome = 4
43+
44+
// Fixed lines outside the scrollable region: header (title + separator + space) + footer (space + help).
45+
const fixedLines = 5
46+
47+
// newReadOnlyScrollDialog creates a new read-only scrollable dialog.
48+
func newReadOnlyScrollDialog(
49+
size readOnlyScrollDialogSize,
50+
render contentRenderer,
51+
) readOnlyScrollDialog {
52+
return readOnlyScrollDialog{
53+
scrollview: scrollview.New(
54+
scrollview.WithKeyMap(scrollview.ReadOnlyScrollKeyMap()),
55+
scrollview.WithReserveScrollbarSpace(true),
56+
),
57+
closeKey: key.NewBinding(key.WithKeys("esc", "enter", "q"), key.WithHelp("Esc", "close")),
58+
size: size,
59+
render: render,
60+
helpKeys: []string{"↑↓", "scroll", "Esc", "close"},
61+
}
62+
}
63+
64+
func (d *readOnlyScrollDialog) Init() tea.Cmd {
65+
return nil
66+
}
67+
68+
func (d *readOnlyScrollDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
69+
if handled, cmd := d.scrollview.Update(msg); handled {
70+
return d, cmd
71+
}
72+
73+
switch msg := msg.(type) {
74+
case tea.WindowSizeMsg:
75+
cmd := d.SetSize(msg.Width, msg.Height)
76+
return d, cmd
77+
78+
case tea.KeyPressMsg:
79+
if key.Matches(msg, d.closeKey) {
80+
return d, core.CmdHandler(CloseDialogMsg{})
81+
}
82+
}
83+
return d, nil
84+
}
85+
86+
func (d *readOnlyScrollDialog) dialogWidth() (dialogWidth, contentWidth int) {
87+
s := d.size
88+
dialogWidth = d.ComputeDialogWidth(s.widthPercent, s.minWidth, s.maxWidth)
89+
contentWidth = d.ContentWidth(dialogWidth, 2) - d.scrollview.ReservedCols()
90+
return dialogWidth, contentWidth
91+
}
92+
93+
// maxViewport returns the maximum number of scrollable lines that fit.
94+
func (d *readOnlyScrollDialog) maxViewport() int {
95+
s := d.size
96+
maxHeight := min(d.Height()*s.heightPercent/100, s.heightMax)
97+
return max(1, maxHeight-fixedLines-dialogChrome)
98+
}
99+
100+
// dialogHeight computes the actual dialog height based on content and viewport.
101+
func (d *readOnlyScrollDialog) dialogHeight(contentLineCount int) int {
102+
s := d.size
103+
maxHeight := min(d.Height()*s.heightPercent/100, s.heightMax)
104+
needed := contentLineCount + fixedLines + dialogChrome
105+
return min(needed, maxHeight)
106+
}
107+
108+
func (d *readOnlyScrollDialog) Position() (row, col int) {
109+
dw, _ := d.dialogWidth()
110+
// Use max possible height for stable centering.
111+
s := d.size
112+
maxHeight := min(d.Height()*s.heightPercent/100, s.heightMax)
113+
return CenterPosition(d.Width(), d.Height(), dw, maxHeight)
114+
}
115+
116+
func (d *readOnlyScrollDialog) View() string {
117+
dialogWidth, contentWidth := d.dialogWidth()
118+
maxViewport := d.maxViewport()
119+
allLines := d.render(contentWidth, maxViewport)
120+
121+
const headerLines = 3 // title + separator + space
122+
contentLines := allLines[headerLines:]
123+
124+
// Viewport: show all content if it fits, otherwise cap at maxViewport.
125+
viewport := min(len(contentLines), maxViewport)
126+
127+
regionWidth := contentWidth + d.scrollview.ReservedCols()
128+
d.scrollview.SetSize(regionWidth, viewport)
129+
130+
dialogRow, dialogCol := d.Position()
131+
d.scrollview.SetPosition(dialogCol+3, dialogRow+2+headerLines)
132+
d.scrollview.SetContent(contentLines, len(contentLines))
133+
134+
// Use ViewWithLines to guarantee exactly `viewport` lines of output.
135+
scrollOut := d.scrollview.View()
136+
scrollOutLines := strings.Split(scrollOut, "\n")
137+
for len(scrollOutLines) < viewport {
138+
scrollOutLines = append(scrollOutLines, "")
139+
}
140+
scrollOutLines = scrollOutLines[:viewport]
141+
142+
parts := make([]string, 0, headerLines+viewport+2)
143+
parts = append(parts, allLines[:headerLines]...)
144+
parts = append(parts, scrollOutLines...)
145+
parts = append(parts, "", RenderHelpKeys(regionWidth, d.helpKeys...))
146+
147+
height := d.dialogHeight(len(contentLines))
148+
content := lipgloss.JoinVertical(lipgloss.Left, parts...)
149+
return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Height(height).MaxHeight(height).Render(content)
150+
}

0 commit comments

Comments
 (0)