1package permissions
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	"github.com/charmbracelet/bubbles/v2/help"
  8	"github.com/charmbracelet/bubbles/v2/key"
  9	"github.com/charmbracelet/bubbles/v2/viewport"
 10	tea "github.com/charmbracelet/bubbletea/v2"
 11	"github.com/charmbracelet/crush/internal/fsext"
 12	"github.com/charmbracelet/crush/internal/llm/tools"
 13	"github.com/charmbracelet/crush/internal/permission"
 14	"github.com/charmbracelet/crush/internal/tui/components/core"
 15	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 16	"github.com/charmbracelet/crush/internal/tui/styles"
 17	"github.com/charmbracelet/crush/internal/tui/util"
 18	"github.com/charmbracelet/lipgloss/v2"
 19	uv "github.com/charmbracelet/ultraviolet"
 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	diffSplitMode bool // true for split, false for unified
 57	diffXOffset   int  // horizontal scroll offset
 58	diffYOffset   int  // vertical scroll offset
 59
 60	// Caching
 61	cachedContent string
 62	contentDirty  bool
 63
 64	keyMap KeyMap
 65}
 66
 67func NewPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialogCmp {
 68	// Create viewport for content
 69	contentViewport := viewport.New()
 70	return &permissionDialogCmp{
 71		contentViewPort: contentViewport,
 72		selectedOption:  0, // Default to "Allow"
 73		permission:      permission,
 74		keyMap:          DefaultKeyMap(),
 75		contentDirty:    true, // Mark as dirty initially
 76	}
 77}
 78
 79func (p *permissionDialogCmp) Init() tea.Cmd {
 80	return p.contentViewPort.Init()
 81}
 82
 83func (p *permissionDialogCmp) supportsDiffView() bool {
 84	return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName
 85}
 86
 87func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 88	var cmds []tea.Cmd
 89
 90	switch msg := msg.(type) {
 91	case tea.WindowSizeMsg:
 92		p.wWidth = msg.Width
 93		p.wHeight = msg.Height
 94		p.contentDirty = true // Mark content as dirty on window resize
 95		cmd := p.SetSize()
 96		cmds = append(cmds, cmd)
 97	case tea.KeyPressMsg:
 98		switch {
 99		case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
100			p.selectedOption = (p.selectedOption + 1) % 3
101			return p, nil
102		case key.Matches(msg, p.keyMap.Left):
103			p.selectedOption = (p.selectedOption + 2) % 3
104		case key.Matches(msg, p.keyMap.Select):
105			return p, p.selectCurrentOption()
106		case key.Matches(msg, p.keyMap.Allow):
107			return p, tea.Batch(
108				util.CmdHandler(dialogs.CloseDialogMsg{}),
109				util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
110			)
111		case key.Matches(msg, p.keyMap.AllowSession):
112			return p, tea.Batch(
113				util.CmdHandler(dialogs.CloseDialogMsg{}),
114				util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
115			)
116		case key.Matches(msg, p.keyMap.Deny):
117			return p, tea.Batch(
118				util.CmdHandler(dialogs.CloseDialogMsg{}),
119				util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
120			)
121		case key.Matches(msg, p.keyMap.ToggleDiffMode):
122			if p.supportsDiffView() {
123				p.diffSplitMode = !p.diffSplitMode
124				p.contentDirty = true // Mark content as dirty when diff mode changes
125				return p, nil
126			}
127		case key.Matches(msg, p.keyMap.ScrollDown):
128			if p.supportsDiffView() {
129				p.diffYOffset += 1
130				p.contentDirty = true // Mark content as dirty when scrolling
131				return p, nil
132			}
133		case key.Matches(msg, p.keyMap.ScrollUp):
134			if p.supportsDiffView() {
135				p.diffYOffset = max(0, p.diffYOffset-1)
136				p.contentDirty = true // Mark content as dirty when scrolling
137				return p, nil
138			}
139		case key.Matches(msg, p.keyMap.ScrollLeft):
140			if p.supportsDiffView() {
141				p.diffXOffset = max(0, p.diffXOffset-5)
142				p.contentDirty = true // Mark content as dirty when scrolling
143				return p, nil
144			}
145		case key.Matches(msg, p.keyMap.ScrollRight):
146			if p.supportsDiffView() {
147				p.diffXOffset += 5
148				p.contentDirty = true // Mark content as dirty when scrolling
149				return p, nil
150			}
151		default:
152			// Pass other keys to viewport
153			viewPort, cmd := p.contentViewPort.Update(msg)
154			p.contentViewPort = viewPort
155			cmds = append(cmds, cmd)
156		}
157	}
158
159	return p, tea.Batch(cmds...)
160}
161
162func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
163	var action PermissionAction
164
165	switch p.selectedOption {
166	case 0:
167		action = PermissionAllow
168	case 1:
169		action = PermissionAllowForSession
170	case 2:
171		action = PermissionDeny
172	}
173
174	return tea.Batch(
175		util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
176		util.CmdHandler(dialogs.CloseDialogMsg{}),
177	)
178}
179
180func (p *permissionDialogCmp) renderButtons() string {
181	t := styles.CurrentTheme()
182	baseStyle := t.S().Base
183
184	buttons := []core.ButtonOpts{
185		{
186			Text:           "Allow",
187			UnderlineIndex: 0, // "A"
188			Selected:       p.selectedOption == 0,
189		},
190		{
191			Text:           "Allow for Session",
192			UnderlineIndex: 10, // "S" in "Session"
193			Selected:       p.selectedOption == 1,
194		},
195		{
196			Text:           "Deny",
197			UnderlineIndex: 0, // "D"
198			Selected:       p.selectedOption == 2,
199		},
200	}
201
202	content := core.SelectableButtons(buttons, "  ")
203
204	return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
205}
206
207func (p *permissionDialogCmp) renderHeader() string {
208	t := styles.CurrentTheme()
209	baseStyle := t.S().Base
210
211	toolKey := t.S().Muted.Render("Tool")
212	toolValue := t.S().Text.
213		Width(p.width - lipgloss.Width(toolKey)).
214		Render(fmt.Sprintf(" %s", p.permission.ToolName))
215
216	pathKey := t.S().Muted.Render("Path")
217	pathValue := t.S().Text.
218		Width(p.width - lipgloss.Width(pathKey)).
219		Render(fmt.Sprintf(" %s", fsext.PrettyPath(p.permission.Path)))
220
221	headerParts := []string{
222		lipgloss.JoinHorizontal(
223			lipgloss.Left,
224			toolKey,
225			toolValue,
226		),
227		baseStyle.Render(strings.Repeat(" ", p.width)),
228		lipgloss.JoinHorizontal(
229			lipgloss.Left,
230			pathKey,
231			pathValue,
232		),
233		baseStyle.Render(strings.Repeat(" ", p.width)),
234	}
235
236	// Add tool-specific header information
237	switch p.permission.ToolName {
238	case tools.BashToolName:
239		headerParts = append(headerParts, t.S().Muted.Width(p.width).Render("Command"))
240	case tools.EditToolName:
241		params := p.permission.Params.(tools.EditPermissionsParams)
242		fileKey := t.S().Muted.Render("File")
243		filePath := t.S().Text.
244			Width(p.width - lipgloss.Width(fileKey)).
245			Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
246		headerParts = append(headerParts,
247			lipgloss.JoinHorizontal(
248				lipgloss.Left,
249				fileKey,
250				filePath,
251			),
252			baseStyle.Render(strings.Repeat(" ", p.width)),
253		)
254
255	case tools.WriteToolName:
256		params := p.permission.Params.(tools.WritePermissionsParams)
257		fileKey := t.S().Muted.Render("File")
258		filePath := t.S().Text.
259			Width(p.width - lipgloss.Width(fileKey)).
260			Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
261		headerParts = append(headerParts,
262			lipgloss.JoinHorizontal(
263				lipgloss.Left,
264				fileKey,
265				filePath,
266			),
267			baseStyle.Render(strings.Repeat(" ", p.width)),
268		)
269	case tools.FetchToolName:
270		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
271	}
272
273	return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
274}
275
276func (p *permissionDialogCmp) getOrGenerateContent() string {
277	// Return cached content if available and not dirty
278	if !p.contentDirty && p.cachedContent != "" {
279		return p.cachedContent
280	}
281
282	// Generate new content
283	var content string
284	switch p.permission.ToolName {
285	case tools.BashToolName:
286		content = p.generateBashContent()
287	case tools.EditToolName:
288		content = p.generateEditContent()
289	case tools.WriteToolName:
290		content = p.generateWriteContent()
291	case tools.FetchToolName:
292		content = p.generateFetchContent()
293	default:
294		content = p.generateDefaultContent()
295	}
296
297	// Cache the result
298	p.cachedContent = content
299	p.contentDirty = false
300
301	return content
302}
303
304func (p *permissionDialogCmp) generateBashContent() string {
305	t := styles.CurrentTheme()
306	baseStyle := t.S().Base.Background(t.BgSubtle)
307	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
308		content := pr.Command
309		t := styles.CurrentTheme()
310		content = strings.TrimSpace(content)
311		content = "\n" + content + "\n"
312		lines := strings.Split(content, "\n")
313
314		width := p.width - 4
315		var out []string
316		for _, ln := range lines {
317			ln = " " + ln // left padding
318			if len(ln) > width {
319				ln = ansi.Truncate(ln, width, "…")
320			}
321			out = append(out, t.S().Muted.
322				Width(width).
323				Foreground(t.FgBase).
324				Background(t.BgSubtle).
325				Render(ln))
326		}
327
328		// Use the cache for markdown rendering
329		renderedContent := strings.Join(out, "\n")
330		finalContent := baseStyle.
331			Width(p.contentViewPort.Width()).
332			Render(renderedContent)
333
334		return finalContent
335	}
336	return ""
337}
338
339func (p *permissionDialogCmp) generateEditContent() string {
340	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
341		formatter := core.DiffFormatter().
342			Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
343			After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
344			Height(p.contentViewPort.Height()).
345			Width(p.contentViewPort.Width()).
346			XOffset(p.diffXOffset).
347			YOffset(p.diffYOffset)
348		if p.diffSplitMode {
349			formatter = formatter.Split()
350		} else {
351			formatter = formatter.Unified()
352		}
353
354		diff := formatter.String()
355		return diff
356	}
357	return ""
358}
359
360func (p *permissionDialogCmp) generateWriteContent() string {
361	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
362		// Use the cache for diff rendering
363		formatter := core.DiffFormatter().
364			Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
365			After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
366			Height(p.contentViewPort.Height()).
367			Width(p.contentViewPort.Width()).
368			XOffset(p.diffXOffset).
369			YOffset(p.diffYOffset)
370		if p.diffSplitMode {
371			formatter = formatter.Split()
372		} else {
373			formatter = formatter.Unified()
374		}
375
376		diff := formatter.String()
377		return diff
378	}
379	return ""
380}
381
382func (p *permissionDialogCmp) generateFetchContent() string {
383	t := styles.CurrentTheme()
384	baseStyle := t.S().Base.Background(t.BgSubtle)
385	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
386		content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
387
388		// Use the cache for markdown rendering
389		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
390			r := styles.GetMarkdownRenderer(p.width - 4)
391			s, err := r.Render(content)
392			return s, err
393		})
394
395		finalContent := baseStyle.
396			Width(p.contentViewPort.Width()).
397			Render(renderedContent)
398
399		// We render the content into a buffer with the size of the viewport
400		// width and height of the content. Then we make sure each cell has the
401		// appropriate background color.
402		ss := uv.NewStyledString(finalContent)
403		scr := uv.NewScreenBuffer(p.contentViewPort.Width(), lipgloss.Height(finalContent))
404		ss.Draw(scr, scr.Bounds())
405		for y := 0; y < scr.Height(); y++ {
406			for x := 0; x < scr.Width(); x++ {
407				cell := scr.CellAt(x, y)
408				if cell != nil {
409					cell.Style.Bg = t.BgSubtle
410				}
411			}
412		}
413
414		return scr.Render()
415	}
416	return ""
417}
418
419func (p *permissionDialogCmp) generateDefaultContent() string {
420	t := styles.CurrentTheme()
421	baseStyle := t.S().Base.Background(t.BgSubtle)
422
423	content := p.permission.Description
424
425	// Use the cache for markdown rendering
426	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
427		r := styles.GetMarkdownRenderer(p.width - 4)
428		s, err := r.Render(content)
429		return s, err
430	})
431
432	finalContent := baseStyle.
433		Width(p.contentViewPort.Width()).
434		Render(renderedContent)
435
436	if renderedContent == "" {
437		return ""
438	}
439
440	return finalContent
441}
442
443func (p *permissionDialogCmp) styleViewport() string {
444	t := styles.CurrentTheme()
445	return t.S().Base.Render(p.contentViewPort.View())
446}
447
448func (p *permissionDialogCmp) render() string {
449	t := styles.CurrentTheme()
450	baseStyle := t.S().Base
451	title := core.Title("Permission Required", p.width-4)
452	// Render header
453	headerContent := p.renderHeader()
454	// Render buttons
455	buttons := p.renderButtons()
456
457	p.contentViewPort.SetWidth(p.width - 4)
458
459	// Get cached or generate content
460	contentFinal := p.getOrGenerateContent()
461
462	// Always set viewport content (the caching is handled in getOrGenerateContent)
463	contentHeight := min(p.height-9, lipgloss.Height(contentFinal))
464	p.contentViewPort.SetHeight(contentHeight)
465	p.contentViewPort.SetContent(contentFinal)
466
467	var contentHelp string
468	if p.supportsDiffView() {
469		contentHelp = help.New().View(p.keyMap)
470	}
471
472	// Calculate content height dynamically based on window size
473	strs := []string{
474		title,
475		"",
476		headerContent,
477		p.styleViewport(),
478		"",
479		buttons,
480		"",
481	}
482	if contentHelp != "" {
483		strs = append(strs, "", contentHelp)
484	}
485	content := lipgloss.JoinVertical(lipgloss.Top, strs...)
486
487	return baseStyle.
488		Padding(0, 1).
489		Border(lipgloss.RoundedBorder()).
490		BorderForeground(t.BorderFocus).
491		Width(p.width).
492		Render(
493			content,
494		)
495}
496
497func (p *permissionDialogCmp) View() string {
498	return p.render()
499}
500
501func (p *permissionDialogCmp) SetSize() tea.Cmd {
502	if p.permission.ID == "" {
503		return nil
504	}
505
506	oldWidth, oldHeight := p.width, p.height
507
508	switch p.permission.ToolName {
509	case tools.BashToolName:
510		p.width = int(float64(p.wWidth) * 0.4)
511		p.height = int(float64(p.wHeight) * 0.3)
512	case tools.EditToolName:
513		p.width = int(float64(p.wWidth) * 0.8)
514		p.height = int(float64(p.wHeight) * 0.8)
515	case tools.WriteToolName:
516		p.width = int(float64(p.wWidth) * 0.8)
517		p.height = int(float64(p.wHeight) * 0.8)
518	case tools.FetchToolName:
519		p.width = int(float64(p.wWidth) * 0.4)
520		p.height = int(float64(p.wHeight) * 0.3)
521	default:
522		p.width = int(float64(p.wWidth) * 0.7)
523		p.height = int(float64(p.wHeight) * 0.5)
524	}
525
526	// Mark content as dirty if size changed
527	if oldWidth != p.width || oldHeight != p.height {
528		p.contentDirty = true
529	}
530
531	return nil
532}
533
534func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
535	content, err := generator()
536	if err != nil {
537		return fmt.Sprintf("Error rendering markdown: %v", err)
538	}
539
540	return content
541}
542
543// ID implements PermissionDialogCmp.
544func (p *permissionDialogCmp) ID() dialogs.DialogID {
545	return PermissionsDialogID
546}
547
548// Position implements PermissionDialogCmp.
549func (p *permissionDialogCmp) Position() (int, int) {
550	row := (p.wHeight / 2) - 2 // Just a bit above the center
551	row -= p.height / 2
552	col := p.wWidth / 2
553	col -= p.width / 2
554	return row, col
555}