permission.go

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