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