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