1package dialog
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/lipgloss/v2"
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 util.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.KeyPressMsg:
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 params := p.permission.Params.(tools.EditPermissionsParams)
234 fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
235 filePath := baseStyle.
236 Foreground(t.Text()).
237 Width(p.width - lipgloss.Width(fileKey)).
238 Render(fmt.Sprintf(": %s", params.FilePath))
239 headerParts = append(headerParts,
240 lipgloss.JoinHorizontal(
241 lipgloss.Left,
242 fileKey,
243 filePath,
244 ),
245 baseStyle.Render(strings.Repeat(" ", p.width)),
246 )
247
248 case tools.WriteToolName:
249 params := p.permission.Params.(tools.WritePermissionsParams)
250 fileKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("File")
251 filePath := baseStyle.
252 Foreground(t.Text()).
253 Width(p.width - lipgloss.Width(fileKey)).
254 Render(fmt.Sprintf(": %s", params.FilePath))
255 headerParts = append(headerParts,
256 lipgloss.JoinHorizontal(
257 lipgloss.Left,
258 fileKey,
259 filePath,
260 ),
261 baseStyle.Render(strings.Repeat(" ", p.width)),
262 )
263 case tools.FetchToolName:
264 headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
265 }
266
267 return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
268}
269
270func (p *permissionDialogCmp) renderBashContent() string {
271 baseStyle := styles.BaseStyle()
272
273 if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
274 content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
275
276 // Use the cache for markdown rendering
277 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
278 r := styles.GetMarkdownRenderer(p.width - 10)
279 s, err := r.Render(content)
280 return s, err
281 })
282
283 finalContent := baseStyle.
284 Width(p.contentViewPort.Width()).
285 Render(renderedContent)
286 p.contentViewPort.SetContent(finalContent)
287 return p.styleViewport()
288 }
289 return ""
290}
291
292func (p *permissionDialogCmp) renderEditContent() string {
293 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
294 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
295 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
296 })
297
298 p.contentViewPort.SetContent(diff)
299 return p.styleViewport()
300 }
301 return ""
302}
303
304func (p *permissionDialogCmp) renderPatchContent() string {
305 if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
306 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
307 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
308 })
309
310 p.contentViewPort.SetContent(diff)
311 return p.styleViewport()
312 }
313 return ""
314}
315
316func (p *permissionDialogCmp) renderWriteContent() string {
317 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
318 // Use the cache for diff rendering
319 diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
320 return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width()))
321 })
322
323 p.contentViewPort.SetContent(diff)
324 return p.styleViewport()
325 }
326 return ""
327}
328
329func (p *permissionDialogCmp) renderFetchContent() string {
330 baseStyle := styles.BaseStyle()
331
332 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
333 content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
334
335 // Use the cache for markdown rendering
336 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
337 r := styles.GetMarkdownRenderer(p.width - 10)
338 s, err := r.Render(content)
339 return s, err
340 })
341
342 finalContent := baseStyle.
343 Width(p.contentViewPort.Width()).
344 Render(renderedContent)
345 p.contentViewPort.SetContent(finalContent)
346 return p.styleViewport()
347 }
348 return ""
349}
350
351func (p *permissionDialogCmp) renderDefaultContent() string {
352 baseStyle := styles.BaseStyle()
353
354 content := p.permission.Description
355
356 // Use the cache for markdown rendering
357 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
358 r := styles.GetMarkdownRenderer(p.width - 10)
359 s, err := r.Render(content)
360 return s, err
361 })
362
363 finalContent := baseStyle.
364 Width(p.contentViewPort.Width()).
365 Render(renderedContent)
366 p.contentViewPort.SetContent(finalContent)
367
368 if renderedContent == "" {
369 return ""
370 }
371
372 return p.styleViewport()
373}
374
375func (p *permissionDialogCmp) styleViewport() string {
376 t := theme.CurrentTheme()
377 contentStyle := lipgloss.NewStyle().
378 Background(t.Background())
379
380 return contentStyle.Render(p.contentViewPort.View())
381}
382
383func (p *permissionDialogCmp) render() string {
384 t := theme.CurrentTheme()
385 baseStyle := styles.BaseStyle()
386
387 title := baseStyle.
388 Bold(true).
389 Width(p.width - 4).
390 Foreground(t.Primary()).
391 Render("Permission Required")
392 // Render header
393 headerContent := p.renderHeader()
394 // Render buttons
395 buttons := p.renderButtons()
396
397 // Calculate content height dynamically based on window size
398 p.contentViewPort.SetHeight(p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title))
399 p.contentViewPort.SetWidth(p.width - 4)
400
401 // Render content based on tool type
402 var contentFinal string
403 switch p.permission.ToolName {
404 case tools.BashToolName:
405 contentFinal = p.renderBashContent()
406 case tools.EditToolName:
407 contentFinal = p.renderEditContent()
408 case tools.PatchToolName:
409 contentFinal = p.renderPatchContent()
410 case tools.WriteToolName:
411 contentFinal = p.renderWriteContent()
412 case tools.FetchToolName:
413 contentFinal = p.renderFetchContent()
414 default:
415 contentFinal = p.renderDefaultContent()
416 }
417
418 content := lipgloss.JoinVertical(
419 lipgloss.Top,
420 title,
421 baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
422 headerContent,
423 contentFinal,
424 buttons,
425 baseStyle.Render(strings.Repeat(" ", p.width-4)),
426 )
427
428 return baseStyle.
429 Padding(1, 0, 0, 1).
430 Border(lipgloss.RoundedBorder()).
431 BorderBackground(t.Background()).
432 BorderForeground(t.TextMuted()).
433 Width(p.width).
434 Height(p.height).
435 Render(
436 content,
437 )
438}
439
440func (p *permissionDialogCmp) View() tea.View {
441 return tea.NewView(p.render())
442}
443
444func (p *permissionDialogCmp) BindingKeys() []key.Binding {
445 return layout.KeyMapToSlice(permissionsKeys)
446}
447
448func (p *permissionDialogCmp) SetSize() tea.Cmd {
449 if p.permission.ID == "" {
450 return nil
451 }
452 switch p.permission.ToolName {
453 case tools.BashToolName:
454 p.width = int(float64(p.windowSize.Width) * 0.4)
455 p.height = int(float64(p.windowSize.Height) * 0.3)
456 case tools.EditToolName:
457 p.width = int(float64(p.windowSize.Width) * 0.8)
458 p.height = int(float64(p.windowSize.Height) * 0.8)
459 case tools.WriteToolName:
460 p.width = int(float64(p.windowSize.Width) * 0.8)
461 p.height = int(float64(p.windowSize.Height) * 0.8)
462 case tools.FetchToolName:
463 p.width = int(float64(p.windowSize.Width) * 0.4)
464 p.height = int(float64(p.windowSize.Height) * 0.3)
465 default:
466 p.width = int(float64(p.windowSize.Width) * 0.7)
467 p.height = int(float64(p.windowSize.Height) * 0.5)
468 }
469 return nil
470}
471
472func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
473 p.permission = permission
474 return p.SetSize()
475}
476
477// Helper to get or set cached diff content
478func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
479 if cached, ok := c.diffCache[key]; ok {
480 return cached
481 }
482
483 content, err := generator()
484 if err != nil {
485 return fmt.Sprintf("Error formatting diff: %v", err)
486 }
487
488 c.diffCache[key] = content
489
490 return content
491}
492
493// Helper to get or set cached markdown content
494func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
495 if cached, ok := c.markdownCache[key]; ok {
496 return cached
497 }
498
499 content, err := generator()
500 if err != nil {
501 return fmt.Sprintf("Error rendering markdown: %v", err)
502 }
503
504 c.markdownCache[key] = content
505
506 return content
507}
508
509func NewPermissionDialogCmp() PermissionDialogCmp {
510 // Create viewport for content
511 contentViewport := viewport.New()
512
513 return &permissionDialogCmp{
514 contentViewPort: contentViewport,
515 selectedOption: 0, // Default to "Allow"
516 diffCache: make(map[string]string),
517 markdownCache: make(map[string]string),
518 }
519}