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