permissions.go

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