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