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