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