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// ToolCallID returns the tool call ID associated with this dialog's
228// permission request.
229func (p *Permissions) ToolCallID() string {
230	return p.permission.ToolCallID
231}
232
233// HandleMsg implements [Dialog].
234func (p *Permissions) HandleMsg(msg tea.Msg) Action {
235	switch msg := msg.(type) {
236	case tea.KeyPressMsg:
237		switch {
238		case key.Matches(msg, p.keyMap.Close):
239			// Escape denies the permission request.
240			return p.respond(PermissionDeny)
241		case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab):
242			p.selectedOption = (p.selectedOption + 1) % 3
243		case key.Matches(msg, p.keyMap.Left):
244			// Add 2 instead of subtracting 1 to avoid negative modulo.
245			p.selectedOption = (p.selectedOption + 2) % 3
246		case key.Matches(msg, p.keyMap.Select):
247			return p.selectCurrentOption()
248		case key.Matches(msg, p.keyMap.Allow):
249			return p.respond(PermissionAllow)
250		case key.Matches(msg, p.keyMap.AllowSession):
251			return p.respond(PermissionAllowForSession)
252		case key.Matches(msg, p.keyMap.Deny):
253			return p.respond(PermissionDeny)
254		case key.Matches(msg, p.keyMap.ToggleDiffMode):
255			if p.hasDiffView() {
256				newMode := !p.isSplitMode()
257				p.diffSplitMode = &newMode
258				p.viewportDirty = true
259			}
260		case key.Matches(msg, p.keyMap.ToggleFullscreen):
261			if p.hasDiffView() {
262				p.fullscreen = !p.fullscreen
263			}
264		case key.Matches(msg, p.keyMap.ScrollDown):
265			p.viewport, _ = p.viewport.Update(msg)
266		case key.Matches(msg, p.keyMap.ScrollUp):
267			p.viewport, _ = p.viewport.Update(msg)
268		case key.Matches(msg, p.keyMap.ScrollLeft):
269			if p.hasDiffView() {
270				p.scrollLeft()
271			} else {
272				p.viewport, _ = p.viewport.Update(msg)
273			}
274		case key.Matches(msg, p.keyMap.ScrollRight):
275			if p.hasDiffView() {
276				p.scrollRight()
277			} else {
278				p.viewport, _ = p.viewport.Update(msg)
279			}
280		}
281	case tea.MouseWheelMsg:
282		if p.hasDiffView() {
283			switch msg.Button {
284			case tea.MouseWheelLeft:
285				p.scrollLeft()
286			case tea.MouseWheelRight:
287				p.scrollRight()
288			default:
289				p.viewport, _ = p.viewport.Update(msg)
290			}
291		} else {
292			p.viewport, _ = p.viewport.Update(msg)
293		}
294	default:
295		// Pass unhandled keys to viewport for non-diff content scrolling.
296		if !p.hasDiffView() {
297			p.viewport, _ = p.viewport.Update(msg)
298			p.viewportDirty = true
299		}
300	}
301
302	return nil
303}
304
305func (p *Permissions) selectCurrentOption() tea.Msg {
306	switch p.selectedOption {
307	case 0:
308		return p.respond(PermissionAllow)
309	case 1:
310		return p.respond(PermissionAllowForSession)
311	default:
312		return p.respond(PermissionDeny)
313	}
314}
315
316func (p *Permissions) respond(action PermissionAction) tea.Msg {
317	return ActionPermissionResponse{
318		Permission: p.permission,
319		Action:     action,
320	}
321}
322
323func (p *Permissions) hasDiffView() bool {
324	switch p.permission.ToolName {
325	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName:
326		return true
327	}
328	return false
329}
330
331func (p *Permissions) isSplitMode() bool {
332	if p.diffSplitMode != nil {
333		return *p.diffSplitMode
334	}
335	return p.defaultDiffSplitMode
336}
337
338const horizontalScrollStep = 5
339
340func (p *Permissions) scrollLeft() {
341	p.diffXOffset = max(0, p.diffXOffset-horizontalScrollStep)
342	p.viewportDirty = true
343}
344
345func (p *Permissions) scrollRight() {
346	p.diffXOffset += horizontalScrollStep
347	p.viewportDirty = true
348}
349
350// Draw implements [Dialog].
351func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
352	t := p.com.Styles
353	// Force fullscreen when window is too small.
354	forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
355
356	// Calculate dialog dimensions based on fullscreen state and content type.
357	var width, maxHeight int
358	if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
359		// Use nearly full window for fullscreen.
360		width = area.Dx()
361		maxHeight = area.Dy()
362	} else if p.hasDiffView() {
363		// Wide for side-by-side diffs, capped for readability.
364		width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
365		maxHeight = int(float64(area.Dy()) * diffSizeRatio)
366	} else {
367		// Narrower for simple content like commands/URLs.
368		width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
369		maxHeight = int(float64(area.Dy()) * simpleHeightRatio)
370	}
371
372	dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
373
374	contentWidth := p.calculateContentWidth(width)
375	header := p.renderHeader(contentWidth)
376	buttons := p.renderButtons(contentWidth)
377	helpView := p.help.View(p)
378
379	// Calculate available height for content.
380	headerHeight := lipgloss.Height(header)
381	buttonsHeight := lipgloss.Height(buttons)
382	helpHeight := lipgloss.Height(helpView)
383	frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
384
385	p.defaultDiffSplitMode = width >= splitModeMinWidth
386
387	// Pre-render content to measure its actual height.
388	renderedContent := p.renderContent(contentWidth)
389	contentHeight := lipgloss.Height(renderedContent)
390
391	// For non-diff views, shrink dialog to fit content if it's smaller than max.
392	var availableHeight int
393	if !p.hasDiffView() && !forceFullscreen {
394		fixedHeight := headerHeight + buttonsHeight + helpHeight + frameHeight
395		neededHeight := fixedHeight + contentHeight
396		if neededHeight < maxHeight {
397			availableHeight = contentHeight
398		} else {
399			availableHeight = maxHeight - fixedHeight
400		}
401		availableHeight = max(availableHeight, 3)
402	} else {
403		availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight
404	}
405
406	// Determine if scrollbar is needed.
407	needsScrollbar := p.hasDiffView() || contentHeight > availableHeight
408	viewportWidth := contentWidth
409	if needsScrollbar {
410		viewportWidth = contentWidth - 1 // Reserve space for scrollbar.
411	}
412
413	if p.viewport.Width() != viewportWidth {
414		// Mark content as dirty if width has changed.
415		p.viewportDirty = true
416		renderedContent = p.renderContent(viewportWidth)
417	}
418
419	var content string
420	var scrollbar string
421	p.viewport.SetWidth(viewportWidth)
422	p.viewport.SetHeight(availableHeight)
423	if p.viewportDirty {
424		p.viewport.SetContent(renderedContent)
425		p.viewportWidth = p.viewport.Width()
426		p.viewportDirty = false
427	}
428	content = p.viewport.View()
429	if needsScrollbar {
430		scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
431	}
432
433	// Join content with scrollbar if present.
434	if scrollbar != "" {
435		content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
436	}
437
438	parts := []string{header}
439	if content != "" {
440		parts = append(parts, "", content)
441	}
442	parts = append(parts, "", buttons, "", helpView)
443
444	innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
445	DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
446	return nil
447}
448
449func (p *Permissions) renderHeader(contentWidth int) string {
450	t := p.com.Styles
451
452	title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize(), t.Dialog.TitleGradFromColor, t.Dialog.TitleGradToColor)
453	title = t.Dialog.Title.Render(title)
454
455	// Tool info.
456	toolLine := p.renderToolName(contentWidth)
457	pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
458
459	lines := []string{title, "", toolLine, pathLine}
460
461	// Add tool-specific header info.
462	switch p.permission.ToolName {
463	case tools.BashToolName:
464		if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
465			lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth))
466		}
467	case tools.DownloadToolName:
468		if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
469			lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
470			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
471		}
472	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
473		var filePath string
474		switch params := p.permission.Params.(type) {
475		case tools.EditPermissionsParams:
476			filePath = params.FilePath
477		case tools.WritePermissionsParams:
478			filePath = params.FilePath
479		case tools.MultiEditPermissionsParams:
480			filePath = params.FilePath
481		case tools.ViewPermissionsParams:
482			filePath = params.FilePath
483		}
484		if filePath != "" {
485			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
486		}
487	case tools.LSToolName:
488		if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
489			lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
490		}
491	}
492
493	return lipgloss.JoinVertical(lipgloss.Left, lines...)
494}
495
496func (p *Permissions) renderKeyValue(key, value string, width int) string {
497	t := p.com.Styles
498	keyStyle := t.Dialog.Permissions.KeyText
499	valueStyle := t.Dialog.Permissions.ValueText
500
501	keyStr := keyStyle.Render(key)
502	valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
503
504	return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
505}
506
507func (p *Permissions) renderToolName(width int) string {
508	toolName := p.permission.ToolName
509
510	// Check if this is an MCP tool (format: mcp_<mcpname>_<toolname>).
511	if strings.HasPrefix(toolName, "mcp_") {
512		parts := strings.SplitN(toolName, "_", 3)
513		if len(parts) == 3 {
514			mcpName := prettyName(parts[1])
515			toolPart := prettyName(parts[2])
516			toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart)
517		}
518	}
519
520	return p.renderKeyValue("Tool", toolName, width)
521}
522
523// prettyName converts snake_case or kebab-case to Title Case.
524func prettyName(name string) string {
525	name = strings.ReplaceAll(name, "_", " ")
526	name = strings.ReplaceAll(name, "-", " ")
527	return stringext.Capitalize(name)
528}
529
530func (p *Permissions) renderContent(width int) string {
531	switch p.permission.ToolName {
532	case tools.BashToolName:
533		return p.renderBashContent(width)
534	case tools.EditToolName:
535		return p.renderEditContent(width)
536	case tools.WriteToolName:
537		return p.renderWriteContent(width)
538	case tools.MultiEditToolName:
539		return p.renderMultiEditContent(width)
540	case tools.DownloadToolName:
541		return p.renderDownloadContent(width)
542	case tools.FetchToolName:
543		return p.renderFetchContent(width)
544	case tools.AgenticFetchToolName:
545		return p.renderAgenticFetchContent(width)
546	case tools.ViewToolName:
547		return p.renderViewContent(width)
548	case tools.LSToolName:
549		return p.renderLSContent(width)
550	default:
551		return p.renderDefaultContent(width)
552	}
553}
554
555func (p *Permissions) renderBashContent(width int) string {
556	params, ok := p.permission.Params.(tools.BashPermissionsParams)
557	if !ok {
558		return ""
559	}
560
561	return p.renderContentPanel(params.Command, width)
562}
563
564func (p *Permissions) renderEditContent(contentWidth int) string {
565	params, ok := p.permission.Params.(tools.EditPermissionsParams)
566	if !ok {
567		return ""
568	}
569	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
570}
571
572func (p *Permissions) renderWriteContent(contentWidth int) string {
573	params, ok := p.permission.Params.(tools.WritePermissionsParams)
574	if !ok {
575		return ""
576	}
577	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
578}
579
580func (p *Permissions) renderMultiEditContent(contentWidth int) string {
581	params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
582	if !ok {
583		return ""
584	}
585	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
586}
587
588func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
589	if !p.viewportDirty {
590		if p.isSplitMode() {
591			return p.splitDiffContent
592		}
593		return p.unifiedDiffContent
594	}
595
596	isSplitMode := p.isSplitMode()
597	formatter := common.DiffFormatter(p.com.Styles).
598		Before(fsext.PrettyPath(filePath), oldContent).
599		After(fsext.PrettyPath(filePath), newContent).
600		XOffset(p.diffXOffset).
601		Width(contentWidth)
602
603	var result string
604	if isSplitMode {
605		formatter = formatter.Split()
606		p.splitDiffContent = formatter.String()
607		result = p.splitDiffContent
608	} else {
609		formatter = formatter.Unified()
610		p.unifiedDiffContent = formatter.String()
611		result = p.unifiedDiffContent
612	}
613
614	return result
615}
616
617func (p *Permissions) renderDownloadContent(width int) string {
618	params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
619	if !ok {
620		return ""
621	}
622
623	content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
624	if params.Timeout > 0 {
625		content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
626	}
627
628	return p.renderContentPanel(content, width)
629}
630
631func (p *Permissions) renderFetchContent(width int) string {
632	params, ok := p.permission.Params.(tools.FetchPermissionsParams)
633	if !ok {
634		return ""
635	}
636
637	return p.renderContentPanel(params.URL, width)
638}
639
640func (p *Permissions) renderAgenticFetchContent(width int) string {
641	params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
642	if !ok {
643		return ""
644	}
645
646	var content string
647	if params.URL != "" {
648		content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
649	} else {
650		content = fmt.Sprintf("Prompt: %s", params.Prompt)
651	}
652
653	return p.renderContentPanel(content, width)
654}
655
656func (p *Permissions) renderViewContent(width int) string {
657	params, ok := p.permission.Params.(tools.ViewPermissionsParams)
658	if !ok {
659		return ""
660	}
661
662	content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
663	if params.Offset > 0 {
664		content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
665	}
666	if params.Limit > 0 && params.Limit != 2000 {
667		content += fmt.Sprintf("\nLines to read: %d", params.Limit)
668	}
669
670	return p.renderContentPanel(content, width)
671}
672
673func (p *Permissions) renderLSContent(width int) string {
674	params, ok := p.permission.Params.(tools.LSPermissionsParams)
675	if !ok {
676		return ""
677	}
678
679	content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
680	if len(params.Ignore) > 0 {
681		content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
682	}
683
684	return p.renderContentPanel(content, width)
685}
686
687func (p *Permissions) renderDefaultContent(width int) string {
688	t := p.com.Styles
689	var content string
690	// do not add the description for mcp tools
691	if !strings.HasPrefix(p.permission.ToolName, "mcp_") {
692		content = p.permission.Description
693	}
694
695	// Pretty-print JSON params if available.
696	if p.permission.Params != nil {
697		var paramStr string
698		if str, ok := p.permission.Params.(string); ok {
699			paramStr = str
700		} else {
701			paramStr = fmt.Sprintf("%v", p.permission.Params)
702		}
703
704		var parsed any
705		if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
706			if b, err := json.MarshalIndent(parsed, "", "  "); err == nil {
707				jsonContent := string(b)
708				highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.Dialog.Permissions.ParamsBg)
709				if err == nil {
710					jsonContent = highlighted
711				}
712				if content != "" {
713					content += "\n\n"
714				}
715				content += jsonContent
716			}
717		} else if paramStr != "" {
718			if content != "" {
719				content += "\n\n"
720			}
721			content += paramStr
722		}
723	}
724
725	if content == "" {
726		return ""
727	}
728
729	return p.renderContentPanel(strings.TrimSpace(content), width)
730}
731
732// renderContentPanel renders content in a panel with the full width.
733func (p *Permissions) renderContentPanel(content string, width int) string {
734	panelStyle := p.com.Styles.Dialog.ContentPanel
735	return panelStyle.Width(width).Render(content)
736}
737
738func (p *Permissions) renderButtons(contentWidth int) string {
739	buttons := []common.ButtonOpts{
740		{Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
741		{Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
742		{Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
743	}
744
745	content := common.ButtonGroup(p.com.Styles, buttons, "  ")
746
747	// If buttons are too wide, stack them vertically.
748	if lipgloss.Width(content) > contentWidth {
749		content = common.ButtonGroup(p.com.Styles, buttons, "\n")
750		return lipgloss.NewStyle().
751			Width(contentWidth).
752			Align(lipgloss.Center).
753			Render(content)
754	}
755
756	return lipgloss.NewStyle().
757		Width(contentWidth).
758		Align(lipgloss.Right).
759		Render(content)
760}
761
762func (p *Permissions) canScroll() bool {
763	if p.hasDiffView() {
764		// Diff views can always scroll.
765		return true
766	}
767	// For non-diff content, check if viewport has scrollable content.
768	return !p.viewport.AtTop() || !p.viewport.AtBottom()
769}
770
771// ShortHelp implements [help.KeyMap].
772func (p *Permissions) ShortHelp() []key.Binding {
773	bindings := []key.Binding{
774		p.keyMap.Choose,
775		p.keyMap.Select,
776		p.keyMap.Close,
777	}
778
779	if p.canScroll() {
780		bindings = append(bindings, p.keyMap.Scroll)
781	}
782
783	if p.hasDiffView() {
784		bindings = append(
785			bindings,
786			p.keyMap.ToggleDiffMode,
787			p.keyMap.ToggleFullscreen,
788		)
789	}
790
791	return bindings
792}
793
794// FullHelp implements [help.KeyMap].
795func (p *Permissions) FullHelp() [][]key.Binding {
796	return [][]key.Binding{p.ShortHelp()}
797}