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	"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	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				p.diffSplitMode = !p.diffSplitMode
126				p.contentDirty = true // Mark content as dirty when diff mode changes
127				return p, nil
128			}
129		case key.Matches(msg, p.keyMap.ScrollDown):
130			if p.supportsDiffView() {
131				p.diffYOffset += 1
132				p.contentDirty = true // Mark content as dirty when scrolling
133				return p, nil
134			}
135		case key.Matches(msg, p.keyMap.ScrollUp):
136			if p.supportsDiffView() {
137				p.diffYOffset = max(0, p.diffYOffset-1)
138				p.contentDirty = true // Mark content as dirty when scrolling
139				return p, nil
140			}
141		case key.Matches(msg, p.keyMap.ScrollLeft):
142			if p.supportsDiffView() {
143				p.diffXOffset = max(0, p.diffXOffset-5)
144				p.contentDirty = true // Mark content as dirty when scrolling
145				return p, nil
146			}
147		case key.Matches(msg, p.keyMap.ScrollRight):
148			if p.supportsDiffView() {
149				p.diffXOffset += 5
150				p.contentDirty = true // Mark content as dirty when scrolling
151				return p, nil
152			}
153		default:
154			// Pass other keys to viewport
155			viewPort, cmd := p.contentViewPort.Update(msg)
156			p.contentViewPort = viewPort
157			cmds = append(cmds, cmd)
158		}
159	}
160
161	return p, tea.Batch(cmds...)
162}
163
164func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
165	var action PermissionAction
166
167	switch p.selectedOption {
168	case 0:
169		action = PermissionAllow
170	case 1:
171		action = PermissionAllowForSession
172	case 2:
173		action = PermissionDeny
174	}
175
176	return tea.Batch(
177		util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
178		util.CmdHandler(dialogs.CloseDialogMsg{}),
179	)
180}
181
182func (p *permissionDialogCmp) renderButtons() string {
183	t := styles.CurrentTheme()
184	baseStyle := t.S().Base
185
186	buttons := []core.ButtonOpts{
187		{
188			Text:           "Allow",
189			UnderlineIndex: 0, // "A"
190			Selected:       p.selectedOption == 0,
191		},
192		{
193			Text:           "Allow for Session",
194			UnderlineIndex: 10, // "S" in "Session"
195			Selected:       p.selectedOption == 1,
196		},
197		{
198			Text:           "Deny",
199			UnderlineIndex: 0, // "D"
200			Selected:       p.selectedOption == 2,
201		},
202	}
203
204	content := core.SelectableButtons(buttons, "  ")
205	if lipgloss.Width(content) > p.width-4 {
206		content = core.SelectableButtonsVertical(buttons, 1)
207		return baseStyle.AlignVertical(lipgloss.Center).
208			AlignHorizontal(lipgloss.Center).
209			Width(p.width - 4).
210			Render(content)
211	}
212
213	return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
214}
215
216func (p *permissionDialogCmp) renderHeader() string {
217	t := styles.CurrentTheme()
218	baseStyle := t.S().Base
219
220	toolKey := t.S().Muted.Render("Tool")
221	toolValue := t.S().Text.
222		Width(p.width - lipgloss.Width(toolKey)).
223		Render(fmt.Sprintf(" %s", p.permission.ToolName))
224
225	pathKey := t.S().Muted.Render("Path")
226	pathValue := t.S().Text.
227		Width(p.width - lipgloss.Width(pathKey)).
228		Render(fmt.Sprintf(" %s", fsext.PrettyPath(p.permission.Path)))
229
230	headerParts := []string{
231		lipgloss.JoinHorizontal(
232			lipgloss.Left,
233			toolKey,
234			toolValue,
235		),
236		baseStyle.Render(strings.Repeat(" ", p.width)),
237		lipgloss.JoinHorizontal(
238			lipgloss.Left,
239			pathKey,
240			pathValue,
241		),
242		baseStyle.Render(strings.Repeat(" ", p.width)),
243	}
244
245	// Add tool-specific header information
246	switch p.permission.ToolName {
247	case tools.BashToolName:
248		headerParts = append(headerParts, t.S().Muted.Width(p.width).Render("Command"))
249	case tools.EditToolName:
250		params := p.permission.Params.(tools.EditPermissionsParams)
251		fileKey := t.S().Muted.Render("File")
252		filePath := t.S().Text.
253			Width(p.width - lipgloss.Width(fileKey)).
254			Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
255		headerParts = append(headerParts,
256			lipgloss.JoinHorizontal(
257				lipgloss.Left,
258				fileKey,
259				filePath,
260			),
261			baseStyle.Render(strings.Repeat(" ", p.width)),
262		)
263
264	case tools.WriteToolName:
265		params := p.permission.Params.(tools.WritePermissionsParams)
266		fileKey := t.S().Muted.Render("File")
267		filePath := t.S().Text.
268			Width(p.width - lipgloss.Width(fileKey)).
269			Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
270		headerParts = append(headerParts,
271			lipgloss.JoinHorizontal(
272				lipgloss.Left,
273				fileKey,
274				filePath,
275			),
276			baseStyle.Render(strings.Repeat(" ", p.width)),
277		)
278	case tools.FetchToolName:
279		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
280	}
281
282	return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
283}
284
285func (p *permissionDialogCmp) getOrGenerateContent() string {
286	// Return cached content if available and not dirty
287	if !p.contentDirty && p.cachedContent != "" {
288		return p.cachedContent
289	}
290
291	// Generate new content
292	var content string
293	switch p.permission.ToolName {
294	case tools.BashToolName:
295		content = p.generateBashContent()
296	case tools.EditToolName:
297		content = p.generateEditContent()
298	case tools.WriteToolName:
299		content = p.generateWriteContent()
300	case tools.FetchToolName:
301		content = p.generateFetchContent()
302	default:
303		content = p.generateDefaultContent()
304	}
305
306	// Cache the result
307	p.cachedContent = content
308	p.contentDirty = false
309
310	return content
311}
312
313func (p *permissionDialogCmp) generateBashContent() string {
314	t := styles.CurrentTheme()
315	baseStyle := t.S().Base.Background(t.BgSubtle)
316	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
317		content := pr.Command
318		t := styles.CurrentTheme()
319		content = strings.TrimSpace(content)
320		content = "\n" + content + "\n"
321		lines := strings.Split(content, "\n")
322
323		width := p.width - 4
324		var out []string
325		for _, ln := range lines {
326			ln = " " + ln // left padding
327			if len(ln) > width {
328				ln = ansi.Truncate(ln, width, "…")
329			}
330			out = append(out, t.S().Muted.
331				Width(width).
332				Foreground(t.FgBase).
333				Background(t.BgSubtle).
334				Render(ln))
335		}
336
337		// Use the cache for markdown rendering
338		renderedContent := strings.Join(out, "\n")
339		finalContent := baseStyle.
340			Width(p.contentViewPort.Width()).
341			Render(renderedContent)
342
343		return finalContent
344	}
345	return ""
346}
347
348func (p *permissionDialogCmp) generateEditContent() string {
349	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
350		formatter := core.DiffFormatter().
351			Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
352			After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
353			Height(p.contentViewPort.Height()).
354			Width(p.contentViewPort.Width()).
355			XOffset(p.diffXOffset).
356			YOffset(p.diffYOffset)
357		if p.diffSplitMode {
358			formatter = formatter.Split()
359		} else {
360			formatter = formatter.Unified()
361		}
362
363		diff := formatter.String()
364		return diff
365	}
366	return ""
367}
368
369func (p *permissionDialogCmp) generateWriteContent() string {
370	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
371		// Use the cache for diff rendering
372		formatter := core.DiffFormatter().
373			Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
374			After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
375			Height(p.contentViewPort.Height()).
376			Width(p.contentViewPort.Width()).
377			XOffset(p.diffXOffset).
378			YOffset(p.diffYOffset)
379		if p.diffSplitMode {
380			formatter = formatter.Split()
381		} else {
382			formatter = formatter.Unified()
383		}
384
385		diff := formatter.String()
386		return diff
387	}
388	return ""
389}
390
391func (p *permissionDialogCmp) generateFetchContent() string {
392	t := styles.CurrentTheme()
393	baseStyle := t.S().Base.Background(t.BgSubtle)
394	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
395		finalContent := baseStyle.
396			Padding(1, 2).
397			Width(p.contentViewPort.Width()).
398			Render(pr.URL)
399		return finalContent
400	}
401	return ""
402}
403
404func (p *permissionDialogCmp) generateDefaultContent() string {
405	t := styles.CurrentTheme()
406	baseStyle := t.S().Base.Background(t.BgSubtle)
407
408	content := p.permission.Description
409
410	// Use the cache for markdown rendering
411	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
412		r := styles.GetMarkdownRenderer(p.width - 4)
413		s, err := r.Render(content)
414		return s, err
415	})
416
417	finalContent := baseStyle.
418		Width(p.contentViewPort.Width()).
419		Render(renderedContent)
420
421	if renderedContent == "" {
422		return ""
423	}
424
425	return finalContent
426}
427
428func (p *permissionDialogCmp) styleViewport() string {
429	t := styles.CurrentTheme()
430	return t.S().Base.Render(p.contentViewPort.View())
431}
432
433func (p *permissionDialogCmp) render() string {
434	t := styles.CurrentTheme()
435	baseStyle := t.S().Base
436	title := core.Title("Permission Required", p.width-4)
437	// Render header
438	headerContent := p.renderHeader()
439	// Render buttons
440	buttons := p.renderButtons()
441
442	p.contentViewPort.SetWidth(p.width - 4)
443
444	// Get cached or generate content
445	contentFinal := p.getOrGenerateContent()
446
447	// Always set viewport content (the caching is handled in getOrGenerateContent)
448	contentHeight := min(p.height-9, lipgloss.Height(contentFinal))
449	p.contentViewPort.SetHeight(contentHeight)
450	p.contentViewPort.SetContent(contentFinal)
451
452	p.positionRow = p.wHeight / 2
453	p.positionRow -= (contentHeight + 9) / 2
454
455	var contentHelp string
456	if p.supportsDiffView() {
457		contentHelp = help.New().View(p.keyMap)
458	}
459
460	// Calculate content height dynamically based on window size
461	strs := []string{
462		title,
463		"",
464		headerContent,
465		p.styleViewport(),
466		"",
467		buttons,
468		"",
469	}
470	if contentHelp != "" {
471		strs = append(strs, "", contentHelp)
472	}
473	content := lipgloss.JoinVertical(lipgloss.Top, strs...)
474
475	return baseStyle.
476		Padding(0, 1).
477		Border(lipgloss.RoundedBorder()).
478		BorderForeground(t.BorderFocus).
479		Width(p.width).
480		Render(
481			content,
482		)
483}
484
485func (p *permissionDialogCmp) View() string {
486	return p.render()
487}
488
489func (p *permissionDialogCmp) SetSize() tea.Cmd {
490	if p.permission.ID == "" {
491		return nil
492	}
493
494	oldWidth, oldHeight := p.width, p.height
495
496	switch p.permission.ToolName {
497	case tools.BashToolName:
498		p.width = int(float64(p.wWidth) * 0.8)
499		p.height = int(float64(p.wHeight) * 0.3)
500	case tools.EditToolName:
501		p.width = int(float64(p.wWidth) * 0.8)
502		p.height = int(float64(p.wHeight) * 0.8)
503	case tools.WriteToolName:
504		p.width = int(float64(p.wWidth) * 0.8)
505		p.height = int(float64(p.wHeight) * 0.8)
506	case tools.FetchToolName:
507		p.width = int(float64(p.wWidth) * 0.8)
508		p.height = int(float64(p.wHeight) * 0.3)
509	default:
510		p.width = int(float64(p.wWidth) * 0.7)
511		p.height = int(float64(p.wHeight) * 0.5)
512	}
513
514	// Mark content as dirty if size changed
515	if oldWidth != p.width || oldHeight != p.height {
516		p.contentDirty = true
517	}
518	p.positionRow = p.wHeight / 2
519	p.positionRow -= p.height / 2
520	p.positionCol = p.wWidth / 2
521	p.positionCol -= p.width / 2
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	return p.positionRow, p.positionCol
542}