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 content = strings.TrimSpace(content)
413 content = "\n" + content + "\n"
414 lines := strings.Split(content, "\n")
415
416 width := p.width - 4
417 var out []string
418 for _, ln := range lines {
419 ln = " " + ln // left padding
420 if len(ln) > width {
421 ln = ansi.Truncate(ln, width, "…")
422 }
423 out = append(out, t.S().Muted.
424 Width(width).
425 Foreground(t.FgBase).
426 Background(t.BgSubtle).
427 Render(ln))
428 }
429
430 // Use the cache for markdown rendering
431 renderedContent := strings.Join(out, "\n")
432 finalContent := baseStyle.
433 Width(p.contentViewPort.Width()).
434 Render(renderedContent)
435
436 if renderedContent == "" {
437 return ""
438 }
439
440 return finalContent
441}
442
443func (p *permissionDialogCmp) useDiffSplitMode() bool {
444 if p.diffSplitMode != nil {
445 return *p.diffSplitMode
446 } else {
447 return p.defaultDiffSplitMode
448 }
449}
450
451func (p *permissionDialogCmp) styleViewport() string {
452 t := styles.CurrentTheme()
453 return t.S().Base.Render(p.contentViewPort.View())
454}
455
456func (p *permissionDialogCmp) render() string {
457 t := styles.CurrentTheme()
458 baseStyle := t.S().Base
459 title := core.Title("Permission Required", p.width-4)
460 // Render header
461 headerContent := p.renderHeader()
462 // Render buttons
463 buttons := p.renderButtons()
464
465 p.contentViewPort.SetWidth(p.width - 4)
466
467 // Get cached or generate content
468 contentFinal := p.getOrGenerateContent()
469
470 // Always set viewport content (the caching is handled in getOrGenerateContent)
471 const minContentHeight = 9
472 contentHeight := min(
473 max(minContentHeight, p.height-minContentHeight),
474 lipgloss.Height(contentFinal),
475 )
476 p.contentViewPort.SetHeight(contentHeight)
477 p.contentViewPort.SetContent(contentFinal)
478
479 p.positionRow = p.wHeight / 2
480 p.positionRow -= (contentHeight + 9) / 2
481 p.positionRow -= 3 // Move dialog slightly higher than middle
482
483 var contentHelp string
484 if p.supportsDiffView() {
485 contentHelp = help.New().View(p.keyMap)
486 }
487
488 // Calculate content height dynamically based on window size
489 strs := []string{
490 title,
491 "",
492 headerContent,
493 p.styleViewport(),
494 "",
495 buttons,
496 "",
497 }
498 if contentHelp != "" {
499 strs = append(strs, "", contentHelp)
500 }
501 content := lipgloss.JoinVertical(lipgloss.Top, strs...)
502
503 return baseStyle.
504 Padding(0, 1).
505 Border(lipgloss.RoundedBorder()).
506 BorderForeground(t.BorderFocus).
507 Width(p.width).
508 Render(
509 content,
510 )
511}
512
513func (p *permissionDialogCmp) View() string {
514 return p.render()
515}
516
517func (p *permissionDialogCmp) SetSize() tea.Cmd {
518 if p.permission.ID == "" {
519 return nil
520 }
521
522 oldWidth, oldHeight := p.width, p.height
523
524 switch p.permission.ToolName {
525 case tools.BashToolName:
526 p.width = int(float64(p.wWidth) * 0.8)
527 p.height = int(float64(p.wHeight) * 0.3)
528 case tools.EditToolName:
529 p.width = int(float64(p.wWidth) * 0.8)
530 p.height = int(float64(p.wHeight) * 0.8)
531 case tools.WriteToolName:
532 p.width = int(float64(p.wWidth) * 0.8)
533 p.height = int(float64(p.wHeight) * 0.8)
534 case tools.FetchToolName:
535 p.width = int(float64(p.wWidth) * 0.8)
536 p.height = int(float64(p.wHeight) * 0.3)
537 default:
538 p.width = int(float64(p.wWidth) * 0.7)
539 p.height = int(float64(p.wHeight) * 0.5)
540 }
541
542 // Default to diff split mode when dialog is wide enough.
543 p.defaultDiffSplitMode = p.width >= 140
544
545 // Set a maximum width for the dialog
546 p.width = min(p.width, 180)
547
548 // Mark content as dirty if size changed
549 if oldWidth != p.width || oldHeight != p.height {
550 p.contentDirty = true
551 }
552 p.positionRow = p.wHeight / 2
553 p.positionRow -= p.height / 2
554 p.positionRow -= 3 // Move dialog slightly higher than middle
555 p.positionCol = p.wWidth / 2
556 p.positionCol -= p.width / 2
557 return nil
558}
559
560func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
561 content, err := generator()
562 if err != nil {
563 return fmt.Sprintf("Error rendering markdown: %v", err)
564 }
565
566 return content
567}
568
569// ID implements PermissionDialogCmp.
570func (p *permissionDialogCmp) ID() dialogs.DialogID {
571 return PermissionsDialogID
572}
573
574// Position implements PermissionDialogCmp.
575func (p *permissionDialogCmp) Position() (int, int) {
576 return p.positionRow, p.positionCol
577}