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