permissions.go

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