Skip to content
Merged
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
5 changes: 5 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/tools/builtin/script_shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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](),
Expand Down
1 change: 1 addition & 0 deletions pkg/tools/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 10 additions & 11 deletions pkg/tui/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
95 changes: 9 additions & 86 deletions pkg/tui/dialog/permissions.go
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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...)
}
150 changes: 150 additions & 0 deletions pkg/tui/dialog/readonly_scroll_dialog.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading