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