@@ -0,0 +1,490 @@
1+package permissions
2+
3+import (
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+
22+type PermissionAction string
23+
24+// Permission responses
25+const (
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
34+type PermissionResponseMsg struct {
35+ Permission permission.PermissionRequest
36+ Action PermissionAction
37+}
38+
39+// PermissionDialogCmp interface for permission dialog component
40+type PermissionDialogCmp interface {
41+ dialogs.DialogModel
42+}
43+
44+// permissionDialogCmp is the implementation of PermissionDialog
45+type 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+
57+func 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+
68+func (p *permissionDialogCmp) Init() tea.Cmd {
69+ return p.contentViewPort.Init()
70+}
71+
72+func (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+
116+func (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+
134+func (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+
178+func (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+
247+func (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+
285+func (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+
299+func (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+
313+func (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+
328+func (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+
353+func (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+
378+func (p *permissionDialogCmp) styleViewport() string {
379+ t := styles.CurrentTheme()
380+ return t.S().Base.Render(p.contentViewPort.View())
381+}
382+
383+func (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+
433+func (p *permissionDialogCmp) View() tea.View {
434+ return tea.NewView(p.render())
435+}
436+
437+func (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+
461+func (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+
469+func (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.
479+func (p *permissionDialogCmp) ID() dialogs.DialogID {
480+ return PermissionsDialogID
481+}
482+
483+// Position implements PermissionDialogCmp.
484+func (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+}