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/util"
 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	util.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.KeyPressMsg:
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 := styles.CurrentTheme()
152
153	allowStyle := t.S().Text
154	allowSessionStyle := allowStyle
155	denyStyle := allowStyle
156
157	// Style the selected button
158	switch p.selectedOption {
159	case 0:
160		allowStyle = allowStyle.Background(t.Secondary)
161		allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
162		denyStyle = denyStyle.Background(t.BgSubtle)
163	case 1:
164		allowStyle = allowStyle.Background(t.BgSubtle)
165		allowSessionStyle = allowSessionStyle.Background(t.Secondary)
166		denyStyle = denyStyle.Background(t.BgSubtle)
167	case 2:
168		allowStyle = allowStyle.Background(t.BgSubtle)
169		allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
170		denyStyle = denyStyle.Background(t.Secondary)
171	}
172
173	allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
174	allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
175	denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
176
177	content := lipgloss.JoinHorizontal(
178		lipgloss.Left,
179		allowButton,
180		"  ",
181		allowSessionButton,
182		"  ",
183		denyButton,
184		"  ",
185	)
186
187	remainingWidth := p.width - lipgloss.Width(content)
188	if remainingWidth > 0 {
189		content = strings.Repeat(" ", remainingWidth) + content
190	}
191	return content
192}
193
194func (p *permissionDialogCmp) renderHeader() string {
195	t := styles.CurrentTheme()
196	baseStyle := t.S().Base
197
198	toolKey := t.S().Muted.Bold(true).Render("Tool")
199	toolValue := t.S().Text.
200		Width(p.width - lipgloss.Width(toolKey)).
201		Render(fmt.Sprintf(": %s", p.permission.ToolName))
202
203	pathKey := t.S().Muted.Bold(true).Render("Path")
204	pathValue := t.S().Text.
205		Width(p.width - lipgloss.Width(pathKey)).
206		Render(fmt.Sprintf(": %s", p.permission.Path))
207
208	headerParts := []string{
209		lipgloss.JoinHorizontal(
210			lipgloss.Left,
211			toolKey,
212			toolValue,
213		),
214		baseStyle.Render(strings.Repeat(" ", p.width)),
215		lipgloss.JoinHorizontal(
216			lipgloss.Left,
217			pathKey,
218			pathValue,
219		),
220		baseStyle.Render(strings.Repeat(" ", p.width)),
221	}
222
223	// Add tool-specific header information
224	switch p.permission.ToolName {
225	case tools.BashToolName:
226		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("Command"))
227	case tools.EditToolName:
228		params := p.permission.Params.(tools.EditPermissionsParams)
229		fileKey := t.S().Muted.Bold(true).Render("File")
230		filePath := t.S().Text.
231			Width(p.width - lipgloss.Width(fileKey)).
232			Render(fmt.Sprintf(": %s", params.FilePath))
233		headerParts = append(headerParts,
234			lipgloss.JoinHorizontal(
235				lipgloss.Left,
236				fileKey,
237				filePath,
238			),
239			baseStyle.Render(strings.Repeat(" ", p.width)),
240		)
241
242	case tools.WriteToolName:
243		params := p.permission.Params.(tools.WritePermissionsParams)
244		fileKey := t.S().Muted.Bold(true).Render("File")
245		filePath := t.S().Text.
246			Width(p.width - lipgloss.Width(fileKey)).
247			Render(fmt.Sprintf(": %s", params.FilePath))
248		headerParts = append(headerParts,
249			lipgloss.JoinHorizontal(
250				lipgloss.Left,
251				fileKey,
252				filePath,
253			),
254			baseStyle.Render(strings.Repeat(" ", p.width)),
255		)
256	case tools.FetchToolName:
257		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
258	}
259
260	return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
261}
262
263func (p *permissionDialogCmp) renderBashContent() string {
264	baseStyle := styles.CurrentTheme().S().Base
265	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
266		content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
267
268		// Use the cache for markdown rendering
269		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
270			r := styles.GetMarkdownRenderer(p.width - 10)
271			s, err := r.Render(content)
272			return s, err
273		})
274
275		finalContent := baseStyle.
276			Width(p.contentViewPort.Width()).
277			Render(renderedContent)
278		p.contentViewPort.SetContent(finalContent)
279		return p.styleViewport()
280	}
281	return ""
282}
283
284func (p *permissionDialogCmp) renderEditContent() string {
285	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
286		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
287			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
288		})
289
290		p.contentViewPort.SetContent(diff)
291		return p.styleViewport()
292	}
293	return ""
294}
295
296func (p *permissionDialogCmp) renderPatchContent() string {
297	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
298		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
299			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
300		})
301
302		p.contentViewPort.SetContent(diff)
303		return p.styleViewport()
304	}
305	return ""
306}
307
308func (p *permissionDialogCmp) renderWriteContent() string {
309	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
310		// Use the cache for diff rendering
311		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
312			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
313		})
314
315		p.contentViewPort.SetContent(diff)
316		return p.styleViewport()
317	}
318	return ""
319}
320
321func (p *permissionDialogCmp) renderFetchContent() string {
322	baseStyle := styles.CurrentTheme().S().Base
323	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
324		content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
325
326		// Use the cache for markdown rendering
327		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
328			r := styles.GetMarkdownRenderer(p.width - 10)
329			s, err := r.Render(content)
330			return s, err
331		})
332
333		finalContent := baseStyle.
334			Width(p.contentViewPort.Width()).
335			Render(renderedContent)
336		p.contentViewPort.SetContent(finalContent)
337		return p.styleViewport()
338	}
339	return ""
340}
341
342func (p *permissionDialogCmp) renderDefaultContent() string {
343	baseStyle := styles.CurrentTheme().S().Base
344
345	content := p.permission.Description
346
347	// Use the cache for markdown rendering
348	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
349		r := styles.GetMarkdownRenderer(p.width - 10)
350		s, err := r.Render(content)
351		return s, err
352	})
353
354	finalContent := baseStyle.
355		Width(p.contentViewPort.Width()).
356		Render(renderedContent)
357	p.contentViewPort.SetContent(finalContent)
358
359	if renderedContent == "" {
360		return ""
361	}
362
363	return p.styleViewport()
364}
365
366func (p *permissionDialogCmp) styleViewport() string {
367	t := styles.CurrentTheme()
368
369	return t.S().Base.Render(p.contentViewPort.View())
370}
371
372func (p *permissionDialogCmp) render() string {
373	t := styles.CurrentTheme()
374	baseStyle := t.S().Base
375
376	title := baseStyle.
377		Bold(true).
378		Width(p.width - 4).
379		Foreground(t.Primary).
380		Render("Permission Required")
381	// Render header
382	headerContent := p.renderHeader()
383	// Render buttons
384	buttons := p.renderButtons()
385
386	// Calculate content height dynamically based on window size
387	p.contentViewPort.SetHeight(p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title))
388	p.contentViewPort.SetWidth(p.width - 4)
389
390	// Render content based on tool type
391	var contentFinal string
392	switch p.permission.ToolName {
393	case tools.BashToolName:
394		contentFinal = p.renderBashContent()
395	case tools.EditToolName:
396		contentFinal = p.renderEditContent()
397	case tools.PatchToolName:
398		contentFinal = p.renderPatchContent()
399	case tools.WriteToolName:
400		contentFinal = p.renderWriteContent()
401	case tools.FetchToolName:
402		contentFinal = p.renderFetchContent()
403	default:
404		contentFinal = p.renderDefaultContent()
405	}
406
407	content := lipgloss.JoinVertical(
408		lipgloss.Top,
409		title,
410		baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
411		headerContent,
412		contentFinal,
413		buttons,
414		baseStyle.Render(strings.Repeat(" ", p.width-4)),
415	)
416
417	return baseStyle.
418		Padding(1, 0, 0, 1).
419		Border(lipgloss.RoundedBorder()).
420		BorderForeground(t.BorderFocus).
421		Width(p.width).
422		Height(p.height).
423		Render(
424			content,
425		)
426}
427
428func (p *permissionDialogCmp) View() tea.View {
429	return tea.NewView(p.render())
430}
431
432func (p *permissionDialogCmp) BindingKeys() []key.Binding {
433	return layout.KeyMapToSlice(permissionsKeys)
434}
435
436func (p *permissionDialogCmp) SetSize() tea.Cmd {
437	if p.permission.ID == "" {
438		return nil
439	}
440	switch p.permission.ToolName {
441	case tools.BashToolName:
442		p.width = int(float64(p.windowSize.Width) * 0.4)
443		p.height = int(float64(p.windowSize.Height) * 0.3)
444	case tools.EditToolName:
445		p.width = int(float64(p.windowSize.Width) * 0.8)
446		p.height = int(float64(p.windowSize.Height) * 0.8)
447	case tools.WriteToolName:
448		p.width = int(float64(p.windowSize.Width) * 0.8)
449		p.height = int(float64(p.windowSize.Height) * 0.8)
450	case tools.FetchToolName:
451		p.width = int(float64(p.windowSize.Width) * 0.4)
452		p.height = int(float64(p.windowSize.Height) * 0.3)
453	default:
454		p.width = int(float64(p.windowSize.Width) * 0.7)
455		p.height = int(float64(p.windowSize.Height) * 0.5)
456	}
457	return nil
458}
459
460func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
461	p.permission = permission
462	return p.SetSize()
463}
464
465// Helper to get or set cached diff content
466func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
467	if cached, ok := c.diffCache[key]; ok {
468		return cached
469	}
470
471	content, err := generator()
472	if err != nil {
473		return fmt.Sprintf("Error formatting diff: %v", err)
474	}
475
476	c.diffCache[key] = content
477
478	return content
479}
480
481// Helper to get or set cached markdown content
482func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
483	if cached, ok := c.markdownCache[key]; ok {
484		return cached
485	}
486
487	content, err := generator()
488	if err != nil {
489		return fmt.Sprintf("Error rendering markdown: %v", err)
490	}
491
492	c.markdownCache[key] = content
493
494	return content
495}
496
497func NewPermissionDialogCmp() PermissionDialogCmp {
498	// Create viewport for content
499	contentViewport := viewport.New()
500
501	return &permissionDialogCmp{
502		contentViewPort: contentViewport,
503		selectedOption:  0, // Default to "Allow"
504		diffCache:       make(map[string]string),
505		markdownCache:   make(map[string]string),
506	}
507}