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
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.FetchToolName:
309 headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
310 }
311
312 return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
313}
314
315func (p *permissionDialogCmp) getOrGenerateContent() string {
316 // Return cached content if available and not dirty
317 if !p.contentDirty && p.cachedContent != "" {
318 return p.cachedContent
319 }
320
321 // Generate new content
322 var content string
323 switch p.permission.ToolName {
324 case tools.BashToolName:
325 content = p.generateBashContent()
326 case tools.DownloadToolName:
327 content = p.generateDownloadContent()
328 case tools.EditToolName:
329 content = p.generateEditContent()
330 case tools.WriteToolName:
331 content = p.generateWriteContent()
332 case tools.FetchToolName:
333 content = p.generateFetchContent()
334 default:
335 content = p.generateDefaultContent()
336 }
337
338 // Cache the result
339 p.cachedContent = content
340 p.contentDirty = false
341
342 return content
343}
344
345func (p *permissionDialogCmp) generateBashContent() string {
346 t := styles.CurrentTheme()
347 baseStyle := t.S().Base.Background(t.BgSubtle)
348 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
349 content := pr.Command
350 t := styles.CurrentTheme()
351 content = strings.TrimSpace(content)
352 lines := strings.Split(content, "\n")
353
354 width := p.width - 4
355 var out []string
356 for _, ln := range lines {
357 out = append(out, t.S().Muted.
358 Width(width).
359 Padding(0, 3).
360 Foreground(t.FgBase).
361 Background(t.BgSubtle).
362 Render(ln))
363 }
364
365 // Use the cache for markdown rendering
366 renderedContent := strings.Join(out, "\n")
367 finalContent := baseStyle.
368 Width(p.contentViewPort.Width()).
369 Padding(1, 0).
370 Render(renderedContent)
371
372 return finalContent
373 }
374 return ""
375}
376
377func (p *permissionDialogCmp) generateEditContent() string {
378 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
379 formatter := core.DiffFormatter().
380 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
381 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
382 Height(p.contentViewPort.Height()).
383 Width(p.contentViewPort.Width()).
384 XOffset(p.diffXOffset).
385 YOffset(p.diffYOffset)
386 if p.useDiffSplitMode() {
387 formatter = formatter.Split()
388 } else {
389 formatter = formatter.Unified()
390 }
391
392 diff := formatter.String()
393 return diff
394 }
395 return ""
396}
397
398func (p *permissionDialogCmp) generateWriteContent() string {
399 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
400 // Use the cache for diff rendering
401 formatter := core.DiffFormatter().
402 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
403 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
404 Height(p.contentViewPort.Height()).
405 Width(p.contentViewPort.Width()).
406 XOffset(p.diffXOffset).
407 YOffset(p.diffYOffset)
408 if p.useDiffSplitMode() {
409 formatter = formatter.Split()
410 } else {
411 formatter = formatter.Unified()
412 }
413
414 diff := formatter.String()
415 return diff
416 }
417 return ""
418}
419
420func (p *permissionDialogCmp) generateDownloadContent() string {
421 t := styles.CurrentTheme()
422 baseStyle := t.S().Base.Background(t.BgSubtle)
423 if pr, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
424 content := fmt.Sprintf("URL: %s\nFile: %s", pr.URL, fsext.PrettyPath(pr.FilePath))
425 if pr.Timeout > 0 {
426 content += fmt.Sprintf("\nTimeout: %ds", pr.Timeout)
427 }
428
429 finalContent := baseStyle.
430 Padding(1, 2).
431 Width(p.contentViewPort.Width()).
432 Render(content)
433 return finalContent
434 }
435 return ""
436}
437
438func (p *permissionDialogCmp) generateFetchContent() string {
439 t := styles.CurrentTheme()
440 baseStyle := t.S().Base.Background(t.BgSubtle)
441 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
442 finalContent := baseStyle.
443 Padding(1, 2).
444 Width(p.contentViewPort.Width()).
445 Render(pr.URL)
446 return finalContent
447 }
448 return ""
449}
450
451func (p *permissionDialogCmp) generateDefaultContent() string {
452 t := styles.CurrentTheme()
453 baseStyle := t.S().Base.Background(t.BgSubtle)
454
455 content := p.permission.Description
456
457 content = strings.TrimSpace(content)
458 content = "\n" + content + "\n"
459 lines := strings.Split(content, "\n")
460
461 width := p.width - 4
462 var out []string
463 for _, ln := range lines {
464 ln = " " + ln // left padding
465 if len(ln) > width {
466 ln = ansi.Truncate(ln, width, "…")
467 }
468 out = append(out, t.S().Muted.
469 Width(width).
470 Foreground(t.FgBase).
471 Background(t.BgSubtle).
472 Render(ln))
473 }
474
475 // Use the cache for markdown rendering
476 renderedContent := strings.Join(out, "\n")
477 finalContent := baseStyle.
478 Width(p.contentViewPort.Width()).
479 Render(renderedContent)
480
481 if renderedContent == "" {
482 return ""
483 }
484
485 return finalContent
486}
487
488func (p *permissionDialogCmp) useDiffSplitMode() bool {
489 if p.diffSplitMode != nil {
490 return *p.diffSplitMode
491 } else {
492 return p.defaultDiffSplitMode
493 }
494}
495
496func (p *permissionDialogCmp) styleViewport() string {
497 t := styles.CurrentTheme()
498 return t.S().Base.Render(p.contentViewPort.View())
499}
500
501func (p *permissionDialogCmp) render() string {
502 t := styles.CurrentTheme()
503 baseStyle := t.S().Base
504 title := core.Title("Permission Required", p.width-4)
505 // Render header
506 headerContent := p.renderHeader()
507 // Render buttons
508 buttons := p.renderButtons()
509
510 p.contentViewPort.SetWidth(p.width - 4)
511
512 // Get cached or generate content
513 contentFinal := p.getOrGenerateContent()
514
515 // Always set viewport content (the caching is handled in getOrGenerateContent)
516 const minContentHeight = 9
517 contentHeight := min(
518 max(minContentHeight, p.height-minContentHeight),
519 lipgloss.Height(contentFinal),
520 )
521 p.contentViewPort.SetHeight(contentHeight)
522 p.contentViewPort.SetContent(contentFinal)
523
524 p.positionRow = p.wHeight / 2
525 p.positionRow -= (contentHeight + 9) / 2
526 p.positionRow -= 3 // Move dialog slightly higher than middle
527
528 var contentHelp string
529 if p.supportsDiffView() {
530 contentHelp = help.New().View(p.keyMap)
531 }
532
533 // Calculate content height dynamically based on window size
534 strs := []string{
535 title,
536 "",
537 headerContent,
538 p.styleViewport(),
539 "",
540 buttons,
541 "",
542 }
543 if contentHelp != "" {
544 strs = append(strs, "", contentHelp)
545 }
546 content := lipgloss.JoinVertical(lipgloss.Top, strs...)
547
548 return baseStyle.
549 Padding(0, 1).
550 Border(lipgloss.RoundedBorder()).
551 BorderForeground(t.BorderFocus).
552 Width(p.width).
553 Render(
554 content,
555 )
556}
557
558func (p *permissionDialogCmp) View() string {
559 return p.render()
560}
561
562func (p *permissionDialogCmp) SetSize() tea.Cmd {
563 if p.permission.ID == "" {
564 return nil
565 }
566
567 oldWidth, oldHeight := p.width, p.height
568
569 switch p.permission.ToolName {
570 case tools.BashToolName:
571 p.width = int(float64(p.wWidth) * 0.8)
572 p.height = int(float64(p.wHeight) * 0.3)
573 case tools.DownloadToolName:
574 p.width = int(float64(p.wWidth) * 0.8)
575 p.height = int(float64(p.wHeight) * 0.4)
576 case tools.EditToolName:
577 p.width = int(float64(p.wWidth) * 0.8)
578 p.height = int(float64(p.wHeight) * 0.8)
579 case tools.WriteToolName:
580 p.width = int(float64(p.wWidth) * 0.8)
581 p.height = int(float64(p.wHeight) * 0.8)
582 case tools.FetchToolName:
583 p.width = int(float64(p.wWidth) * 0.8)
584 p.height = int(float64(p.wHeight) * 0.3)
585 default:
586 p.width = int(float64(p.wWidth) * 0.7)
587 p.height = int(float64(p.wHeight) * 0.5)
588 }
589
590 // Default to diff split mode when dialog is wide enough.
591 p.defaultDiffSplitMode = p.width >= 140
592
593 // Set a maximum width for the dialog
594 p.width = min(p.width, 180)
595
596 // Mark content as dirty if size changed
597 if oldWidth != p.width || oldHeight != p.height {
598 p.contentDirty = true
599 }
600 p.positionRow = p.wHeight / 2
601 p.positionRow -= p.height / 2
602 p.positionRow -= 3 // Move dialog slightly higher than middle
603 p.positionCol = p.wWidth / 2
604 p.positionCol -= p.width / 2
605 return nil
606}
607
608func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
609 content, err := generator()
610 if err != nil {
611 return fmt.Sprintf("Error rendering markdown: %v", err)
612 }
613
614 return content
615}
616
617// ID implements PermissionDialogCmp.
618func (p *permissionDialogCmp) ID() dialogs.DialogID {
619 return PermissionsDialogID
620}
621
622// Position implements PermissionDialogCmp.
623func (p *permissionDialogCmp) Position() (int, int) {
624 return p.positionRow, p.positionCol
625}