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