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