permissions.go

  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	case tools.DeleteToolName:
455		content = p.generateDeleteContent()
456	default:
457		content = p.generateDefaultContent()
458	}
459
460	// Cache the result
461	p.cachedContent = content
462	p.contentDirty = false
463
464	return content
465}
466
467func (p *permissionDialogCmp) generateBashContent() string {
468	t := styles.CurrentTheme()
469	baseStyle := t.S().Base.Background(t.BgSubtle)
470	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
471		content := pr.Command
472		t := styles.CurrentTheme()
473		content = strings.TrimSpace(content)
474		lines := strings.Split(content, "\n")
475
476		width := p.width - 4
477		var out []string
478		for _, ln := range lines {
479			out = append(out, t.S().Muted.
480				Width(width).
481				Padding(0, 3).
482				Foreground(t.FgBase).
483				Background(t.BgSubtle).
484				Render(ln))
485		}
486
487		// Ensure minimum of 7 lines for command display
488		minLines := 7
489		for len(out) < minLines {
490			out = append(out, t.S().Muted.
491				Width(width).
492				Padding(0, 3).
493				Foreground(t.FgBase).
494				Background(t.BgSubtle).
495				Render(""))
496		}
497
498		// Use the cache for markdown rendering
499		renderedContent := strings.Join(out, "\n")
500		finalContent := baseStyle.
501			Width(p.contentViewPort.Width()).
502			Padding(1, 0).
503			Render(renderedContent)
504
505		return finalContent
506	}
507	return ""
508}
509
510func (p *permissionDialogCmp) generateEditContent() string {
511	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
512		formatter := core.DiffFormatter().
513			Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
514			After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
515			Height(p.contentViewPort.Height()).
516			Width(p.contentViewPort.Width()).
517			XOffset(p.diffXOffset).
518			YOffset(p.diffYOffset)
519		if p.useDiffSplitMode() {
520			formatter = formatter.Split()
521		} else {
522			formatter = formatter.Unified()
523		}
524
525		diff := formatter.String()
526		return diff
527	}
528	return ""
529}
530
531func (p *permissionDialogCmp) generateWriteContent() string {
532	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
533		// Use the cache for diff rendering
534		formatter := core.DiffFormatter().
535			Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
536			After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
537			Height(p.contentViewPort.Height()).
538			Width(p.contentViewPort.Width()).
539			XOffset(p.diffXOffset).
540			YOffset(p.diffYOffset)
541		if p.useDiffSplitMode() {
542			formatter = formatter.Split()
543		} else {
544			formatter = formatter.Unified()
545		}
546
547		diff := formatter.String()
548		return diff
549	}
550	return ""
551}
552
553func (p *permissionDialogCmp) generateDownloadContent() string {
554	t := styles.CurrentTheme()
555	baseStyle := t.S().Base.Background(t.BgSubtle)
556	if pr, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
557		content := fmt.Sprintf("URL: %s\nFile: %s", pr.URL, fsext.PrettyPath(pr.FilePath))
558		if pr.Timeout > 0 {
559			content += fmt.Sprintf("\nTimeout: %ds", pr.Timeout)
560		}
561
562		finalContent := baseStyle.
563			Padding(1, 2).
564			Width(p.contentViewPort.Width()).
565			Render(content)
566		return finalContent
567	}
568	return ""
569}
570
571func (p *permissionDialogCmp) generateMultiEditContent() string {
572	if pr, ok := p.permission.Params.(tools.MultiEditPermissionsParams); ok {
573		// Use the cache for diff rendering
574		formatter := core.DiffFormatter().
575			Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
576			After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
577			Height(p.contentViewPort.Height()).
578			Width(p.contentViewPort.Width()).
579			XOffset(p.diffXOffset).
580			YOffset(p.diffYOffset)
581		if p.useDiffSplitMode() {
582			formatter = formatter.Split()
583		} else {
584			formatter = formatter.Unified()
585		}
586
587		diff := formatter.String()
588		return diff
589	}
590	return ""
591}
592
593func (p *permissionDialogCmp) generateFetchContent() string {
594	t := styles.CurrentTheme()
595	baseStyle := t.S().Base.Background(t.BgSubtle)
596	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
597		finalContent := baseStyle.
598			Padding(1, 2).
599			Width(p.contentViewPort.Width()).
600			Render(pr.URL)
601		return finalContent
602	}
603	return ""
604}
605
606func (p *permissionDialogCmp) generateAgenticFetchContent() string {
607	t := styles.CurrentTheme()
608	baseStyle := t.S().Base.Background(t.BgSubtle)
609	if pr, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams); ok {
610		var content string
611		if pr.URL != "" {
612			content = fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt)
613		} else {
614			content = fmt.Sprintf("Prompt: %s", pr.Prompt)
615		}
616		finalContent := baseStyle.
617			Padding(1, 2).
618			Width(p.contentViewPort.Width()).
619			Render(content)
620		return finalContent
621	}
622	return ""
623}
624
625func (p *permissionDialogCmp) generateViewContent() string {
626	t := styles.CurrentTheme()
627	baseStyle := t.S().Base.Background(t.BgSubtle)
628	if pr, ok := p.permission.Params.(tools.ViewPermissionsParams); ok {
629		content := fmt.Sprintf("File: %s", fsext.PrettyPath(pr.FilePath))
630		if pr.Offset > 0 {
631			content += fmt.Sprintf("\nStarting from line: %d", pr.Offset+1)
632		}
633		if pr.Limit > 0 && pr.Limit != 2000 { // 2000 is the default limit
634			content += fmt.Sprintf("\nLines to read: %d", pr.Limit)
635		}
636
637		finalContent := baseStyle.
638			Padding(1, 2).
639			Width(p.contentViewPort.Width()).
640			Render(content)
641		return finalContent
642	}
643	return ""
644}
645
646func (p *permissionDialogCmp) generateLSContent() string {
647	t := styles.CurrentTheme()
648	baseStyle := t.S().Base.Background(t.BgSubtle)
649	if pr, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
650		content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(pr.Path))
651		if len(pr.Ignore) > 0 {
652			content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(pr.Ignore, ", "))
653		}
654
655		finalContent := baseStyle.
656			Padding(1, 2).
657			Width(p.contentViewPort.Width()).
658			Render(content)
659		return finalContent
660	}
661	return ""
662}
663
664func (p *permissionDialogCmp) generateDeleteContent() string {
665	params, ok := p.permission.Params.(tools.DeletePermissionsParams)
666	if !ok {
667		return ""
668	}
669	t := styles.CurrentTheme()
670	baseStyle := t.S().Base.Background(t.BgSubtle)
671
672	content := fmt.Sprintf("Filepath: %s", fsext.PrettyPath(params.FilePath))
673	if params.Recursive {
674		content += " (recursive)"
675	}
676	return baseStyle.
677		Padding(1, 2).
678		Width(p.contentViewPort.Width()).
679		Render(content)
680}
681
682func (p *permissionDialogCmp) generateDefaultContent() string {
683	t := styles.CurrentTheme()
684	baseStyle := t.S().Base.Background(t.BgSubtle)
685
686	content := p.permission.Description
687
688	// Add pretty-printed JSON parameters for MCP tools
689	if p.permission.Params != nil {
690		var paramStr string
691
692		// Ensure params is a string
693		if str, ok := p.permission.Params.(string); ok {
694			paramStr = str
695		} else {
696			paramStr = fmt.Sprintf("%v", p.permission.Params)
697		}
698
699		// Try to parse as JSON for pretty printing
700		var parsed any
701		if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
702			if b, err := json.MarshalIndent(parsed, "", "  "); err == nil {
703				if content != "" {
704					content += "\n\n"
705				}
706				content += string(b)
707			}
708		} else {
709			// Not JSON, show as-is
710			if content != "" {
711				content += "\n\n"
712			}
713			content += paramStr
714		}
715	}
716
717	content = strings.TrimSpace(content)
718	content = "\n" + content + "\n"
719	lines := strings.Split(content, "\n")
720
721	width := p.width - 4
722	var out []string
723	for _, ln := range lines {
724		ln = " " + ln // left padding
725		if len(ln) > width {
726			ln = ansi.Truncate(ln, width, "…")
727		}
728		out = append(out, t.S().Muted.
729			Width(width).
730			Foreground(t.FgBase).
731			Background(t.BgSubtle).
732			Render(ln))
733	}
734
735	// Use the cache for markdown rendering
736	renderedContent := strings.Join(out, "\n")
737	finalContent := baseStyle.
738		Width(p.contentViewPort.Width()).
739		Render(renderedContent)
740
741	if renderedContent == "" {
742		return ""
743	}
744
745	return finalContent
746}
747
748func (p *permissionDialogCmp) useDiffSplitMode() bool {
749	if p.diffSplitMode != nil {
750		return *p.diffSplitMode
751	}
752	return p.defaultDiffSplitMode
753}
754
755func (p *permissionDialogCmp) styleViewport() string {
756	t := styles.CurrentTheme()
757	return t.S().Base.Render(p.contentViewPort.View())
758}
759
760func (p *permissionDialogCmp) render() string {
761	t := styles.CurrentTheme()
762	baseStyle := t.S().Base
763	title := core.Title("Permission Required", p.width-4)
764	// Render header
765	headerContent := p.renderHeader()
766	// Render buttons
767	buttons := p.renderButtons()
768
769	p.contentViewPort.SetWidth(p.width - 4)
770
771	// Always set viewport content (the caching is handled in getOrGenerateContent)
772	const minContentHeight = 9
773
774	availableDialogHeight := max(minContentHeight, p.height-minContentHeight)
775	p.contentViewPort.SetHeight(availableDialogHeight)
776	contentFinal := p.getOrGenerateContent()
777	contentHeight := min(availableDialogHeight, lipgloss.Height(contentFinal))
778
779	p.contentViewPort.SetHeight(contentHeight)
780	p.contentViewPort.SetContent(contentFinal)
781
782	p.positionRow = p.wHeight / 2
783	p.positionRow -= (contentHeight + 9) / 2
784	p.positionRow -= 3 // Move dialog slightly higher than middle
785
786	var contentHelp string
787	if p.supportsDiffView() {
788		contentHelp = help.New().View(p.keyMap)
789	}
790
791	// Calculate content height dynamically based on window size
792	strs := []string{
793		title,
794		"",
795		headerContent,
796		"",
797		p.styleViewport(),
798		"",
799		buttons,
800		"",
801	}
802	if contentHelp != "" {
803		strs = append(strs, "", contentHelp)
804	}
805	content := lipgloss.JoinVertical(lipgloss.Top, strs...)
806
807	dialog := baseStyle.
808		Padding(0, 1).
809		Border(lipgloss.RoundedBorder()).
810		BorderForeground(t.BorderFocus).
811		Width(p.width).
812		Render(
813			content,
814		)
815	p.finalDialogHeight = lipgloss.Height(dialog)
816	return dialog
817}
818
819func (p *permissionDialogCmp) View() string {
820	return p.render()
821}
822
823func (p *permissionDialogCmp) SetSize() tea.Cmd {
824	if p.permission.ID == "" {
825		return nil
826	}
827
828	oldWidth, oldHeight := p.width, p.height
829
830	switch p.permission.ToolName {
831	case tools.BashToolName:
832		p.width = int(float64(p.wWidth) * 0.8)
833		p.height = int(float64(p.wHeight) * 0.3)
834	case tools.DownloadToolName:
835		p.width = int(float64(p.wWidth) * 0.8)
836		p.height = int(float64(p.wHeight) * 0.4)
837	case tools.EditToolName:
838		p.width = int(float64(p.wWidth) * 0.8)
839		p.height = int(float64(p.wHeight) * 0.8)
840	case tools.WriteToolName:
841		p.width = int(float64(p.wWidth) * 0.8)
842		p.height = int(float64(p.wHeight) * 0.8)
843	case tools.MultiEditToolName:
844		p.width = int(float64(p.wWidth) * 0.8)
845		p.height = int(float64(p.wHeight) * 0.8)
846	case tools.FetchToolName:
847		p.width = int(float64(p.wWidth) * 0.8)
848		p.height = int(float64(p.wHeight) * 0.3)
849	case tools.AgenticFetchToolName:
850		p.width = int(float64(p.wWidth) * 0.8)
851		p.height = int(float64(p.wHeight) * 0.4)
852	case tools.ViewToolName:
853		p.width = int(float64(p.wWidth) * 0.8)
854		p.height = int(float64(p.wHeight) * 0.4)
855	case tools.LSToolName:
856		p.width = int(float64(p.wWidth) * 0.8)
857		p.height = int(float64(p.wHeight) * 0.4)
858	default:
859		p.width = int(float64(p.wWidth) * 0.7)
860		p.height = int(float64(p.wHeight) * 0.5)
861	}
862
863	// Default to diff split mode when dialog is wide enough.
864	p.defaultDiffSplitMode = p.width >= 140
865
866	// Set a maximum width for the dialog
867	p.width = min(p.width, 180)
868
869	// Mark content as dirty if size changed
870	if oldWidth != p.width || oldHeight != p.height {
871		p.contentDirty = true
872	}
873	p.positionRow = p.wHeight / 2
874	p.positionRow -= p.height / 2
875	p.positionRow -= 3 // Move dialog slightly higher than middle
876	p.positionCol = p.wWidth / 2
877	p.positionCol -= p.width / 2
878	return nil
879}
880
881func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
882	content, err := generator()
883	if err != nil {
884		return fmt.Sprintf("Error rendering markdown: %v", err)
885	}
886
887	return content
888}
889
890// ID implements PermissionDialogCmp.
891func (p *permissionDialogCmp) ID() dialogs.DialogID {
892	return PermissionsDialogID
893}
894
895// Position implements PermissionDialogCmp.
896func (p *permissionDialogCmp) Position() (int, int) {
897	return p.positionRow, p.positionCol
898}
899
900// Options for create a new permission dialog
901type Options struct {
902	DiffMode string // split or unified, empty means use defaultDiffSplitMode
903}
904
905// isSplitMode returns internal representation of diff mode switch
906func (o Options) isSplitMode() *bool {
907	var split bool
908
909	switch o.DiffMode {
910	case "split":
911		split = true
912	case "unified":
913		split = false
914	default:
915		return nil
916	}
917
918	return &split
919}