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