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