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