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