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