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