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