1package permissions
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 "git.secluded.site/crush/internal/agent/tools"
14 "git.secluded.site/crush/internal/fsext"
15 "git.secluded.site/crush/internal/permission"
16 "git.secluded.site/crush/internal/tui/components/core"
17 "git.secluded.site/crush/internal/tui/components/dialogs"
18 "git.secluded.site/crush/internal/tui/styles"
19 "git.secluded.site/crush/internal/tui/util"
20 "github.com/charmbracelet/x/ansi"
21)
22
23type PermissionAction string
24
25// Permission responses
26const (
27 PermissionAllow PermissionAction = "allow"
28 PermissionAllowForSession PermissionAction = "allow_session"
29 PermissionDeny PermissionAction = "deny"
30
31 PermissionsDialogID dialogs.DialogID = "permissions"
32)
33
34// PermissionResponseMsg represents the user's response to a permission request
35type PermissionResponseMsg struct {
36 Permission permission.PermissionRequest
37 Action PermissionAction
38}
39
40// PermissionInteractionMsg signals that the user has interacted with the permission dialog
41type PermissionInteractionMsg struct {
42 ToolCallID string
43}
44
45// PermissionDialogCmp interface for permission dialog component
46type PermissionDialogCmp interface {
47 dialogs.DialogModel
48}
49
50// permissionDialogCmp is the implementation of PermissionDialog
51type permissionDialogCmp struct {
52 wWidth int
53 wHeight int
54 width int
55 height int
56 permission permission.PermissionRequest
57 contentViewPort viewport.Model
58 selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
59
60 // Diff view state
61 defaultDiffSplitMode bool // true for split, false for unified
62 diffSplitMode *bool // nil means use defaultDiffSplitMode
63 diffXOffset int // horizontal scroll offset
64 diffYOffset int // vertical scroll offset
65
66 // Caching
67 cachedContent string
68 contentDirty bool
69
70 positionRow int // Row position for dialog
71 positionCol int // Column position for dialog
72
73 finalDialogHeight int
74
75 keyMap KeyMap
76
77 // hasInteracted prevents a stream of redundant interaction events for every scroll
78 // when we only care about the first event for notification purposes. If we later care
79 // about all interaction events, maybe the flag should be removed.
80 hasInteracted bool
81}
82
83func NewPermissionDialogCmp(permission permission.PermissionRequest, opts *Options) PermissionDialogCmp {
84 if opts == nil {
85 opts = &Options{}
86 }
87
88 // Create viewport for content
89 contentViewport := viewport.New()
90 return &permissionDialogCmp{
91 contentViewPort: contentViewport,
92 selectedOption: 0, // Default to "Allow"
93 permission: permission,
94 diffSplitMode: opts.isSplitMode(),
95 keyMap: DefaultKeyMap(),
96 contentDirty: true, // Mark as dirty initially
97 }
98}
99
100func (p *permissionDialogCmp) Init() tea.Cmd {
101 return p.contentViewPort.Init()
102}
103
104func (p *permissionDialogCmp) supportsDiffView() bool {
105 return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName
106}
107
108func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
109 var cmds []tea.Cmd
110 var interactionCmd tea.Cmd
111
112 switch msg := msg.(type) {
113 case tea.WindowSizeMsg:
114 p.wWidth = msg.Width
115 p.wHeight = msg.Height
116 p.contentDirty = true // Mark content as dirty on window resize
117 cmd := p.SetSize()
118 cmds = append(cmds, cmd)
119 case tea.KeyPressMsg:
120 interactionCmd = p.emitInteraction()
121 switch {
122 case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
123 p.selectedOption = (p.selectedOption + 1) % 3
124 case key.Matches(msg, p.keyMap.Left):
125 p.selectedOption = (p.selectedOption + 2) % 3
126 case key.Matches(msg, p.keyMap.Select):
127 cmds = append(cmds, p.selectCurrentOption())
128 case key.Matches(msg, p.keyMap.Allow):
129 cmds = append(cmds, tea.Batch(
130 util.CmdHandler(dialogs.CloseDialogMsg{}),
131 util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
132 ))
133 case key.Matches(msg, p.keyMap.AllowSession):
134 cmds = append(cmds, tea.Batch(
135 util.CmdHandler(dialogs.CloseDialogMsg{}),
136 util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
137 ))
138 case key.Matches(msg, p.keyMap.Deny):
139 cmds = append(cmds, tea.Batch(
140 util.CmdHandler(dialogs.CloseDialogMsg{}),
141 util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
142 ))
143 case key.Matches(msg, p.keyMap.ToggleDiffMode):
144 if p.supportsDiffView() {
145 if p.diffSplitMode == nil {
146 diffSplitMode := !p.defaultDiffSplitMode
147 p.diffSplitMode = &diffSplitMode
148 } else {
149 *p.diffSplitMode = !*p.diffSplitMode
150 }
151 p.contentDirty = true // Mark content as dirty when diff mode changes
152 }
153 case key.Matches(msg, p.keyMap.ScrollDown):
154 if p.supportsDiffView() {
155 p.scrollDown()
156 }
157 case key.Matches(msg, p.keyMap.ScrollUp):
158 if p.supportsDiffView() {
159 p.scrollUp()
160 }
161 case key.Matches(msg, p.keyMap.ScrollLeft):
162 if p.supportsDiffView() {
163 p.scrollLeft()
164 }
165 case key.Matches(msg, p.keyMap.ScrollRight):
166 if p.supportsDiffView() {
167 p.scrollRight()
168 }
169 default:
170 // Pass other keys to viewport
171 viewPort, cmd := p.contentViewPort.Update(msg)
172 p.contentViewPort = viewPort
173 cmds = append(cmds, cmd)
174 }
175 case tea.MouseWheelMsg:
176 if p.supportsDiffView() && p.isMouseOverDialog(msg.Mouse().X, msg.Mouse().Y) {
177 interactionCmd = p.emitInteraction()
178 switch msg.Button {
179 case tea.MouseWheelDown:
180 p.scrollDown()
181 case tea.MouseWheelUp:
182 p.scrollUp()
183 case tea.MouseWheelLeft:
184 p.scrollLeft()
185 case tea.MouseWheelRight:
186 p.scrollRight()
187 }
188 }
189 }
190
191 if interactionCmd != nil {
192 cmds = append(cmds, interactionCmd)
193 }
194
195 return p, tea.Batch(cmds...)
196}
197
198func (p *permissionDialogCmp) scrollDown() {
199 p.diffYOffset += 1
200 p.contentDirty = true
201}
202
203func (p *permissionDialogCmp) scrollUp() {
204 p.diffYOffset = max(0, p.diffYOffset-1)
205 p.contentDirty = true
206}
207
208func (p *permissionDialogCmp) scrollLeft() {
209 p.diffXOffset = max(0, p.diffXOffset-5)
210 p.contentDirty = true
211}
212
213func (p *permissionDialogCmp) scrollRight() {
214 p.diffXOffset += 5
215 p.contentDirty = true
216}
217
218func (p *permissionDialogCmp) emitInteraction() tea.Cmd {
219 if p.hasInteracted {
220 return nil
221 }
222 p.hasInteracted = true
223 return util.CmdHandler(PermissionInteractionMsg{
224 ToolCallID: p.permission.ToolCallID,
225 })
226}
227
228// isMouseOverDialog checks if the given mouse coordinates are within the dialog bounds.
229// Returns true if the mouse is over the dialog area, false otherwise.
230func (p *permissionDialogCmp) isMouseOverDialog(x, y int) bool {
231 if p.permission.ID == "" {
232 return false
233 }
234 var (
235 dialogX = p.positionCol
236 dialogY = p.positionRow
237 dialogWidth = p.width
238 dialogHeight = p.finalDialogHeight
239 )
240 return x >= dialogX && x < dialogX+dialogWidth && y >= dialogY && y < dialogY+dialogHeight
241}
242
243func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
244 var action PermissionAction
245
246 switch p.selectedOption {
247 case 0:
248 action = PermissionAllow
249 case 1:
250 action = PermissionAllowForSession
251 case 2:
252 action = PermissionDeny
253 }
254
255 return tea.Batch(
256 util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
257 util.CmdHandler(dialogs.CloseDialogMsg{}),
258 )
259}
260
261func (p *permissionDialogCmp) renderButtons() string {
262 t := styles.CurrentTheme()
263 baseStyle := t.S().Base
264
265 buttons := []core.ButtonOpts{
266 {
267 Text: "Allow",
268 UnderlineIndex: 0, // "A"
269 Selected: p.selectedOption == 0,
270 },
271 {
272 Text: "Allow for Session",
273 UnderlineIndex: 10, // "S" in "Session"
274 Selected: p.selectedOption == 1,
275 },
276 {
277 Text: "Deny",
278 UnderlineIndex: 0, // "D"
279 Selected: p.selectedOption == 2,
280 },
281 }
282
283 content := core.SelectableButtons(buttons, " ")
284 if lipgloss.Width(content) > p.width-4 {
285 content = core.SelectableButtonsVertical(buttons, 1)
286 return baseStyle.AlignVertical(lipgloss.Center).
287 AlignHorizontal(lipgloss.Center).
288 Width(p.width - 4).
289 Render(content)
290 }
291
292 return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
293}
294
295func (p *permissionDialogCmp) renderHeader() string {
296 t := styles.CurrentTheme()
297 baseStyle := t.S().Base
298
299 toolKey := t.S().Muted.Render("Tool")
300 toolValue := t.S().Text.
301 Width(p.width - lipgloss.Width(toolKey)).
302 Render(fmt.Sprintf(" %s", p.permission.ToolName))
303
304 pathKey := t.S().Muted.Render("Path")
305 pathValue := t.S().Text.
306 Width(p.width - lipgloss.Width(pathKey)).
307 Render(fmt.Sprintf(" %s", fsext.PrettyPath(p.permission.Path)))
308
309 headerParts := []string{
310 lipgloss.JoinHorizontal(
311 lipgloss.Left,
312 toolKey,
313 toolValue,
314 ),
315 lipgloss.JoinHorizontal(
316 lipgloss.Left,
317 pathKey,
318 pathValue,
319 ),
320 }
321
322 // Add tool-specific header information
323 switch p.permission.ToolName {
324 case tools.BashToolName:
325 params := p.permission.Params.(tools.BashPermissionsParams)
326 descKey := t.S().Muted.Render("Desc")
327 descValue := t.S().Text.
328 Width(p.width - lipgloss.Width(descKey)).
329 Render(fmt.Sprintf(" %s", params.Description))
330 headerParts = append(headerParts,
331 lipgloss.JoinHorizontal(
332 lipgloss.Left,
333 descKey,
334 descValue,
335 ),
336 baseStyle.Render(strings.Repeat(" ", p.width)),
337 t.S().Muted.Width(p.width).Render("Command"),
338 )
339 case tools.DownloadToolName:
340 params := p.permission.Params.(tools.DownloadPermissionsParams)
341 urlKey := t.S().Muted.Render("URL")
342 urlValue := t.S().Text.
343 Width(p.width - lipgloss.Width(urlKey)).
344 Render(fmt.Sprintf(" %s", params.URL))
345 fileKey := t.S().Muted.Render("File")
346 filePath := t.S().Text.
347 Width(p.width - lipgloss.Width(fileKey)).
348 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
349 headerParts = append(headerParts,
350 lipgloss.JoinHorizontal(
351 lipgloss.Left,
352 urlKey,
353 urlValue,
354 ),
355 lipgloss.JoinHorizontal(
356 lipgloss.Left,
357 fileKey,
358 filePath,
359 ),
360 baseStyle.Render(strings.Repeat(" ", p.width)),
361 )
362 case tools.EditToolName:
363 params := p.permission.Params.(tools.EditPermissionsParams)
364 fileKey := t.S().Muted.Render("File")
365 filePath := t.S().Text.
366 Width(p.width - lipgloss.Width(fileKey)).
367 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
368 headerParts = append(headerParts,
369 lipgloss.JoinHorizontal(
370 lipgloss.Left,
371 fileKey,
372 filePath,
373 ),
374 baseStyle.Render(strings.Repeat(" ", p.width)),
375 )
376
377 case tools.WriteToolName:
378 params := p.permission.Params.(tools.WritePermissionsParams)
379 fileKey := t.S().Muted.Render("File")
380 filePath := t.S().Text.
381 Width(p.width - lipgloss.Width(fileKey)).
382 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
383 headerParts = append(headerParts,
384 lipgloss.JoinHorizontal(
385 lipgloss.Left,
386 fileKey,
387 filePath,
388 ),
389 baseStyle.Render(strings.Repeat(" ", p.width)),
390 )
391 case tools.MultiEditToolName:
392 params := p.permission.Params.(tools.MultiEditPermissionsParams)
393 fileKey := t.S().Muted.Render("File")
394 filePath := t.S().Text.
395 Width(p.width - lipgloss.Width(fileKey)).
396 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
397 headerParts = append(headerParts,
398 lipgloss.JoinHorizontal(
399 lipgloss.Left,
400 fileKey,
401 filePath,
402 ),
403 baseStyle.Render(strings.Repeat(" ", p.width)),
404 )
405 case tools.FetchToolName:
406 headerParts = append(headerParts,
407 baseStyle.Render(strings.Repeat(" ", p.width)),
408 t.S().Muted.Width(p.width).Bold(true).Render("URL"),
409 )
410 case tools.AgenticFetchToolName:
411 headerParts = append(headerParts,
412 baseStyle.Render(strings.Repeat(" ", p.width)),
413 t.S().Muted.Width(p.width).Bold(true).Render("URL"),
414 )
415 case tools.ViewToolName:
416 params := p.permission.Params.(tools.ViewPermissionsParams)
417 fileKey := t.S().Muted.Render("File")
418 filePath := t.S().Text.
419 Width(p.width - lipgloss.Width(fileKey)).
420 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
421 headerParts = append(headerParts,
422 lipgloss.JoinHorizontal(
423 lipgloss.Left,
424 fileKey,
425 filePath,
426 ),
427 baseStyle.Render(strings.Repeat(" ", p.width)),
428 )
429 case tools.LSToolName:
430 params := p.permission.Params.(tools.LSPermissionsParams)
431 pathKey := t.S().Muted.Render("Directory")
432 pathValue := t.S().Text.
433 Width(p.width - lipgloss.Width(pathKey)).
434 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.Path)))
435 headerParts = append(headerParts,
436 lipgloss.JoinHorizontal(
437 lipgloss.Left,
438 pathKey,
439 pathValue,
440 ),
441 baseStyle.Render(strings.Repeat(" ", p.width)),
442 )
443 }
444
445 return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
446}
447
448func (p *permissionDialogCmp) getOrGenerateContent() string {
449 // Return cached content if available and not dirty
450 if !p.contentDirty && p.cachedContent != "" {
451 return p.cachedContent
452 }
453
454 // Generate new content
455 var content string
456 switch p.permission.ToolName {
457 case tools.BashToolName:
458 content = p.generateBashContent()
459 case tools.DownloadToolName:
460 content = p.generateDownloadContent()
461 case tools.EditToolName:
462 content = p.generateEditContent()
463 case tools.WriteToolName:
464 content = p.generateWriteContent()
465 case tools.MultiEditToolName:
466 content = p.generateMultiEditContent()
467 case tools.FetchToolName:
468 content = p.generateFetchContent()
469 case tools.AgenticFetchToolName:
470 content = p.generateAgenticFetchContent()
471 case tools.ViewToolName:
472 content = p.generateViewContent()
473 case tools.LSToolName:
474 content = p.generateLSContent()
475 default:
476 content = p.generateDefaultContent()
477 }
478
479 // Cache the result
480 p.cachedContent = content
481 p.contentDirty = false
482
483 return content
484}
485
486func (p *permissionDialogCmp) generateBashContent() string {
487 t := styles.CurrentTheme()
488 baseStyle := t.S().Base.Background(t.BgSubtle)
489 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
490 content := pr.Command
491 t := styles.CurrentTheme()
492 content = strings.TrimSpace(content)
493 lines := strings.Split(content, "\n")
494
495 width := p.width - 4
496 var out []string
497 for _, ln := range lines {
498 out = append(out, t.S().Muted.
499 Width(width).
500 Padding(0, 3).
501 Foreground(t.FgBase).
502 Background(t.BgSubtle).
503 Render(ln))
504 }
505
506 // Ensure minimum of 7 lines for command display
507 minLines := 7
508 for len(out) < minLines {
509 out = append(out, t.S().Muted.
510 Width(width).
511 Padding(0, 3).
512 Foreground(t.FgBase).
513 Background(t.BgSubtle).
514 Render(""))
515 }
516
517 // Use the cache for markdown rendering
518 renderedContent := strings.Join(out, "\n")
519 finalContent := baseStyle.
520 Width(p.contentViewPort.Width()).
521 Padding(1, 0).
522 Render(renderedContent)
523
524 return finalContent
525 }
526 return ""
527}
528
529func (p *permissionDialogCmp) generateEditContent() string {
530 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
531 formatter := core.DiffFormatter().
532 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
533 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
534 Height(p.contentViewPort.Height()).
535 Width(p.contentViewPort.Width()).
536 XOffset(p.diffXOffset).
537 YOffset(p.diffYOffset)
538 if p.useDiffSplitMode() {
539 formatter = formatter.Split()
540 } else {
541 formatter = formatter.Unified()
542 }
543
544 diff := formatter.String()
545 return diff
546 }
547 return ""
548}
549
550func (p *permissionDialogCmp) generateWriteContent() string {
551 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
552 // Use the cache for diff rendering
553 formatter := core.DiffFormatter().
554 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
555 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
556 Height(p.contentViewPort.Height()).
557 Width(p.contentViewPort.Width()).
558 XOffset(p.diffXOffset).
559 YOffset(p.diffYOffset)
560 if p.useDiffSplitMode() {
561 formatter = formatter.Split()
562 } else {
563 formatter = formatter.Unified()
564 }
565
566 diff := formatter.String()
567 return diff
568 }
569 return ""
570}
571
572func (p *permissionDialogCmp) generateDownloadContent() string {
573 t := styles.CurrentTheme()
574 baseStyle := t.S().Base.Background(t.BgSubtle)
575 if pr, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
576 content := fmt.Sprintf("URL: %s\nFile: %s", pr.URL, fsext.PrettyPath(pr.FilePath))
577 if pr.Timeout > 0 {
578 content += fmt.Sprintf("\nTimeout: %ds", pr.Timeout)
579 }
580
581 finalContent := baseStyle.
582 Padding(1, 2).
583 Width(p.contentViewPort.Width()).
584 Render(content)
585 return finalContent
586 }
587 return ""
588}
589
590func (p *permissionDialogCmp) generateMultiEditContent() string {
591 if pr, ok := p.permission.Params.(tools.MultiEditPermissionsParams); ok {
592 // Use the cache for diff rendering
593 formatter := core.DiffFormatter().
594 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
595 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
596 Height(p.contentViewPort.Height()).
597 Width(p.contentViewPort.Width()).
598 XOffset(p.diffXOffset).
599 YOffset(p.diffYOffset)
600 if p.useDiffSplitMode() {
601 formatter = formatter.Split()
602 } else {
603 formatter = formatter.Unified()
604 }
605
606 diff := formatter.String()
607 return diff
608 }
609 return ""
610}
611
612func (p *permissionDialogCmp) generateFetchContent() string {
613 t := styles.CurrentTheme()
614 baseStyle := t.S().Base.Background(t.BgSubtle)
615 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
616 finalContent := baseStyle.
617 Padding(1, 2).
618 Width(p.contentViewPort.Width()).
619 Render(pr.URL)
620 return finalContent
621 }
622 return ""
623}
624
625func (p *permissionDialogCmp) generateAgenticFetchContent() string {
626 t := styles.CurrentTheme()
627 baseStyle := t.S().Base.Background(t.BgSubtle)
628 if pr, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams); ok {
629 content := fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt)
630 finalContent := baseStyle.
631 Padding(1, 2).
632 Width(p.contentViewPort.Width()).
633 Render(content)
634 return finalContent
635 }
636 return ""
637}
638
639func (p *permissionDialogCmp) generateViewContent() string {
640 t := styles.CurrentTheme()
641 baseStyle := t.S().Base.Background(t.BgSubtle)
642 if pr, ok := p.permission.Params.(tools.ViewPermissionsParams); ok {
643 content := fmt.Sprintf("File: %s", fsext.PrettyPath(pr.FilePath))
644 if pr.Offset > 0 {
645 content += fmt.Sprintf("\nStarting from line: %d", pr.Offset+1)
646 }
647 if pr.Limit > 0 && pr.Limit != 2000 { // 2000 is the default limit
648 content += fmt.Sprintf("\nLines to read: %d", pr.Limit)
649 }
650
651 finalContent := baseStyle.
652 Padding(1, 2).
653 Width(p.contentViewPort.Width()).
654 Render(content)
655 return finalContent
656 }
657 return ""
658}
659
660func (p *permissionDialogCmp) generateLSContent() string {
661 t := styles.CurrentTheme()
662 baseStyle := t.S().Base.Background(t.BgSubtle)
663 if pr, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
664 content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(pr.Path))
665 if len(pr.Ignore) > 0 {
666 content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(pr.Ignore, ", "))
667 }
668
669 finalContent := baseStyle.
670 Padding(1, 2).
671 Width(p.contentViewPort.Width()).
672 Render(content)
673 return finalContent
674 }
675 return ""
676}
677
678func (p *permissionDialogCmp) generateDefaultContent() string {
679 t := styles.CurrentTheme()
680 baseStyle := t.S().Base.Background(t.BgSubtle)
681
682 content := p.permission.Description
683
684 // Add pretty-printed JSON parameters for MCP tools
685 if p.permission.Params != nil {
686 var paramStr string
687
688 // Ensure params is a string
689 if str, ok := p.permission.Params.(string); ok {
690 paramStr = str
691 } else {
692 paramStr = fmt.Sprintf("%v", p.permission.Params)
693 }
694
695 // Try to parse as JSON for pretty printing
696 var parsed any
697 if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
698 if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
699 if content != "" {
700 content += "\n\n"
701 }
702 content += string(b)
703 }
704 } else {
705 // Not JSON, show as-is
706 if content != "" {
707 content += "\n\n"
708 }
709 content += paramStr
710 }
711 }
712
713 content = strings.TrimSpace(content)
714 content = "\n" + content + "\n"
715 lines := strings.Split(content, "\n")
716
717 width := p.width - 4
718 var out []string
719 for _, ln := range lines {
720 ln = " " + ln // left padding
721 if len(ln) > width {
722 ln = ansi.Truncate(ln, width, "…")
723 }
724 out = append(out, t.S().Muted.
725 Width(width).
726 Foreground(t.FgBase).
727 Background(t.BgSubtle).
728 Render(ln))
729 }
730
731 // Use the cache for markdown rendering
732 renderedContent := strings.Join(out, "\n")
733 finalContent := baseStyle.
734 Width(p.contentViewPort.Width()).
735 Render(renderedContent)
736
737 if renderedContent == "" {
738 return ""
739 }
740
741 return finalContent
742}
743
744func (p *permissionDialogCmp) useDiffSplitMode() bool {
745 if p.diffSplitMode != nil {
746 return *p.diffSplitMode
747 }
748 return p.defaultDiffSplitMode
749}
750
751func (p *permissionDialogCmp) styleViewport() string {
752 t := styles.CurrentTheme()
753 return t.S().Base.Render(p.contentViewPort.View())
754}
755
756func (p *permissionDialogCmp) render() string {
757 t := styles.CurrentTheme()
758 baseStyle := t.S().Base
759 title := core.Title("Permission Required", p.width-4)
760 // Render header
761 headerContent := p.renderHeader()
762 // Render buttons
763 buttons := p.renderButtons()
764
765 p.contentViewPort.SetWidth(p.width - 4)
766
767 // Always set viewport content (the caching is handled in getOrGenerateContent)
768 const minContentHeight = 9
769
770 availableDialogHeight := max(minContentHeight, p.height-minContentHeight)
771 p.contentViewPort.SetHeight(availableDialogHeight)
772 contentFinal := p.getOrGenerateContent()
773 contentHeight := min(availableDialogHeight, lipgloss.Height(contentFinal))
774
775 p.contentViewPort.SetHeight(contentHeight)
776 p.contentViewPort.SetContent(contentFinal)
777
778 p.positionRow = p.wHeight / 2
779 p.positionRow -= (contentHeight + 9) / 2
780 p.positionRow -= 3 // Move dialog slightly higher than middle
781
782 var contentHelp string
783 if p.supportsDiffView() {
784 contentHelp = help.New().View(p.keyMap)
785 }
786
787 // Calculate content height dynamically based on window size
788 strs := []string{
789 title,
790 "",
791 headerContent,
792 "",
793 p.styleViewport(),
794 "",
795 buttons,
796 "",
797 }
798 if contentHelp != "" {
799 strs = append(strs, "", contentHelp)
800 }
801 content := lipgloss.JoinVertical(lipgloss.Top, strs...)
802
803 dialog := baseStyle.
804 Padding(0, 1).
805 Border(lipgloss.RoundedBorder()).
806 BorderForeground(t.BorderFocus).
807 Width(p.width).
808 Render(
809 content,
810 )
811 p.finalDialogHeight = lipgloss.Height(dialog)
812 return dialog
813}
814
815func (p *permissionDialogCmp) View() string {
816 return p.render()
817}
818
819func (p *permissionDialogCmp) SetSize() tea.Cmd {
820 if p.permission.ID == "" {
821 return nil
822 }
823
824 oldWidth, oldHeight := p.width, p.height
825
826 switch p.permission.ToolName {
827 case tools.BashToolName:
828 p.width = int(float64(p.wWidth) * 0.8)
829 p.height = int(float64(p.wHeight) * 0.3)
830 case tools.DownloadToolName:
831 p.width = int(float64(p.wWidth) * 0.8)
832 p.height = int(float64(p.wHeight) * 0.4)
833 case tools.EditToolName:
834 p.width = int(float64(p.wWidth) * 0.8)
835 p.height = int(float64(p.wHeight) * 0.8)
836 case tools.WriteToolName:
837 p.width = int(float64(p.wWidth) * 0.8)
838 p.height = int(float64(p.wHeight) * 0.8)
839 case tools.MultiEditToolName:
840 p.width = int(float64(p.wWidth) * 0.8)
841 p.height = int(float64(p.wHeight) * 0.8)
842 case tools.FetchToolName:
843 p.width = int(float64(p.wWidth) * 0.8)
844 p.height = int(float64(p.wHeight) * 0.3)
845 case tools.AgenticFetchToolName:
846 p.width = int(float64(p.wWidth) * 0.8)
847 p.height = int(float64(p.wHeight) * 0.4)
848 case tools.ViewToolName:
849 p.width = int(float64(p.wWidth) * 0.8)
850 p.height = int(float64(p.wHeight) * 0.4)
851 case tools.LSToolName:
852 p.width = int(float64(p.wWidth) * 0.8)
853 p.height = int(float64(p.wHeight) * 0.4)
854 default:
855 p.width = int(float64(p.wWidth) * 0.7)
856 p.height = int(float64(p.wHeight) * 0.5)
857 }
858
859 // Default to diff split mode when dialog is wide enough.
860 p.defaultDiffSplitMode = p.width >= 140
861
862 // Set a maximum width for the dialog
863 p.width = min(p.width, 180)
864
865 // Mark content as dirty if size changed
866 if oldWidth != p.width || oldHeight != p.height {
867 p.contentDirty = true
868 }
869 p.positionRow = p.wHeight / 2
870 p.positionRow -= p.height / 2
871 p.positionRow -= 3 // Move dialog slightly higher than middle
872 p.positionCol = p.wWidth / 2
873 p.positionCol -= p.width / 2
874 return nil
875}
876
877func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
878 content, err := generator()
879 if err != nil {
880 return fmt.Sprintf("Error rendering markdown: %v", err)
881 }
882
883 return content
884}
885
886// ID implements PermissionDialogCmp.
887func (p *permissionDialogCmp) ID() dialogs.DialogID {
888 return PermissionsDialogID
889}
890
891// Position implements PermissionDialogCmp.
892func (p *permissionDialogCmp) Position() (int, int) {
893 return p.positionRow, p.positionCol
894}
895
896// Options for create a new permission dialog
897type Options struct {
898 DiffMode string // split or unified, empty means use defaultDiffSplitMode
899}
900
901// isSplitMode returns internal representation of diff mode switch
902func (o Options) isSplitMode() *bool {
903 var split bool
904
905 switch o.DiffMode {
906 case "split":
907 split = true
908 case "unified":
909 split = false
910 default:
911 return nil
912 }
913
914 return &split
915}