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