permissions.go

  1package dialog
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"strings"
  7
  8	"charm.land/bubbles/v2/help"
  9	"charm.land/bubbles/v2/key"
 10	"charm.land/bubbles/v2/viewport"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	"github.com/charmbracelet/crush/internal/agent/tools"
 14	"github.com/charmbracelet/crush/internal/fsext"
 15	"github.com/charmbracelet/crush/internal/permission"
 16	"github.com/charmbracelet/crush/internal/stringext"
 17	"github.com/charmbracelet/crush/internal/ui/common"
 18	"github.com/charmbracelet/crush/internal/ui/styles"
 19	uv "github.com/charmbracelet/ultraviolet"
 20)
 21
 22// PermissionsID is the identifier for the permissions dialog.
 23const PermissionsID = "permissions"
 24
 25// PermissionAction represents the user's response to a permission request.
 26type PermissionAction string
 27
 28const (
 29	PermissionAllow           PermissionAction = "allow"
 30	PermissionAllowForSession PermissionAction = "allow_session"
 31	PermissionDeny            PermissionAction = "deny"
 32)
 33
 34// Permissions dialog sizing constants.
 35const (
 36	// diffMaxWidth is the maximum width for diff views.
 37	diffMaxWidth = 180
 38	// diffSizeRatio is the size ratio for diff views relative to window.
 39	diffSizeRatio = 0.8
 40	// simpleMaxWidth is the maximum width for simple content dialogs.
 41	simpleMaxWidth = 100
 42	// simpleSizeRatio is the size ratio for simple content dialogs.
 43	simpleSizeRatio = 0.6
 44	// simpleHeightRatio is the height ratio for simple content dialogs.
 45	simpleHeightRatio = 0.5
 46	// splitModeMinWidth is the minimum width to enable split diff mode.
 47	splitModeMinWidth = 140
 48	// layoutSpacingLines is the number of empty lines used for layout spacing.
 49	layoutSpacingLines = 4
 50	// minWindowWidth is the minimum window width before forcing fullscreen.
 51	minWindowWidth = 77
 52	// minWindowHeight is the minimum window height before forcing fullscreen.
 53	minWindowHeight = 20
 54)
 55
 56// Permissions represents a dialog for permission requests.
 57type Permissions struct {
 58	com          *common.Common
 59	windowWidth  int // Terminal window dimensions.
 60	windowHeight int
 61	fullscreen   bool // true when dialog is fullscreen
 62
 63	permission     permission.PermissionRequest
 64	selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
 65
 66	viewport      viewport.Model
 67	viewportDirty bool // true when viewport content needs to be re-rendered
 68	viewportWidth int
 69
 70	// Diff view state.
 71	diffSplitMode        *bool // nil means use default based on width
 72	defaultDiffSplitMode bool  // default split mode based on width
 73	diffXOffset          int   // horizontal scroll offset for diff view
 74	unifiedDiffContent   string
 75	splitDiffContent     string
 76
 77	help   help.Model
 78	keyMap permissionsKeyMap
 79}
 80
 81type permissionsKeyMap struct {
 82	Left             key.Binding
 83	Right            key.Binding
 84	Tab              key.Binding
 85	Select           key.Binding
 86	Allow            key.Binding
 87	AllowSession     key.Binding
 88	Deny             key.Binding
 89	Close            key.Binding
 90	ToggleDiffMode   key.Binding
 91	ToggleFullscreen key.Binding
 92	ScrollUp         key.Binding
 93	ScrollDown       key.Binding
 94	ScrollLeft       key.Binding
 95	ScrollRight      key.Binding
 96	Choose           key.Binding
 97	Scroll           key.Binding
 98}
 99
100func defaultPermissionsKeyMap() permissionsKeyMap {
101	return permissionsKeyMap{
102		Left: key.NewBinding(
103			key.WithKeys("left", "h"),
104			key.WithHelp("←", "previous"),
105		),
106		Right: key.NewBinding(
107			key.WithKeys("right", "l"),
108			key.WithHelp("→", "next"),
109		),
110		Tab: key.NewBinding(
111			key.WithKeys("tab"),
112			key.WithHelp("tab", "next option"),
113		),
114		Select: key.NewBinding(
115			key.WithKeys("enter", "ctrl+y"),
116			key.WithHelp("enter", "confirm"),
117		),
118		Allow: key.NewBinding(
119			key.WithKeys("a", "A", "ctrl+a"),
120			key.WithHelp("a", "allow"),
121		),
122		AllowSession: key.NewBinding(
123			key.WithKeys("s", "S", "ctrl+s"),
124			key.WithHelp("s", "allow session"),
125		),
126		Deny: key.NewBinding(
127			key.WithKeys("d", "D"),
128			key.WithHelp("d", "deny"),
129		),
130		Close: CloseKey,
131		ToggleDiffMode: key.NewBinding(
132			key.WithKeys("t"),
133			key.WithHelp("t", "toggle diff view"),
134		),
135		ToggleFullscreen: key.NewBinding(
136			key.WithKeys("f"),
137			key.WithHelp("f", "toggle fullscreen"),
138		),
139		ScrollUp: key.NewBinding(
140			key.WithKeys("shift+up", "K"),
141			key.WithHelp("shift+↑", "scroll up"),
142		),
143		ScrollDown: key.NewBinding(
144			key.WithKeys("shift+down", "J"),
145			key.WithHelp("shift+↓", "scroll down"),
146		),
147		ScrollLeft: key.NewBinding(
148			key.WithKeys("shift+left", "H"),
149			key.WithHelp("shift+←", "scroll left"),
150		),
151		ScrollRight: key.NewBinding(
152			key.WithKeys("shift+right", "L"),
153			key.WithHelp("shift+→", "scroll right"),
154		),
155		Choose: key.NewBinding(
156			key.WithKeys("left", "right"),
157			key.WithHelp("←/→", "choose"),
158		),
159		Scroll: key.NewBinding(
160			key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
161			key.WithHelp("shift+←↓↑→", "scroll"),
162		),
163	}
164}
165
166var _ Dialog = (*Permissions)(nil)
167
168// PermissionsOption configures the permissions dialog.
169type PermissionsOption func(*Permissions)
170
171// WithDiffMode sets the initial diff mode (split or unified).
172func WithDiffMode(split bool) PermissionsOption {
173	return func(p *Permissions) {
174		p.diffSplitMode = &split
175	}
176}
177
178// NewPermissions creates a new permissions dialog.
179func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions {
180	h := help.New()
181	h.Styles = com.Styles.DialogHelpStyles()
182
183	km := defaultPermissionsKeyMap()
184
185	// Configure viewport with matching keybindings.
186	vp := viewport.New()
187	vp.KeyMap = viewport.KeyMap{
188		Up:    km.ScrollUp,
189		Down:  km.ScrollDown,
190		Left:  km.ScrollLeft,
191		Right: km.ScrollRight,
192		// Disable other viewport keys to avoid conflicts with dialog shortcuts.
193		PageUp:       key.NewBinding(key.WithDisabled()),
194		PageDown:     key.NewBinding(key.WithDisabled()),
195		HalfPageUp:   key.NewBinding(key.WithDisabled()),
196		HalfPageDown: key.NewBinding(key.WithDisabled()),
197	}
198
199	p := &Permissions{
200		com:            com,
201		permission:     perm,
202		selectedOption: 0,
203		viewport:       vp,
204		help:           h,
205		keyMap:         km,
206	}
207
208	for _, opt := range opts {
209		opt(p)
210	}
211
212	return p
213}
214
215// Calculate usable content width (dialog border + horizontal padding).
216func (p *Permissions) calculateContentWidth(width int) int {
217	t := p.com.Styles
218	const dialogHorizontalPadding = 2
219	return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding
220}
221
222// ID implements [Dialog].
223func (*Permissions) ID() string {
224	return PermissionsID
225}
226
227// HandleMsg implements [Dialog].
228func (p *Permissions) HandleMsg(msg tea.Msg) Action {
229	switch msg := msg.(type) {
230	case tea.KeyPressMsg:
231		switch {
232		case key.Matches(msg, p.keyMap.Close):
233			// Escape denies the permission request.
234			return p.respond(PermissionDeny)
235		case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab):
236			p.selectedOption = (p.selectedOption + 1) % 3
237		case key.Matches(msg, p.keyMap.Left):
238			// Add 2 instead of subtracting 1 to avoid negative modulo.
239			p.selectedOption = (p.selectedOption + 2) % 3
240		case key.Matches(msg, p.keyMap.Select):
241			return p.selectCurrentOption()
242		case key.Matches(msg, p.keyMap.Allow):
243			return p.respond(PermissionAllow)
244		case key.Matches(msg, p.keyMap.AllowSession):
245			return p.respond(PermissionAllowForSession)
246		case key.Matches(msg, p.keyMap.Deny):
247			return p.respond(PermissionDeny)
248		case key.Matches(msg, p.keyMap.ToggleDiffMode):
249			if p.hasDiffView() {
250				newMode := !p.isSplitMode()
251				p.diffSplitMode = &newMode
252				p.viewportDirty = true
253			}
254		case key.Matches(msg, p.keyMap.ToggleFullscreen):
255			if p.hasDiffView() {
256				p.fullscreen = !p.fullscreen
257			}
258		case key.Matches(msg, p.keyMap.ScrollDown):
259			p.viewport, _ = p.viewport.Update(msg)
260		case key.Matches(msg, p.keyMap.ScrollUp):
261			p.viewport, _ = p.viewport.Update(msg)
262		case key.Matches(msg, p.keyMap.ScrollLeft):
263			if p.hasDiffView() {
264				p.scrollLeft()
265			} else {
266				p.viewport, _ = p.viewport.Update(msg)
267			}
268		case key.Matches(msg, p.keyMap.ScrollRight):
269			if p.hasDiffView() {
270				p.scrollRight()
271			} else {
272				p.viewport, _ = p.viewport.Update(msg)
273			}
274		}
275	case tea.MouseWheelMsg:
276		if p.hasDiffView() {
277			switch msg.Button {
278			case tea.MouseWheelLeft:
279				p.scrollLeft()
280			case tea.MouseWheelRight:
281				p.scrollRight()
282			default:
283				p.viewport, _ = p.viewport.Update(msg)
284			}
285		} else {
286			p.viewport, _ = p.viewport.Update(msg)
287		}
288	default:
289		// Pass unhandled keys to viewport for non-diff content scrolling.
290		if !p.hasDiffView() {
291			p.viewport, _ = p.viewport.Update(msg)
292			p.viewportDirty = true
293		}
294	}
295
296	return nil
297}
298
299func (p *Permissions) selectCurrentOption() tea.Msg {
300	switch p.selectedOption {
301	case 0:
302		return p.respond(PermissionAllow)
303	case 1:
304		return p.respond(PermissionAllowForSession)
305	default:
306		return p.respond(PermissionDeny)
307	}
308}
309
310func (p *Permissions) respond(action PermissionAction) tea.Msg {
311	return ActionPermissionResponse{
312		Permission: p.permission,
313		Action:     action,
314	}
315}
316
317func (p *Permissions) hasDiffView() bool {
318	switch p.permission.ToolName {
319	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName:
320		return true
321	}
322	return false
323}
324
325func (p *Permissions) isSplitMode() bool {
326	if p.diffSplitMode != nil {
327		return *p.diffSplitMode
328	}
329	return p.defaultDiffSplitMode
330}
331
332const horizontalScrollStep = 5
333
334func (p *Permissions) scrollLeft() {
335	p.diffXOffset = max(0, p.diffXOffset-horizontalScrollStep)
336	p.viewportDirty = true
337}
338
339func (p *Permissions) scrollRight() {
340	p.diffXOffset += horizontalScrollStep
341	p.viewportDirty = true
342}
343
344// Draw implements [Dialog].
345func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
346	t := p.com.Styles
347	// Force fullscreen when window is too small.
348	forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
349
350	// Calculate dialog dimensions based on fullscreen state and content type.
351	var width, maxHeight int
352	if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
353		// Use nearly full window for fullscreen.
354		width = area.Dx()
355		maxHeight = area.Dy()
356	} else if p.hasDiffView() {
357		// Wide for side-by-side diffs, capped for readability.
358		width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
359		maxHeight = int(float64(area.Dy()) * diffSizeRatio)
360	} else {
361		// Narrower for simple content like commands/URLs.
362		width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
363		maxHeight = int(float64(area.Dy()) * simpleHeightRatio)
364	}
365
366	dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
367
368	contentWidth := p.calculateContentWidth(width)
369	header := p.renderHeader(contentWidth)
370	buttons := p.renderButtons(contentWidth)
371	helpView := p.help.View(p)
372
373	// Calculate available height for content.
374	headerHeight := lipgloss.Height(header)
375	buttonsHeight := lipgloss.Height(buttons)
376	helpHeight := lipgloss.Height(helpView)
377	frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
378
379	p.defaultDiffSplitMode = width >= splitModeMinWidth
380
381	// Pre-render content to measure its actual height.
382	renderedContent := p.renderContent(contentWidth)
383	contentHeight := lipgloss.Height(renderedContent)
384
385	// For non-diff views, shrink dialog to fit content if it's smaller than max.
386	var availableHeight int
387	if !p.hasDiffView() && !forceFullscreen {
388		fixedHeight := headerHeight + buttonsHeight + helpHeight + frameHeight
389		neededHeight := fixedHeight + contentHeight
390		if neededHeight < maxHeight {
391			availableHeight = contentHeight
392		} else {
393			availableHeight = maxHeight - fixedHeight
394		}
395		availableHeight = max(availableHeight, 3)
396	} else {
397		availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight
398	}
399
400	// Determine if scrollbar is needed.
401	needsScrollbar := p.hasDiffView() || contentHeight > availableHeight
402	viewportWidth := contentWidth
403	if needsScrollbar {
404		viewportWidth = contentWidth - 1 // Reserve space for scrollbar.
405	}
406
407	if p.viewport.Width() != viewportWidth {
408		// Mark content as dirty if width has changed.
409		p.viewportDirty = true
410		renderedContent = p.renderContent(viewportWidth)
411	}
412
413	var content string
414	var scrollbar string
415	p.viewport.SetWidth(viewportWidth)
416	p.viewport.SetHeight(availableHeight)
417	if p.viewportDirty {
418		p.viewport.SetContent(renderedContent)
419		p.viewportWidth = p.viewport.Width()
420		p.viewportDirty = false
421	}
422	content = p.viewport.View()
423	if needsScrollbar {
424		scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
425	}
426
427	// Join content with scrollbar if present.
428	if scrollbar != "" {
429		content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
430	}
431
432	parts := []string{header}
433	if content != "" {
434		parts = append(parts, "", content)
435	}
436	parts = append(parts, "", buttons, "", helpView)
437
438	innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
439	DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
440	return nil
441}
442
443func (p *Permissions) renderHeader(contentWidth int) string {
444	t := p.com.Styles
445
446	title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize(), t.Primary, t.Secondary)
447	title = t.Dialog.Title.Render(title)
448
449	// Tool info.
450	toolLine := p.renderToolName(contentWidth)
451	pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
452
453	lines := []string{title, "", toolLine, pathLine}
454
455	// Add tool-specific header info.
456	switch p.permission.ToolName {
457	case tools.BashToolName:
458		if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
459			lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth))
460		}
461	case tools.DownloadToolName:
462		if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
463			lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
464			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
465		}
466	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
467		var filePath string
468		switch params := p.permission.Params.(type) {
469		case tools.EditPermissionsParams:
470			filePath = params.FilePath
471		case tools.WritePermissionsParams:
472			filePath = params.FilePath
473		case tools.MultiEditPermissionsParams:
474			filePath = params.FilePath
475		case tools.ViewPermissionsParams:
476			filePath = params.FilePath
477		}
478		if filePath != "" {
479			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
480		}
481	case tools.LSToolName:
482		if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
483			lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
484		}
485	}
486
487	return lipgloss.JoinVertical(lipgloss.Left, lines...)
488}
489
490func (p *Permissions) renderKeyValue(key, value string, width int) string {
491	t := p.com.Styles
492	keyStyle := t.Muted
493	valueStyle := t.Base
494
495	keyStr := keyStyle.Render(key)
496	valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
497
498	return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
499}
500
501func (p *Permissions) renderToolName(width int) string {
502	toolName := p.permission.ToolName
503
504	// Check if this is an MCP tool (format: mcp_<mcpname>_<toolname>).
505	if strings.HasPrefix(toolName, "mcp_") {
506		parts := strings.SplitN(toolName, "_", 3)
507		if len(parts) == 3 {
508			mcpName := prettyName(parts[1])
509			toolPart := prettyName(parts[2])
510			toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart)
511		}
512	}
513
514	return p.renderKeyValue("Tool", toolName, width)
515}
516
517// prettyName converts snake_case or kebab-case to Title Case.
518func prettyName(name string) string {
519	name = strings.ReplaceAll(name, "_", " ")
520	name = strings.ReplaceAll(name, "-", " ")
521	return stringext.Capitalize(name)
522}
523
524func (p *Permissions) renderContent(width int) string {
525	switch p.permission.ToolName {
526	case tools.BashToolName:
527		return p.renderBashContent(width)
528	case tools.EditToolName:
529		return p.renderEditContent(width)
530	case tools.WriteToolName:
531		return p.renderWriteContent(width)
532	case tools.MultiEditToolName:
533		return p.renderMultiEditContent(width)
534	case tools.DownloadToolName:
535		return p.renderDownloadContent(width)
536	case tools.FetchToolName:
537		return p.renderFetchContent(width)
538	case tools.AgenticFetchToolName:
539		return p.renderAgenticFetchContent(width)
540	case tools.ViewToolName:
541		return p.renderViewContent(width)
542	case tools.LSToolName:
543		return p.renderLSContent(width)
544	default:
545		return p.renderDefaultContent(width)
546	}
547}
548
549func (p *Permissions) renderBashContent(width int) string {
550	params, ok := p.permission.Params.(tools.BashPermissionsParams)
551	if !ok {
552		return ""
553	}
554
555	return p.renderContentPanel(params.Command, width)
556}
557
558func (p *Permissions) renderEditContent(contentWidth int) string {
559	params, ok := p.permission.Params.(tools.EditPermissionsParams)
560	if !ok {
561		return ""
562	}
563	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
564}
565
566func (p *Permissions) renderWriteContent(contentWidth int) string {
567	params, ok := p.permission.Params.(tools.WritePermissionsParams)
568	if !ok {
569		return ""
570	}
571	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
572}
573
574func (p *Permissions) renderMultiEditContent(contentWidth int) string {
575	params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
576	if !ok {
577		return ""
578	}
579	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
580}
581
582func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
583	if !p.viewportDirty {
584		if p.isSplitMode() {
585			return p.splitDiffContent
586		}
587		return p.unifiedDiffContent
588	}
589
590	isSplitMode := p.isSplitMode()
591	formatter := common.DiffFormatter(p.com.Styles).
592		Before(fsext.PrettyPath(filePath), oldContent).
593		After(fsext.PrettyPath(filePath), newContent).
594		XOffset(p.diffXOffset).
595		Width(contentWidth)
596
597	var result string
598	if isSplitMode {
599		formatter = formatter.Split()
600		p.splitDiffContent = formatter.String()
601		result = p.splitDiffContent
602	} else {
603		formatter = formatter.Unified()
604		p.unifiedDiffContent = formatter.String()
605		result = p.unifiedDiffContent
606	}
607
608	return result
609}
610
611func (p *Permissions) renderDownloadContent(width int) string {
612	params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
613	if !ok {
614		return ""
615	}
616
617	content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
618	if params.Timeout > 0 {
619		content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
620	}
621
622	return p.renderContentPanel(content, width)
623}
624
625func (p *Permissions) renderFetchContent(width int) string {
626	params, ok := p.permission.Params.(tools.FetchPermissionsParams)
627	if !ok {
628		return ""
629	}
630
631	return p.renderContentPanel(params.URL, width)
632}
633
634func (p *Permissions) renderAgenticFetchContent(width int) string {
635	params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
636	if !ok {
637		return ""
638	}
639
640	var content string
641	if params.URL != "" {
642		content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
643	} else {
644		content = fmt.Sprintf("Prompt: %s", params.Prompt)
645	}
646
647	return p.renderContentPanel(content, width)
648}
649
650func (p *Permissions) renderViewContent(width int) string {
651	params, ok := p.permission.Params.(tools.ViewPermissionsParams)
652	if !ok {
653		return ""
654	}
655
656	content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
657	if params.Offset > 0 {
658		content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
659	}
660	if params.Limit > 0 && params.Limit != 2000 {
661		content += fmt.Sprintf("\nLines to read: %d", params.Limit)
662	}
663
664	return p.renderContentPanel(content, width)
665}
666
667func (p *Permissions) renderLSContent(width int) string {
668	params, ok := p.permission.Params.(tools.LSPermissionsParams)
669	if !ok {
670		return ""
671	}
672
673	content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
674	if len(params.Ignore) > 0 {
675		content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
676	}
677
678	return p.renderContentPanel(content, width)
679}
680
681func (p *Permissions) renderDefaultContent(width int) string {
682	t := p.com.Styles
683	var content string
684	// do not add the description for mcp tools
685	if !strings.HasPrefix(p.permission.ToolName, "mcp_") {
686		content = p.permission.Description
687	}
688
689	// Pretty-print JSON params if available.
690	if p.permission.Params != nil {
691		var paramStr string
692		if str, ok := p.permission.Params.(string); ok {
693			paramStr = str
694		} else {
695			paramStr = fmt.Sprintf("%v", p.permission.Params)
696		}
697
698		var parsed any
699		if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
700			if b, err := json.MarshalIndent(parsed, "", "  "); err == nil {
701				jsonContent := string(b)
702				highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.BgSubtle)
703				if err == nil {
704					jsonContent = highlighted
705				}
706				if content != "" {
707					content += "\n\n"
708				}
709				content += jsonContent
710			}
711		} else if paramStr != "" {
712			if content != "" {
713				content += "\n\n"
714			}
715			content += paramStr
716		}
717	}
718
719	if content == "" {
720		return ""
721	}
722
723	return p.renderContentPanel(strings.TrimSpace(content), width)
724}
725
726// renderContentPanel renders content in a panel with the full width.
727func (p *Permissions) renderContentPanel(content string, width int) string {
728	panelStyle := p.com.Styles.Dialog.ContentPanel
729	return panelStyle.Width(width).Render(content)
730}
731
732func (p *Permissions) renderButtons(contentWidth int) string {
733	buttons := []common.ButtonOpts{
734		{Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
735		{Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
736		{Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
737	}
738
739	content := common.ButtonGroup(p.com.Styles, buttons, "  ")
740
741	// If buttons are too wide, stack them vertically.
742	if lipgloss.Width(content) > contentWidth {
743		content = common.ButtonGroup(p.com.Styles, buttons, "\n")
744		return lipgloss.NewStyle().
745			Width(contentWidth).
746			Align(lipgloss.Center).
747			Render(content)
748	}
749
750	return lipgloss.NewStyle().
751		Width(contentWidth).
752		Align(lipgloss.Right).
753		Render(content)
754}
755
756func (p *Permissions) canScroll() bool {
757	if p.hasDiffView() {
758		// Diff views can always scroll.
759		return true
760	}
761	// For non-diff content, check if viewport has scrollable content.
762	return !p.viewport.AtTop() || !p.viewport.AtBottom()
763}
764
765// ShortHelp implements [help.KeyMap].
766func (p *Permissions) ShortHelp() []key.Binding {
767	bindings := []key.Binding{
768		p.keyMap.Choose,
769		p.keyMap.Select,
770		p.keyMap.Close,
771	}
772
773	if p.canScroll() {
774		bindings = append(bindings, p.keyMap.Scroll)
775	}
776
777	if p.hasDiffView() {
778		bindings = append(bindings,
779			p.keyMap.ToggleDiffMode,
780			p.keyMap.ToggleFullscreen,
781		)
782	}
783
784	return bindings
785}
786
787// FullHelp implements [help.KeyMap].
788func (p *Permissions) FullHelp() [][]key.Binding {
789	return [][]key.Binding{p.ShortHelp()}
790}