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