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