1package permissions
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/charmbracelet/bubbles/v2/help"
8 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/viewport"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/crush/internal/fsext"
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 // Diff view state
55 diffSplitMode bool // true for split, false for unified
56 diffXOffset int // horizontal scroll offset
57 diffYOffset int // vertical scroll offset
58
59 // Caching
60 cachedContent string
61 contentDirty bool
62
63 keyMap KeyMap
64}
65
66func NewPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialogCmp {
67 // Create viewport for content
68 contentViewport := viewport.New()
69 return &permissionDialogCmp{
70 contentViewPort: contentViewport,
71 selectedOption: 0, // Default to "Allow"
72 permission: permission,
73 keyMap: DefaultKeyMap(),
74 contentDirty: true, // Mark as dirty initially
75 }
76}
77
78func (p *permissionDialogCmp) Init() tea.Cmd {
79 return p.contentViewPort.Init()
80}
81
82func (p *permissionDialogCmp) supportsDiffView() bool {
83 return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName
84}
85
86func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
87 var cmds []tea.Cmd
88
89 switch msg := msg.(type) {
90 case tea.WindowSizeMsg:
91 p.wWidth = msg.Width
92 p.wHeight = msg.Height
93 p.contentDirty = true // Mark content as dirty on window resize
94 cmd := p.SetSize()
95 cmds = append(cmds, cmd)
96 case tea.KeyPressMsg:
97 switch {
98 case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
99 p.selectedOption = (p.selectedOption + 1) % 3
100 return p, nil
101 case key.Matches(msg, p.keyMap.Left):
102 p.selectedOption = (p.selectedOption + 2) % 3
103 case key.Matches(msg, p.keyMap.Select):
104 return p, p.selectCurrentOption()
105 case key.Matches(msg, p.keyMap.Allow):
106 return p, tea.Batch(
107 util.CmdHandler(dialogs.CloseDialogMsg{}),
108 util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
109 )
110 case key.Matches(msg, p.keyMap.AllowSession):
111 return p, tea.Batch(
112 util.CmdHandler(dialogs.CloseDialogMsg{}),
113 util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
114 )
115 case key.Matches(msg, p.keyMap.Deny):
116 return p, tea.Batch(
117 util.CmdHandler(dialogs.CloseDialogMsg{}),
118 util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
119 )
120 case key.Matches(msg, p.keyMap.ToggleDiffMode):
121 if p.supportsDiffView() {
122 p.diffSplitMode = !p.diffSplitMode
123 p.contentDirty = true // Mark content as dirty when diff mode changes
124 return p, nil
125 }
126 case key.Matches(msg, p.keyMap.ScrollDown):
127 if p.supportsDiffView() {
128 p.diffYOffset += 1
129 p.contentDirty = true // Mark content as dirty when scrolling
130 return p, nil
131 }
132 case key.Matches(msg, p.keyMap.ScrollUp):
133 if p.supportsDiffView() {
134 p.diffYOffset = max(0, p.diffYOffset-1)
135 p.contentDirty = true // Mark content as dirty when scrolling
136 return p, nil
137 }
138 case key.Matches(msg, p.keyMap.ScrollLeft):
139 if p.supportsDiffView() {
140 p.diffXOffset = max(0, p.diffXOffset-5)
141 p.contentDirty = true // Mark content as dirty when scrolling
142 return p, nil
143 }
144 case key.Matches(msg, p.keyMap.ScrollRight):
145 if p.supportsDiffView() {
146 p.diffXOffset += 5
147 p.contentDirty = true // Mark content as dirty when scrolling
148 return p, nil
149 }
150 default:
151 // Pass other keys to viewport
152 viewPort, cmd := p.contentViewPort.Update(msg)
153 p.contentViewPort = viewPort
154 cmds = append(cmds, cmd)
155 }
156 }
157
158 return p, tea.Batch(cmds...)
159}
160
161func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
162 var action PermissionAction
163
164 switch p.selectedOption {
165 case 0:
166 action = PermissionAllow
167 case 1:
168 action = PermissionAllowForSession
169 case 2:
170 action = PermissionDeny
171 }
172
173 return tea.Batch(
174 util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
175 util.CmdHandler(dialogs.CloseDialogMsg{}),
176 )
177}
178
179func (p *permissionDialogCmp) renderButtons() string {
180 t := styles.CurrentTheme()
181 baseStyle := t.S().Base
182
183 buttons := []core.ButtonOpts{
184 {
185 Text: "Allow",
186 UnderlineIndex: 0, // "A"
187 Selected: p.selectedOption == 0,
188 },
189 {
190 Text: "Allow for Session",
191 UnderlineIndex: 10, // "S" in "Session"
192 Selected: p.selectedOption == 1,
193 },
194 {
195 Text: "Deny",
196 UnderlineIndex: 0, // "D"
197 Selected: p.selectedOption == 2,
198 },
199 }
200
201 content := core.SelectableButtons(buttons, " ")
202 if lipgloss.Width(content) > p.width-4 {
203 content = core.SelectableButtonsVertical(buttons, 1)
204 return baseStyle.AlignVertical(lipgloss.Center).
205 AlignHorizontal(lipgloss.Center).
206 Width(p.width - 4).
207 Render(content)
208 }
209
210 return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
211}
212
213func (p *permissionDialogCmp) renderHeader() string {
214 t := styles.CurrentTheme()
215 baseStyle := t.S().Base
216
217 toolKey := t.S().Muted.Render("Tool")
218 toolValue := t.S().Text.
219 Width(p.width - lipgloss.Width(toolKey)).
220 Render(fmt.Sprintf(" %s", p.permission.ToolName))
221
222 pathKey := t.S().Muted.Render("Path")
223 pathValue := t.S().Text.
224 Width(p.width - lipgloss.Width(pathKey)).
225 Render(fmt.Sprintf(" %s", fsext.PrettyPath(p.permission.Path)))
226
227 headerParts := []string{
228 lipgloss.JoinHorizontal(
229 lipgloss.Left,
230 toolKey,
231 toolValue,
232 ),
233 baseStyle.Render(strings.Repeat(" ", p.width)),
234 }
235
236 // Only show Path field for non-fetch tools
237 if p.permission.ToolName != tools.FetchToolName {
238 headerParts = append(headerParts,
239 lipgloss.JoinHorizontal(
240 lipgloss.Left,
241 pathKey,
242 pathValue,
243 ),
244 baseStyle.Render(strings.Repeat(" ", p.width)),
245 )
246 }
247
248 // Add tool-specific header information
249 switch p.permission.ToolName {
250 case tools.BashToolName:
251 headerParts = append(headerParts, t.S().Muted.Width(p.width).Render("Command"))
252 case tools.EditToolName:
253 params := p.permission.Params.(tools.EditPermissionsParams)
254 fileKey := t.S().Muted.Render("File")
255 filePath := t.S().Text.
256 Width(p.width - lipgloss.Width(fileKey)).
257 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
258 headerParts = append(headerParts,
259 lipgloss.JoinHorizontal(
260 lipgloss.Left,
261 fileKey,
262 filePath,
263 ),
264 baseStyle.Render(strings.Repeat(" ", p.width)),
265 )
266
267 case tools.WriteToolName:
268 params := p.permission.Params.(tools.WritePermissionsParams)
269 fileKey := t.S().Muted.Render("File")
270 filePath := t.S().Text.
271 Width(p.width - lipgloss.Width(fileKey)).
272 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
273 headerParts = append(headerParts,
274 lipgloss.JoinHorizontal(
275 lipgloss.Left,
276 fileKey,
277 filePath,
278 ),
279 baseStyle.Render(strings.Repeat(" ", p.width)),
280 )
281 case tools.FetchToolName:
282 if params, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
283 urlKey := t.S().Muted.Render("URL")
284 urlValue := t.S().Text.
285 Width(p.width - lipgloss.Width(urlKey)).
286 Render(fmt.Sprintf(" %s", params.URL))
287 headerParts = append(headerParts,
288 lipgloss.JoinHorizontal(
289 lipgloss.Left,
290 urlKey,
291 urlValue,
292 ),
293 baseStyle.Render(strings.Repeat(" ", p.width)),
294 )
295 }
296 }
297
298 return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
299}
300
301func (p *permissionDialogCmp) getOrGenerateContent() string {
302 // Return cached content if available and not dirty
303 if !p.contentDirty && p.cachedContent != "" {
304 return p.cachedContent
305 }
306
307 // Generate new content
308 var content string
309 switch p.permission.ToolName {
310 case tools.BashToolName:
311 content = p.generateBashContent()
312 case tools.EditToolName:
313 content = p.generateEditContent()
314 case tools.WriteToolName:
315 content = p.generateWriteContent()
316 case tools.FetchToolName:
317 content = p.generateFetchContent()
318 default:
319 content = p.generateDefaultContent()
320 }
321
322 // Cache the result
323 p.cachedContent = content
324 p.contentDirty = false
325
326 return content
327}
328
329func (p *permissionDialogCmp) generateBashContent() string {
330 t := styles.CurrentTheme()
331 baseStyle := t.S().Base.Background(t.BgSubtle)
332 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
333 content := pr.Command
334 t := styles.CurrentTheme()
335 content = strings.TrimSpace(content)
336 content = "\n" + content + "\n"
337 lines := strings.Split(content, "\n")
338
339 width := p.width - 4
340 var out []string
341 for _, ln := range lines {
342 ln = " " + ln // left padding
343 if len(ln) > width {
344 ln = ansi.Truncate(ln, width, "…")
345 }
346 out = append(out, t.S().Muted.
347 Width(width).
348 Foreground(t.FgBase).
349 Background(t.BgSubtle).
350 Render(ln))
351 }
352
353 // Use the cache for markdown rendering
354 renderedContent := strings.Join(out, "\n")
355 finalContent := baseStyle.
356 Width(p.contentViewPort.Width()).
357 Render(renderedContent)
358
359 return finalContent
360 }
361 return ""
362}
363
364func (p *permissionDialogCmp) generateEditContent() string {
365 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
366 formatter := core.DiffFormatter().
367 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
368 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
369 Height(p.contentViewPort.Height()).
370 Width(p.contentViewPort.Width()).
371 XOffset(p.diffXOffset).
372 YOffset(p.diffYOffset)
373 if p.diffSplitMode {
374 formatter = formatter.Split()
375 } else {
376 formatter = formatter.Unified()
377 }
378
379 diff := formatter.String()
380 return diff
381 }
382 return ""
383}
384
385func (p *permissionDialogCmp) generateWriteContent() string {
386 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
387 // Use the cache for diff rendering
388 formatter := core.DiffFormatter().
389 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
390 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
391 Height(p.contentViewPort.Height()).
392 Width(p.contentViewPort.Width()).
393 XOffset(p.diffXOffset).
394 YOffset(p.diffYOffset)
395 if p.diffSplitMode {
396 formatter = formatter.Split()
397 } else {
398 formatter = formatter.Unified()
399 }
400
401 diff := formatter.String()
402 return diff
403 }
404 return ""
405}
406
407func (p *permissionDialogCmp) generateFetchContent() string {
408 return ""
409}
410
411func (p *permissionDialogCmp) generateDefaultContent() string {
412 t := styles.CurrentTheme()
413 baseStyle := t.S().Base.Background(t.BgSubtle)
414
415 content := p.permission.Description
416
417 // Use the cache for markdown rendering
418 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
419 r := styles.GetMarkdownRenderer(p.width - 4)
420 s, err := r.Render(content)
421 return s, err
422 })
423
424 finalContent := baseStyle.
425 Width(p.contentViewPort.Width()).
426 Render(renderedContent)
427
428 if renderedContent == "" {
429 return ""
430 }
431
432 return finalContent
433}
434
435func (p *permissionDialogCmp) styleViewport() string {
436 t := styles.CurrentTheme()
437 return t.S().Base.Render(p.contentViewPort.View())
438}
439
440func (p *permissionDialogCmp) render() string {
441 t := styles.CurrentTheme()
442 baseStyle := t.S().Base
443 title := core.Title("Permission Required", p.width-4)
444 // Render header
445 headerContent := p.renderHeader()
446 // Render buttons
447 buttons := p.renderButtons()
448
449 p.contentViewPort.SetWidth(p.width - 4)
450
451 // Get cached or generate content
452 contentFinal := p.getOrGenerateContent()
453
454 // Always set viewport content (the caching is handled in getOrGenerateContent)
455 contentHeight := min(p.height-9, lipgloss.Height(contentFinal))
456 p.contentViewPort.SetHeight(contentHeight)
457 p.contentViewPort.SetContent(contentFinal)
458
459 var contentHelp string
460 if p.supportsDiffView() {
461 contentHelp = help.New().View(p.keyMap)
462 }
463
464 // Calculate content height dynamically based on window size
465 strs := []string{
466 title,
467 "",
468 headerContent,
469 p.styleViewport(),
470 "",
471 buttons,
472 "",
473 }
474 if contentHelp != "" {
475 strs = append(strs, "", contentHelp)
476 }
477 content := lipgloss.JoinVertical(lipgloss.Top, strs...)
478
479 return baseStyle.
480 Padding(0, 1).
481 Border(lipgloss.RoundedBorder()).
482 BorderForeground(t.BorderFocus).
483 Width(p.width).
484 Render(
485 content,
486 )
487}
488
489func (p *permissionDialogCmp) View() string {
490 return p.render()
491}
492
493func (p *permissionDialogCmp) SetSize() tea.Cmd {
494 if p.permission.ID == "" {
495 return nil
496 }
497
498 oldWidth, oldHeight := p.width, p.height
499
500 switch p.permission.ToolName {
501 case tools.BashToolName:
502 p.width = int(float64(p.wWidth) * 0.8)
503 p.height = int(float64(p.wHeight) * 0.3)
504 case tools.EditToolName:
505 p.width = int(float64(p.wWidth) * 0.8)
506 p.height = int(float64(p.wHeight) * 0.8)
507 case tools.WriteToolName:
508 p.width = int(float64(p.wWidth) * 0.8)
509 p.height = int(float64(p.wHeight) * 0.8)
510 case tools.FetchToolName:
511 p.width = int(float64(p.wWidth) * 0.8)
512 p.height = int(float64(p.wHeight) * 0.3)
513 default:
514 p.width = int(float64(p.wWidth) * 0.7)
515 p.height = int(float64(p.wHeight) * 0.5)
516 }
517
518 // Mark content as dirty if size changed
519 if oldWidth != p.width || oldHeight != p.height {
520 p.contentDirty = true
521 }
522
523 return nil
524}
525
526func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
527 content, err := generator()
528 if err != nil {
529 return fmt.Sprintf("Error rendering markdown: %v", err)
530 }
531
532 return content
533}
534
535// ID implements PermissionDialogCmp.
536func (p *permissionDialogCmp) ID() dialogs.DialogID {
537 return PermissionsDialogID
538}
539
540// Position implements PermissionDialogCmp.
541func (p *permissionDialogCmp) Position() (int, int) {
542 row := (p.wHeight / 2) - 2 // Just a bit above the center
543 row -= p.height / 2
544 col := p.wWidth / 2
545 col -= p.width / 2
546 return row, col
547}