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