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