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.TouchToolName, 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.Dialog.TitleGradFromColor, t.Dialog.TitleGradToColor)
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.TouchToolName, 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.TouchPermissionsParams:
472 filePath = params.FilePath
473 case tools.WritePermissionsParams:
474 filePath = params.FilePath
475 case tools.MultiEditPermissionsParams:
476 filePath = params.FilePath
477 case tools.ViewPermissionsParams:
478 filePath = params.FilePath
479 }
480 if filePath != "" {
481 lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
482 }
483 case tools.LSToolName:
484 if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
485 lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
486 }
487 }
488
489 return lipgloss.JoinVertical(lipgloss.Left, lines...)
490}
491
492func (p *Permissions) renderKeyValue(key, value string, width int) string {
493 t := p.com.Styles
494 keyStyle := t.Dialog.Permissions.KeyText
495 valueStyle := t.Dialog.Permissions.ValueText
496
497 keyStr := keyStyle.Render(key)
498 valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
499
500 return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
501}
502
503func (p *Permissions) renderToolName(width int) string {
504 toolName := p.permission.ToolName
505
506 // Check if this is an MCP tool (format: mcp_<mcpname>_<toolname>).
507 if strings.HasPrefix(toolName, "mcp_") {
508 parts := strings.SplitN(toolName, "_", 3)
509 if len(parts) == 3 {
510 mcpName := prettyName(parts[1])
511 toolPart := prettyName(parts[2])
512 toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart)
513 }
514 }
515
516 return p.renderKeyValue("Tool", toolName, width)
517}
518
519// prettyName converts snake_case or kebab-case to Title Case.
520func prettyName(name string) string {
521 name = strings.ReplaceAll(name, "_", " ")
522 name = strings.ReplaceAll(name, "-", " ")
523 return stringext.Capitalize(name)
524}
525
526func (p *Permissions) renderContent(width int) string {
527 switch p.permission.ToolName {
528 case tools.BashToolName:
529 return p.renderBashContent(width)
530 case tools.EditToolName:
531 return p.renderEditContent(width)
532 case tools.TouchToolName:
533 return p.renderTouchContent(width)
534 case tools.WriteToolName:
535 return p.renderWriteContent(width)
536 case tools.MultiEditToolName:
537 return p.renderMultiEditContent(width)
538 case tools.DownloadToolName:
539 return p.renderDownloadContent(width)
540 case tools.FetchToolName:
541 return p.renderFetchContent(width)
542 case tools.AgenticFetchToolName:
543 return p.renderAgenticFetchContent(width)
544 case tools.ViewToolName:
545 return p.renderViewContent(width)
546 case tools.LSToolName:
547 return p.renderLSContent(width)
548 default:
549 return p.renderDefaultContent(width)
550 }
551}
552
553func (p *Permissions) renderBashContent(width int) string {
554 params, ok := p.permission.Params.(tools.BashPermissionsParams)
555 if !ok {
556 return ""
557 }
558
559 return p.renderContentPanel(params.Command, width)
560}
561
562func (p *Permissions) renderEditContent(contentWidth int) string {
563 params, ok := p.permission.Params.(tools.EditPermissionsParams)
564 if !ok {
565 return ""
566 }
567 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
568}
569
570func (p *Permissions) renderTouchContent(contentWidth int) string {
571 params, ok := p.permission.Params.(tools.TouchPermissionsParams)
572 if !ok {
573 return ""
574 }
575 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
576}
577
578func (p *Permissions) renderWriteContent(contentWidth int) string {
579 params, ok := p.permission.Params.(tools.WritePermissionsParams)
580 if !ok {
581 return ""
582 }
583 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
584}
585
586func (p *Permissions) renderMultiEditContent(contentWidth int) string {
587 params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
588 if !ok {
589 return ""
590 }
591 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
592}
593
594func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
595 if !p.viewportDirty {
596 if p.isSplitMode() {
597 return p.splitDiffContent
598 }
599 return p.unifiedDiffContent
600 }
601
602 isSplitMode := p.isSplitMode()
603 formatter := common.DiffFormatter(p.com.Styles).
604 Before(fsext.PrettyPath(filePath), oldContent).
605 After(fsext.PrettyPath(filePath), newContent).
606 XOffset(p.diffXOffset).
607 Width(contentWidth)
608
609 var result string
610 if isSplitMode {
611 formatter = formatter.Split()
612 p.splitDiffContent = formatter.String()
613 result = p.splitDiffContent
614 } else {
615 formatter = formatter.Unified()
616 p.unifiedDiffContent = formatter.String()
617 result = p.unifiedDiffContent
618 }
619
620 return result
621}
622
623func (p *Permissions) renderDownloadContent(width int) string {
624 params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
625 if !ok {
626 return ""
627 }
628
629 content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
630 if params.Timeout > 0 {
631 content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
632 }
633
634 return p.renderContentPanel(content, width)
635}
636
637func (p *Permissions) renderFetchContent(width int) string {
638 params, ok := p.permission.Params.(tools.FetchPermissionsParams)
639 if !ok {
640 return ""
641 }
642
643 return p.renderContentPanel(params.URL, width)
644}
645
646func (p *Permissions) renderAgenticFetchContent(width int) string {
647 params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
648 if !ok {
649 return ""
650 }
651
652 var content string
653 if params.URL != "" {
654 content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
655 } else {
656 content = fmt.Sprintf("Prompt: %s", params.Prompt)
657 }
658
659 return p.renderContentPanel(content, width)
660}
661
662func (p *Permissions) renderViewContent(width int) string {
663 params, ok := p.permission.Params.(tools.ViewPermissionsParams)
664 if !ok {
665 return ""
666 }
667
668 content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
669 if params.Offset > 0 {
670 content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
671 }
672 if params.Limit > 0 && params.Limit != 2000 {
673 content += fmt.Sprintf("\nLines to read: %d", params.Limit)
674 }
675
676 return p.renderContentPanel(content, width)
677}
678
679func (p *Permissions) renderLSContent(width int) string {
680 params, ok := p.permission.Params.(tools.LSPermissionsParams)
681 if !ok {
682 return ""
683 }
684
685 content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
686 if len(params.Ignore) > 0 {
687 content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
688 }
689
690 return p.renderContentPanel(content, width)
691}
692
693func (p *Permissions) renderDefaultContent(width int) string {
694 t := p.com.Styles
695 var content string
696 // do not add the description for mcp tools
697 if !strings.HasPrefix(p.permission.ToolName, "mcp_") {
698 content = p.permission.Description
699 }
700
701 // Pretty-print JSON params if available.
702 if p.permission.Params != nil {
703 var paramStr string
704 if str, ok := p.permission.Params.(string); ok {
705 paramStr = str
706 } else {
707 paramStr = fmt.Sprintf("%v", p.permission.Params)
708 }
709
710 var parsed any
711 if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
712 if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
713 jsonContent := string(b)
714 highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.Dialog.Permissions.ParamsBg)
715 if err == nil {
716 jsonContent = highlighted
717 }
718 if content != "" {
719 content += "\n\n"
720 }
721 content += jsonContent
722 }
723 } else if paramStr != "" {
724 if content != "" {
725 content += "\n\n"
726 }
727 content += paramStr
728 }
729 }
730
731 if content == "" {
732 return ""
733 }
734
735 return p.renderContentPanel(strings.TrimSpace(content), width)
736}
737
738// renderContentPanel renders content in a panel with the full width.
739func (p *Permissions) renderContentPanel(content string, width int) string {
740 panelStyle := p.com.Styles.Dialog.ContentPanel
741 return panelStyle.Width(width).Render(content)
742}
743
744func (p *Permissions) renderButtons(contentWidth int) string {
745 buttons := []common.ButtonOpts{
746 {Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
747 {Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
748 {Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
749 }
750
751 content := common.ButtonGroup(p.com.Styles, buttons, " ")
752
753 // If buttons are too wide, stack them vertically.
754 if lipgloss.Width(content) > contentWidth {
755 content = common.ButtonGroup(p.com.Styles, buttons, "\n")
756 return lipgloss.NewStyle().
757 Width(contentWidth).
758 Align(lipgloss.Center).
759 Render(content)
760 }
761
762 return lipgloss.NewStyle().
763 Width(contentWidth).
764 Align(lipgloss.Right).
765 Render(content)
766}
767
768func (p *Permissions) canScroll() bool {
769 if p.hasDiffView() {
770 // Diff views can always scroll.
771 return true
772 }
773 // For non-diff content, check if viewport has scrollable content.
774 return !p.viewport.AtTop() || !p.viewport.AtBottom()
775}
776
777// ShortHelp implements [help.KeyMap].
778func (p *Permissions) ShortHelp() []key.Binding {
779 bindings := []key.Binding{
780 p.keyMap.Choose,
781 p.keyMap.Select,
782 p.keyMap.Close,
783 }
784
785 if p.canScroll() {
786 bindings = append(bindings, p.keyMap.Scroll)
787 }
788
789 if p.hasDiffView() {
790 bindings = append(bindings,
791 p.keyMap.ToggleDiffMode,
792 p.keyMap.ToggleFullscreen,
793 )
794 }
795
796 return bindings
797}
798
799// FullHelp implements [help.KeyMap].
800func (p *Permissions) FullHelp() [][]key.Binding {
801 return [][]key.Binding{p.ShortHelp()}
802}