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