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// ToolCallID returns the tool call ID associated with this dialog's
228// permission request.
229func (p *Permissions) ToolCallID() string {
230 return p.permission.ToolCallID
231}
232
233// HandleMsg implements [Dialog].
234func (p *Permissions) HandleMsg(msg tea.Msg) Action {
235 switch msg := msg.(type) {
236 case tea.KeyPressMsg:
237 switch {
238 case key.Matches(msg, p.keyMap.Close):
239 // Escape denies the permission request.
240 return p.respond(PermissionDeny)
241 case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab):
242 p.selectedOption = (p.selectedOption + 1) % 3
243 case key.Matches(msg, p.keyMap.Left):
244 // Add 2 instead of subtracting 1 to avoid negative modulo.
245 p.selectedOption = (p.selectedOption + 2) % 3
246 case key.Matches(msg, p.keyMap.Select):
247 return p.selectCurrentOption()
248 case key.Matches(msg, p.keyMap.Allow):
249 return p.respond(PermissionAllow)
250 case key.Matches(msg, p.keyMap.AllowSession):
251 return p.respond(PermissionAllowForSession)
252 case key.Matches(msg, p.keyMap.Deny):
253 return p.respond(PermissionDeny)
254 case key.Matches(msg, p.keyMap.ToggleDiffMode):
255 if p.hasDiffView() {
256 newMode := !p.isSplitMode()
257 p.diffSplitMode = &newMode
258 p.viewportDirty = true
259 }
260 case key.Matches(msg, p.keyMap.ToggleFullscreen):
261 if p.hasDiffView() {
262 p.fullscreen = !p.fullscreen
263 }
264 case key.Matches(msg, p.keyMap.ScrollDown):
265 p.viewport, _ = p.viewport.Update(msg)
266 case key.Matches(msg, p.keyMap.ScrollUp):
267 p.viewport, _ = p.viewport.Update(msg)
268 case key.Matches(msg, p.keyMap.ScrollLeft):
269 if p.hasDiffView() {
270 p.scrollLeft()
271 } else {
272 p.viewport, _ = p.viewport.Update(msg)
273 }
274 case key.Matches(msg, p.keyMap.ScrollRight):
275 if p.hasDiffView() {
276 p.scrollRight()
277 } else {
278 p.viewport, _ = p.viewport.Update(msg)
279 }
280 }
281 case tea.MouseWheelMsg:
282 if p.hasDiffView() {
283 switch msg.Button {
284 case tea.MouseWheelLeft:
285 p.scrollLeft()
286 case tea.MouseWheelRight:
287 p.scrollRight()
288 default:
289 p.viewport, _ = p.viewport.Update(msg)
290 }
291 } else {
292 p.viewport, _ = p.viewport.Update(msg)
293 }
294 default:
295 // Pass unhandled keys to viewport for non-diff content scrolling.
296 if !p.hasDiffView() {
297 p.viewport, _ = p.viewport.Update(msg)
298 p.viewportDirty = true
299 }
300 }
301
302 return nil
303}
304
305func (p *Permissions) selectCurrentOption() tea.Msg {
306 switch p.selectedOption {
307 case 0:
308 return p.respond(PermissionAllow)
309 case 1:
310 return p.respond(PermissionAllowForSession)
311 default:
312 return p.respond(PermissionDeny)
313 }
314}
315
316func (p *Permissions) respond(action PermissionAction) tea.Msg {
317 return ActionPermissionResponse{
318 Permission: p.permission,
319 Action: action,
320 }
321}
322
323func (p *Permissions) hasDiffView() bool {
324 switch p.permission.ToolName {
325 case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName:
326 return true
327 }
328 return false
329}
330
331func (p *Permissions) isSplitMode() bool {
332 if p.diffSplitMode != nil {
333 return *p.diffSplitMode
334 }
335 return p.defaultDiffSplitMode
336}
337
338const horizontalScrollStep = 5
339
340func (p *Permissions) scrollLeft() {
341 p.diffXOffset = max(0, p.diffXOffset-horizontalScrollStep)
342 p.viewportDirty = true
343}
344
345func (p *Permissions) scrollRight() {
346 p.diffXOffset += horizontalScrollStep
347 p.viewportDirty = true
348}
349
350// Draw implements [Dialog].
351func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
352 t := p.com.Styles
353 // Force fullscreen when window is too small.
354 forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
355
356 // Calculate dialog dimensions based on fullscreen state and content type.
357 var width, maxHeight int
358 if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
359 // Use nearly full window for fullscreen.
360 width = area.Dx()
361 maxHeight = area.Dy()
362 } else if p.hasDiffView() {
363 // Wide for side-by-side diffs, capped for readability.
364 width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
365 maxHeight = int(float64(area.Dy()) * diffSizeRatio)
366 } else {
367 // Narrower for simple content like commands/URLs.
368 width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
369 maxHeight = int(float64(area.Dy()) * simpleHeightRatio)
370 }
371
372 dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
373
374 contentWidth := p.calculateContentWidth(width)
375 header := p.renderHeader(contentWidth)
376 buttons := p.renderButtons(contentWidth)
377 helpView := p.help.View(p)
378
379 // Calculate available height for content.
380 headerHeight := lipgloss.Height(header)
381 buttonsHeight := lipgloss.Height(buttons)
382 helpHeight := lipgloss.Height(helpView)
383 frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
384
385 p.defaultDiffSplitMode = width >= splitModeMinWidth
386
387 // Pre-render content to measure its actual height.
388 renderedContent := p.renderContent(contentWidth)
389 contentHeight := lipgloss.Height(renderedContent)
390
391 // For non-diff views, shrink dialog to fit content if it's smaller than max.
392 var availableHeight int
393 if !p.hasDiffView() && !forceFullscreen {
394 fixedHeight := headerHeight + buttonsHeight + helpHeight + frameHeight
395 neededHeight := fixedHeight + contentHeight
396 if neededHeight < maxHeight {
397 availableHeight = contentHeight
398 } else {
399 availableHeight = maxHeight - fixedHeight
400 }
401 availableHeight = max(availableHeight, 3)
402 } else {
403 availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight
404 }
405
406 // Determine if scrollbar is needed.
407 needsScrollbar := p.hasDiffView() || contentHeight > availableHeight
408 viewportWidth := contentWidth
409 if needsScrollbar {
410 viewportWidth = contentWidth - 1 // Reserve space for scrollbar.
411 }
412
413 if p.viewport.Width() != viewportWidth {
414 // Mark content as dirty if width has changed.
415 p.viewportDirty = true
416 renderedContent = p.renderContent(viewportWidth)
417 }
418
419 var content string
420 var scrollbar string
421 p.viewport.SetWidth(viewportWidth)
422 p.viewport.SetHeight(availableHeight)
423 if p.viewportDirty {
424 p.viewport.SetContent(renderedContent)
425 p.viewportWidth = p.viewport.Width()
426 p.viewportDirty = false
427 }
428 content = p.viewport.View()
429 if needsScrollbar {
430 scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
431 }
432
433 // Join content with scrollbar if present.
434 if scrollbar != "" {
435 content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
436 }
437
438 parts := []string{header}
439 if content != "" {
440 parts = append(parts, "", content)
441 }
442 parts = append(parts, "", buttons, "", helpView)
443
444 innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
445 DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
446 return nil
447}
448
449func (p *Permissions) renderHeader(contentWidth int) string {
450 t := p.com.Styles
451
452 title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize(), t.Dialog.TitleGradFromColor, t.Dialog.TitleGradToColor)
453 title = t.Dialog.Title.Render(title)
454
455 // Tool info.
456 toolLine := p.renderToolName(contentWidth)
457 pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
458
459 lines := []string{title, "", toolLine, pathLine}
460
461 // Add tool-specific header info.
462 switch p.permission.ToolName {
463 case tools.BashToolName:
464 if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
465 lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth))
466 }
467 case tools.DownloadToolName:
468 if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
469 lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
470 lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
471 }
472 case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
473 var filePath string
474 switch params := p.permission.Params.(type) {
475 case tools.EditPermissionsParams:
476 filePath = params.FilePath
477 case tools.WritePermissionsParams:
478 filePath = params.FilePath
479 case tools.MultiEditPermissionsParams:
480 filePath = params.FilePath
481 case tools.ViewPermissionsParams:
482 filePath = params.FilePath
483 }
484 if filePath != "" {
485 lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
486 }
487 case tools.LSToolName:
488 if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
489 lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
490 }
491 }
492
493 return lipgloss.JoinVertical(lipgloss.Left, lines...)
494}
495
496func (p *Permissions) renderKeyValue(key, value string, width int) string {
497 t := p.com.Styles
498 keyStyle := t.Dialog.Permissions.KeyText
499 valueStyle := t.Dialog.Permissions.ValueText
500
501 keyStr := keyStyle.Render(key)
502 valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
503
504 return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
505}
506
507func (p *Permissions) renderToolName(width int) string {
508 toolName := p.permission.ToolName
509
510 // Check if this is an MCP tool (format: mcp_<mcpname>_<toolname>).
511 if strings.HasPrefix(toolName, "mcp_") {
512 parts := strings.SplitN(toolName, "_", 3)
513 if len(parts) == 3 {
514 mcpName := prettyName(parts[1])
515 toolPart := prettyName(parts[2])
516 toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart)
517 }
518 }
519
520 return p.renderKeyValue("Tool", toolName, width)
521}
522
523// prettyName converts snake_case or kebab-case to Title Case.
524func prettyName(name string) string {
525 name = strings.ReplaceAll(name, "_", " ")
526 name = strings.ReplaceAll(name, "-", " ")
527 return stringext.Capitalize(name)
528}
529
530func (p *Permissions) renderContent(width int) string {
531 switch p.permission.ToolName {
532 case tools.BashToolName:
533 return p.renderBashContent(width)
534 case tools.EditToolName:
535 return p.renderEditContent(width)
536 case tools.WriteToolName:
537 return p.renderWriteContent(width)
538 case tools.MultiEditToolName:
539 return p.renderMultiEditContent(width)
540 case tools.DownloadToolName:
541 return p.renderDownloadContent(width)
542 case tools.FetchToolName:
543 return p.renderFetchContent(width)
544 case tools.AgenticFetchToolName:
545 return p.renderAgenticFetchContent(width)
546 case tools.ViewToolName:
547 return p.renderViewContent(width)
548 case tools.LSToolName:
549 return p.renderLSContent(width)
550 default:
551 return p.renderDefaultContent(width)
552 }
553}
554
555func (p *Permissions) renderBashContent(width int) string {
556 params, ok := p.permission.Params.(tools.BashPermissionsParams)
557 if !ok {
558 return ""
559 }
560
561 return p.renderContentPanel(params.Command, width)
562}
563
564func (p *Permissions) renderEditContent(contentWidth int) string {
565 params, ok := p.permission.Params.(tools.EditPermissionsParams)
566 if !ok {
567 return ""
568 }
569 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
570}
571
572func (p *Permissions) renderWriteContent(contentWidth int) string {
573 params, ok := p.permission.Params.(tools.WritePermissionsParams)
574 if !ok {
575 return ""
576 }
577 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
578}
579
580func (p *Permissions) renderMultiEditContent(contentWidth int) string {
581 params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
582 if !ok {
583 return ""
584 }
585 return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
586}
587
588func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
589 if !p.viewportDirty {
590 if p.isSplitMode() {
591 return p.splitDiffContent
592 }
593 return p.unifiedDiffContent
594 }
595
596 isSplitMode := p.isSplitMode()
597 formatter := common.DiffFormatter(p.com.Styles).
598 Before(fsext.PrettyPath(filePath), oldContent).
599 After(fsext.PrettyPath(filePath), newContent).
600 XOffset(p.diffXOffset).
601 Width(contentWidth)
602
603 var result string
604 if isSplitMode {
605 formatter = formatter.Split()
606 p.splitDiffContent = formatter.String()
607 result = p.splitDiffContent
608 } else {
609 formatter = formatter.Unified()
610 p.unifiedDiffContent = formatter.String()
611 result = p.unifiedDiffContent
612 }
613
614 return result
615}
616
617func (p *Permissions) renderDownloadContent(width int) string {
618 params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
619 if !ok {
620 return ""
621 }
622
623 content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
624 if params.Timeout > 0 {
625 content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
626 }
627
628 return p.renderContentPanel(content, width)
629}
630
631func (p *Permissions) renderFetchContent(width int) string {
632 params, ok := p.permission.Params.(tools.FetchPermissionsParams)
633 if !ok {
634 return ""
635 }
636
637 return p.renderContentPanel(params.URL, width)
638}
639
640func (p *Permissions) renderAgenticFetchContent(width int) string {
641 params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
642 if !ok {
643 return ""
644 }
645
646 var content string
647 if params.URL != "" {
648 content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
649 } else {
650 content = fmt.Sprintf("Prompt: %s", params.Prompt)
651 }
652
653 return p.renderContentPanel(content, width)
654}
655
656func (p *Permissions) renderViewContent(width int) string {
657 params, ok := p.permission.Params.(tools.ViewPermissionsParams)
658 if !ok {
659 return ""
660 }
661
662 content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
663 if params.Offset > 0 {
664 content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
665 }
666 if params.Limit > 0 && params.Limit != 2000 {
667 content += fmt.Sprintf("\nLines to read: %d", params.Limit)
668 }
669
670 return p.renderContentPanel(content, width)
671}
672
673func (p *Permissions) renderLSContent(width int) string {
674 params, ok := p.permission.Params.(tools.LSPermissionsParams)
675 if !ok {
676 return ""
677 }
678
679 content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
680 if len(params.Ignore) > 0 {
681 content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
682 }
683
684 return p.renderContentPanel(content, width)
685}
686
687func (p *Permissions) renderDefaultContent(width int) string {
688 t := p.com.Styles
689 var content string
690 // do not add the description for mcp tools
691 if !strings.HasPrefix(p.permission.ToolName, "mcp_") {
692 content = p.permission.Description
693 }
694
695 // Pretty-print JSON params if available.
696 if p.permission.Params != nil {
697 var paramStr string
698 if str, ok := p.permission.Params.(string); ok {
699 paramStr = str
700 } else {
701 paramStr = fmt.Sprintf("%v", p.permission.Params)
702 }
703
704 var parsed any
705 if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
706 if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
707 jsonContent := string(b)
708 highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.Dialog.Permissions.ParamsBg)
709 if err == nil {
710 jsonContent = highlighted
711 }
712 if content != "" {
713 content += "\n\n"
714 }
715 content += jsonContent
716 }
717 } else if paramStr != "" {
718 if content != "" {
719 content += "\n\n"
720 }
721 content += paramStr
722 }
723 }
724
725 if content == "" {
726 return ""
727 }
728
729 return p.renderContentPanel(strings.TrimSpace(content), width)
730}
731
732// renderContentPanel renders content in a panel with the full width.
733func (p *Permissions) renderContentPanel(content string, width int) string {
734 panelStyle := p.com.Styles.Dialog.ContentPanel
735 return panelStyle.Width(width).Render(content)
736}
737
738func (p *Permissions) renderButtons(contentWidth int) string {
739 buttons := []common.ButtonOpts{
740 {Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
741 {Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
742 {Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
743 }
744
745 content := common.ButtonGroup(p.com.Styles, buttons, " ")
746
747 // If buttons are too wide, stack them vertically.
748 if lipgloss.Width(content) > contentWidth {
749 content = common.ButtonGroup(p.com.Styles, buttons, "\n")
750 return lipgloss.NewStyle().
751 Width(contentWidth).
752 Align(lipgloss.Center).
753 Render(content)
754 }
755
756 return lipgloss.NewStyle().
757 Width(contentWidth).
758 Align(lipgloss.Right).
759 Render(content)
760}
761
762func (p *Permissions) canScroll() bool {
763 if p.hasDiffView() {
764 // Diff views can always scroll.
765 return true
766 }
767 // For non-diff content, check if viewport has scrollable content.
768 return !p.viewport.AtTop() || !p.viewport.AtBottom()
769}
770
771// ShortHelp implements [help.KeyMap].
772func (p *Permissions) ShortHelp() []key.Binding {
773 bindings := []key.Binding{
774 p.keyMap.Choose,
775 p.keyMap.Select,
776 p.keyMap.Close,
777 }
778
779 if p.canScroll() {
780 bindings = append(bindings, p.keyMap.Scroll)
781 }
782
783 if p.hasDiffView() {
784 bindings = append(
785 bindings,
786 p.keyMap.ToggleDiffMode,
787 p.keyMap.ToggleFullscreen,
788 )
789 }
790
791 return bindings
792}
793
794// FullHelp implements [help.KeyMap].
795func (p *Permissions) FullHelp() [][]key.Binding {
796 return [][]key.Binding{p.ShortHelp()}
797}