@@ -0,0 +1,490 @@
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"
22+type PermissionAction string
24+// Permission responses
26+ PermissionAllow PermissionAction = "allow"
27+ PermissionAllowForSession PermissionAction = "allow_session"
28+ PermissionDeny PermissionAction = "deny"
30+ PermissionsDialogID dialogs.DialogID = "permissions"
33+// PermissionResponseMsg represents the user's response to a permission request
34+type PermissionResponseMsg struct {
35+ Permission permission.PermissionRequest
36+ Action PermissionAction
39+// PermissionDialogCmp interface for permission dialog component
40+type PermissionDialogCmp interface {
44+// permissionDialogCmp is the implementation of PermissionDialog
45+type permissionDialogCmp struct {
50+ permission permission.PermissionRequest
51+ contentViewPort viewport.Model
52+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
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(),
68+func (p *permissionDialogCmp) Init() tea.Cmd {
69+ return p.contentViewPort.Init()
72+func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
75+ switch msg := msg.(type) {
76+ case tea.WindowSizeMsg:
78+ p.wHeight = msg.Height
80+ cmds = append(cmds, cmd)
81+ case tea.KeyPressMsg:
83+ case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
84+ p.selectedOption = (p.selectedOption + 1) % 3
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):
92+ util.CmdHandler(dialogs.CloseDialogMsg{}),
93+ util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
95+ case key.Matches(msg, p.keyMap.AllowSession):
97+ util.CmdHandler(dialogs.CloseDialogMsg{}),
98+ util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
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}),
106+ // Pass other keys to viewport
107+ viewPort, cmd := p.contentViewPort.Update(msg)
108+ p.contentViewPort = viewPort
109+ cmds = append(cmds, cmd)
113+ return p, tea.Batch(cmds...)
116+func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
117+ var action PermissionAction
119+ switch p.selectedOption {
121+ action = PermissionAllow
123+ action = PermissionAllowForSession
125+ action = PermissionDeny
129+ util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
130+ util.CmdHandler(dialogs.CloseDialogMsg{}),
134+func (p *permissionDialogCmp) renderButtons() string {
135+ t := styles.CurrentTheme()
137+ allowStyle := t.S().Text
138+ allowSessionStyle := allowStyle
139+ denyStyle := allowStyle
141+ // Style the selected button
142+ switch p.selectedOption {
144+ allowStyle = allowStyle.Foreground(t.White).Background(t.Secondary)
145+ allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
146+ denyStyle = denyStyle.Background(t.BgSubtle)
148+ allowStyle = allowStyle.Background(t.BgSubtle)
149+ allowSessionStyle = allowSessionStyle.Foreground(t.White).Background(t.Secondary)
150+ denyStyle = denyStyle.Background(t.BgSubtle)
152+ allowStyle = allowStyle.Background(t.BgSubtle)
153+ allowSessionStyle = allowSessionStyle.Background(t.BgSubtle)
154+ denyStyle = denyStyle.Foreground(t.White).Background(t.Secondary)
157+ baseStyle := t.S().Base
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)
166+ content := lipgloss.JoinHorizontal(
175+ return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
178+func (p *permissionDialogCmp) renderHeader() string {
179+ t := styles.CurrentTheme()
180+ baseStyle := t.S().Base
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))
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)))
192+ headerParts := []string{
193+ lipgloss.JoinHorizontal(
198+ baseStyle.Render(strings.Repeat(" ", p.width)),
199+ lipgloss.JoinHorizontal(
204+ baseStyle.Render(strings.Repeat(" ", p.width)),
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(
223+ baseStyle.Render(strings.Repeat(" ", p.width)),
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(
238+ baseStyle.Render(strings.Repeat(" ", p.width)),
240+ case tools.FetchToolName:
241+ headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
244+ return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
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")
257+ width := p.width - 4
259+ for _, ln := range lines {
260+ ln = " " + ln // left padding
261+ if len(ln) > width {
262+ ln = ansi.Truncate(ln, width, "…")
264+ out = append(out, t.S().Muted.
266+ Foreground(t.FgBase).
267+ Background(t.BgSubtle).
271+ // Use the cache for markdown rendering
272+ renderedContent := strings.Join(out, "\n")
273+ finalContent := baseStyle.
274+ Width(p.contentViewPort.Width()).
275+ Render(renderedContent)
277+ contentHeight := min(p.height-9, lipgloss.Height(finalContent))
278+ p.contentViewPort.SetHeight(contentHeight)
279+ p.contentViewPort.SetContent(finalContent)
280+ return p.styleViewport()
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()))
291+ contentHeight := min(p.height-9, lipgloss.Height(diff))
292+ p.contentViewPort.SetHeight(contentHeight)
293+ p.contentViewPort.SetContent(diff)
294+ return p.styleViewport()
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()))
305+ contentHeight := min(p.height-9, lipgloss.Height(diff))
306+ p.contentViewPort.SetHeight(contentHeight)
307+ p.contentViewPort.SetContent(diff)
308+ return p.styleViewport()
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()))
320+ contentHeight := min(p.height-9, lipgloss.Height(diff))
321+ p.contentViewPort.SetHeight(contentHeight)
322+ p.contentViewPort.SetContent(diff)
323+ return p.styleViewport()
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)
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)
341+ finalContent := baseStyle.
342+ Width(p.contentViewPort.Width()).
343+ Render(renderedContent)
345+ contentHeight := min(p.height-9, lipgloss.Height(finalContent))
346+ p.contentViewPort.SetHeight(contentHeight)
347+ p.contentViewPort.SetContent(finalContent)
348+ return p.styleViewport()
353+func (p *permissionDialogCmp) renderDefaultContent() string {
354+ t := styles.CurrentTheme()
355+ baseStyle := t.S().Base.Background(t.BgSubtle)
357+ content := p.permission.Description
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)
366+ finalContent := baseStyle.
367+ Width(p.contentViewPort.Width()).
368+ Render(renderedContent)
369+ p.contentViewPort.SetContent(finalContent)
371+ if renderedContent == "" {
375+ return p.styleViewport()
378+func (p *permissionDialogCmp) styleViewport() string {
379+ t := styles.CurrentTheme()
380+ return t.S().Base.Render(p.contentViewPort.View())
383+func (p *permissionDialogCmp) render() string {
384+ t := styles.CurrentTheme()
385+ baseStyle := t.S().Base
386+ title := core.Title("Permission Required", p.width-4)
388+ headerContent := p.renderHeader()
390+ buttons := p.renderButtons()
392+ p.contentViewPort.SetWidth(p.width - 4)
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()
408+ contentFinal = p.renderDefaultContent()
410+ // Calculate content height dynamically based on window size
412+ content := lipgloss.JoinVertical(
425+ Border(lipgloss.RoundedBorder()).
426+ BorderForeground(t.BorderFocus).
433+func (p *permissionDialogCmp) View() tea.View {
434+ return tea.NewView(p.render())
437+func (p *permissionDialogCmp) SetSize() tea.Cmd {
438+ if p.permission.ID == "" {
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)
455+ p.width = int(float64(p.wWidth) * 0.7)
456+ p.height = int(float64(p.wHeight) * 0.5)
461+func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
462+ content, err := generator()
464+ return fmt.Sprintf("Error formatting diff: %v", err)
469+func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
470+ content, err := generator()
472+ return fmt.Sprintf("Error rendering markdown: %v", err)
478+// ID implements PermissionDialogCmp.
479+func (p *permissionDialogCmp) ID() dialogs.DialogID {
480+ return PermissionsDialogID
483+// Position implements PermissionDialogCmp.
484+func (p *permissionDialogCmp) Position() (int, int) {
485+ row := (p.wHeight / 2) - 2 // Just a bit above the center