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