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