permission.go

  1package dialog
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	"github.com/charmbracelet/bubbles/v2/key"
  8	"github.com/charmbracelet/bubbles/v2/viewport"
  9	tea "github.com/charmbracelet/bubbletea/v2"
 10	"github.com/charmbracelet/lipgloss/v2"
 11	"github.com/opencode-ai/opencode/internal/diff"
 12	"github.com/opencode-ai/opencode/internal/llm/tools"
 13	"github.com/opencode-ai/opencode/internal/permission"
 14	"github.com/opencode-ai/opencode/internal/tui/layout"
 15	"github.com/opencode-ai/opencode/internal/tui/styles"
 16	"github.com/opencode-ai/opencode/internal/tui/theme"
 17	"github.com/opencode-ai/opencode/internal/tui/util"
 18)
 19
 20type PermissionAction string
 21
 22// Permission responses
 23const (
 24	PermissionAllow           PermissionAction = "allow"
 25	PermissionAllowForSession PermissionAction = "allow_session"
 26	PermissionDeny            PermissionAction = "deny"
 27)
 28
 29// PermissionResponseMsg represents the user's response to a permission request
 30type PermissionResponseMsg struct {
 31	Permission permission.PermissionRequest
 32	Action     PermissionAction
 33}
 34
 35// PermissionDialogCmp interface for permission dialog component
 36type PermissionDialogCmp interface {
 37	util.Model
 38	layout.Bindings
 39	SetPermissions(permission permission.PermissionRequest) tea.Cmd
 40}
 41
 42type permissionsMapping struct {
 43	Left         key.Binding
 44	Right        key.Binding
 45	EnterSpace   key.Binding
 46	Allow        key.Binding
 47	AllowSession key.Binding
 48	Deny         key.Binding
 49	Tab          key.Binding
 50}
 51
 52var permissionsKeys = permissionsMapping{
 53	Left: key.NewBinding(
 54		key.WithKeys("left"),
 55		key.WithHelp("←", "switch options"),
 56	),
 57	Right: key.NewBinding(
 58		key.WithKeys("right"),
 59		key.WithHelp("→", "switch options"),
 60	),
 61	EnterSpace: key.NewBinding(
 62		key.WithKeys("enter", " "),
 63		key.WithHelp("enter/space", "confirm"),
 64	),
 65	Allow: key.NewBinding(
 66		key.WithKeys("a"),
 67		key.WithHelp("a", "allow"),
 68	),
 69	AllowSession: key.NewBinding(
 70		key.WithKeys("s"),
 71		key.WithHelp("s", "allow for session"),
 72	),
 73	Deny: key.NewBinding(
 74		key.WithKeys("d"),
 75		key.WithHelp("d", "deny"),
 76	),
 77	Tab: key.NewBinding(
 78		key.WithKeys("tab"),
 79		key.WithHelp("tab", "switch options"),
 80	),
 81}
 82
 83// permissionDialogCmp is the implementation of PermissionDialog
 84type permissionDialogCmp struct {
 85	width           int
 86	height          int
 87	permission      permission.PermissionRequest
 88	windowSize      tea.WindowSizeMsg
 89	contentViewPort viewport.Model
 90	selectedOption  int // 0: Allow, 1: Allow for session, 2: Deny
 91
 92	diffCache     map[string]string
 93	markdownCache map[string]string
 94}
 95
 96func (p *permissionDialogCmp) Init() tea.Cmd {
 97	return p.contentViewPort.Init()
 98}
 99
100func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101	var cmds []tea.Cmd
102
103	switch msg := msg.(type) {
104	case tea.WindowSizeMsg:
105		p.windowSize = msg
106		cmd := p.SetSize()
107		cmds = append(cmds, cmd)
108		p.markdownCache = make(map[string]string)
109		p.diffCache = make(map[string]string)
110	case tea.KeyPressMsg:
111		switch {
112		case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
113			p.selectedOption = (p.selectedOption + 1) % 3
114			return p, nil
115		case key.Matches(msg, permissionsKeys.Left):
116			p.selectedOption = (p.selectedOption + 2) % 3
117		case key.Matches(msg, permissionsKeys.EnterSpace):
118			return p, p.selectCurrentOption()
119		case key.Matches(msg, permissionsKeys.Allow):
120			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
121		case key.Matches(msg, permissionsKeys.AllowSession):
122			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
123		case key.Matches(msg, permissionsKeys.Deny):
124			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
125		default:
126			// Pass other keys to viewport
127			viewPort, cmd := p.contentViewPort.Update(msg)
128			p.contentViewPort = viewPort
129			cmds = append(cmds, cmd)
130		}
131	}
132
133	return p, tea.Batch(cmds...)
134}
135
136func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
137	var action PermissionAction
138
139	switch p.selectedOption {
140	case 0:
141		action = PermissionAllow
142	case 1:
143		action = PermissionAllowForSession
144	case 2:
145		action = PermissionDeny
146	}
147
148	return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
149}
150
151func (p *permissionDialogCmp) renderButtons() string {
152	t := theme.CurrentTheme()
153	baseStyle := styles.BaseStyle()
154
155	allowStyle := baseStyle
156	allowSessionStyle := baseStyle
157	denyStyle := baseStyle
158	spacerStyle := baseStyle.Background(t.Background())
159
160	// Style the selected button
161	switch p.selectedOption {
162	case 0:
163		allowStyle = allowStyle.Background(t.Primary()).Foreground(t.Background())
164		allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
165		denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
166	case 1:
167		allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
168		allowSessionStyle = allowSessionStyle.Background(t.Primary()).Foreground(t.Background())
169		denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
170	case 2:
171		allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
172		allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
173		denyStyle = denyStyle.Background(t.Primary()).Foreground(t.Background())
174	}
175
176	allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
177	allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
178	denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
179
180	content := lipgloss.JoinHorizontal(
181		lipgloss.Left,
182		allowButton,
183		spacerStyle.Render("  "),
184		allowSessionButton,
185		spacerStyle.Render("  "),
186		denyButton,
187		spacerStyle.Render("  "),
188	)
189
190	remainingWidth := p.width - lipgloss.Width(content)
191	if remainingWidth > 0 {
192		content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
193	}
194	return content
195}
196
197func (p *permissionDialogCmp) renderHeader() string {
198	t := theme.CurrentTheme()
199	baseStyle := styles.BaseStyle()
200
201	toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
202	toolValue := baseStyle.
203		Foreground(t.Text()).
204		Width(p.width - lipgloss.Width(toolKey)).
205		Render(fmt.Sprintf(": %s", p.permission.ToolName))
206
207	pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
208	pathValue := baseStyle.
209		Foreground(t.Text()).
210		Width(p.width - lipgloss.Width(pathKey)).
211		Render(fmt.Sprintf(": %s", p.permission.Path))
212
213	headerParts := []string{
214		lipgloss.JoinHorizontal(
215			lipgloss.Left,
216			toolKey,
217			toolValue,
218		),
219		baseStyle.Render(strings.Repeat(" ", p.width)),
220		lipgloss.JoinHorizontal(
221			lipgloss.Left,
222			pathKey,
223			pathValue,
224		),
225		baseStyle.Render(strings.Repeat(" ", p.width)),
226	}
227
228	// Add tool-specific header information
229	switch p.permission.ToolName {
230	case tools.BashToolName:
231		headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
232	case tools.EditToolName:
233		params := p.permission.Params.(tools.EditPermissionsParams)
234		fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
235		filePath := baseStyle.
236			Foreground(t.Text()).
237			Width(p.width - lipgloss.Width(fileKey)).
238			Render(fmt.Sprintf(": %s", params.FilePath))
239		headerParts = append(headerParts,
240			lipgloss.JoinHorizontal(
241				lipgloss.Left,
242				fileKey,
243				filePath,
244			),
245			baseStyle.Render(strings.Repeat(" ", p.width)),
246		)
247
248	case tools.WriteToolName:
249		params := p.permission.Params.(tools.WritePermissionsParams)
250		fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
251		filePath := baseStyle.
252			Foreground(t.Text()).
253			Width(p.width - lipgloss.Width(fileKey)).
254			Render(fmt.Sprintf(": %s", params.FilePath))
255		headerParts = append(headerParts,
256			lipgloss.JoinHorizontal(
257				lipgloss.Left,
258				fileKey,
259				filePath,
260			),
261			baseStyle.Render(strings.Repeat(" ", p.width)),
262		)
263	case tools.FetchToolName:
264		headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
265	}
266
267	return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
268}
269
270func (p *permissionDialogCmp) renderBashContent() string {
271	baseStyle := styles.BaseStyle()
272
273	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
274		content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
275
276		// Use the cache for markdown rendering
277		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
278			r := styles.GetMarkdownRenderer(p.width - 10)
279			s, err := r.Render(content)
280			return s, err
281		})
282
283		finalContent := baseStyle.
284			Width(p.contentViewPort.Width()).
285			Render(renderedContent)
286		p.contentViewPort.SetContent(finalContent)
287		return p.styleViewport()
288	}
289	return ""
290}
291
292func (p *permissionDialogCmp) renderEditContent() string {
293	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
294		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
295			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
296		})
297
298		p.contentViewPort.SetContent(diff)
299		return p.styleViewport()
300	}
301	return ""
302}
303
304func (p *permissionDialogCmp) renderPatchContent() string {
305	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
306		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
307			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
308		})
309
310		p.contentViewPort.SetContent(diff)
311		return p.styleViewport()
312	}
313	return ""
314}
315
316func (p *permissionDialogCmp) renderWriteContent() string {
317	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
318		// Use the cache for diff rendering
319		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
320			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
321		})
322
323		p.contentViewPort.SetContent(diff)
324		return p.styleViewport()
325	}
326	return ""
327}
328
329func (p *permissionDialogCmp) renderFetchContent() string {
330	baseStyle := styles.BaseStyle()
331
332	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
333		content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
334
335		// Use the cache for markdown rendering
336		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
337			r := styles.GetMarkdownRenderer(p.width - 10)
338			s, err := r.Render(content)
339			return s, err
340		})
341
342		finalContent := baseStyle.
343			Width(p.contentViewPort.Width()).
344			Render(renderedContent)
345		p.contentViewPort.SetContent(finalContent)
346		return p.styleViewport()
347	}
348	return ""
349}
350
351func (p *permissionDialogCmp) renderDefaultContent() string {
352	baseStyle := styles.BaseStyle()
353
354	content := p.permission.Description
355
356	// Use the cache for markdown rendering
357	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
358		r := styles.GetMarkdownRenderer(p.width - 10)
359		s, err := r.Render(content)
360		return s, err
361	})
362
363	finalContent := baseStyle.
364		Width(p.contentViewPort.Width()).
365		Render(renderedContent)
366	p.contentViewPort.SetContent(finalContent)
367
368	if renderedContent == "" {
369		return ""
370	}
371
372	return p.styleViewport()
373}
374
375func (p *permissionDialogCmp) styleViewport() string {
376	t := theme.CurrentTheme()
377	contentStyle := lipgloss.NewStyle().
378		Background(t.Background())
379
380	return contentStyle.Render(p.contentViewPort.View())
381}
382
383func (p *permissionDialogCmp) render() string {
384	t := theme.CurrentTheme()
385	baseStyle := styles.BaseStyle()
386
387	title := baseStyle.
388		Bold(true).
389		Width(p.width - 4).
390		Foreground(t.Primary()).
391		Render("Permission Required")
392	// Render header
393	headerContent := p.renderHeader()
394	// Render buttons
395	buttons := p.renderButtons()
396
397	// Calculate content height dynamically based on window size
398	p.contentViewPort.SetHeight(p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title))
399	p.contentViewPort.SetWidth(p.width - 4)
400
401	// Render content based on tool type
402	var contentFinal string
403	switch p.permission.ToolName {
404	case tools.BashToolName:
405		contentFinal = p.renderBashContent()
406	case tools.EditToolName:
407		contentFinal = p.renderEditContent()
408	case tools.PatchToolName:
409		contentFinal = p.renderPatchContent()
410	case tools.WriteToolName:
411		contentFinal = p.renderWriteContent()
412	case tools.FetchToolName:
413		contentFinal = p.renderFetchContent()
414	default:
415		contentFinal = p.renderDefaultContent()
416	}
417
418	content := lipgloss.JoinVertical(
419		lipgloss.Top,
420		title,
421		baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
422		headerContent,
423		contentFinal,
424		buttons,
425		baseStyle.Render(strings.Repeat(" ", p.width-4)),
426	)
427
428	return baseStyle.
429		Padding(1, 0, 0, 1).
430		Border(lipgloss.RoundedBorder()).
431		BorderBackground(t.Background()).
432		BorderForeground(t.TextMuted()).
433		Width(p.width).
434		Height(p.height).
435		Render(
436			content,
437		)
438}
439
440func (p *permissionDialogCmp) View() tea.View {
441	return tea.NewView(p.render())
442}
443
444func (p *permissionDialogCmp) BindingKeys() []key.Binding {
445	return layout.KeyMapToSlice(permissionsKeys)
446}
447
448func (p *permissionDialogCmp) SetSize() tea.Cmd {
449	if p.permission.ID == "" {
450		return nil
451	}
452	switch p.permission.ToolName {
453	case tools.BashToolName:
454		p.width = int(float64(p.windowSize.Width) * 0.4)
455		p.height = int(float64(p.windowSize.Height) * 0.3)
456	case tools.EditToolName:
457		p.width = int(float64(p.windowSize.Width) * 0.8)
458		p.height = int(float64(p.windowSize.Height) * 0.8)
459	case tools.WriteToolName:
460		p.width = int(float64(p.windowSize.Width) * 0.8)
461		p.height = int(float64(p.windowSize.Height) * 0.8)
462	case tools.FetchToolName:
463		p.width = int(float64(p.windowSize.Width) * 0.4)
464		p.height = int(float64(p.windowSize.Height) * 0.3)
465	default:
466		p.width = int(float64(p.windowSize.Width) * 0.7)
467		p.height = int(float64(p.windowSize.Height) * 0.5)
468	}
469	return nil
470}
471
472func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
473	p.permission = permission
474	return p.SetSize()
475}
476
477// Helper to get or set cached diff content
478func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
479	if cached, ok := c.diffCache[key]; ok {
480		return cached
481	}
482
483	content, err := generator()
484	if err != nil {
485		return fmt.Sprintf("Error formatting diff: %v", err)
486	}
487
488	c.diffCache[key] = content
489
490	return content
491}
492
493// Helper to get or set cached markdown content
494func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
495	if cached, ok := c.markdownCache[key]; ok {
496		return cached
497	}
498
499	content, err := generator()
500	if err != nil {
501		return fmt.Sprintf("Error rendering markdown: %v", err)
502	}
503
504	c.markdownCache[key] = content
505
506	return content
507}
508
509func NewPermissionDialogCmp() PermissionDialogCmp {
510	// Create viewport for content
511	contentViewport := viewport.New()
512
513	return &permissionDialogCmp{
514		contentViewPort: contentViewport,
515		selectedOption:  0, // Default to "Allow"
516		diffCache:       make(map[string]string),
517		markdownCache:   make(map[string]string),
518	}
519}