permission.go

  1package dialog
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	"github.com/charmbracelet/bubbles/key"
  8	"github.com/charmbracelet/bubbles/viewport"
  9	tea "github.com/charmbracelet/bubbletea"
 10	"github.com/charmbracelet/lipgloss"
 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	tea.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.KeyMsg:
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	t := theme.CurrentTheme()
272	baseStyle := styles.BaseStyle()
273
274	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
275		content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
276
277		// Use the cache for markdown rendering
278		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
279			r := styles.GetMarkdownRenderer(p.width - 10)
280			s, err := r.Render(content)
281			return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
282		})
283
284		finalContent := baseStyle.
285			Width(p.contentViewPort.Width).
286			Render(renderedContent)
287		p.contentViewPort.SetContent(finalContent)
288		return p.styleViewport()
289	}
290	return ""
291}
292
293func (p *permissionDialogCmp) renderEditContent() string {
294	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
295		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
296			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
297		})
298
299		p.contentViewPort.SetContent(diff)
300		return p.styleViewport()
301	}
302	return ""
303}
304
305func (p *permissionDialogCmp) renderPatchContent() string {
306	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
307		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
308			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
309		})
310
311		p.contentViewPort.SetContent(diff)
312		return p.styleViewport()
313	}
314	return ""
315}
316
317func (p *permissionDialogCmp) renderWriteContent() string {
318	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
319		// Use the cache for diff rendering
320		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
321			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
322		})
323
324		p.contentViewPort.SetContent(diff)
325		return p.styleViewport()
326	}
327	return ""
328}
329
330func (p *permissionDialogCmp) renderFetchContent() string {
331	t := theme.CurrentTheme()
332	baseStyle := styles.BaseStyle()
333
334	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
335		content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
336
337		// Use the cache for markdown rendering
338		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
339			r := styles.GetMarkdownRenderer(p.width - 10)
340			s, err := r.Render(content)
341			return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
342		})
343
344		finalContent := baseStyle.
345			Width(p.contentViewPort.Width).
346			Render(renderedContent)
347		p.contentViewPort.SetContent(finalContent)
348		return p.styleViewport()
349	}
350	return ""
351}
352
353func (p *permissionDialogCmp) renderDefaultContent() string {
354	t := theme.CurrentTheme()
355	baseStyle := styles.BaseStyle()
356
357	content := p.permission.Description
358
359	// Use the cache for markdown rendering
360	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
361		r := styles.GetMarkdownRenderer(p.width - 10)
362		s, err := r.Render(content)
363		return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
364	})
365
366	finalContent := baseStyle.
367		Width(p.contentViewPort.Width).
368		Render(renderedContent)
369	p.contentViewPort.SetContent(finalContent)
370
371	if renderedContent == "" {
372		return ""
373	}
374
375	return p.styleViewport()
376}
377
378func (p *permissionDialogCmp) styleViewport() string {
379	t := theme.CurrentTheme()
380	contentStyle := lipgloss.NewStyle().
381		Background(t.Background())
382
383	return contentStyle.Render(p.contentViewPort.View())
384}
385
386func (p *permissionDialogCmp) render() string {
387	t := theme.CurrentTheme()
388	baseStyle := styles.BaseStyle()
389
390	title := baseStyle.
391		Bold(true).
392		Width(p.width - 4).
393		Foreground(t.Primary()).
394		Render("Permission Required")
395	// Render header
396	headerContent := p.renderHeader()
397	// Render buttons
398	buttons := p.renderButtons()
399
400	// Calculate content height dynamically based on window size
401	p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
402	p.contentViewPort.Width = p.width - 4
403
404	// Render content based on tool type
405	var contentFinal string
406	switch p.permission.ToolName {
407	case tools.BashToolName:
408		contentFinal = p.renderBashContent()
409	case tools.EditToolName:
410		contentFinal = p.renderEditContent()
411	case tools.PatchToolName:
412		contentFinal = p.renderPatchContent()
413	case tools.WriteToolName:
414		contentFinal = p.renderWriteContent()
415	case tools.FetchToolName:
416		contentFinal = p.renderFetchContent()
417	default:
418		contentFinal = p.renderDefaultContent()
419	}
420
421	content := lipgloss.JoinVertical(
422		lipgloss.Top,
423		title,
424		baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
425		headerContent,
426		contentFinal,
427		buttons,
428		baseStyle.Render(strings.Repeat(" ", p.width-4)),
429	)
430
431	return baseStyle.
432		Padding(1, 0, 0, 1).
433		Border(lipgloss.RoundedBorder()).
434		BorderBackground(t.Background()).
435		BorderForeground(t.TextMuted()).
436		Width(p.width).
437		Height(p.height).
438		Render(
439			content,
440		)
441}
442
443func (p *permissionDialogCmp) View() string {
444	return p.render()
445}
446
447func (p *permissionDialogCmp) BindingKeys() []key.Binding {
448	return layout.KeyMapToSlice(permissionsKeys)
449}
450
451func (p *permissionDialogCmp) SetSize() tea.Cmd {
452	if p.permission.ID == "" {
453		return nil
454	}
455	switch p.permission.ToolName {
456	case tools.BashToolName:
457		p.width = int(float64(p.windowSize.Width) * 0.4)
458		p.height = int(float64(p.windowSize.Height) * 0.3)
459	case tools.EditToolName:
460		p.width = int(float64(p.windowSize.Width) * 0.8)
461		p.height = int(float64(p.windowSize.Height) * 0.8)
462	case tools.WriteToolName:
463		p.width = int(float64(p.windowSize.Width) * 0.8)
464		p.height = int(float64(p.windowSize.Height) * 0.8)
465	case tools.FetchToolName:
466		p.width = int(float64(p.windowSize.Width) * 0.4)
467		p.height = int(float64(p.windowSize.Height) * 0.3)
468	default:
469		p.width = int(float64(p.windowSize.Width) * 0.7)
470		p.height = int(float64(p.windowSize.Height) * 0.5)
471	}
472	return nil
473}
474
475func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
476	p.permission = permission
477	return p.SetSize()
478}
479
480// Helper to get or set cached diff content
481func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
482	if cached, ok := c.diffCache[key]; ok {
483		return cached
484	}
485
486	content, err := generator()
487	if err != nil {
488		return fmt.Sprintf("Error formatting diff: %v", err)
489	}
490
491	c.diffCache[key] = content
492
493	return content
494}
495
496// Helper to get or set cached markdown content
497func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
498	if cached, ok := c.markdownCache[key]; ok {
499		return cached
500	}
501
502	content, err := generator()
503	if err != nil {
504		return fmt.Sprintf("Error rendering markdown: %v", err)
505	}
506
507	c.markdownCache[key] = content
508
509	return content
510}
511
512func NewPermissionDialogCmp() PermissionDialogCmp {
513	// Create viewport for content
514	contentViewport := viewport.New(0, 0)
515
516	return &permissionDialogCmp{
517		contentViewPort: contentViewport,
518		selectedOption:  0, // Default to "Allow"
519		diffCache:       make(map[string]string),
520		markdownCache:   make(map[string]string),
521	}
522}