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/termai/internal/diff"
13 "github.com/kujtimiihoxha/termai/internal/llm/tools"
14 "github.com/kujtimiihoxha/termai/internal/permission"
15 "github.com/kujtimiihoxha/termai/internal/tui/layout"
16 "github.com/kujtimiihoxha/termai/internal/tui/styles"
17 "github.com/kujtimiihoxha/termai/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) renderWriteContent() string {
270 if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
271 // Use the cache for diff rendering
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) renderFetchContent() string {
283 if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
284 content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
285
286 // Use the cache for markdown rendering
287 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
288 r, _ := glamour.NewTermRenderer(
289 glamour.WithStyles(styles.MarkdownTheme(true)),
290 glamour.WithWordWrap(p.width-10),
291 )
292 s, err := r.Render(content)
293 return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
294 })
295
296 p.contentViewPort.SetContent(renderedContent)
297 return p.styleViewport()
298 }
299 return ""
300}
301
302func (p *permissionDialogCmp) renderDefaultContent() string {
303 content := p.permission.Description
304
305 // Use the cache for markdown rendering
306 renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
307 r, _ := glamour.NewTermRenderer(
308 glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
309 glamour.WithWordWrap(p.width-10),
310 )
311 s, err := r.Render(content)
312 return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
313 })
314
315 p.contentViewPort.SetContent(renderedContent)
316
317 if renderedContent == "" {
318 return ""
319 }
320
321 return p.styleViewport()
322}
323
324func (p *permissionDialogCmp) styleViewport() string {
325 contentStyle := lipgloss.NewStyle().
326 Background(styles.Background)
327
328 return contentStyle.Render(p.contentViewPort.View())
329}
330
331func (p *permissionDialogCmp) render() string {
332 title := styles.BaseStyle.
333 Bold(true).
334 Width(p.width - 4).
335 Foreground(styles.PrimaryColor).
336 Render("Permission Required")
337 // Render header
338 headerContent := p.renderHeader()
339 // Render buttons
340 buttons := p.renderButtons()
341
342 // Calculate content height dynamically based on window size
343 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
344 p.contentViewPort.Width = p.width - 4
345
346 // Render content based on tool type
347 var contentFinal string
348 switch p.permission.ToolName {
349 case tools.BashToolName:
350 contentFinal = p.renderBashContent()
351 case tools.EditToolName:
352 contentFinal = p.renderEditContent()
353 case tools.WriteToolName:
354 contentFinal = p.renderWriteContent()
355 case tools.FetchToolName:
356 contentFinal = p.renderFetchContent()
357 default:
358 contentFinal = p.renderDefaultContent()
359 }
360
361 content := lipgloss.JoinVertical(
362 lipgloss.Top,
363 title,
364 styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
365 headerContent,
366 contentFinal,
367 buttons,
368 )
369
370 return styles.BaseStyle.
371 Padding(1, 0, 0, 1).
372 Border(lipgloss.RoundedBorder()).
373 BorderBackground(styles.Background).
374 BorderForeground(styles.ForgroundDim).
375 Width(p.width).
376 Height(p.height).
377 Render(
378 content,
379 )
380}
381
382func (p *permissionDialogCmp) View() string {
383 return p.render()
384}
385
386func (p *permissionDialogCmp) BindingKeys() []key.Binding {
387 return layout.KeyMapToSlice(helpKeys)
388}
389
390func (p *permissionDialogCmp) SetSize() {
391 if p.permission.ID == "" {
392 return
393 }
394 switch p.permission.ToolName {
395 case tools.BashToolName:
396 p.width = int(float64(p.windowSize.Width) * 0.4)
397 p.height = int(float64(p.windowSize.Height) * 0.3)
398 case tools.EditToolName:
399 p.width = int(float64(p.windowSize.Width) * 0.8)
400 p.height = int(float64(p.windowSize.Height) * 0.8)
401 case tools.WriteToolName:
402 p.width = int(float64(p.windowSize.Width) * 0.8)
403 p.height = int(float64(p.windowSize.Height) * 0.8)
404 case tools.FetchToolName:
405 p.width = int(float64(p.windowSize.Width) * 0.4)
406 p.height = int(float64(p.windowSize.Height) * 0.3)
407 default:
408 p.width = int(float64(p.windowSize.Width) * 0.7)
409 p.height = int(float64(p.windowSize.Height) * 0.5)
410 }
411}
412
413func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) {
414 p.permission = permission
415 p.SetSize()
416}
417
418// Helper to get or set cached diff content
419func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
420 if cached, ok := c.diffCache[key]; ok {
421 return cached
422 }
423
424 content, err := generator()
425 if err != nil {
426 return fmt.Sprintf("Error formatting diff: %v", err)
427 }
428
429 c.diffCache[key] = content
430
431 return content
432}
433
434// Helper to get or set cached markdown content
435func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
436 if cached, ok := c.markdownCache[key]; ok {
437 return cached
438 }
439
440 content, err := generator()
441 if err != nil {
442 return fmt.Sprintf("Error rendering markdown: %v", err)
443 }
444
445 c.markdownCache[key] = content
446
447 return content
448}
449
450func NewPermissionDialogCmp() PermissionDialogCmp {
451 // Create viewport for content
452 contentViewport := viewport.New(0, 0)
453
454 return &permissionDialogCmp{
455 contentViewPort: contentViewport,
456 selectedOption: 0, // Default to "Allow"
457 diffCache: make(map[string]string),
458 markdownCache: make(map[string]string),
459 }
460}