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 "github.com/charmbracelet/crush/internal/agent/tools"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/permission"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
18 "github.com/charmbracelet/crush/internal/tui/styles"
19 "github.com/charmbracelet/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("Web"),
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 var content string
609 if pr.URL != "" {
610 content = fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt)
611 } else {
612 content = fmt.Sprintf("Prompt: %s", pr.Prompt)
613 }
614 finalContent := baseStyle.
615 Padding(1, 2).
616 Width(p.contentViewPort.Width()).
617 Render(content)
618 return finalContent
619 }
620 return ""
621}
622
623func (p *permissionDialogCmp) generateViewContent() string {
624 t := styles.CurrentTheme()
625 baseStyle := t.S().Base.Background(t.BgSubtle)
626 if pr, ok := p.permission.Params.(tools.ViewPermissionsParams); ok {
627 content := fmt.Sprintf("File: %s", fsext.PrettyPath(pr.FilePath))
628 if pr.Offset > 0 {
629 content += fmt.Sprintf("\nStarting from line: %d", pr.Offset+1)
630 }
631 if pr.Limit > 0 && pr.Limit != 2000 { // 2000 is the default limit
632 content += fmt.Sprintf("\nLines to read: %d", pr.Limit)
633 }
634
635 finalContent := baseStyle.
636 Padding(1, 2).
637 Width(p.contentViewPort.Width()).
638 Render(content)
639 return finalContent
640 }
641 return ""
642}
643
644func (p *permissionDialogCmp) generateLSContent() string {
645 t := styles.CurrentTheme()
646 baseStyle := t.S().Base.Background(t.BgSubtle)
647 if pr, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
648 content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(pr.Path))
649 if len(pr.Ignore) > 0 {
650 content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(pr.Ignore, ", "))
651 }
652
653 finalContent := baseStyle.
654 Padding(1, 2).
655 Width(p.contentViewPort.Width()).
656 Render(content)
657 return finalContent
658 }
659 return ""
660}
661
662func (p *permissionDialogCmp) generateDefaultContent() string {
663 t := styles.CurrentTheme()
664 baseStyle := t.S().Base.Background(t.BgSubtle)
665
666 content := p.permission.Description
667
668 // Add pretty-printed JSON parameters for MCP tools
669 if p.permission.Params != nil {
670 var paramStr string
671
672 // Ensure params is a string
673 if str, ok := p.permission.Params.(string); ok {
674 paramStr = str
675 } else {
676 paramStr = fmt.Sprintf("%v", p.permission.Params)
677 }
678
679 // Try to parse as JSON for pretty printing
680 var parsed any
681 if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
682 if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
683 if content != "" {
684 content += "\n\n"
685 }
686 content += string(b)
687 }
688 } else {
689 // Not JSON, show as-is
690 if content != "" {
691 content += "\n\n"
692 }
693 content += paramStr
694 }
695 }
696
697 content = strings.TrimSpace(content)
698 content = "\n" + content + "\n"
699 lines := strings.Split(content, "\n")
700
701 width := p.width - 4
702 var out []string
703 for _, ln := range lines {
704 ln = " " + ln // left padding
705 if len(ln) > width {
706 ln = ansi.Truncate(ln, width, "…")
707 }
708 out = append(out, t.S().Muted.
709 Width(width).
710 Foreground(t.FgBase).
711 Background(t.BgSubtle).
712 Render(ln))
713 }
714
715 // Use the cache for markdown rendering
716 renderedContent := strings.Join(out, "\n")
717 finalContent := baseStyle.
718 Width(p.contentViewPort.Width()).
719 Render(renderedContent)
720
721 if renderedContent == "" {
722 return ""
723 }
724
725 return finalContent
726}
727
728func (p *permissionDialogCmp) useDiffSplitMode() bool {
729 if p.diffSplitMode != nil {
730 return *p.diffSplitMode
731 }
732 return p.defaultDiffSplitMode
733}
734
735func (p *permissionDialogCmp) styleViewport() string {
736 t := styles.CurrentTheme()
737 return t.S().Base.Render(p.contentViewPort.View())
738}
739
740func (p *permissionDialogCmp) render() string {
741 t := styles.CurrentTheme()
742 baseStyle := t.S().Base
743 title := core.Title("Permission Required", p.width-4)
744 // Render header
745 headerContent := p.renderHeader()
746 // Render buttons
747 buttons := p.renderButtons()
748
749 p.contentViewPort.SetWidth(p.width - 4)
750
751 // Always set viewport content (the caching is handled in getOrGenerateContent)
752 const minContentHeight = 9
753
754 availableDialogHeight := max(minContentHeight, p.height-minContentHeight)
755 p.contentViewPort.SetHeight(availableDialogHeight)
756 contentFinal := p.getOrGenerateContent()
757 contentHeight := min(availableDialogHeight, lipgloss.Height(contentFinal))
758
759 p.contentViewPort.SetHeight(contentHeight)
760 p.contentViewPort.SetContent(contentFinal)
761
762 p.positionRow = p.wHeight / 2
763 p.positionRow -= (contentHeight + 9) / 2
764 p.positionRow -= 3 // Move dialog slightly higher than middle
765
766 var contentHelp string
767 if p.supportsDiffView() {
768 contentHelp = help.New().View(p.keyMap)
769 }
770
771 // Calculate content height dynamically based on window size
772 strs := []string{
773 title,
774 "",
775 headerContent,
776 "",
777 p.styleViewport(),
778 "",
779 buttons,
780 "",
781 }
782 if contentHelp != "" {
783 strs = append(strs, "", contentHelp)
784 }
785 content := lipgloss.JoinVertical(lipgloss.Top, strs...)
786
787 dialog := baseStyle.
788 Padding(0, 1).
789 Border(lipgloss.RoundedBorder()).
790 BorderForeground(t.BorderFocus).
791 Width(p.width).
792 Render(
793 content,
794 )
795 p.finalDialogHeight = lipgloss.Height(dialog)
796 return dialog
797}
798
799func (p *permissionDialogCmp) View() string {
800 return p.render()
801}
802
803func (p *permissionDialogCmp) SetSize() tea.Cmd {
804 if p.permission.ID == "" {
805 return nil
806 }
807
808 oldWidth, oldHeight := p.width, p.height
809
810 switch p.permission.ToolName {
811 case tools.BashToolName:
812 p.width = int(float64(p.wWidth) * 0.8)
813 p.height = int(float64(p.wHeight) * 0.3)
814 case tools.DownloadToolName:
815 p.width = int(float64(p.wWidth) * 0.8)
816 p.height = int(float64(p.wHeight) * 0.4)
817 case tools.EditToolName:
818 p.width = int(float64(p.wWidth) * 0.8)
819 p.height = int(float64(p.wHeight) * 0.8)
820 case tools.WriteToolName:
821 p.width = int(float64(p.wWidth) * 0.8)
822 p.height = int(float64(p.wHeight) * 0.8)
823 case tools.MultiEditToolName:
824 p.width = int(float64(p.wWidth) * 0.8)
825 p.height = int(float64(p.wHeight) * 0.8)
826 case tools.FetchToolName:
827 p.width = int(float64(p.wWidth) * 0.8)
828 p.height = int(float64(p.wHeight) * 0.3)
829 case tools.AgenticFetchToolName:
830 p.width = int(float64(p.wWidth) * 0.8)
831 p.height = int(float64(p.wHeight) * 0.4)
832 case tools.ViewToolName:
833 p.width = int(float64(p.wWidth) * 0.8)
834 p.height = int(float64(p.wHeight) * 0.4)
835 case tools.LSToolName:
836 p.width = int(float64(p.wWidth) * 0.8)
837 p.height = int(float64(p.wHeight) * 0.4)
838 default:
839 p.width = int(float64(p.wWidth) * 0.7)
840 p.height = int(float64(p.wHeight) * 0.5)
841 }
842
843 // Default to diff split mode when dialog is wide enough.
844 p.defaultDiffSplitMode = p.width >= 140
845
846 // Set a maximum width for the dialog
847 p.width = min(p.width, 180)
848
849 // Mark content as dirty if size changed
850 if oldWidth != p.width || oldHeight != p.height {
851 p.contentDirty = true
852 }
853 p.positionRow = p.wHeight / 2
854 p.positionRow -= p.height / 2
855 p.positionRow -= 3 // Move dialog slightly higher than middle
856 p.positionCol = p.wWidth / 2
857 p.positionCol -= p.width / 2
858 return nil
859}
860
861func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
862 content, err := generator()
863 if err != nil {
864 return fmt.Sprintf("Error rendering markdown: %v", err)
865 }
866
867 return content
868}
869
870// ID implements PermissionDialogCmp.
871func (p *permissionDialogCmp) ID() dialogs.DialogID {
872 return PermissionsDialogID
873}
874
875// Position implements PermissionDialogCmp.
876func (p *permissionDialogCmp) Position() (int, int) {
877 return p.positionRow, p.positionCol
878}
879
880// Options for create a new permission dialog
881type Options struct {
882 DiffMode string // split or unified, empty means use defaultDiffSplitMode
883}
884
885// isSplitMode returns internal representation of diff mode switch
886func (o Options) isSplitMode() *bool {
887 var split bool
888
889 switch o.DiffMode {
890 case "split":
891 split = true
892 case "unified":
893 split = false
894 default:
895 return nil
896 }
897
898 return &split
899}