@@ -0,0 +1,695 @@
1+package dialog
2+
3+import (
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.
21+const PermissionsID = "permissions"
22+
23+// PermissionAction represents the user's response to a permission request.
24+type PermissionAction string
25+
26+const (
27+ PermissionAllow PermissionAction = "allow"
28+ PermissionAllowForSession PermissionAction = "allow_session"
29+ PermissionDeny PermissionAction = "deny"
30+)
31+
32+// Permissions dialog sizing constants.
33+const (
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.
55+type 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+
78+type 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+
97+func 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+
163+var _ Dialog = (*Permissions)(nil)
164+
165+// PermissionsOption configures the permissions dialog.
166+type PermissionsOption func(*Permissions)
167+
168+// WithDiffMode sets the initial diff mode (split or unified).
169+func WithDiffMode(split bool) PermissionsOption {
170+ return func(p *Permissions) {
171+ p.diffSplitMode = &split
172+ }
173+}
174+
175+// NewPermissions creates a new permissions dialog.
176+func 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).
213+func (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].
220+func (*Permissions) ID() string {
221+ return PermissionsID
222+}
223+
224+// HandleMsg implements [Dialog].
225+func (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+
277+func (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+
288+func (p *Permissions) respond(action PermissionAction) tea.Msg {
289+ return ActionPermissionResponse{
290+ Permission: p.permission,
291+ Action: action,
292+ }
293+}
294+
295+func (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+
303+func (p *Permissions) isSplitMode() bool {
304+ if p.diffSplitMode != nil {
305+ return *p.diffSplitMode
306+ }
307+ return p.defaultDiffSplitMode
308+}
309+
310+// Draw implements [Dialog].
311+func (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+
384+func (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+
431+func (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+
442+func (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+
467+func (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+
476+func (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+
484+func (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+
492+func (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+
500+func (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+
532+func (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+
546+func (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+
555+func (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+
571+func (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+
588+func (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+
602+func (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+
637+func (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+
661+func (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].
671+func (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].
693+func (p *Permissions) FullHelp() [][]key.Binding {
694+ return [][]key.Binding{p.ShortHelp()}
695+}