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) getOrGenerateContent() string {
277 // Return cached content if available and not dirty
278 if !p.contentDirty && p.cachedContent != "" {
279 return p.cachedContent
280 }
281
282 // Generate new content
283 var content string
284 switch p.permission.ToolName {
285 case tools.BashToolName:
286 content = p.generateBashContent()
287 case tools.EditToolName:
288 content = p.generateEditContent()
289 case tools.WriteToolName:
290 content = p.generateWriteContent()
291 case tools.FetchToolName:
292 content = p.generateFetchContent()
293 default:
294 content = p.generateDefaultContent()
295 }
296
297 // Cache the result
298 p.cachedContent = content
299 p.contentDirty = false
300
301 return content
302}
303
304func (p *permissionDialogCmp) generateBashContent() string {
305 t := styles.CurrentTheme()
306 baseStyle := t.S().Base.Background(t.BgSubtle)
307 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
308 content := pr.Command
309 t := styles.CurrentTheme()
310 content = strings.TrimSpace(content)
311 content = "\n" + content + "\n"
312 lines := strings.Split(content, "\n")
313
314 width := p.width - 4
315 var out []string
316 for _, ln := range lines {
317 ln = " " + ln // left padding
318 if len(ln) > width {
319 ln = ansi.Truncate(ln, width, "…")
320 }
321 out = append(out, t.S().Muted.
322 Width(width).
323 Foreground(t.FgBase).
324 Background(t.BgSubtle).
325 Render(ln))
326 }
327
328 // Use the cache for markdown rendering
329 renderedContent := strings.Join(out, "\n")
330 finalContent := baseStyle.
331 Width(p.contentViewPort.Width()).
332 Render(renderedContent)
333
334 return finalContent
335 }
336 return ""
337}
338
339func (p *permissionDialogCmp) generateEditContent() string {
340 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
341 formatter := core.DiffFormatter().
342 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
343 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
344 Height(p.contentViewPort.Height()).
345 Width(p.contentViewPort.Width()).
346 XOffset(p.diffXOffset).
347 YOffset(p.diffYOffset)
348 if p.diffSplitMode {
349 formatter = formatter.Split()
350 } else {
351 formatter = formatter.Unified()
352 }
353
354 diff := formatter.String()
355 return diff
356 }
357 return ""
358}
359
360func (p *permissionDialogCmp) generateWriteContent() string {
361 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
362 // Use the cache for diff rendering
363 formatter := core.DiffFormatter().
364 Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
365 After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
366 Height(p.contentViewPort.Height()).
367 Width(p.contentViewPort.Width()).
368 XOffset(p.diffXOffset).
369 YOffset(p.diffYOffset)
370 if p.diffSplitMode {
371 formatter = formatter.Split()
372 } else {
373 formatter = formatter.Unified()
374 }
375
376 diff := formatter.String()
377 return diff
378 }
379 return ""
380}
381
382func (p *permissionDialogCmp) generateFetchContent() string {
383 t := styles.CurrentTheme()
384 baseStyle := t.S().Base.Background(t.BgSubtle)
385 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
386 content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
387
388 // Use the cache for markdown rendering
389 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
390 r := styles.GetMarkdownRenderer(p.width - 4)
391 s, err := r.Render(content)
392 return s, err
393 })
394
395 finalContent := baseStyle.
396 Width(p.contentViewPort.Width()).
397 Render(renderedContent)
398
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 // Use the cache for markdown rendering
411 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
412 r := styles.GetMarkdownRenderer(p.width - 4)
413 s, err := r.Render(content)
414 return s, err
415 })
416
417 finalContent := baseStyle.
418 Width(p.contentViewPort.Width()).
419 Render(renderedContent)
420
421 if renderedContent == "" {
422 return ""
423 }
424
425 return finalContent
426}
427
428func (p *permissionDialogCmp) styleViewport() string {
429 t := styles.CurrentTheme()
430 return t.S().Base.Render(p.contentViewPort.View())
431}
432
433func (p *permissionDialogCmp) render() string {
434 t := styles.CurrentTheme()
435 baseStyle := t.S().Base
436 title := core.Title("Permission Required", p.width-4)
437 // Render header
438 headerContent := p.renderHeader()
439 // Render buttons
440 buttons := p.renderButtons()
441
442 p.contentViewPort.SetWidth(p.width - 4)
443
444 // Get cached or generate content
445 contentFinal := p.getOrGenerateContent()
446
447 // Always set viewport content (the caching is handled in getOrGenerateContent)
448 contentHeight := min(p.height-9, lipgloss.Height(contentFinal))
449 p.contentViewPort.SetHeight(contentHeight)
450 p.contentViewPort.SetContent(contentFinal)
451
452 var contentHelp string
453 if p.supportsDiffView() {
454 contentHelp = help.New().View(p.keyMap)
455 }
456 // Calculate content height dynamically based on window size
457
458 strs := []string{
459 title,
460 "",
461 headerContent,
462 p.styleViewport(),
463 "",
464 buttons,
465 "",
466 }
467 if contentHelp != "" {
468 strs = append(strs, "", contentHelp)
469 }
470 content := lipgloss.JoinVertical(lipgloss.Top, strs...)
471
472 return baseStyle.
473 Padding(0, 1).
474 Border(lipgloss.RoundedBorder()).
475 BorderForeground(t.BorderFocus).
476 Width(p.width).
477 Render(
478 content,
479 )
480}
481
482func (p *permissionDialogCmp) View() tea.View {
483 return tea.NewView(p.render())
484}
485
486func (p *permissionDialogCmp) SetSize() tea.Cmd {
487 if p.permission.ID == "" {
488 return nil
489 }
490
491 oldWidth, oldHeight := p.width, p.height
492
493 switch p.permission.ToolName {
494 case tools.BashToolName:
495 p.width = int(float64(p.wWidth) * 0.4)
496 p.height = int(float64(p.wHeight) * 0.3)
497 case tools.EditToolName:
498 p.width = int(float64(p.wWidth) * 0.8)
499 p.height = int(float64(p.wHeight) * 0.8)
500 case tools.WriteToolName:
501 p.width = int(float64(p.wWidth) * 0.8)
502 p.height = int(float64(p.wHeight) * 0.8)
503 case tools.FetchToolName:
504 p.width = int(float64(p.wWidth) * 0.4)
505 p.height = int(float64(p.wHeight) * 0.3)
506 default:
507 p.width = int(float64(p.wWidth) * 0.7)
508 p.height = int(float64(p.wHeight) * 0.5)
509 }
510
511 // Mark content as dirty if size changed
512 if oldWidth != p.width || oldHeight != p.height {
513 p.contentDirty = true
514 }
515
516 return nil
517}
518
519func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
520 content, err := generator()
521 if err != nil {
522 return fmt.Sprintf("Error rendering markdown: %v", err)
523 }
524
525 return content
526}
527
528// ID implements PermissionDialogCmp.
529func (p *permissionDialogCmp) ID() dialogs.DialogID {
530 return PermissionsDialogID
531}
532
533// Position implements PermissionDialogCmp.
534func (p *permissionDialogCmp) Position() (int, int) {
535 row := (p.wHeight / 2) - 2 // Just a bit above the center
536 row -= p.height / 2
537 col := p.wWidth / 2
538 col -= p.width / 2
539 return row, col
540}