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