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