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}