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