permissions.go

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