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 // 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 p.positionRow = p.wHeight / 2
453 p.positionRow -= (contentHeight + 9) / 2
454 p.positionRow -= 3 // Move dialog slightly higher than middle
455
456 var contentHelp string
457 if p.supportsDiffView() {
458 contentHelp = help.New().View(p.keyMap)
459 }
460
461 // Calculate content height dynamically based on window size
462 strs := []string{
463 title,
464 "",
465 headerContent,
466 p.styleViewport(),
467 "",
468 buttons,
469 "",
470 }
471 if contentHelp != "" {
472 strs = append(strs, "", contentHelp)
473 }
474 content := lipgloss.JoinVertical(lipgloss.Top, strs...)
475
476 return baseStyle.
477 Padding(0, 1).
478 Border(lipgloss.RoundedBorder()).
479 BorderForeground(t.BorderFocus).
480 Width(p.width).
481 Render(
482 content,
483 )
484}
485
486func (p *permissionDialogCmp) View() string {
487 return p.render()
488}
489
490func (p *permissionDialogCmp) SetSize() tea.Cmd {
491 if p.permission.ID == "" {
492 return nil
493 }
494
495 oldWidth, oldHeight := p.width, p.height
496
497 switch p.permission.ToolName {
498 case tools.BashToolName:
499 p.width = int(float64(p.wWidth) * 0.8)
500 p.height = int(float64(p.wHeight) * 0.3)
501 case tools.EditToolName:
502 p.width = int(float64(p.wWidth) * 0.8)
503 p.height = int(float64(p.wHeight) * 0.8)
504 case tools.WriteToolName:
505 p.width = int(float64(p.wWidth) * 0.8)
506 p.height = int(float64(p.wHeight) * 0.8)
507 case tools.FetchToolName:
508 p.width = int(float64(p.wWidth) * 0.8)
509 p.height = int(float64(p.wHeight) * 0.3)
510 default:
511 p.width = int(float64(p.wWidth) * 0.7)
512 p.height = int(float64(p.wHeight) * 0.5)
513 }
514
515 // Mark content as dirty if size changed
516 if oldWidth != p.width || oldHeight != p.height {
517 p.contentDirty = true
518 }
519 p.positionRow = p.wHeight / 2
520 p.positionRow -= p.height / 2
521 p.positionRow -= 3 // Move dialog slightly higher than middle
522 p.positionCol = p.wWidth / 2
523 p.positionCol -= p.width / 2
524 return nil
525}
526
527func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
528 content, err := generator()
529 if err != nil {
530 return fmt.Sprintf("Error rendering markdown: %v", err)
531 }
532
533 return content
534}
535
536// ID implements PermissionDialogCmp.
537func (p *permissionDialogCmp) ID() dialogs.DialogID {
538 return PermissionsDialogID
539}
540
541// Position implements PermissionDialogCmp.
542func (p *permissionDialogCmp) Position() (int, int) {
543 return p.positionRow, p.positionCol
544}