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