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