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	baseStyle := t.S().Base
137
138	buttons := []core.ButtonOpts{
139		{
140			Text:           "Allow",
141			UnderlineIndex: 0, // "A"
142			Selected:       p.selectedOption == 0,
143		},
144		{
145			Text:           "Allow for Session",
146			UnderlineIndex: 10, // "S" in "Session"
147			Selected:       p.selectedOption == 1,
148		},
149		{
150			Text:           "Deny",
151			UnderlineIndex: 0, // "D"
152			Selected:       p.selectedOption == 2,
153		},
154	}
155
156	content := core.SelectableButtons(buttons, "  ")
157
158	return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
159}
160
161func (p *permissionDialogCmp) renderHeader() string {
162	t := styles.CurrentTheme()
163	baseStyle := t.S().Base
164
165	toolKey := t.S().Muted.Render("Tool")
166	toolValue := t.S().Text.
167		Width(p.width - lipgloss.Width(toolKey)).
168		Render(fmt.Sprintf(" %s", p.permission.ToolName))
169
170	pathKey := t.S().Muted.Render("Path")
171	pathValue := t.S().Text.
172		Width(p.width - lipgloss.Width(pathKey)).
173		Render(fmt.Sprintf(" %s", fileutil.PrettyPath(p.permission.Path)))
174
175	headerParts := []string{
176		lipgloss.JoinHorizontal(
177			lipgloss.Left,
178			toolKey,
179			toolValue,
180		),
181		baseStyle.Render(strings.Repeat(" ", p.width)),
182		lipgloss.JoinHorizontal(
183			lipgloss.Left,
184			pathKey,
185			pathValue,
186		),
187		baseStyle.Render(strings.Repeat(" ", p.width)),
188	}
189
190	// Add tool-specific header information
191	switch p.permission.ToolName {
192	case tools.BashToolName:
193		headerParts = append(headerParts, t.S().Muted.Width(p.width).Render("Command"))
194	case tools.EditToolName:
195		params := p.permission.Params.(tools.EditPermissionsParams)
196		fileKey := t.S().Muted.Render("File")
197		filePath := t.S().Text.
198			Width(p.width - lipgloss.Width(fileKey)).
199			Render(fmt.Sprintf(" %s", fileutil.PrettyPath(params.FilePath)))
200		headerParts = append(headerParts,
201			lipgloss.JoinHorizontal(
202				lipgloss.Left,
203				fileKey,
204				filePath,
205			),
206			baseStyle.Render(strings.Repeat(" ", p.width)),
207		)
208
209	case tools.WriteToolName:
210		params := p.permission.Params.(tools.WritePermissionsParams)
211		fileKey := t.S().Muted.Render("File")
212		filePath := t.S().Text.
213			Width(p.width - lipgloss.Width(fileKey)).
214			Render(fmt.Sprintf(" %s", fileutil.PrettyPath(params.FilePath)))
215		headerParts = append(headerParts,
216			lipgloss.JoinHorizontal(
217				lipgloss.Left,
218				fileKey,
219				filePath,
220			),
221			baseStyle.Render(strings.Repeat(" ", p.width)),
222		)
223	case tools.FetchToolName:
224		headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
225	}
226
227	return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
228}
229
230func (p *permissionDialogCmp) renderBashContent() string {
231	t := styles.CurrentTheme()
232	baseStyle := t.S().Base.Background(t.BgSubtle)
233	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
234		content := pr.Command
235		t := styles.CurrentTheme()
236		content = strings.TrimSpace(content)
237		content = "\n" + content + "\n"
238		lines := strings.Split(content, "\n")
239
240		width := p.width - 4
241		var out []string
242		for _, ln := range lines {
243			ln = " " + ln // left padding
244			if len(ln) > width {
245				ln = ansi.Truncate(ln, width, "…")
246			}
247			out = append(out, t.S().Muted.
248				Width(width).
249				Foreground(t.FgBase).
250				Background(t.BgSubtle).
251				Render(ln))
252		}
253
254		// Use the cache for markdown rendering
255		renderedContent := strings.Join(out, "\n")
256		finalContent := baseStyle.
257			Width(p.contentViewPort.Width()).
258			Render(renderedContent)
259
260		contentHeight := min(p.height-9, lipgloss.Height(finalContent))
261		p.contentViewPort.SetHeight(contentHeight)
262		p.contentViewPort.SetContent(finalContent)
263		return p.styleViewport()
264	}
265	return ""
266}
267
268func (p *permissionDialogCmp) renderEditContent() string {
269	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
270		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
271			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
272		})
273
274		contentHeight := min(p.height-9, lipgloss.Height(diff))
275		p.contentViewPort.SetHeight(contentHeight)
276		p.contentViewPort.SetContent(diff)
277		return p.styleViewport()
278	}
279	return ""
280}
281
282func (p *permissionDialogCmp) renderPatchContent() string {
283	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
284		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
285			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
286		})
287
288		contentHeight := min(p.height-9, lipgloss.Height(diff))
289		p.contentViewPort.SetHeight(contentHeight)
290		p.contentViewPort.SetContent(diff)
291		return p.styleViewport()
292	}
293	return ""
294}
295
296func (p *permissionDialogCmp) renderWriteContent() string {
297	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
298		// Use the cache for diff rendering
299		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
300			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
301		})
302
303		contentHeight := min(p.height-9, lipgloss.Height(diff))
304		p.contentViewPort.SetHeight(contentHeight)
305		p.contentViewPort.SetContent(diff)
306		return p.styleViewport()
307	}
308	return ""
309}
310
311func (p *permissionDialogCmp) renderFetchContent() string {
312	t := styles.CurrentTheme()
313	baseStyle := t.S().Base.Background(t.BgSubtle)
314	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
315		content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
316
317		// Use the cache for markdown rendering
318		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
319			r := styles.GetMarkdownRenderer(p.width - 4)
320			s, err := r.Render(content)
321			return s, err
322		})
323
324		finalContent := baseStyle.
325			Width(p.contentViewPort.Width()).
326			Render(renderedContent)
327
328		contentHeight := min(p.height-9, lipgloss.Height(finalContent))
329		p.contentViewPort.SetHeight(contentHeight)
330		p.contentViewPort.SetContent(finalContent)
331		return p.styleViewport()
332	}
333	return ""
334}
335
336func (p *permissionDialogCmp) renderDefaultContent() string {
337	t := styles.CurrentTheme()
338	baseStyle := t.S().Base.Background(t.BgSubtle)
339
340	content := p.permission.Description
341
342	// Use the cache for markdown rendering
343	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
344		r := styles.GetMarkdownRenderer(p.width - 4)
345		s, err := r.Render(content)
346		return s, err
347	})
348
349	finalContent := baseStyle.
350		Width(p.contentViewPort.Width()).
351		Render(renderedContent)
352	p.contentViewPort.SetContent(finalContent)
353
354	if renderedContent == "" {
355		return ""
356	}
357
358	return p.styleViewport()
359}
360
361func (p *permissionDialogCmp) styleViewport() string {
362	t := styles.CurrentTheme()
363	return t.S().Base.Render(p.contentViewPort.View())
364}
365
366func (p *permissionDialogCmp) render() string {
367	t := styles.CurrentTheme()
368	baseStyle := t.S().Base
369	title := core.Title("Permission Required", p.width-4)
370	// Render header
371	headerContent := p.renderHeader()
372	// Render buttons
373	buttons := p.renderButtons()
374
375	p.contentViewPort.SetWidth(p.width - 4)
376
377	// Render content based on tool type
378	var contentFinal string
379	switch p.permission.ToolName {
380	case tools.BashToolName:
381		contentFinal = p.renderBashContent()
382	case tools.EditToolName:
383		contentFinal = p.renderEditContent()
384	case tools.PatchToolName:
385		contentFinal = p.renderPatchContent()
386	case tools.WriteToolName:
387		contentFinal = p.renderWriteContent()
388	case tools.FetchToolName:
389		contentFinal = p.renderFetchContent()
390	default:
391		contentFinal = p.renderDefaultContent()
392	}
393	// Calculate content height dynamically based on window size
394
395	content := lipgloss.JoinVertical(
396		lipgloss.Top,
397		title,
398		"",
399		headerContent,
400		contentFinal,
401		"",
402		buttons,
403		"",
404	)
405
406	return baseStyle.
407		Padding(0, 1).
408		Border(lipgloss.RoundedBorder()).
409		BorderForeground(t.BorderFocus).
410		Width(p.width).
411		Render(
412			content,
413		)
414}
415
416func (p *permissionDialogCmp) View() tea.View {
417	return tea.NewView(p.render())
418}
419
420func (p *permissionDialogCmp) SetSize() tea.Cmd {
421	if p.permission.ID == "" {
422		return nil
423	}
424	switch p.permission.ToolName {
425	case tools.BashToolName:
426		p.width = int(float64(p.wWidth) * 0.4)
427		p.height = int(float64(p.wHeight) * 0.3)
428	case tools.EditToolName:
429		p.width = int(float64(p.wWidth) * 0.8)
430		p.height = int(float64(p.wHeight) * 0.8)
431	case tools.WriteToolName:
432		p.width = int(float64(p.wWidth) * 0.8)
433		p.height = int(float64(p.wHeight) * 0.8)
434	case tools.FetchToolName:
435		p.width = int(float64(p.wWidth) * 0.4)
436		p.height = int(float64(p.wHeight) * 0.3)
437	default:
438		p.width = int(float64(p.wWidth) * 0.7)
439		p.height = int(float64(p.wHeight) * 0.5)
440	}
441	return nil
442}
443
444func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
445	content, err := generator()
446	if err != nil {
447		return fmt.Sprintf("Error formatting diff: %v", err)
448	}
449	return content
450}
451
452func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
453	content, err := generator()
454	if err != nil {
455		return fmt.Sprintf("Error rendering markdown: %v", err)
456	}
457
458	return content
459}
460
461// ID implements PermissionDialogCmp.
462func (p *permissionDialogCmp) ID() dialogs.DialogID {
463	return PermissionsDialogID
464}
465
466// Position implements PermissionDialogCmp.
467func (p *permissionDialogCmp) Position() (int, int) {
468	row := (p.wHeight / 2) - 2 // Just a bit above the center
469	row -= p.height / 2
470	col := p.wWidth / 2
471	col -= p.width / 2
472	return row, col
473}