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 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 p.diffSplitMode = !p.diffSplitMode
126 p.contentDirty = true // Mark content as dirty when diff mode changes
127 return p, nil
128 }
129 case key.Matches(msg, p.keyMap.ScrollDown):
130 if p.supportsDiffView() {
131 p.diffYOffset += 1
132 p.contentDirty = true // Mark content as dirty when scrolling
133 return p, nil
134 }
135 case key.Matches(msg, p.keyMap.ScrollUp):
136 if p.supportsDiffView() {
137 p.diffYOffset = max(0, 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.ScrollLeft):
142 if p.supportsDiffView() {
143 p.diffXOffset = max(0, p.diffXOffset-5)
144 p.contentDirty = true // Mark content as dirty when scrolling
145 return p, nil
146 }
147 case key.Matches(msg, p.keyMap.ScrollRight):
148 if p.supportsDiffView() {
149 p.diffXOffset += 5
150 p.contentDirty = true // Mark content as dirty when scrolling
151 return p, nil
152 }
153 default:
154 // Pass other keys to viewport
155 viewPort, cmd := p.contentViewPort.Update(msg)
156 p.contentViewPort = viewPort
157 cmds = append(cmds, cmd)
158 }
159 }
160
161 return p, tea.Batch(cmds...)
162}
163
164func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
165 var action PermissionAction
166
167 switch p.selectedOption {
168 case 0:
169 action = PermissionAllow
170 case 1:
171 action = PermissionAllowForSession
172 case 2:
173 action = PermissionDeny
174 }
175
176 return tea.Batch(
177 util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
178 util.CmdHandler(dialogs.CloseDialogMsg{}),
179 )
180}
181
182func (p *permissionDialogCmp) renderButtons() string {
183 t := styles.CurrentTheme()
184 baseStyle := t.S().Base
185
186 buttons := []core.ButtonOpts{
187 {
188 Text: "Allow",
189 UnderlineIndex: 0, // "A"
190 Selected: p.selectedOption == 0,
191 },
192 {
193 Text: "Allow for Session",
194 UnderlineIndex: 10, // "S" in "Session"
195 Selected: p.selectedOption == 1,
196 },
197 {
198 Text: "Deny",
199 UnderlineIndex: 0, // "D"
200 Selected: p.selectedOption == 2,
201 },
202 }
203
204 content := core.SelectableButtons(buttons, " ")
205 if lipgloss.Width(content) > p.width-4 {
206 content = core.SelectableButtonsVertical(buttons, 1)
207 return baseStyle.AlignVertical(lipgloss.Center).
208 AlignHorizontal(lipgloss.Center).
209 Width(p.width - 4).
210 Render(content)
211 }
212
213 return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
214}
215
216func (p *permissionDialogCmp) renderHeader() string {
217 t := styles.CurrentTheme()
218 baseStyle := t.S().Base
219
220 toolKey := t.S().Muted.Render("Tool")
221 toolValue := t.S().Text.
222 Width(p.width - lipgloss.Width(toolKey)).
223 Render(fmt.Sprintf(" %s", p.permission.ToolName))
224
225 pathKey := t.S().Muted.Render("Path")
226 pathValue := t.S().Text.
227 Width(p.width - lipgloss.Width(pathKey)).
228 Render(fmt.Sprintf(" %s", fsext.PrettyPath(p.permission.Path)))
229
230 headerParts := []string{
231 lipgloss.JoinHorizontal(
232 lipgloss.Left,
233 toolKey,
234 toolValue,
235 ),
236 baseStyle.Render(strings.Repeat(" ", p.width)),
237 lipgloss.JoinHorizontal(
238 lipgloss.Left,
239 pathKey,
240 pathValue,
241 ),
242 baseStyle.Render(strings.Repeat(" ", p.width)),
243 }
244
245 // Add tool-specific header information
246 switch p.permission.ToolName {
247 case tools.BashToolName:
248 headerParts = append(headerParts, t.S().Muted.Width(p.width).Render("Command"))
249 case tools.EditToolName:
250 params := p.permission.Params.(tools.EditPermissionsParams)
251 fileKey := t.S().Muted.Render("File")
252 filePath := t.S().Text.
253 Width(p.width - lipgloss.Width(fileKey)).
254 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
255 headerParts = append(headerParts,
256 lipgloss.JoinHorizontal(
257 lipgloss.Left,
258 fileKey,
259 filePath,
260 ),
261 baseStyle.Render(strings.Repeat(" ", p.width)),
262 )
263
264 case tools.WriteToolName:
265 params := p.permission.Params.(tools.WritePermissionsParams)
266 fileKey := t.S().Muted.Render("File")
267 filePath := t.S().Text.
268 Width(p.width - lipgloss.Width(fileKey)).
269 Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
270 headerParts = append(headerParts,
271 lipgloss.JoinHorizontal(
272 lipgloss.Left,
273 fileKey,
274 filePath,
275 ),
276 baseStyle.Render(strings.Repeat(" ", p.width)),
277 )
278 case tools.FetchToolName:
279 headerParts = append(headerParts, t.S().Muted.Width(p.width).Bold(true).Render("URL"))
280 }
281
282 return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
283}
284
285func (p *permissionDialogCmp) getOrGenerateContent() string {
286 // Return cached content if available and not dirty
287 if !p.contentDirty && p.cachedContent != "" {
288 return p.cachedContent
289 }
290
291 // Generate new content
292 var content string
293 switch p.permission.ToolName {
294 case tools.BashToolName:
295 content = p.generateBashContent()
296 case tools.EditToolName:
297 content = p.generateEditContent()
298 case tools.WriteToolName:
299 content = p.generateWriteContent()
300 case tools.FetchToolName:
301 content = p.generateFetchContent()
302 default:
303 content = p.generateDefaultContent()
304 }
305
306 // Cache the result
307 p.cachedContent = content
308 p.contentDirty = false
309
310 return content
311}
312
313func (p *permissionDialogCmp) generateBashContent() string {
314 t := styles.CurrentTheme()
315 baseStyle := t.S().Base.Background(t.BgSubtle)
316 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
317 content := pr.Command
318 t := styles.CurrentTheme()
319 content = strings.TrimSpace(content)
320 content = "\n" + content + "\n"
321 lines := strings.Split(content, "\n")
322
323 width := p.width - 4
324 var out []string
325 for _, ln := range lines {
326 ln = " " + ln // left padding
327 if len(ln) > width {
328 ln = ansi.Truncate(ln, width, "…")
329 }
330 out = append(out, t.S().Muted.
331 Width(width).
332 Foreground(t.FgBase).
333 Background(t.BgSubtle).
334 Render(ln))
335 }
336
337 // Use the cache for markdown rendering
338 renderedContent := strings.Join(out, "\n")
339 finalContent := baseStyle.
340 Width(p.contentViewPort.Width()).
341 Render(renderedContent)
342
343 return finalContent
344 }
345 return ""
346}
347
348func (p *permissionDialogCmp) generateEditContent() string {
349 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
350 formatter := core.DiffFormatter().
351 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
352 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
353 Height(p.contentViewPort.Height()).
354 Width(p.contentViewPort.Width()).
355 XOffset(p.diffXOffset).
356 YOffset(p.diffYOffset)
357 if p.diffSplitMode {
358 formatter = formatter.Split()
359 } else {
360 formatter = formatter.Unified()
361 }
362
363 diff := formatter.String()
364 return diff
365 }
366 return ""
367}
368
369func (p *permissionDialogCmp) generateWriteContent() string {
370 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
371 // Use the cache for diff rendering
372 formatter := core.DiffFormatter().
373 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
374 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
375 Height(p.contentViewPort.Height()).
376 Width(p.contentViewPort.Width()).
377 XOffset(p.diffXOffset).
378 YOffset(p.diffYOffset)
379 if p.diffSplitMode {
380 formatter = formatter.Split()
381 } else {
382 formatter = formatter.Unified()
383 }
384
385 diff := formatter.String()
386 return diff
387 }
388 return ""
389}
390
391func (p *permissionDialogCmp) generateFetchContent() string {
392 t := styles.CurrentTheme()
393 baseStyle := t.S().Base.Background(t.BgSubtle)
394 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
395 finalContent := baseStyle.
396 Padding(1, 2).
397 Width(p.contentViewPort.Width()).
398 Render(pr.URL)
399 return finalContent
400 }
401 return ""
402}
403
404func (p *permissionDialogCmp) generateDefaultContent() string {
405 t := styles.CurrentTheme()
406 baseStyle := t.S().Base.Background(t.BgSubtle)
407
408 content := p.permission.Description
409
410 content = strings.TrimSpace(content)
411 content = "\n" + content + "\n"
412 lines := strings.Split(content, "\n")
413
414 width := p.width - 4
415 var out []string
416 for _, ln := range lines {
417 ln = " " + ln // left padding
418 if len(ln) > width {
419 ln = ansi.Truncate(ln, width, "…")
420 }
421 out = append(out, t.S().Muted.
422 Width(width).
423 Foreground(t.FgBase).
424 Background(t.BgSubtle).
425 Render(ln))
426 }
427
428 // Use the cache for markdown rendering
429 renderedContent := strings.Join(out, "\n")
430 finalContent := baseStyle.
431 Width(p.contentViewPort.Width()).
432 Render(renderedContent)
433
434 if renderedContent == "" {
435 return ""
436 }
437
438 return finalContent
439}
440
441func (p *permissionDialogCmp) styleViewport() string {
442 t := styles.CurrentTheme()
443 return t.S().Base.Render(p.contentViewPort.View())
444}
445
446func (p *permissionDialogCmp) render() string {
447 t := styles.CurrentTheme()
448 baseStyle := t.S().Base
449 title := core.Title("Permission Required", p.width-4)
450 // Render header
451 headerContent := p.renderHeader()
452 // Render buttons
453 buttons := p.renderButtons()
454
455 p.contentViewPort.SetWidth(p.width - 4)
456
457 // Get cached or generate content
458 contentFinal := p.getOrGenerateContent()
459
460 // Always set viewport content (the caching is handled in getOrGenerateContent)
461 contentHeight := min(p.height-9, lipgloss.Height(contentFinal))
462 p.contentViewPort.SetHeight(contentHeight)
463 p.contentViewPort.SetContent(contentFinal)
464
465 p.positionRow = p.wHeight / 2
466 p.positionRow -= (contentHeight + 9) / 2
467 p.positionRow -= 3 // Move dialog slightly higher than middle
468
469 var contentHelp string
470 if p.supportsDiffView() {
471 contentHelp = help.New().View(p.keyMap)
472 }
473
474 // Calculate content height dynamically based on window size
475 strs := []string{
476 title,
477 "",
478 headerContent,
479 p.styleViewport(),
480 "",
481 buttons,
482 "",
483 }
484 if contentHelp != "" {
485 strs = append(strs, "", contentHelp)
486 }
487 content := lipgloss.JoinVertical(lipgloss.Top, strs...)
488
489 return baseStyle.
490 Padding(0, 1).
491 Border(lipgloss.RoundedBorder()).
492 BorderForeground(t.BorderFocus).
493 Width(p.width).
494 Render(
495 content,
496 )
497}
498
499func (p *permissionDialogCmp) View() string {
500 return p.render()
501}
502
503func (p *permissionDialogCmp) SetSize() tea.Cmd {
504 if p.permission.ID == "" {
505 return nil
506 }
507
508 oldWidth, oldHeight := p.width, p.height
509
510 switch p.permission.ToolName {
511 case tools.BashToolName:
512 p.width = int(float64(p.wWidth) * 0.8)
513 p.height = int(float64(p.wHeight) * 0.3)
514 case tools.EditToolName:
515 p.width = int(float64(p.wWidth) * 0.8)
516 p.height = int(float64(p.wHeight) * 0.8)
517 case tools.WriteToolName:
518 p.width = int(float64(p.wWidth) * 0.8)
519 p.height = int(float64(p.wHeight) * 0.8)
520 case tools.FetchToolName:
521 p.width = int(float64(p.wWidth) * 0.8)
522 p.height = int(float64(p.wHeight) * 0.3)
523 default:
524 p.width = int(float64(p.wWidth) * 0.7)
525 p.height = int(float64(p.wHeight) * 0.5)
526 }
527
528 // Mark content as dirty if size changed
529 if oldWidth != p.width || oldHeight != p.height {
530 p.contentDirty = true
531 }
532 p.positionRow = p.wHeight / 2
533 p.positionRow -= p.height / 2
534 p.positionRow -= 3 // Move dialog slightly higher than middle
535 p.positionCol = p.wWidth / 2
536 p.positionCol -= p.width / 2
537 return nil
538}
539
540func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
541 content, err := generator()
542 if err != nil {
543 return fmt.Sprintf("Error rendering markdown: %v", err)
544 }
545
546 return content
547}
548
549// ID implements PermissionDialogCmp.
550func (p *permissionDialogCmp) ID() dialogs.DialogID {
551 return PermissionsDialogID
552}
553
554// Position implements PermissionDialogCmp.
555func (p *permissionDialogCmp) Position() (int, int) {
556 return p.positionRow, p.positionCol
557}