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 finalContent := baseStyle.
386 Padding(1, 1).
387 Width(p.contentViewPort.Width()).
388 Render(pr.URL)
389 return finalContent
390 }
391 return ""
392}
393
394func (p *permissionDialogCmp) generateDefaultContent() string {
395 t := styles.CurrentTheme()
396 baseStyle := t.S().Base.Background(t.BgSubtle)
397
398 content := p.permission.Description
399
400 // Use the cache for markdown rendering
401 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
402 r := styles.GetMarkdownRenderer(p.width - 4)
403 s, err := r.Render(content)
404 return s, err
405 })
406
407 finalContent := baseStyle.
408 Width(p.contentViewPort.Width()).
409 Render(renderedContent)
410
411 if renderedContent == "" {
412 return ""
413 }
414
415 return finalContent
416}
417
418func (p *permissionDialogCmp) styleViewport() string {
419 t := styles.CurrentTheme()
420 return t.S().Base.Render(p.contentViewPort.View())
421}
422
423func (p *permissionDialogCmp) render() string {
424 t := styles.CurrentTheme()
425 baseStyle := t.S().Base
426 title := core.Title("Permission Required", p.width-4)
427 // Render header
428 headerContent := p.renderHeader()
429 // Render buttons
430 buttons := p.renderButtons()
431
432 p.contentViewPort.SetWidth(p.width - 4)
433
434 // Get cached or generate content
435 contentFinal := p.getOrGenerateContent()
436
437 // Always set viewport content (the caching is handled in getOrGenerateContent)
438 contentHeight := min(p.height-9, lipgloss.Height(contentFinal))
439 p.contentViewPort.SetHeight(contentHeight)
440 p.contentViewPort.SetContent(contentFinal)
441
442 var contentHelp string
443 if p.supportsDiffView() {
444 contentHelp = help.New().View(p.keyMap)
445 }
446
447 // Calculate content height dynamically based on window size
448 strs := []string{
449 title,
450 "",
451 headerContent,
452 p.styleViewport(),
453 "",
454 buttons,
455 "",
456 }
457 if contentHelp != "" {
458 strs = append(strs, "", contentHelp)
459 }
460 content := lipgloss.JoinVertical(lipgloss.Top, strs...)
461
462 return baseStyle.
463 Padding(0, 1).
464 Border(lipgloss.RoundedBorder()).
465 BorderForeground(t.BorderFocus).
466 Width(p.width).
467 Render(
468 content,
469 )
470}
471
472func (p *permissionDialogCmp) View() string {
473 return p.render()
474}
475
476func (p *permissionDialogCmp) SetSize() tea.Cmd {
477 if p.permission.ID == "" {
478 return nil
479 }
480
481 oldWidth, oldHeight := p.width, p.height
482
483 switch p.permission.ToolName {
484 case tools.BashToolName:
485 p.width = int(float64(p.wWidth) * 0.4)
486 p.height = int(float64(p.wHeight) * 0.3)
487 case tools.EditToolName:
488 p.width = int(float64(p.wWidth) * 0.8)
489 p.height = int(float64(p.wHeight) * 0.8)
490 case tools.WriteToolName:
491 p.width = int(float64(p.wWidth) * 0.8)
492 p.height = int(float64(p.wHeight) * 0.8)
493 case tools.FetchToolName:
494 p.width = int(float64(p.wWidth) * 0.4)
495 p.height = int(float64(p.wHeight) * 0.3)
496 default:
497 p.width = int(float64(p.wWidth) * 0.7)
498 p.height = int(float64(p.wHeight) * 0.5)
499 }
500
501 // Mark content as dirty if size changed
502 if oldWidth != p.width || oldHeight != p.height {
503 p.contentDirty = true
504 }
505
506 return nil
507}
508
509func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
510 content, err := generator()
511 if err != nil {
512 return fmt.Sprintf("Error rendering markdown: %v", err)
513 }
514
515 return content
516}
517
518// ID implements PermissionDialogCmp.
519func (p *permissionDialogCmp) ID() dialogs.DialogID {
520 return PermissionsDialogID
521}
522
523// Position implements PermissionDialogCmp.
524func (p *permissionDialogCmp) Position() (int, int) {
525 row := (p.wHeight / 2) - 2 // Just a bit above the center
526 row -= p.height / 2
527 col := p.wWidth / 2
528 col -= p.width / 2
529 return row, col
530}