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