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/lipgloss"
11 "github.com/opencode-ai/opencode/internal/diff"
12 "github.com/opencode-ai/opencode/internal/llm/tools"
13 "github.com/opencode-ai/opencode/internal/permission"
14 "github.com/opencode-ai/opencode/internal/tui/layout"
15 "github.com/opencode-ai/opencode/internal/tui/styles"
16 "github.com/opencode-ai/opencode/internal/tui/theme"
17 "github.com/opencode-ai/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 t := theme.CurrentTheme()
153 baseStyle := styles.BaseStyle()
154
155 allowStyle := baseStyle
156 allowSessionStyle := baseStyle
157 denyStyle := baseStyle
158 spacerStyle := baseStyle.Background(t.Background())
159
160 // Style the selected button
161 switch p.selectedOption {
162 case 0:
163 allowStyle = allowStyle.Background(t.Primary()).Foreground(t.Background())
164 allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
165 denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
166 case 1:
167 allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
168 allowSessionStyle = allowSessionStyle.Background(t.Primary()).Foreground(t.Background())
169 denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
170 case 2:
171 allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
172 allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
173 denyStyle = denyStyle.Background(t.Primary()).Foreground(t.Background())
174 }
175
176 allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
177 allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
178 denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
179
180 content := lipgloss.JoinHorizontal(
181 lipgloss.Left,
182 allowButton,
183 spacerStyle.Render(" "),
184 allowSessionButton,
185 spacerStyle.Render(" "),
186 denyButton,
187 spacerStyle.Render(" "),
188 )
189
190 remainingWidth := p.width - lipgloss.Width(content)
191 if remainingWidth > 0 {
192 content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
193 }
194 return content
195}
196
197func (p *permissionDialogCmp) renderHeader() string {
198 t := theme.CurrentTheme()
199 baseStyle := styles.BaseStyle()
200
201 toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
202 toolValue := baseStyle.
203 Foreground(t.Text()).
204 Width(p.width - lipgloss.Width(toolKey)).
205 Render(fmt.Sprintf(": %s", p.permission.ToolName))
206
207 pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
208 pathValue := baseStyle.
209 Foreground(t.Text()).
210 Width(p.width - lipgloss.Width(pathKey)).
211 Render(fmt.Sprintf(": %s", p.permission.Path))
212
213 headerParts := []string{
214 lipgloss.JoinHorizontal(
215 lipgloss.Left,
216 toolKey,
217 toolValue,
218 ),
219 baseStyle.Render(strings.Repeat(" ", p.width)),
220 lipgloss.JoinHorizontal(
221 lipgloss.Left,
222 pathKey,
223 pathValue,
224 ),
225 baseStyle.Render(strings.Repeat(" ", p.width)),
226 }
227
228 // Add tool-specific header information
229 switch p.permission.ToolName {
230 case tools.BashToolName:
231 headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
232 case tools.EditToolName:
233 headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
234 case tools.WriteToolName:
235 headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
236 case tools.FetchToolName:
237 headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
238 }
239
240 return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
241}
242
243func (p *permissionDialogCmp) renderBashContent() string {
244 t := theme.CurrentTheme()
245 baseStyle := styles.BaseStyle()
246
247 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
248 content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
249
250 // Use the cache for markdown rendering
251 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
252 r := styles.GetMarkdownRenderer(p.width-10)
253 s, err := r.Render(content)
254 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
255 })
256
257 finalContent := baseStyle.
258 Width(p.contentViewPort.Width).
259 Render(renderedContent)
260 p.contentViewPort.SetContent(finalContent)
261 return p.styleViewport()
262 }
263 return ""
264}
265
266func (p *permissionDialogCmp) renderEditContent() string {
267 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
268 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
269 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
270 })
271
272 p.contentViewPort.SetContent(diff)
273 return p.styleViewport()
274 }
275 return ""
276}
277
278func (p *permissionDialogCmp) renderPatchContent() string {
279 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
280 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
281 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
282 })
283
284 p.contentViewPort.SetContent(diff)
285 return p.styleViewport()
286 }
287 return ""
288}
289
290func (p *permissionDialogCmp) renderWriteContent() string {
291 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
292 // Use the cache for diff rendering
293 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
294 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
295 })
296
297 p.contentViewPort.SetContent(diff)
298 return p.styleViewport()
299 }
300 return ""
301}
302
303func (p *permissionDialogCmp) renderFetchContent() string {
304 t := theme.CurrentTheme()
305 baseStyle := styles.BaseStyle()
306
307 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
308 content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
309
310 // Use the cache for markdown rendering
311 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
312 r := styles.GetMarkdownRenderer(p.width-10)
313 s, err := r.Render(content)
314 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
315 })
316
317 finalContent := baseStyle.
318 Width(p.contentViewPort.Width).
319 Render(renderedContent)
320 p.contentViewPort.SetContent(finalContent)
321 return p.styleViewport()
322 }
323 return ""
324}
325
326func (p *permissionDialogCmp) renderDefaultContent() string {
327 t := theme.CurrentTheme()
328 baseStyle := styles.BaseStyle()
329
330 content := p.permission.Description
331
332 // Use the cache for markdown rendering
333 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
334 r := styles.GetMarkdownRenderer(p.width-10)
335 s, err := r.Render(content)
336 return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
337 })
338
339 finalContent := baseStyle.
340 Width(p.contentViewPort.Width).
341 Render(renderedContent)
342 p.contentViewPort.SetContent(finalContent)
343
344 if renderedContent == "" {
345 return ""
346 }
347
348 return p.styleViewport()
349}
350
351func (p *permissionDialogCmp) styleViewport() string {
352 t := theme.CurrentTheme()
353 contentStyle := lipgloss.NewStyle().
354 Background(t.Background())
355
356 return contentStyle.Render(p.contentViewPort.View())
357}
358
359func (p *permissionDialogCmp) render() string {
360 t := theme.CurrentTheme()
361 baseStyle := styles.BaseStyle()
362
363 title := baseStyle.
364 Bold(true).
365 Width(p.width - 4).
366 Foreground(t.Primary()).
367 Render("Permission Required")
368 // Render header
369 headerContent := p.renderHeader()
370 // Render buttons
371 buttons := p.renderButtons()
372
373 // Calculate content height dynamically based on window size
374 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
375 p.contentViewPort.Width = 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
394 content := lipgloss.JoinVertical(
395 lipgloss.Top,
396 title,
397 baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
398 headerContent,
399 contentFinal,
400 buttons,
401 baseStyle.Render(strings.Repeat(" ", p.width-4)),
402 )
403
404 return baseStyle.
405 Padding(1, 0, 0, 1).
406 Border(lipgloss.RoundedBorder()).
407 BorderBackground(t.Background()).
408 BorderForeground(t.TextMuted()).
409 Width(p.width).
410 Height(p.height).
411 Render(
412 content,
413 )
414}
415
416func (p *permissionDialogCmp) View() string {
417 return p.render()
418}
419
420func (p *permissionDialogCmp) BindingKeys() []key.Binding {
421 return layout.KeyMapToSlice(permissionsKeys)
422}
423
424func (p *permissionDialogCmp) SetSize() tea.Cmd {
425 if p.permission.ID == "" {
426 return nil
427 }
428 switch p.permission.ToolName {
429 case tools.BashToolName:
430 p.width = int(float64(p.windowSize.Width) * 0.4)
431 p.height = int(float64(p.windowSize.Height) * 0.3)
432 case tools.EditToolName:
433 p.width = int(float64(p.windowSize.Width) * 0.8)
434 p.height = int(float64(p.windowSize.Height) * 0.8)
435 case tools.WriteToolName:
436 p.width = int(float64(p.windowSize.Width) * 0.8)
437 p.height = int(float64(p.windowSize.Height) * 0.8)
438 case tools.FetchToolName:
439 p.width = int(float64(p.windowSize.Width) * 0.4)
440 p.height = int(float64(p.windowSize.Height) * 0.3)
441 default:
442 p.width = int(float64(p.windowSize.Width) * 0.7)
443 p.height = int(float64(p.windowSize.Height) * 0.5)
444 }
445 return nil
446}
447
448func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
449 p.permission = permission
450 return p.SetSize()
451}
452
453// Helper to get or set cached diff content
454func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
455 if cached, ok := c.diffCache[key]; ok {
456 return cached
457 }
458
459 content, err := generator()
460 if err != nil {
461 return fmt.Sprintf("Error formatting diff: %v", err)
462 }
463
464 c.diffCache[key] = content
465
466 return content
467}
468
469// Helper to get or set cached markdown content
470func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
471 if cached, ok := c.markdownCache[key]; ok {
472 return cached
473 }
474
475 content, err := generator()
476 if err != nil {
477 return fmt.Sprintf("Error rendering markdown: %v", err)
478 }
479
480 c.markdownCache[key] = content
481
482 return content
483}
484
485func NewPermissionDialogCmp() PermissionDialogCmp {
486 // Create viewport for content
487 contentViewport := viewport.New(0, 0)
488
489 return &permissionDialogCmp{
490 contentViewPort: contentViewport,
491 selectedOption: 0, // Default to "Allow"
492 diffCache: make(map[string]string),
493 markdownCache: make(map[string]string),
494 }
495}