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