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 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 } else {
396 availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight
397 }
398
399 // Determine if scrollbar is needed.
400 needsScrollbar := p.hasDiffView() || contentHeight > availableHeight
401 viewportWidth := contentWidth
402 if needsScrollbar {
403 viewportWidth = contentWidth - 1 // Reserve space for scrollbar.
404 }
405
406 if p.viewport.Width() != viewportWidth {
407 // Mark content as dirty if width has changed.
408 p.viewportDirty = true
409 renderedContent = p.renderContent(viewportWidth)
410 }
411
412 var content string
413 var scrollbar string
414 p.viewport.SetWidth(viewportWidth)
415 p.viewport.SetHeight(availableHeight)
416 if p.viewportDirty {
417 p.viewport.SetContent(renderedContent)
418 p.viewportWidth = p.viewport.Width()
419 p.viewportDirty = false
420 }
421 content = p.viewport.View()
422 if needsScrollbar {
423 scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
424 }
425
426 // Join content with scrollbar if present.
427 if scrollbar != "" {
428 content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
429 }
430
431 parts := []string{header}
432 if content != "" {
433 parts = append(parts, "", content)
434 }
435 parts = append(parts, "", buttons, "", helpView)
436
437 innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
438 DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
439 return nil
440}
441
442func (p *Permissions) renderHeader(contentWidth int) string {
443 t := p.com.Styles
444
445 title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize(), t.Primary, t.Secondary)
446 title = t.Dialog.Title.Render(title)
447
448 // Tool info.
449 toolLine := p.renderToolName(contentWidth)
450 pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
451
452 lines := []string{title, "", toolLine, pathLine}
453
454 // Add tool-specific header info.
455 switch p.permission.ToolName {
456 case tools.BashToolName:
457 if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
458 lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth))
459 }
460 case tools.DownloadToolName:
461 if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
462 lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
463 lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
464 }
465 case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
466 var filePath string
467 switch params := p.permission.Params.(type) {
468 case tools.EditPermissionsParams:
469 filePath = params.FilePath
470 case tools.WritePermissionsParams:
471 filePath = params.FilePath
472 case tools.MultiEditPermissionsParams:
473 filePath = params.FilePath
474 case tools.ViewPermissionsParams:
475 filePath = params.FilePath
476 }
477 if filePath != "" {
478 lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
479 }
480 case tools.LSToolName:
481 if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
482 lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
483 }
484 }
485
486 return lipgloss.JoinVertical(lipgloss.Left, lines...)
487}
488
489func (p *Permissions) renderKeyValue(key, value string, width int) string {
490 t := p.com.Styles
491 keyStyle := t.Muted
492 valueStyle := t.Base
493
494 keyStr := keyStyle.Render(key)
495 valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
496
497 return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
498}
499
500func (p *Permissions) renderToolName(width int) string {
501 toolName := p.permission.ToolName
502
503 // Check if this is an MCP tool (format: mcp_<mcpname>_<toolname>).
504 if strings.HasPrefix(toolName, "mcp_") {
505 parts := strings.SplitN(toolName, "_", 3)
506 if len(parts) == 3 {
507 mcpName := prettyName(parts[1])
508 toolPart := prettyName(parts[2])
509 toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart)
510 }
511 }
512
513 return p.renderKeyValue("Tool", toolName, width)
514}
515
516// prettyName converts snake_case or kebab-case to Title Case.
517func prettyName(name string) string {
518 name = strings.ReplaceAll(name, "_", " ")
519 name = strings.ReplaceAll(name, "-", " ")
520 return stringext.Capitalize(name)
521}
522
523func (p *Permissions) renderContent(width int) string {
524 switch p.permission.ToolName {
525 case tools.BashToolName:
526 return p.renderBashContent(width)
527 case tools.EditToolName:
528 return p.renderEditContent(width)
529 case tools.WriteToolName:
530 return p.renderWriteContent(width)
531 case tools.MultiEditToolName:
532 return p.renderMultiEditContent(width)
533 case tools.DownloadToolName:
534 return p.renderDownloadContent(width)
535 case tools.FetchToolName:
536 return p.renderFetchContent(width)
537 case tools.AgenticFetchToolName:
538 return p.renderAgenticFetchContent(width)
539 case tools.ViewToolName:
540 return p.renderViewContent(width)
541 case tools.LSToolName:
542 return p.renderLSContent(width)
543 default:
544 return p.renderDefaultContent(width)
545 }
546}
547
548func (p *Permissions) renderBashContent(width int) string {
549 params, ok := p.permission.Params.(tools.BashPermissionsParams)
550 if !ok {
551 return ""
552 }
553
554 return p.renderContentPanel(params.Command, width)
555}
556
557func (p *Permissions) renderEditContent(contentWidth int) string {
558 params, ok := p.permission.Params.(tools.EditPermissionsParams)
559 if !ok {
560 return ""
561 }
562 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
563}
564
565func (p *Permissions) renderWriteContent(contentWidth int) string {
566 params, ok := p.permission.Params.(tools.WritePermissionsParams)
567 if !ok {
568 return ""
569 }
570 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
571}
572
573func (p *Permissions) renderMultiEditContent(contentWidth int) string {
574 params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
575 if !ok {
576 return ""
577 }
578 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
579}
580
581func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
582 if !p.viewportDirty {
583 if p.isSplitMode() {
584 return p.splitDiffContent
585 }
586 return p.unifiedDiffContent
587 }
588
589 isSplitMode := p.isSplitMode()
590 formatter := common.DiffFormatter(p.com.Styles).
591 Before(fsext.PrettyPath(filePath), oldContent).
592 After(fsext.PrettyPath(filePath), newContent).
593 XOffset(p.diffXOffset).
594 Width(contentWidth)
595
596 var result string
597 if isSplitMode {
598 formatter = formatter.Split()
599 p.splitDiffContent = formatter.String()
600 result = p.splitDiffContent
601 } else {
602 formatter = formatter.Unified()
603 p.unifiedDiffContent = formatter.String()
604 result = p.unifiedDiffContent
605 }
606
607 return result
608}
609
610func (p *Permissions) renderDownloadContent(width int) string {
611 params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
612 if !ok {
613 return ""
614 }
615
616 content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
617 if params.Timeout > 0 {
618 content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
619 }
620
621 return p.renderContentPanel(content, width)
622}
623
624func (p *Permissions) renderFetchContent(width int) string {
625 params, ok := p.permission.Params.(tools.FetchPermissionsParams)
626 if !ok {
627 return ""
628 }
629
630 return p.renderContentPanel(params.URL, width)
631}
632
633func (p *Permissions) renderAgenticFetchContent(width int) string {
634 params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
635 if !ok {
636 return ""
637 }
638
639 var content string
640 if params.URL != "" {
641 content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
642 } else {
643 content = fmt.Sprintf("Prompt: %s", params.Prompt)
644 }
645
646 return p.renderContentPanel(content, width)
647}
648
649func (p *Permissions) renderViewContent(width int) string {
650 params, ok := p.permission.Params.(tools.ViewPermissionsParams)
651 if !ok {
652 return ""
653 }
654
655 content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
656 if params.Offset > 0 {
657 content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
658 }
659 if params.Limit > 0 && params.Limit != 2000 {
660 content += fmt.Sprintf("\nLines to read: %d", params.Limit)
661 }
662
663 return p.renderContentPanel(content, width)
664}
665
666func (p *Permissions) renderLSContent(width int) string {
667 params, ok := p.permission.Params.(tools.LSPermissionsParams)
668 if !ok {
669 return ""
670 }
671
672 content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
673 if len(params.Ignore) > 0 {
674 content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
675 }
676
677 return p.renderContentPanel(content, width)
678}
679
680func (p *Permissions) renderDefaultContent(width int) string {
681 t := p.com.Styles
682 var content string
683 // do not add the description for mcp tools
684 if !strings.HasPrefix(p.permission.ToolName, "mcp_") {
685 content = p.permission.Description
686 }
687
688 // Pretty-print JSON params if available.
689 if p.permission.Params != nil {
690 var paramStr string
691 if str, ok := p.permission.Params.(string); ok {
692 paramStr = str
693 } else {
694 paramStr = fmt.Sprintf("%v", p.permission.Params)
695 }
696
697 var parsed any
698 if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
699 if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
700 jsonContent := string(b)
701 highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.BgSubtle)
702 if err == nil {
703 jsonContent = highlighted
704 }
705 if content != "" {
706 content += "\n\n"
707 }
708 content += jsonContent
709 }
710 } else if paramStr != "" {
711 if content != "" {
712 content += "\n\n"
713 }
714 content += paramStr
715 }
716 }
717
718 if content == "" {
719 return ""
720 }
721
722 return p.renderContentPanel(strings.TrimSpace(content), width)
723}
724
725// renderContentPanel renders content in a panel with the full width.
726func (p *Permissions) renderContentPanel(content string, width int) string {
727 panelStyle := p.com.Styles.Dialog.ContentPanel
728 return panelStyle.Width(width).Render(content)
729}
730
731func (p *Permissions) renderButtons(contentWidth int) string {
732 buttons := []common.ButtonOpts{
733 {Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
734 {Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
735 {Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
736 }
737
738 content := common.ButtonGroup(p.com.Styles, buttons, " ")
739
740 // If buttons are too wide, stack them vertically.
741 if lipgloss.Width(content) > contentWidth {
742 content = common.ButtonGroup(p.com.Styles, buttons, "\n")
743 return lipgloss.NewStyle().
744 Width(contentWidth).
745 Align(lipgloss.Center).
746 Render(content)
747 }
748
749 return lipgloss.NewStyle().
750 Width(contentWidth).
751 Align(lipgloss.Right).
752 Render(content)
753}
754
755func (p *Permissions) canScroll() bool {
756 if p.hasDiffView() {
757 // Diff views can always scroll.
758 return true
759 }
760 // For non-diff content, check if viewport has scrollable content.
761 return !p.viewport.AtTop() || !p.viewport.AtBottom()
762}
763
764// ShortHelp implements [help.KeyMap].
765func (p *Permissions) ShortHelp() []key.Binding {
766 bindings := []key.Binding{
767 p.keyMap.Choose,
768 p.keyMap.Select,
769 p.keyMap.Close,
770 }
771
772 if p.canScroll() {
773 bindings = append(bindings, p.keyMap.Scroll)
774 }
775
776 if p.hasDiffView() {
777 bindings = append(bindings,
778 p.keyMap.ToggleDiffMode,
779 p.keyMap.ToggleFullscreen,
780 )
781 }
782
783 return bindings
784}
785
786// FullHelp implements [help.KeyMap].
787func (p *Permissions) FullHelp() [][]key.Binding {
788 return [][]key.Binding{p.ShortHelp()}
789}