Skip to content

Commit 39d9c66

Browse files
committed
Simplify dialog code with shared readOnlyScrollDialog base
Extract common scrollable read-only dialog boilerplate (Init, Update, Position, View, scrolling) into readOnlyScrollDialog. Both permissionsDialog and toolsDialog now embed it and only provide content rendering logic. Also unhide /permissions command so it always appears in the command palette. Assisted-By: docker-agent
1 parent 1f9776b commit 39d9c66

4 files changed

Lines changed: 158 additions & 204 deletions

File tree

pkg/tui/commands/commands.go

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -327,17 +327,6 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor
327327
// Get session commands and filter based on model capabilities
328328
sessionCommands := builtInSessionCommands()
329329

330-
// Hide /permissions if no permissions are configured
331-
if !application.HasPermissions() {
332-
filtered := make([]Item, 0, len(sessionCommands))
333-
for _, cmd := range sessionCommands {
334-
if cmd.ID != "session.permissions" {
335-
filtered = append(filtered, cmd)
336-
}
337-
}
338-
sessionCommands = filtered
339-
}
340-
341330
categories := []Category{
342331
{
343332
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: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package dialog
2+
3+
import (
4+
"charm.land/bubbles/v2/key"
5+
tea "charm.land/bubbletea/v2"
6+
"charm.land/lipgloss/v2"
7+
8+
"github.com/docker/docker-agent/pkg/tui/components/scrollview"
9+
"github.com/docker/docker-agent/pkg/tui/core"
10+
"github.com/docker/docker-agent/pkg/tui/core/layout"
11+
"github.com/docker/docker-agent/pkg/tui/styles"
12+
)
13+
14+
// readOnlyScrollDialogSize defines the sizing parameters for a read-only scroll dialog.
15+
type readOnlyScrollDialogSize struct {
16+
widthPercent int
17+
minWidth int
18+
maxWidth int
19+
heightPercent int
20+
heightMax int
21+
}
22+
23+
// contentRenderer renders dialog content lines given the available width and max height.
24+
type contentRenderer func(contentWidth, maxHeight int) []string
25+
26+
// readOnlyScrollDialog is a base for simple read-only dialogs with scrollable content.
27+
// It handles Init, Update (scrollview + close key), Position, View, and scrolling.
28+
// Concrete dialogs embed it and provide a contentRenderer and help key bindings.
29+
type readOnlyScrollDialog struct {
30+
BaseDialog
31+
32+
scrollview *scrollview.Model
33+
closeKey key.Binding
34+
size readOnlyScrollDialogSize
35+
render contentRenderer
36+
helpKeys []string // pairs of [key, description] for the footer
37+
}
38+
39+
// newReadOnlyScrollDialog creates a new read-only scrollable dialog.
40+
func newReadOnlyScrollDialog(
41+
size readOnlyScrollDialogSize,
42+
render contentRenderer,
43+
) readOnlyScrollDialog {
44+
return readOnlyScrollDialog{
45+
scrollview: scrollview.New(
46+
scrollview.WithKeyMap(scrollview.ReadOnlyScrollKeyMap()),
47+
scrollview.WithReserveScrollbarSpace(true),
48+
),
49+
closeKey: key.NewBinding(key.WithKeys("esc", "enter", "q"), key.WithHelp("Esc", "close")),
50+
size: size,
51+
render: render,
52+
helpKeys: []string{"↑↓", "scroll", "Esc", "close"},
53+
}
54+
}
55+
56+
func (d *readOnlyScrollDialog) Init() tea.Cmd {
57+
return nil
58+
}
59+
60+
func (d *readOnlyScrollDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
61+
if handled, cmd := d.scrollview.Update(msg); handled {
62+
return d, cmd
63+
}
64+
65+
switch msg := msg.(type) {
66+
case tea.WindowSizeMsg:
67+
cmd := d.SetSize(msg.Width, msg.Height)
68+
return d, cmd
69+
70+
case tea.KeyPressMsg:
71+
if key.Matches(msg, d.closeKey) {
72+
return d, core.CmdHandler(CloseDialogMsg{})
73+
}
74+
}
75+
return d, nil
76+
}
77+
78+
func (d *readOnlyScrollDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) {
79+
s := d.size
80+
dialogWidth = d.ComputeDialogWidth(s.widthPercent, s.minWidth, s.maxWidth)
81+
maxHeight = min(d.Height()*s.heightPercent/100, s.heightMax)
82+
contentWidth = d.ContentWidth(dialogWidth, 2) - d.scrollview.ReservedCols()
83+
return dialogWidth, maxHeight, contentWidth
84+
}
85+
86+
func (d *readOnlyScrollDialog) Position() (row, col int) {
87+
dialogWidth, maxHeight, _ := d.dialogSize()
88+
return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight)
89+
}
90+
91+
func (d *readOnlyScrollDialog) View() string {
92+
dialogWidth, maxHeight, contentWidth := d.dialogSize()
93+
allLines := d.render(contentWidth, maxHeight)
94+
95+
const headerLines = 3 // title + separator + space
96+
contentLines := allLines[headerLines:]
97+
98+
regionWidth := contentWidth + d.scrollview.ReservedCols()
99+
visibleLines := max(1, maxHeight-headerLines-2-4) // 2 = footer (space + help), 4 = dialog chrome
100+
d.scrollview.SetSize(regionWidth, visibleLines)
101+
102+
dialogRow, dialogCol := d.Position()
103+
d.scrollview.SetPosition(dialogCol+3, dialogRow+2+headerLines)
104+
d.scrollview.SetContent(contentLines, len(contentLines))
105+
106+
parts := append(allLines[:headerLines], d.scrollview.View())
107+
parts = append(parts, "", RenderHelpKeys(regionWidth, d.helpKeys...))
108+
109+
content := lipgloss.JoinVertical(lipgloss.Left, parts...)
110+
return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Render(content)
111+
}

0 commit comments

Comments
 (0)