1package dialog
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/charmbracelet/bubbles/key"
8 "github.com/charmbracelet/bubbles/viewport"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/glamour"
11 "github.com/charmbracelet/lipgloss"
12 "github.com/kujtimiihoxha/opencode/internal/diff"
13 "github.com/kujtimiihoxha/opencode/internal/llm/tools"
14 "github.com/kujtimiihoxha/opencode/internal/permission"
15 "github.com/kujtimiihoxha/opencode/internal/tui/layout"
16 "github.com/kujtimiihoxha/opencode/internal/tui/styles"
17 "github.com/kujtimiihoxha/opencode/internal/tui/util"
18)
19
20type PermissionAction string
21
22// Permission responses
23const (
24 PermissionAllow PermissionAction = "allow"
25 PermissionAllowForSession PermissionAction = "allow_session"
26 PermissionDeny PermissionAction = "deny"
27)
28
29// PermissionResponseMsg represents the user's response to a permission request
30type PermissionResponseMsg struct {
31 Permission permission.PermissionRequest
32 Action PermissionAction
33}
34
35// PermissionDialogCmp interface for permission dialog component
36type PermissionDialogCmp interface {
37 tea.Model
38 layout.Bindings
39 SetPermissions(permission permission.PermissionRequest) tea.Cmd
40}
41
42type permissionsMapping struct {
43 Left key.Binding
44 Right key.Binding
45 EnterSpace key.Binding
46 Allow key.Binding
47 AllowSession key.Binding
48 Deny key.Binding
49 Tab key.Binding
50}
51
52var permissionsKeys = permissionsMapping{
53 Left: key.NewBinding(
54 key.WithKeys("left"),
55 key.WithHelp("←", "switch options"),
56 ),
57 Right: key.NewBinding(
58 key.WithKeys("right"),
59 key.WithHelp("→", "switch options"),
60 ),
61 EnterSpace: key.NewBinding(
62 key.WithKeys("enter", " "),
63 key.WithHelp("enter/space", "confirm"),
64 ),
65 Allow: key.NewBinding(
66 key.WithKeys("a"),
67 key.WithHelp("a", "allow"),
68 ),
69 AllowSession: key.NewBinding(
70 key.WithKeys("s"),
71 key.WithHelp("s", "allow for session"),
72 ),
73 Deny: key.NewBinding(
74 key.WithKeys("d"),
75 key.WithHelp("d", "deny"),
76 ),
77 Tab: key.NewBinding(
78 key.WithKeys("tab"),
79 key.WithHelp("tab", "switch options"),
80 ),
81}
82
83// permissionDialogCmp is the implementation of PermissionDialog
84type permissionDialogCmp struct {
85 width int
86 height int
87 permission permission.PermissionRequest
88 windowSize tea.WindowSizeMsg
89 contentViewPort viewport.Model
90 selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
91
92 diffCache map[string]string
93 markdownCache map[string]string
94}
95
96func (p *permissionDialogCmp) Init() tea.Cmd {
97 return p.contentViewPort.Init()
98}
99
100func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101 var cmds []tea.Cmd
102
103 switch msg := msg.(type) {
104 case tea.WindowSizeMsg:
105 p.windowSize = msg
106 cmd := p.SetSize()
107 cmds = append(cmds, cmd)
108 p.markdownCache = make(map[string]string)
109 p.diffCache = make(map[string]string)
110 case tea.KeyMsg:
111 switch {
112 case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
113 p.selectedOption = (p.selectedOption + 1) % 3
114 return p, nil
115 case key.Matches(msg, permissionsKeys.Left):
116 p.selectedOption = (p.selectedOption + 2) % 3
117 case key.Matches(msg, permissionsKeys.EnterSpace):
118 return p, p.selectCurrentOption()
119 case key.Matches(msg, permissionsKeys.Allow):
120 return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
121 case key.Matches(msg, permissionsKeys.AllowSession):
122 return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
123 case key.Matches(msg, permissionsKeys.Deny):
124 return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
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 util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
149}
150
151func (p *permissionDialogCmp) renderButtons() string {
152 allowStyle := styles.BaseStyle
153 allowSessionStyle := styles.BaseStyle
154 denyStyle := styles.BaseStyle
155 spacerStyle := styles.BaseStyle.Background(styles.Background)
156
157 // Style the selected button
158 switch p.selectedOption {
159 case 0:
160 allowStyle = allowStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
161 allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
162 denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
163 case 1:
164 allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
165 allowSessionStyle = allowSessionStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
166 denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
167 case 2:
168 allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
169 allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
170 denyStyle = denyStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
171 }
172
173 allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
174 allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
175 denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
176
177 content := lipgloss.JoinHorizontal(
178 lipgloss.Left,
179 allowButton,
180 spacerStyle.Render(" "),
181 allowSessionButton,
182 spacerStyle.Render(" "),
183 denyButton,
184 spacerStyle.Render(" "),
185 )
186
187 remainingWidth := p.width - lipgloss.Width(content)
188 if remainingWidth > 0 {
189 content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
190 }
191 return content
192}
193
194func (p *permissionDialogCmp) renderHeader() string {
195 toolKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Tool")
196 toolValue := styles.BaseStyle.
197 Foreground(styles.Forground).
198 Width(p.width - lipgloss.Width(toolKey)).
199 Render(fmt.Sprintf(": %s", p.permission.ToolName))
200
201 pathKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Path")
202 pathValue := styles.BaseStyle.
203 Foreground(styles.Forground).
204 Width(p.width - lipgloss.Width(pathKey)).
205 Render(fmt.Sprintf(": %s", p.permission.Path))
206
207 headerParts := []string{
208 lipgloss.JoinHorizontal(
209 lipgloss.Left,
210 toolKey,
211 toolValue,
212 ),
213 styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
214 lipgloss.JoinHorizontal(
215 lipgloss.Left,
216 pathKey,
217 pathValue,
218 ),
219 styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
220 }
221
222 // Add tool-specific header information
223 switch p.permission.ToolName {
224 case tools.BashToolName:
225 headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Command"))
226 case tools.EditToolName:
227 headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
228 case tools.WriteToolName:
229 headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
230 case tools.FetchToolName:
231 headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("URL"))
232 }
233
234 return lipgloss.NewStyle().Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
235}
236
237func (p *permissionDialogCmp) renderBashContent() string {
238 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
239 content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
240
241 // Use the cache for markdown rendering
242 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
243 r, _ := glamour.NewTermRenderer(
244 glamour.WithStyles(styles.MarkdownTheme(true)),
245 glamour.WithWordWrap(p.width-10),
246 )
247 s, err := r.Render(content)
248 return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
249 })
250
251 finalContent := styles.BaseStyle.
252 Width(p.contentViewPort.Width).
253 Render(renderedContent)
254 p.contentViewPort.SetContent(finalContent)
255 return p.styleViewport()
256 }
257 return ""
258}
259
260func (p *permissionDialogCmp) renderEditContent() string {
261 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
262 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
263 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
264 })
265
266 p.contentViewPort.SetContent(diff)
267 return p.styleViewport()
268 }
269 return ""
270}
271
272func (p *permissionDialogCmp) renderPatchContent() string {
273 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
274 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
275 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
276 })
277
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 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
288 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
289 })
290
291 p.contentViewPort.SetContent(diff)
292 return p.styleViewport()
293 }
294 return ""
295}
296
297func (p *permissionDialogCmp) renderFetchContent() string {
298 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
299 content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
300
301 // Use the cache for markdown rendering
302 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
303 r, _ := glamour.NewTermRenderer(
304 glamour.WithStyles(styles.MarkdownTheme(true)),
305 glamour.WithWordWrap(p.width-10),
306 )
307 s, err := r.Render(content)
308 return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
309 })
310
311 p.contentViewPort.SetContent(renderedContent)
312 return p.styleViewport()
313 }
314 return ""
315}
316
317func (p *permissionDialogCmp) renderDefaultContent() string {
318 content := p.permission.Description
319
320 // Use the cache for markdown rendering
321 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
322 r, _ := glamour.NewTermRenderer(
323 glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
324 glamour.WithWordWrap(p.width-10),
325 )
326 s, err := r.Render(content)
327 return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
328 })
329
330 p.contentViewPort.SetContent(renderedContent)
331
332 if renderedContent == "" {
333 return ""
334 }
335
336 return p.styleViewport()
337}
338
339func (p *permissionDialogCmp) styleViewport() string {
340 contentStyle := lipgloss.NewStyle().
341 Background(styles.Background)
342
343 return contentStyle.Render(p.contentViewPort.View())
344}
345
346func (p *permissionDialogCmp) render() string {
347 title := styles.BaseStyle.
348 Bold(true).
349 Width(p.width - 4).
350 Foreground(styles.PrimaryColor).
351 Render("Permission Required")
352 // Render header
353 headerContent := p.renderHeader()
354 // Render buttons
355 buttons := p.renderButtons()
356
357 // Calculate content height dynamically based on window size
358 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
359 p.contentViewPort.Width = p.width - 4
360
361 // Render content based on tool type
362 var contentFinal string
363 switch p.permission.ToolName {
364 case tools.BashToolName:
365 contentFinal = p.renderBashContent()
366 case tools.EditToolName:
367 contentFinal = p.renderEditContent()
368 case tools.PatchToolName:
369 contentFinal = p.renderPatchContent()
370 case tools.WriteToolName:
371 contentFinal = p.renderWriteContent()
372 case tools.FetchToolName:
373 contentFinal = p.renderFetchContent()
374 default:
375 contentFinal = p.renderDefaultContent()
376 }
377
378 content := lipgloss.JoinVertical(
379 lipgloss.Top,
380 title,
381 styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
382 headerContent,
383 contentFinal,
384 buttons,
385 styles.BaseStyle.Render(strings.Repeat(" ", p.width-4)),
386 )
387
388 return styles.BaseStyle.
389 Padding(1, 0, 0, 1).
390 Border(lipgloss.RoundedBorder()).
391 BorderBackground(styles.Background).
392 BorderForeground(styles.ForgroundDim).
393 Width(p.width).
394 Height(p.height).
395 Render(
396 content,
397 )
398}
399
400func (p *permissionDialogCmp) View() string {
401 return p.render()
402}
403
404func (p *permissionDialogCmp) BindingKeys() []key.Binding {
405 return layout.KeyMapToSlice(permissionsKeys)
406}
407
408func (p *permissionDialogCmp) SetSize() tea.Cmd {
409 if p.permission.ID == "" {
410 return nil
411 }
412 switch p.permission.ToolName {
413 case tools.BashToolName:
414 p.width = int(float64(p.windowSize.Width) * 0.4)
415 p.height = int(float64(p.windowSize.Height) * 0.3)
416 case tools.EditToolName:
417 p.width = int(float64(p.windowSize.Width) * 0.8)
418 p.height = int(float64(p.windowSize.Height) * 0.8)
419 case tools.WriteToolName:
420 p.width = int(float64(p.windowSize.Width) * 0.8)
421 p.height = int(float64(p.windowSize.Height) * 0.8)
422 case tools.FetchToolName:
423 p.width = int(float64(p.windowSize.Width) * 0.4)
424 p.height = int(float64(p.windowSize.Height) * 0.3)
425 default:
426 p.width = int(float64(p.windowSize.Width) * 0.7)
427 p.height = int(float64(p.windowSize.Height) * 0.5)
428 }
429 return nil
430}
431
432func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
433 p.permission = permission
434 return p.SetSize()
435}
436
437// Helper to get or set cached diff content
438func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
439 if cached, ok := c.diffCache[key]; ok {
440 return cached
441 }
442
443 content, err := generator()
444 if err != nil {
445 return fmt.Sprintf("Error formatting diff: %v", err)
446 }
447
448 c.diffCache[key] = content
449
450 return content
451}
452
453// Helper to get or set cached markdown content
454func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
455 if cached, ok := c.markdownCache[key]; ok {
456 return cached
457 }
458
459 content, err := generator()
460 if err != nil {
461 return fmt.Sprintf("Error rendering markdown: %v", err)
462 }
463
464 c.markdownCache[key] = content
465
466 return content
467}
468
469func NewPermissionDialogCmp() PermissionDialogCmp {
470 // Create viewport for content
471 contentViewport := viewport.New(0, 0)
472
473 return &permissionDialogCmp{
474 contentViewPort: contentViewport,
475 selectedOption: 0, // Default to "Allow"
476 diffCache: make(map[string]string),
477 markdownCache: make(map[string]string),
478 }
479}