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 baseStyle.Render(strings.Repeat(" ", p.width)),
316 lipgloss.JoinHorizontal(
317 lipgloss.Left,
318 pathKey,
319 pathValue,
320 ),
321 baseStyle.Render(strings.Repeat(" ", p.width)),
322 }
323
324 // Add tool-specific header information
325 switch p.permission.ToolName {
326 case tools.BashToolName:
327 headerParts = append(headerParts, t.S().Muted.Width(p.width).Render("Command"))
328 case tools.DownloadToolName:
329 params := p.permission.Params.(tools.DownloadPermissionsParams)
330 urlKey := t.S().Muted.Render("URL")
331 urlValue := t.S().Text.
332 Width(p.width - lipgloss.Width(urlKey)).
333 Render(fmt.Sprintf(" %s", params.URL))
334 fileKey := t.S().Muted.Render("File")
335 filePath := t.S().Text.
336 Width(p.width - lipgloss.Width(fileKey)).
337 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
338 headerParts = append(headerParts,
339 lipgloss.JoinHorizontal(
340 lipgloss.Left,
341 urlKey,
342 urlValue,
343 ),
344 baseStyle.Render(strings.Repeat(" ", p.width)),
345 lipgloss.JoinHorizontal(
346 lipgloss.Left,
347 fileKey,
348 filePath,
349 ),
350 baseStyle.Render(strings.Repeat(" ", p.width)),
351 )
352 case tools.EditToolName:
353 params := p.permission.Params.(tools.EditPermissionsParams)
354 fileKey := t.S().Muted.Render("File")
355 filePath := t.S().Text.
356 Width(p.width - lipgloss.Width(fileKey)).
357 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
358 headerParts = append(headerParts,
359 lipgloss.JoinHorizontal(
360 lipgloss.Left,
361 fileKey,
362 filePath,
363 ),
364 baseStyle.Render(strings.Repeat(" ", p.width)),
365 )
366
367 case tools.WriteToolName:
368 params := p.permission.Params.(tools.WritePermissionsParams)
369 fileKey := t.S().Muted.Render("File")
370 filePath := t.S().Text.
371 Width(p.width - lipgloss.Width(fileKey)).
372 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
373 headerParts = append(headerParts,
374 lipgloss.JoinHorizontal(
375 lipgloss.Left,
376 fileKey,
377 filePath,
378 ),
379 baseStyle.Render(strings.Repeat(" ", p.width)),
380 )
381 case tools.MultiEditToolName:
382 params := p.permission.Params.(tools.MultiEditPermissionsParams)
383 fileKey := t.S().Muted.Render("File")
384 filePath := t.S().Text.
385 Width(p.width - lipgloss.Width(fileKey)).
386 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
387 headerParts = append(headerParts,
388 lipgloss.JoinHorizontal(
389 lipgloss.Left,
390 fileKey,
391 filePath,
392 ),
393 baseStyle.Render(strings.Repeat(" ", p.width)),
394 )
395 case tools.FetchToolName:
396 headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
397 case tools.AgenticFetchToolName:
398 headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
399 case tools.ViewToolName:
400 params := p.permission.Params.(tools.ViewPermissionsParams)
401 fileKey := t.S().Muted.Render("File")
402 filePath := t.S().Text.
403 Width(p.width - lipgloss.Width(fileKey)).
404 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
405 headerParts = append(headerParts,
406 lipgloss.JoinHorizontal(
407 lipgloss.Left,
408 fileKey,
409 filePath,
410 ),
411 baseStyle.Render(strings.Repeat(" ", p.width)),
412 )
413 case tools.LSToolName:
414 params := p.permission.Params.(tools.LSPermissionsParams)
415 pathKey := t.S().Muted.Render("Directory")
416 pathValue := t.S().Text.
417 Width(p.width - lipgloss.Width(pathKey)).
418 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.Path)))
419 headerParts = append(headerParts,
420 lipgloss.JoinHorizontal(
421 lipgloss.Left,
422 pathKey,
423 pathValue,
424 ),
425 baseStyle.Render(strings.Repeat(" ", p.width)),
426 )
427 }
428
429 return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
430}
431
432func (p *permissionDialogCmp) getOrGenerateContent() string {
433 // Return cached content if available and not dirty
434 if !p.contentDirty && p.cachedContent != "" {
435 return p.cachedContent
436 }
437
438 // Generate new content
439 var content string
440 switch p.permission.ToolName {
441 case tools.BashToolName:
442 content = p.generateBashContent()
443 case tools.DownloadToolName:
444 content = p.generateDownloadContent()
445 case tools.EditToolName:
446 content = p.generateEditContent()
447 case tools.WriteToolName:
448 content = p.generateWriteContent()
449 case tools.MultiEditToolName:
450 content = p.generateMultiEditContent()
451 case tools.FetchToolName:
452 content = p.generateFetchContent()
453 case tools.AgenticFetchToolName:
454 content = p.generateAgenticFetchContent()
455 case tools.ViewToolName:
456 content = p.generateViewContent()
457 case tools.LSToolName:
458 content = p.generateLSContent()
459 default:
460 content = p.generateDefaultContent()
461 }
462
463 // Cache the result
464 p.cachedContent = content
465 p.contentDirty = false
466
467 return content
468}
469
470func (p *permissionDialogCmp) generateBashContent() string {
471 t := styles.CurrentTheme()
472 baseStyle := t.S().Base.Background(t.BgSubtle)
473 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
474 content := pr.Command
475 t := styles.CurrentTheme()
476 content = strings.TrimSpace(content)
477 lines := strings.Split(content, "\n")
478
479 width := p.width - 4
480 var out []string
481 for _, ln := range lines {
482 out = append(out, t.S().Muted.
483 Width(width).
484 Padding(0, 3).
485 Foreground(t.FgBase).
486 Background(t.BgSubtle).
487 Render(ln))
488 }
489
490 // Use the cache for markdown rendering
491 renderedContent := strings.Join(out, "\n")
492 finalContent := baseStyle.
493 Width(p.contentViewPort.Width()).
494 Padding(1, 0).
495 Render(renderedContent)
496
497 return finalContent
498 }
499 return ""
500}
501
502func (p *permissionDialogCmp) generateEditContent() string {
503 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
504 formatter := core.DiffFormatter().
505 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
506 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
507 Height(p.contentViewPort.Height()).
508 Width(p.contentViewPort.Width()).
509 XOffset(p.diffXOffset).
510 YOffset(p.diffYOffset)
511 if p.useDiffSplitMode() {
512 formatter = formatter.Split()
513 } else {
514 formatter = formatter.Unified()
515 }
516
517 diff := formatter.String()
518 return diff
519 }
520 return ""
521}
522
523func (p *permissionDialogCmp) generateWriteContent() string {
524 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
525 // Use the cache for diff rendering
526 formatter := core.DiffFormatter().
527 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
528 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
529 Height(p.contentViewPort.Height()).
530 Width(p.contentViewPort.Width()).
531 XOffset(p.diffXOffset).
532 YOffset(p.diffYOffset)
533 if p.useDiffSplitMode() {
534 formatter = formatter.Split()
535 } else {
536 formatter = formatter.Unified()
537 }
538
539 diff := formatter.String()
540 return diff
541 }
542 return ""
543}
544
545func (p *permissionDialogCmp) generateDownloadContent() string {
546 t := styles.CurrentTheme()
547 baseStyle := t.S().Base.Background(t.BgSubtle)
548 if pr, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
549 content := fmt.Sprintf("URL: %s\nFile: %s", pr.URL, fsext.PrettyPath(pr.FilePath))
550 if pr.Timeout > 0 {
551 content += fmt.Sprintf("\nTimeout: %ds", pr.Timeout)
552 }
553
554 finalContent := baseStyle.
555 Padding(1, 2).
556 Width(p.contentViewPort.Width()).
557 Render(content)
558 return finalContent
559 }
560 return ""
561}
562
563func (p *permissionDialogCmp) generateMultiEditContent() string {
564 if pr, ok := p.permission.Params.(tools.MultiEditPermissionsParams); ok {
565 // Use the cache for diff rendering
566 formatter := core.DiffFormatter().
567 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
568 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
569 Height(p.contentViewPort.Height()).
570 Width(p.contentViewPort.Width()).
571 XOffset(p.diffXOffset).
572 YOffset(p.diffYOffset)
573 if p.useDiffSplitMode() {
574 formatter = formatter.Split()
575 } else {
576 formatter = formatter.Unified()
577 }
578
579 diff := formatter.String()
580 return diff
581 }
582 return ""
583}
584
585func (p *permissionDialogCmp) generateFetchContent() string {
586 t := styles.CurrentTheme()
587 baseStyle := t.S().Base.Background(t.BgSubtle)
588 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
589 finalContent := baseStyle.
590 Padding(1, 2).
591 Width(p.contentViewPort.Width()).
592 Render(pr.URL)
593 return finalContent
594 }
595 return ""
596}
597
598func (p *permissionDialogCmp) generateAgenticFetchContent() string {
599 t := styles.CurrentTheme()
600 baseStyle := t.S().Base.Background(t.BgSubtle)
601 if pr, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams); ok {
602 content := fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt)
603 finalContent := baseStyle.
604 Padding(1, 2).
605 Width(p.contentViewPort.Width()).
606 Render(content)
607 return finalContent
608 }
609 return ""
610}
611
612func (p *permissionDialogCmp) generateViewContent() string {
613 t := styles.CurrentTheme()
614 baseStyle := t.S().Base.Background(t.BgSubtle)
615 if pr, ok := p.permission.Params.(tools.ViewPermissionsParams); ok {
616 content := fmt.Sprintf("File: %s", fsext.PrettyPath(pr.FilePath))
617 if pr.Offset > 0 {
618 content += fmt.Sprintf("\nStarting from line: %d", pr.Offset+1)
619 }
620 if pr.Limit > 0 && pr.Limit != 2000 { // 2000 is the default limit
621 content += fmt.Sprintf("\nLines to read: %d", pr.Limit)
622 }
623
624 finalContent := baseStyle.
625 Padding(1, 2).
626 Width(p.contentViewPort.Width()).
627 Render(content)
628 return finalContent
629 }
630 return ""
631}
632
633func (p *permissionDialogCmp) generateLSContent() string {
634 t := styles.CurrentTheme()
635 baseStyle := t.S().Base.Background(t.BgSubtle)
636 if pr, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
637 content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(pr.Path))
638 if len(pr.Ignore) > 0 {
639 content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(pr.Ignore, ", "))
640 }
641
642 finalContent := baseStyle.
643 Padding(1, 2).
644 Width(p.contentViewPort.Width()).
645 Render(content)
646 return finalContent
647 }
648 return ""
649}
650
651func (p *permissionDialogCmp) generateDefaultContent() string {
652 t := styles.CurrentTheme()
653 baseStyle := t.S().Base.Background(t.BgSubtle)
654
655 content := p.permission.Description
656
657 // Add pretty-printed JSON parameters for MCP tools
658 if p.permission.Params != nil {
659 var paramStr string
660
661 // Ensure params is a string
662 if str, ok := p.permission.Params.(string); ok {
663 paramStr = str
664 } else {
665 paramStr = fmt.Sprintf("%v", p.permission.Params)
666 }
667
668 // Try to parse as JSON for pretty printing
669 var parsed any
670 if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
671 if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
672 if content != "" {
673 content += "\n\n"
674 }
675 content += string(b)
676 }
677 } else {
678 // Not JSON, show as-is
679 if content != "" {
680 content += "\n\n"
681 }
682 content += paramStr
683 }
684 }
685
686 content = strings.TrimSpace(content)
687 content = "\n" + content + "\n"
688 lines := strings.Split(content, "\n")
689
690 width := p.width - 4
691 var out []string
692 for _, ln := range lines {
693 ln = " " + ln // left padding
694 if len(ln) > width {
695 ln = ansi.Truncate(ln, width, "…")
696 }
697 out = append(out, t.S().Muted.
698 Width(width).
699 Foreground(t.FgBase).
700 Background(t.BgSubtle).
701 Render(ln))
702 }
703
704 // Use the cache for markdown rendering
705 renderedContent := strings.Join(out, "\n")
706 finalContent := baseStyle.
707 Width(p.contentViewPort.Width()).
708 Render(renderedContent)
709
710 if renderedContent == "" {
711 return ""
712 }
713
714 return finalContent
715}
716
717func (p *permissionDialogCmp) useDiffSplitMode() bool {
718 if p.diffSplitMode != nil {
719 return *p.diffSplitMode
720 }
721 return p.defaultDiffSplitMode
722}
723
724func (p *permissionDialogCmp) styleViewport() string {
725 t := styles.CurrentTheme()
726 return t.S().Base.Render(p.contentViewPort.View())
727}
728
729func (p *permissionDialogCmp) render() string {
730 t := styles.CurrentTheme()
731 baseStyle := t.S().Base
732 title := core.Title("Permission Required", p.width-4)
733 // Render header
734 headerContent := p.renderHeader()
735 // Render buttons
736 buttons := p.renderButtons()
737
738 p.contentViewPort.SetWidth(p.width - 4)
739
740 // Always set viewport content (the caching is handled in getOrGenerateContent)
741 const minContentHeight = 9
742
743 availableDialogHeight := max(minContentHeight, p.height-minContentHeight)
744 p.contentViewPort.SetHeight(availableDialogHeight)
745 contentFinal := p.getOrGenerateContent()
746 contentHeight := min(availableDialogHeight, lipgloss.Height(contentFinal))
747
748 p.contentViewPort.SetHeight(contentHeight)
749 p.contentViewPort.SetContent(contentFinal)
750
751 p.positionRow = p.wHeight / 2
752 p.positionRow -= (contentHeight + 9) / 2
753 p.positionRow -= 3 // Move dialog slightly higher than middle
754
755 var contentHelp string
756 if p.supportsDiffView() {
757 contentHelp = help.New().View(p.keyMap)
758 }
759
760 // Calculate content height dynamically based on window size
761 strs := []string{
762 title,
763 "",
764 headerContent,
765 p.styleViewport(),
766 "",
767 buttons,
768 "",
769 }
770 if contentHelp != "" {
771 strs = append(strs, "", contentHelp)
772 }
773 content := lipgloss.JoinVertical(lipgloss.Top, strs...)
774
775 dialog := baseStyle.
776 Padding(0, 1).
777 Border(lipgloss.RoundedBorder()).
778 BorderForeground(t.BorderFocus).
779 Width(p.width).
780 Render(
781 content,
782 )
783 p.finalDialogHeight = lipgloss.Height(dialog)
784 return dialog
785}
786
787func (p *permissionDialogCmp) View() string {
788 return p.render()
789}
790
791func (p *permissionDialogCmp) SetSize() tea.Cmd {
792 if p.permission.ID == "" {
793 return nil
794 }
795
796 oldWidth, oldHeight := p.width, p.height
797
798 switch p.permission.ToolName {
799 case tools.BashToolName:
800 p.width = int(float64(p.wWidth) * 0.8)
801 p.height = int(float64(p.wHeight) * 0.3)
802 case tools.DownloadToolName:
803 p.width = int(float64(p.wWidth) * 0.8)
804 p.height = int(float64(p.wHeight) * 0.4)
805 case tools.EditToolName:
806 p.width = int(float64(p.wWidth) * 0.8)
807 p.height = int(float64(p.wHeight) * 0.8)
808 case tools.WriteToolName:
809 p.width = int(float64(p.wWidth) * 0.8)
810 p.height = int(float64(p.wHeight) * 0.8)
811 case tools.MultiEditToolName:
812 p.width = int(float64(p.wWidth) * 0.8)
813 p.height = int(float64(p.wHeight) * 0.8)
814 case tools.FetchToolName:
815 p.width = int(float64(p.wWidth) * 0.8)
816 p.height = int(float64(p.wHeight) * 0.3)
817 case tools.AgenticFetchToolName:
818 p.width = int(float64(p.wWidth) * 0.8)
819 p.height = int(float64(p.wHeight) * 0.4)
820 case tools.ViewToolName:
821 p.width = int(float64(p.wWidth) * 0.8)
822 p.height = int(float64(p.wHeight) * 0.4)
823 case tools.LSToolName:
824 p.width = int(float64(p.wWidth) * 0.8)
825 p.height = int(float64(p.wHeight) * 0.4)
826 default:
827 p.width = int(float64(p.wWidth) * 0.7)
828 p.height = int(float64(p.wHeight) * 0.5)
829 }
830
831 // Default to diff split mode when dialog is wide enough.
832 p.defaultDiffSplitMode = p.width >= 140
833
834 // Set a maximum width for the dialog
835 p.width = min(p.width, 180)
836
837 // Mark content as dirty if size changed
838 if oldWidth != p.width || oldHeight != p.height {
839 p.contentDirty = true
840 }
841 p.positionRow = p.wHeight / 2
842 p.positionRow -= p.height / 2
843 p.positionRow -= 3 // Move dialog slightly higher than middle
844 p.positionCol = p.wWidth / 2
845 p.positionCol -= p.width / 2
846 return nil
847}
848
849func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
850 content, err := generator()
851 if err != nil {
852 return fmt.Sprintf("Error rendering markdown: %v", err)
853 }
854
855 return content
856}
857
858// ID implements PermissionDialogCmp.
859func (p *permissionDialogCmp) ID() dialogs.DialogID {
860 return PermissionsDialogID
861}
862
863// Position implements PermissionDialogCmp.
864func (p *permissionDialogCmp) Position() (int, int) {
865 return p.positionRow, p.positionCol
866}
867
868// Options for create a new permission dialog
869type Options struct {
870 DiffMode string // split or unified, empty means use defaultDiffSplitMode
871}
872
873// isSplitMode returns internal representation of diff mode switch
874func (o Options) isSplitMode() *bool {
875 var split bool
876
877 switch o.DiffMode {
878 case "split":
879 split = true
880 case "unified":
881 split = false
882 default:
883 return nil
884 }
885
886 return &split
887}