diff --git a/pkg/app/app.go b/pkg/app/app.go index 869132bc6..9a5c0def6 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -179,6 +179,11 @@ func (a *App) SendFirstMessage() tea.Cmd { return tea.Sequence(cmds...) } +// CurrentAgentTools returns the tools available to the current agent. +func (a *App) CurrentAgentTools(ctx context.Context) ([]tools.Tool, error) { + return a.runtime.CurrentAgentTools(ctx) +} + // CurrentAgentCommands returns the commands for the active agent func (a *App) CurrentAgentCommands(ctx context.Context) types.Commands { return a.runtime.CurrentAgentInfo(ctx).Commands diff --git a/pkg/tools/builtin/script_shell.go b/pkg/tools/builtin/script_shell.go index 6c45fdf6d..2170265f3 100644 --- a/pkg/tools/builtin/script_shell.go +++ b/pkg/tools/builtin/script_shell.go @@ -116,6 +116,7 @@ func (t *ScriptShellTool) Tools(context.Context) ([]tools.Tool, error) { toolsList = append(toolsList, tools.Tool{ Name: toolName, + Category: "shell", Description: description, Parameters: inputSchema, OutputSchema: tools.MustSchemaFor[string](), diff --git a/pkg/tools/mcp/mcp.go b/pkg/tools/mcp/mcp.go index f75a71571..5a7cf741b 100644 --- a/pkg/tools/mcp/mcp.go +++ b/pkg/tools/mcp/mcp.go @@ -360,6 +360,7 @@ func (ts *Toolset) Tools(ctx context.Context) ([]tools.Tool, error) { tool := tools.Tool{ Name: name, + Category: "mcp", Description: t.Description, Parameters: t.InputSchema, OutputSchema: t.OutputSchema, diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index f5998da7a..d5d5bf459 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -209,6 +209,16 @@ func builtInSessionCommands() []Item { }, }, + { + ID: "session.tools", + Label: "Tools", + SlashCommand: "/tools", + Description: "Show all tools available to the current agent", + Category: "Session", + Execute: func(string) tea.Cmd { + return core.CmdHandler(messages.ShowToolsDialogMsg{}) + }, + }, { ID: "session.title", Label: "Title", @@ -317,17 +327,6 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor // Get session commands and filter based on model capabilities sessionCommands := builtInSessionCommands() - // Hide /permissions if no permissions are configured - if !application.HasPermissions() { - filtered := make([]Item, 0, len(sessionCommands)) - for _, cmd := range sessionCommands { - if cmd.ID != "session.permissions" { - filtered = append(filtered, cmd) - } - } - sessionCommands = filtered - } - categories := []Category{ { Name: "Session", diff --git a/pkg/tui/dialog/permissions.go b/pkg/tui/dialog/permissions.go index c035636a7..ea5a10acf 100644 --- a/pkg/tui/dialog/permissions.go +++ b/pkg/tui/dialog/permissions.go @@ -1,82 +1,34 @@ package dialog import ( - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/docker/docker-agent/pkg/runtime" - "github.com/docker/docker-agent/pkg/tui/components/scrollview" - "github.com/docker/docker-agent/pkg/tui/core" - "github.com/docker/docker-agent/pkg/tui/core/layout" "github.com/docker/docker-agent/pkg/tui/styles" ) // permissionsDialog displays the configured tool permissions (allow/deny patterns). type permissionsDialog struct { - BaseDialog + readOnlyScrollDialog permissions *runtime.PermissionsInfo yoloEnabled bool - closeKey key.Binding - scrollview *scrollview.Model } // NewPermissionsDialog creates a new dialog showing tool permission rules. func NewPermissionsDialog(perms *runtime.PermissionsInfo, yoloEnabled bool) Dialog { - return &permissionsDialog{ + d := &permissionsDialog{ permissions: perms, yoloEnabled: yoloEnabled, - scrollview: scrollview.New( - scrollview.WithKeyMap(scrollview.ReadOnlyScrollKeyMap()), - scrollview.WithReserveScrollbarSpace(true), - ), - closeKey: key.NewBinding(key.WithKeys("esc", "enter", "q"), key.WithHelp("Esc", "close")), } + d.readOnlyScrollDialog = newReadOnlyScrollDialog( + readOnlyScrollDialogSize{widthPercent: 60, minWidth: 40, maxWidth: 70, heightPercent: 70, heightMax: 30}, + d.renderLines, + ) + return d } -func (d *permissionsDialog) Init() tea.Cmd { - return nil -} - -func (d *permissionsDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { - if handled, cmd := d.scrollview.Update(msg); handled { - return d, cmd - } - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - cmd := d.SetSize(msg.Width, msg.Height) - return d, cmd - - case tea.KeyPressMsg: - if key.Matches(msg, d.closeKey) { - return d, core.CmdHandler(CloseDialogMsg{}) - } - } - return d, nil -} - -func (d *permissionsDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) { - dialogWidth = d.ComputeDialogWidth(60, 40, 70) - maxHeight = min(d.Height()*70/100, 30) - contentWidth = d.ContentWidth(dialogWidth, 2) - d.scrollview.ReservedCols() - return dialogWidth, maxHeight, contentWidth -} - -func (d *permissionsDialog) Position() (row, col int) { - dialogWidth, maxHeight, _ := d.dialogSize() - return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight) -} - -func (d *permissionsDialog) View() string { - dialogWidth, maxHeight, contentWidth := d.dialogSize() - content := d.renderContent(contentWidth, maxHeight) - return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Render(content) -} - -func (d *permissionsDialog) renderContent(contentWidth, maxHeight int) string { - // Build all lines +func (d *permissionsDialog) renderLines(contentWidth, _ int) []string { lines := []string{ RenderTitle("Tool Permissions", contentWidth, styles.DialogTitleStyle), RenderSeparator(contentWidth), @@ -89,7 +41,6 @@ func (d *permissionsDialog) renderContent(contentWidth, maxHeight int) string { if d.permissions == nil { lines = append(lines, styles.MutedStyle.Render("No permission patterns configured."), "") } else { - // Deny section (checked first during evaluation) if len(d.permissions.Deny) > 0 { lines = append(lines, d.renderSectionHeader("Deny", "Always blocked, even with yolo mode"), "") for _, pattern := range d.permissions.Deny { @@ -98,7 +49,6 @@ func (d *permissionsDialog) renderContent(contentWidth, maxHeight int) string { lines = append(lines, "") } - // Allow section if len(d.permissions.Allow) > 0 { lines = append(lines, d.renderSectionHeader("Allow", "Auto-approved without confirmation"), "") for _, pattern := range d.permissions.Allow { @@ -107,7 +57,6 @@ func (d *permissionsDialog) renderContent(contentWidth, maxHeight int) string { lines = append(lines, "") } - // Ask section if len(d.permissions.Ask) > 0 { lines = append(lines, d.renderSectionHeader("Ask", "Always requires confirmation, even for read-only tools"), "") for _, pattern := range d.permissions.Ask { @@ -116,14 +65,12 @@ func (d *permissionsDialog) renderContent(contentWidth, maxHeight int) string { lines = append(lines, "") } - // If all are empty if len(d.permissions.Allow) == 0 && len(d.permissions.Ask) == 0 && len(d.permissions.Deny) == 0 { lines = append(lines, styles.MutedStyle.Render("No permission patterns configured."), "") } } - // Apply scrolling - return d.applyScrolling(lines, contentWidth, maxHeight) + return lines } func (d *permissionsDialog) renderYoloStatus() string { @@ -146,7 +93,6 @@ func (d *permissionsDialog) renderSectionHeader(title, description string) strin } func (d *permissionsDialog) renderPattern(pattern string, isDeny bool) string { - // Use different colors for deny (red-ish) vs allow (green-ish) var icon string var style lipgloss.Style if isDeny { @@ -165,26 +111,3 @@ func (d *permissionsDialog) renderAskPattern(pattern string) string { style := lipgloss.NewStyle().Foreground(styles.TextSecondary) return style.Render(icon) + " " + lipgloss.NewStyle().Foreground(styles.Highlight).Render(pattern) } - -func (d *permissionsDialog) applyScrolling(allLines []string, contentWidth, maxHeight int) string { - const headerLines = 3 // title + separator + space - const footerLines = 2 // space + help - - visibleLines := max(1, maxHeight-headerLines-footerLines-4) - contentLines := allLines[headerLines:] - - regionWidth := contentWidth + d.scrollview.ReservedCols() - d.scrollview.SetSize(regionWidth, visibleLines) - - // Set scrollview position for mouse hit-testing (auto-computed from dialog position) - // Y offset: border(1) + padding(1) + headerLines(3) = 5 - dialogRow, dialogCol := d.Position() - d.scrollview.SetPosition(dialogCol+3, dialogRow+2+headerLines) - - d.scrollview.SetContent(contentLines, len(contentLines)) - - scrollableContent := d.scrollview.View() - parts := append(allLines[:headerLines], scrollableContent) - parts = append(parts, "", RenderHelpKeys(regionWidth, "↑↓", "scroll", "Esc", "close")) - return lipgloss.JoinVertical(lipgloss.Left, parts...) -} diff --git a/pkg/tui/dialog/readonly_scroll_dialog.go b/pkg/tui/dialog/readonly_scroll_dialog.go new file mode 100644 index 000000000..b72216019 --- /dev/null +++ b/pkg/tui/dialog/readonly_scroll_dialog.go @@ -0,0 +1,150 @@ +package dialog + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/docker/docker-agent/pkg/tui/components/scrollview" + "github.com/docker/docker-agent/pkg/tui/core" + "github.com/docker/docker-agent/pkg/tui/core/layout" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +// readOnlyScrollDialogSize defines the sizing parameters for a read-only scroll dialog. +type readOnlyScrollDialogSize struct { + widthPercent int + minWidth int + maxWidth int + heightPercent int + heightMax int +} + +// contentRenderer renders dialog content lines given the available width and max height. +type contentRenderer func(contentWidth, maxHeight int) []string + +// readOnlyScrollDialog is a base for simple read-only dialogs with scrollable content. +// It handles Init, Update (scrollview + close key), Position, View, and scrolling. +// Concrete dialogs embed it and provide a contentRenderer and help key bindings. +type readOnlyScrollDialog struct { + BaseDialog + + scrollview *scrollview.Model + closeKey key.Binding + size readOnlyScrollDialogSize + render contentRenderer + helpKeys []string // pairs of [key, description] for the footer +} + +// Dialog chrome: border (top+bottom=2) + padding (top+bottom=2). +const dialogChrome = 4 + +// Fixed lines outside the scrollable region: header (title + separator + space) + footer (space + help). +const fixedLines = 5 + +// newReadOnlyScrollDialog creates a new read-only scrollable dialog. +func newReadOnlyScrollDialog( + size readOnlyScrollDialogSize, + render contentRenderer, +) readOnlyScrollDialog { + return readOnlyScrollDialog{ + scrollview: scrollview.New( + scrollview.WithKeyMap(scrollview.ReadOnlyScrollKeyMap()), + scrollview.WithReserveScrollbarSpace(true), + ), + closeKey: key.NewBinding(key.WithKeys("esc", "enter", "q"), key.WithHelp("Esc", "close")), + size: size, + render: render, + helpKeys: []string{"↑↓", "scroll", "Esc", "close"}, + } +} + +func (d *readOnlyScrollDialog) Init() tea.Cmd { + return nil +} + +func (d *readOnlyScrollDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + if handled, cmd := d.scrollview.Update(msg); handled { + return d, cmd + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + cmd := d.SetSize(msg.Width, msg.Height) + return d, cmd + + case tea.KeyPressMsg: + if key.Matches(msg, d.closeKey) { + return d, core.CmdHandler(CloseDialogMsg{}) + } + } + return d, nil +} + +func (d *readOnlyScrollDialog) dialogWidth() (dialogWidth, contentWidth int) { + s := d.size + dialogWidth = d.ComputeDialogWidth(s.widthPercent, s.minWidth, s.maxWidth) + contentWidth = d.ContentWidth(dialogWidth, 2) - d.scrollview.ReservedCols() + return dialogWidth, contentWidth +} + +// maxViewport returns the maximum number of scrollable lines that fit. +func (d *readOnlyScrollDialog) maxViewport() int { + s := d.size + maxHeight := min(d.Height()*s.heightPercent/100, s.heightMax) + return max(1, maxHeight-fixedLines-dialogChrome) +} + +// dialogHeight computes the actual dialog height based on content and viewport. +func (d *readOnlyScrollDialog) dialogHeight(contentLineCount int) int { + s := d.size + maxHeight := min(d.Height()*s.heightPercent/100, s.heightMax) + needed := contentLineCount + fixedLines + dialogChrome + return min(needed, maxHeight) +} + +func (d *readOnlyScrollDialog) Position() (row, col int) { + dw, _ := d.dialogWidth() + // Use max possible height for stable centering. + s := d.size + maxHeight := min(d.Height()*s.heightPercent/100, s.heightMax) + return CenterPosition(d.Width(), d.Height(), dw, maxHeight) +} + +func (d *readOnlyScrollDialog) View() string { + dialogWidth, contentWidth := d.dialogWidth() + maxViewport := d.maxViewport() + allLines := d.render(contentWidth, maxViewport) + + const headerLines = 3 // title + separator + space + contentLines := allLines[headerLines:] + + // Viewport: show all content if it fits, otherwise cap at maxViewport. + viewport := min(len(contentLines), maxViewport) + + regionWidth := contentWidth + d.scrollview.ReservedCols() + d.scrollview.SetSize(regionWidth, viewport) + + dialogRow, dialogCol := d.Position() + d.scrollview.SetPosition(dialogCol+3, dialogRow+2+headerLines) + d.scrollview.SetContent(contentLines, len(contentLines)) + + // Use ViewWithLines to guarantee exactly `viewport` lines of output. + scrollOut := d.scrollview.View() + scrollOutLines := strings.Split(scrollOut, "\n") + for len(scrollOutLines) < viewport { + scrollOutLines = append(scrollOutLines, "") + } + scrollOutLines = scrollOutLines[:viewport] + + parts := make([]string, 0, headerLines+viewport+2) + parts = append(parts, allLines[:headerLines]...) + parts = append(parts, scrollOutLines...) + parts = append(parts, "", RenderHelpKeys(regionWidth, d.helpKeys...)) + + height := d.dialogHeight(len(contentLines)) + content := lipgloss.JoinVertical(lipgloss.Left, parts...) + return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Height(height).MaxHeight(height).Render(content) +} diff --git a/pkg/tui/dialog/tools.go b/pkg/tui/dialog/tools.go new file mode 100644 index 000000000..1eadf263d --- /dev/null +++ b/pkg/tui/dialog/tools.go @@ -0,0 +1,86 @@ +package dialog + +import ( + "fmt" + "slices" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +// toolsDialog displays all tools available to the current agent. +type toolsDialog struct { + readOnlyScrollDialog + + tools []tools.Tool +} + +// NewToolsDialog creates a new dialog showing all available tools. +func NewToolsDialog(toolList []tools.Tool) Dialog { + // Sort tools by category then name. + sorted := make([]tools.Tool, len(toolList)) + copy(sorted, toolList) + slices.SortFunc(sorted, func(a, b tools.Tool) int { + if c := strings.Compare(strings.ToLower(a.Category), strings.ToLower(b.Category)); c != 0 { + return c + } + return strings.Compare(strings.ToLower(a.DisplayName()), strings.ToLower(b.DisplayName())) + }) + + d := &toolsDialog{tools: sorted} + d.readOnlyScrollDialog = newReadOnlyScrollDialog( + readOnlyScrollDialogSize{widthPercent: 70, minWidth: 50, maxWidth: 80, heightPercent: 80, heightMax: 40}, + d.renderLines, + ) + return d +} + +func (d *toolsDialog) renderLines(contentWidth, _ int) []string { + title := fmt.Sprintf("Tools (%d)", len(d.tools)) + lines := []string{ + RenderTitle(title, contentWidth, styles.DialogTitleStyle), + RenderSeparator(contentWidth), + "", + } + + if len(d.tools) == 0 { + lines = append(lines, styles.MutedStyle.Render("No tools available."), "") + return lines + } + + var lastCategory string + for i := range d.tools { + t := &d.tools[i] + cat := t.Category + if cat == "" { + cat = "Other" + } + if cat != lastCategory { + if lastCategory != "" { + lines = append(lines, "") + } + lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(styles.TextSecondary).Render(cat)) + lastCategory = cat + } + + name := lipgloss.NewStyle().Foreground(styles.Highlight).Render(" " + t.DisplayName()) + if desc, _, _ := strings.Cut(t.Description, "\n"); desc != "" { + separator := " • " + separatorWidth := lipgloss.Width(separator) + nameWidth := lipgloss.Width(name) + availableWidth := contentWidth - nameWidth - separatorWidth + if availableWidth > 0 { + truncated := toolcommon.TruncateText(desc, availableWidth) + name += styles.MutedStyle.Render(separator + truncated) + } + } + lines = append(lines, name) + } + lines = append(lines, "") + + return lines +} diff --git a/pkg/tui/handlers.go b/pkg/tui/handlers.go index 50ab0dddf..3e073722f 100644 --- a/pkg/tui/handlers.go +++ b/pkg/tui/handlers.go @@ -311,6 +311,16 @@ func (m *appModel) handleShowPermissionsDialog() (tea.Model, tea.Cmd) { }) } +func (m *appModel) handleShowToolsDialog() (tea.Model, tea.Cmd) { + agentTools, err := m.application.CurrentAgentTools(context.Background()) + if err != nil { + return m, notification.ErrorCmd(fmt.Sprintf("Failed to load tools: %v", err)) + } + return m, core.CmdHandler(dialog.OpenDialogMsg{ + Model: dialog.NewToolsDialog(agentTools), + }) +} + // --- MCP prompts --- func (m *appModel) handleShowMCPPromptInput(promptName string, promptInfo any) (tea.Model, tea.Cmd) { diff --git a/pkg/tui/messages/toggle.go b/pkg/tui/messages/toggle.go index 968b8fef2..6674fb1d2 100644 --- a/pkg/tui/messages/toggle.go +++ b/pkg/tui/messages/toggle.go @@ -21,4 +21,7 @@ type ( // ShowPermissionsDialogMsg shows the permissions dialog. ShowPermissionsDialogMsg struct{} + + // ShowToolsDialogMsg shows the tools dialog. + ShowToolsDialogMsg struct{} ) diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 0d4d782e8..a592a412c 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -806,6 +806,9 @@ func (m *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case messages.ShowPermissionsDialogMsg: return m.handleShowPermissionsDialog() + case messages.ShowToolsDialogMsg: + return m.handleShowToolsDialog() + case messages.AgentCommandMsg: return m.handleAgentCommand(msg.Command)