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