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	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	} else {
396		availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight
397	}
398
399	// Determine if scrollbar is needed.
400	needsScrollbar := p.hasDiffView() || contentHeight > availableHeight
401	viewportWidth := contentWidth
402	if needsScrollbar {
403		viewportWidth = contentWidth - 1 // Reserve space for scrollbar.
404	}
405
406	if p.viewport.Width() != viewportWidth {
407		// Mark content as dirty if width has changed.
408		p.viewportDirty = true
409		renderedContent = p.renderContent(viewportWidth)
410	}
411
412	var content string
413	var scrollbar string
414	p.viewport.SetWidth(viewportWidth)
415	p.viewport.SetHeight(availableHeight)
416	if p.viewportDirty {
417		p.viewport.SetContent(renderedContent)
418		p.viewportWidth = p.viewport.Width()
419		p.viewportDirty = false
420	}
421	content = p.viewport.View()
422	if needsScrollbar {
423		scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
424	}
425
426	// Join content with scrollbar if present.
427	if scrollbar != "" {
428		content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
429	}
430
431	parts := []string{header}
432	if content != "" {
433		parts = append(parts, "", content)
434	}
435	parts = append(parts, "", buttons, "", helpView)
436
437	innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
438	DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
439	return nil
440}
441
442func (p *Permissions) renderHeader(contentWidth int) string {
443	t := p.com.Styles
444
445	title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize(), t.Primary, t.Secondary)
446	title = t.Dialog.Title.Render(title)
447
448	// Tool info.
449	toolLine := p.renderToolName(contentWidth)
450	pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
451
452	lines := []string{title, "", toolLine, pathLine}
453
454	// Add tool-specific header info.
455	switch p.permission.ToolName {
456	case tools.BashToolName:
457		if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
458			lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth))
459		}
460	case tools.DownloadToolName:
461		if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
462			lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
463			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
464		}
465	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
466		var filePath string
467		switch params := p.permission.Params.(type) {
468		case tools.EditPermissionsParams:
469			filePath = params.FilePath
470		case tools.WritePermissionsParams:
471			filePath = params.FilePath
472		case tools.MultiEditPermissionsParams:
473			filePath = params.FilePath
474		case tools.ViewPermissionsParams:
475			filePath = params.FilePath
476		}
477		if filePath != "" {
478			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
479		}
480	case tools.LSToolName:
481		if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
482			lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
483		}
484	}
485
486	return lipgloss.JoinVertical(lipgloss.Left, lines...)
487}
488
489func (p *Permissions) renderKeyValue(key, value string, width int) string {
490	t := p.com.Styles
491	keyStyle := t.Muted
492	valueStyle := t.Base
493
494	keyStr := keyStyle.Render(key)
495	valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
496
497	return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
498}
499
500func (p *Permissions) renderToolName(width int) string {
501	toolName := p.permission.ToolName
502
503	// Check if this is an MCP tool (format: mcp_<mcpname>_<toolname>).
504	if strings.HasPrefix(toolName, "mcp_") {
505		parts := strings.SplitN(toolName, "_", 3)
506		if len(parts) == 3 {
507			mcpName := prettyName(parts[1])
508			toolPart := prettyName(parts[2])
509			toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart)
510		}
511	}
512
513	return p.renderKeyValue("Tool", toolName, width)
514}
515
516// prettyName converts snake_case or kebab-case to Title Case.
517func prettyName(name string) string {
518	name = strings.ReplaceAll(name, "_", " ")
519	name = strings.ReplaceAll(name, "-", " ")
520	return stringext.Capitalize(name)
521}
522
523func (p *Permissions) renderContent(width int) string {
524	switch p.permission.ToolName {
525	case tools.BashToolName:
526		return p.renderBashContent(width)
527	case tools.EditToolName:
528		return p.renderEditContent(width)
529	case tools.WriteToolName:
530		return p.renderWriteContent(width)
531	case tools.MultiEditToolName:
532		return p.renderMultiEditContent(width)
533	case tools.DownloadToolName:
534		return p.renderDownloadContent(width)
535	case tools.FetchToolName:
536		return p.renderFetchContent(width)
537	case tools.AgenticFetchToolName:
538		return p.renderAgenticFetchContent(width)
539	case tools.ViewToolName:
540		return p.renderViewContent(width)
541	case tools.LSToolName:
542		return p.renderLSContent(width)
543	default:
544		return p.renderDefaultContent(width)
545	}
546}
547
548func (p *Permissions) renderBashContent(width int) string {
549	params, ok := p.permission.Params.(tools.BashPermissionsParams)
550	if !ok {
551		return ""
552	}
553
554	return p.renderContentPanel(params.Command, width)
555}
556
557func (p *Permissions) renderEditContent(contentWidth int) string {
558	params, ok := p.permission.Params.(tools.EditPermissionsParams)
559	if !ok {
560		return ""
561	}
562	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
563}
564
565func (p *Permissions) renderWriteContent(contentWidth int) string {
566	params, ok := p.permission.Params.(tools.WritePermissionsParams)
567	if !ok {
568		return ""
569	}
570	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
571}
572
573func (p *Permissions) renderMultiEditContent(contentWidth int) string {
574	params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
575	if !ok {
576		return ""
577	}
578	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
579}
580
581func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
582	if !p.viewportDirty {
583		if p.isSplitMode() {
584			return p.splitDiffContent
585		}
586		return p.unifiedDiffContent
587	}
588
589	isSplitMode := p.isSplitMode()
590	formatter := common.DiffFormatter(p.com.Styles).
591		Before(fsext.PrettyPath(filePath), oldContent).
592		After(fsext.PrettyPath(filePath), newContent).
593		XOffset(p.diffXOffset).
594		Width(contentWidth)
595
596	var result string
597	if isSplitMode {
598		formatter = formatter.Split()
599		p.splitDiffContent = formatter.String()
600		result = p.splitDiffContent
601	} else {
602		formatter = formatter.Unified()
603		p.unifiedDiffContent = formatter.String()
604		result = p.unifiedDiffContent
605	}
606
607	return result
608}
609
610func (p *Permissions) renderDownloadContent(width int) string {
611	params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
612	if !ok {
613		return ""
614	}
615
616	content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
617	if params.Timeout > 0 {
618		content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
619	}
620
621	return p.renderContentPanel(content, width)
622}
623
624func (p *Permissions) renderFetchContent(width int) string {
625	params, ok := p.permission.Params.(tools.FetchPermissionsParams)
626	if !ok {
627		return ""
628	}
629
630	return p.renderContentPanel(params.URL, width)
631}
632
633func (p *Permissions) renderAgenticFetchContent(width int) string {
634	params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
635	if !ok {
636		return ""
637	}
638
639	var content string
640	if params.URL != "" {
641		content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
642	} else {
643		content = fmt.Sprintf("Prompt: %s", params.Prompt)
644	}
645
646	return p.renderContentPanel(content, width)
647}
648
649func (p *Permissions) renderViewContent(width int) string {
650	params, ok := p.permission.Params.(tools.ViewPermissionsParams)
651	if !ok {
652		return ""
653	}
654
655	content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
656	if params.Offset > 0 {
657		content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
658	}
659	if params.Limit > 0 && params.Limit != 2000 {
660		content += fmt.Sprintf("\nLines to read: %d", params.Limit)
661	}
662
663	return p.renderContentPanel(content, width)
664}
665
666func (p *Permissions) renderLSContent(width int) string {
667	params, ok := p.permission.Params.(tools.LSPermissionsParams)
668	if !ok {
669		return ""
670	}
671
672	content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
673	if len(params.Ignore) > 0 {
674		content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
675	}
676
677	return p.renderContentPanel(content, width)
678}
679
680func (p *Permissions) renderDefaultContent(width int) string {
681	t := p.com.Styles
682	var content string
683	// do not add the description for mcp tools
684	if !strings.HasPrefix(p.permission.ToolName, "mcp_") {
685		content = p.permission.Description
686	}
687
688	// Pretty-print JSON params if available.
689	if p.permission.Params != nil {
690		var paramStr string
691		if str, ok := p.permission.Params.(string); ok {
692			paramStr = str
693		} else {
694			paramStr = fmt.Sprintf("%v", p.permission.Params)
695		}
696
697		var parsed any
698		if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
699			if b, err := json.MarshalIndent(parsed, "", "  "); err == nil {
700				jsonContent := string(b)
701				highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.BgSubtle)
702				if err == nil {
703					jsonContent = highlighted
704				}
705				if content != "" {
706					content += "\n\n"
707				}
708				content += jsonContent
709			}
710		} else if paramStr != "" {
711			if content != "" {
712				content += "\n\n"
713			}
714			content += paramStr
715		}
716	}
717
718	if content == "" {
719		return ""
720	}
721
722	return p.renderContentPanel(strings.TrimSpace(content), width)
723}
724
725// renderContentPanel renders content in a panel with the full width.
726func (p *Permissions) renderContentPanel(content string, width int) string {
727	panelStyle := p.com.Styles.Dialog.ContentPanel
728	return panelStyle.Width(width).Render(content)
729}
730
731func (p *Permissions) renderButtons(contentWidth int) string {
732	buttons := []common.ButtonOpts{
733		{Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
734		{Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
735		{Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
736	}
737
738	content := common.ButtonGroup(p.com.Styles, buttons, "  ")
739
740	// If buttons are too wide, stack them vertically.
741	if lipgloss.Width(content) > contentWidth {
742		content = common.ButtonGroup(p.com.Styles, buttons, "\n")
743		return lipgloss.NewStyle().
744			Width(contentWidth).
745			Align(lipgloss.Center).
746			Render(content)
747	}
748
749	return lipgloss.NewStyle().
750		Width(contentWidth).
751		Align(lipgloss.Right).
752		Render(content)
753}
754
755func (p *Permissions) canScroll() bool {
756	if p.hasDiffView() {
757		// Diff views can always scroll.
758		return true
759	}
760	// For non-diff content, check if viewport has scrollable content.
761	return !p.viewport.AtTop() || !p.viewport.AtBottom()
762}
763
764// ShortHelp implements [help.KeyMap].
765func (p *Permissions) ShortHelp() []key.Binding {
766	bindings := []key.Binding{
767		p.keyMap.Choose,
768		p.keyMap.Select,
769		p.keyMap.Close,
770	}
771
772	if p.canScroll() {
773		bindings = append(bindings, p.keyMap.Scroll)
774	}
775
776	if p.hasDiffView() {
777		bindings = append(bindings,
778			p.keyMap.ToggleDiffMode,
779			p.keyMap.ToggleFullscreen,
780		)
781	}
782
783	return bindings
784}
785
786// FullHelp implements [help.KeyMap].
787func (p *Permissions) FullHelp() [][]key.Binding {
788	return [][]key.Binding{p.ShortHelp()}
789}