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