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	content = strings.TrimSpace(content)
413	content = "\n" + content + "\n"
414	lines := strings.Split(content, "\n")
415
416	width := p.width - 4
417	var out []string
418	for _, ln := range lines {
419		ln = " " + ln // left padding
420		if len(ln) > width {
421			ln = ansi.Truncate(ln, width, "…")
422		}
423		out = append(out, t.S().Muted.
424			Width(width).
425			Foreground(t.FgBase).
426			Background(t.BgSubtle).
427			Render(ln))
428	}
429
430	// Use the cache for markdown rendering
431	renderedContent := strings.Join(out, "\n")
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) useDiffSplitMode() bool {
444	if p.diffSplitMode != nil {
445		return *p.diffSplitMode
446	} else {
447		return p.defaultDiffSplitMode
448	}
449}
450
451func (p *permissionDialogCmp) styleViewport() string {
452	t := styles.CurrentTheme()
453	return t.S().Base.Render(p.contentViewPort.View())
454}
455
456func (p *permissionDialogCmp) render() string {
457	t := styles.CurrentTheme()
458	baseStyle := t.S().Base
459	title := core.Title("Permission Required", p.width-4)
460	// Render header
461	headerContent := p.renderHeader()
462	// Render buttons
463	buttons := p.renderButtons()
464
465	p.contentViewPort.SetWidth(p.width - 4)
466
467	// Get cached or generate content
468	contentFinal := p.getOrGenerateContent()
469
470	// Always set viewport content (the caching is handled in getOrGenerateContent)
471	const minContentHeight = 9
472	contentHeight := min(
473		max(minContentHeight, p.height-minContentHeight),
474		lipgloss.Height(contentFinal),
475	)
476	p.contentViewPort.SetHeight(contentHeight)
477	p.contentViewPort.SetContent(contentFinal)
478
479	p.positionRow = p.wHeight / 2
480	p.positionRow -= (contentHeight + 9) / 2
481	p.positionRow -= 3 // Move dialog slightly higher than middle
482
483	var contentHelp string
484	if p.supportsDiffView() {
485		contentHelp = help.New().View(p.keyMap)
486	}
487
488	// Calculate content height dynamically based on window size
489	strs := []string{
490		title,
491		"",
492		headerContent,
493		p.styleViewport(),
494		"",
495		buttons,
496		"",
497	}
498	if contentHelp != "" {
499		strs = append(strs, "", contentHelp)
500	}
501	content := lipgloss.JoinVertical(lipgloss.Top, strs...)
502
503	return baseStyle.
504		Padding(0, 1).
505		Border(lipgloss.RoundedBorder()).
506		BorderForeground(t.BorderFocus).
507		Width(p.width).
508		Render(
509			content,
510		)
511}
512
513func (p *permissionDialogCmp) View() string {
514	return p.render()
515}
516
517func (p *permissionDialogCmp) SetSize() tea.Cmd {
518	if p.permission.ID == "" {
519		return nil
520	}
521
522	oldWidth, oldHeight := p.width, p.height
523
524	switch p.permission.ToolName {
525	case tools.BashToolName:
526		p.width = int(float64(p.wWidth) * 0.8)
527		p.height = int(float64(p.wHeight) * 0.3)
528	case tools.EditToolName:
529		p.width = int(float64(p.wWidth) * 0.8)
530		p.height = int(float64(p.wHeight) * 0.8)
531	case tools.WriteToolName:
532		p.width = int(float64(p.wWidth) * 0.8)
533		p.height = int(float64(p.wHeight) * 0.8)
534	case tools.FetchToolName:
535		p.width = int(float64(p.wWidth) * 0.8)
536		p.height = int(float64(p.wHeight) * 0.3)
537	default:
538		p.width = int(float64(p.wWidth) * 0.7)
539		p.height = int(float64(p.wHeight) * 0.5)
540	}
541
542	// Default to diff split mode when dialog is wide enough.
543	p.defaultDiffSplitMode = p.width >= 140
544
545	// Set a maximum width for the dialog
546	p.width = min(p.width, 180)
547
548	// Mark content as dirty if size changed
549	if oldWidth != p.width || oldHeight != p.height {
550		p.contentDirty = true
551	}
552	p.positionRow = p.wHeight / 2
553	p.positionRow -= p.height / 2
554	p.positionRow -= 3 // Move dialog slightly higher than middle
555	p.positionCol = p.wWidth / 2
556	p.positionCol -= p.width / 2
557	return nil
558}
559
560func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
561	content, err := generator()
562	if err != nil {
563		return fmt.Sprintf("Error rendering markdown: %v", err)
564	}
565
566	return content
567}
568
569// ID implements PermissionDialogCmp.
570func (p *permissionDialogCmp) ID() dialogs.DialogID {
571	return PermissionsDialogID
572}
573
574// Position implements PermissionDialogCmp.
575func (p *permissionDialogCmp) Position() (int, int) {
576	return p.positionRow, p.positionCol
577}