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