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