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/components/core"
16 "github.com/kujtimiihoxha/termai/internal/tui/layout"
17 "github.com/kujtimiihoxha/termai/internal/tui/styles"
18 "github.com/kujtimiihoxha/termai/internal/tui/util"
19
20 "github.com/charmbracelet/huh"
21)
22
23type PermissionAction string
24
25// Permission responses
26const (
27 PermissionAllow PermissionAction = "allow"
28 PermissionAllowForSession PermissionAction = "allow_session"
29 PermissionDeny PermissionAction = "deny"
30)
31
32// PermissionResponseMsg represents the user's response to a permission request
33type PermissionResponseMsg struct {
34 Permission permission.PermissionRequest
35 Action PermissionAction
36}
37
38// PermissionDialog interface for permission dialog component
39type PermissionDialog interface {
40 tea.Model
41 layout.Sizeable
42 layout.Bindings
43}
44
45type keyMap struct {
46 ChangeFocus key.Binding
47}
48
49var keyMapValue = keyMap{
50 ChangeFocus: key.NewBinding(
51 key.WithKeys("tab"),
52 key.WithHelp("tab", "change focus"),
53 ),
54}
55
56// permissionDialogCmp is the implementation of PermissionDialog
57type permissionDialogCmp struct {
58 form *huh.Form
59 width int
60 height int
61 permission permission.PermissionRequest
62 windowSize tea.WindowSizeMsg
63 r *glamour.TermRenderer
64 contentViewPort viewport.Model
65 isViewportFocus bool
66 selectOption *huh.Select[string]
67}
68
69// formatDiff formats a diff string with colors for additions and deletions
70func formatDiff(diffText string) string {
71 lines := strings.Split(diffText, "\n")
72 var formattedLines []string
73
74 // Define styles for different line types
75 addStyle := lipgloss.NewStyle().Foreground(styles.Green)
76 removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
77 headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
78 contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
79
80 // Process each line
81 for _, line := range lines {
82 if strings.HasPrefix(line, "+") {
83 formattedLines = append(formattedLines, addStyle.Render(line))
84 } else if strings.HasPrefix(line, "-") {
85 formattedLines = append(formattedLines, removeStyle.Render(line))
86 } else if strings.HasPrefix(line, "Changes:") || strings.HasPrefix(line, " ...") {
87 formattedLines = append(formattedLines, headerStyle.Render(line))
88 } else if strings.HasPrefix(line, " ") {
89 formattedLines = append(formattedLines, contextStyle.Render(line))
90 } else {
91 formattedLines = append(formattedLines, line)
92 }
93 }
94
95 // Join all formatted lines
96 return strings.Join(formattedLines, "\n")
97}
98
99func (p *permissionDialogCmp) Init() tea.Cmd {
100 return nil
101}
102
103func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
104 var cmds []tea.Cmd
105
106 switch msg := msg.(type) {
107 case tea.WindowSizeMsg:
108 p.windowSize = msg
109 case tea.KeyMsg:
110 if key.Matches(msg, keyMapValue.ChangeFocus) {
111 p.isViewportFocus = !p.isViewportFocus
112 if p.isViewportFocus {
113 p.selectOption.Blur()
114 // Add a visual indicator for focus change
115 cmds = append(cmds, tea.Batch(
116 util.ReportInfo("Viewing content - use arrow keys to scroll"),
117 ))
118 } else {
119 p.selectOption.Focus()
120 // Add a visual indicator for focus change
121 cmds = append(cmds, tea.Batch(
122 util.CmdHandler(util.ReportInfo("Select an action")),
123 ))
124 }
125 return p, tea.Batch(cmds...)
126 }
127 }
128
129 if p.isViewportFocus {
130 viewPort, cmd := p.contentViewPort.Update(msg)
131 p.contentViewPort = viewPort
132 cmds = append(cmds, cmd)
133 } else {
134 form, cmd := p.form.Update(msg)
135 if f, ok := form.(*huh.Form); ok {
136 p.form = f
137 cmds = append(cmds, cmd)
138 }
139
140 if p.form.State == huh.StateCompleted {
141 // Get the selected action
142 action := p.form.GetString("action")
143
144 // Close the dialog and return the response
145 return p, tea.Batch(
146 util.CmdHandler(core.DialogCloseMsg{}),
147 util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
148 )
149 }
150 }
151 return p, tea.Batch(cmds...)
152}
153
154func (p *permissionDialogCmp) render() string {
155 keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
156 valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
157
158 form := p.form.View()
159
160 headerParts := []string{
161 lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
162 " ",
163 lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
164 " ",
165 }
166
167 // Create the header content first so it can be used in all cases
168 headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
169
170 r, _ := glamour.NewTermRenderer(
171 glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
172 glamour.WithWordWrap(p.width-10),
173 glamour.WithEmoji(),
174 )
175
176 // Handle different tool types
177 switch p.permission.ToolName {
178 case tools.BashToolName:
179 pr := p.permission.Params.(tools.BashPermissionsParams)
180 headerParts = append(headerParts, keyStyle.Render("Command:"))
181 content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
182
183 renderedContent, _ := r.Render(content)
184 p.contentViewPort.Width = p.width - 2 - 2
185
186 // Calculate content height dynamically based on content
187 contentLines := len(strings.Split(renderedContent, "\n"))
188 // Set a reasonable min/max for the viewport height
189 minContentHeight := 3
190 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
191
192 // Add some padding to the content lines
193 contentHeight := contentLines + 2
194 contentHeight = max(contentHeight, minContentHeight)
195 contentHeight = min(contentHeight, maxContentHeight)
196 p.contentViewPort.Height = contentHeight
197
198 p.contentViewPort.SetContent(renderedContent)
199
200 // Style the viewport
201 var contentBorder lipgloss.Border
202 var borderColor lipgloss.TerminalColor
203
204 if p.isViewportFocus {
205 contentBorder = lipgloss.DoubleBorder()
206 borderColor = styles.Blue
207 } else {
208 contentBorder = lipgloss.RoundedBorder()
209 borderColor = styles.Flamingo
210 }
211
212 contentStyle := lipgloss.NewStyle().
213 MarginTop(1).
214 Padding(0, 1).
215 Border(contentBorder).
216 BorderForeground(borderColor)
217
218 if p.isViewportFocus {
219 contentStyle = contentStyle.BorderBackground(styles.Surface0)
220 }
221
222 contentFinal := contentStyle.Render(p.contentViewPort.View())
223
224 return lipgloss.JoinVertical(
225 lipgloss.Top,
226 headerContent,
227 contentFinal,
228 form,
229 )
230
231 case tools.EditToolName:
232 pr := p.permission.Params.(tools.EditPermissionsParams)
233 headerParts = append(headerParts, keyStyle.Render("Update"))
234 // Recreate header content with the updated headerParts
235 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
236
237 // Format the diff with colors
238
239 // Set up viewport for the diff content
240 p.contentViewPort.Width = p.width - 2 - 2
241
242 // Calculate content height dynamically based on window size
243 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
244 p.contentViewPort.Height = maxContentHeight
245 diff, err := diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
246 if err != nil {
247 diff = fmt.Sprintf("Error formatting diff: %v", err)
248 }
249 p.contentViewPort.SetContent(diff)
250
251 // Style the viewport
252 var contentBorder lipgloss.Border
253 var borderColor lipgloss.TerminalColor
254
255 if p.isViewportFocus {
256 contentBorder = lipgloss.DoubleBorder()
257 borderColor = styles.Blue
258 } else {
259 contentBorder = lipgloss.RoundedBorder()
260 borderColor = styles.Flamingo
261 }
262
263 contentStyle := lipgloss.NewStyle().
264 MarginTop(1).
265 Padding(0, 1).
266 Border(contentBorder).
267 BorderForeground(borderColor)
268
269 if p.isViewportFocus {
270 contentStyle = contentStyle.BorderBackground(styles.Surface0)
271 }
272
273 contentFinal := contentStyle.Render(p.contentViewPort.View())
274
275 return lipgloss.JoinVertical(
276 lipgloss.Top,
277 headerContent,
278 contentFinal,
279 form,
280 )
281
282 case tools.WriteToolName:
283 pr := p.permission.Params.(tools.WritePermissionsParams)
284 headerParts = append(headerParts, keyStyle.Render("Content"))
285 // Recreate header content with the updated headerParts
286 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
287
288 // Set up viewport for the content
289 p.contentViewPort.Width = p.width - 2 - 2
290
291 // Calculate content height dynamically based on window size
292 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
293 p.contentViewPort.Height = maxContentHeight
294 diff, err := diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
295 if err != nil {
296 diff = fmt.Sprintf("Error formatting diff: %v", err)
297 }
298 p.contentViewPort.SetContent(diff)
299
300 // Style the viewport
301 var contentBorder lipgloss.Border
302 var borderColor lipgloss.TerminalColor
303
304 if p.isViewportFocus {
305 contentBorder = lipgloss.DoubleBorder()
306 borderColor = styles.Blue
307 } else {
308 contentBorder = lipgloss.RoundedBorder()
309 borderColor = styles.Flamingo
310 }
311
312 contentStyle := lipgloss.NewStyle().
313 MarginTop(1).
314 Padding(0, 1).
315 Border(contentBorder).
316 BorderForeground(borderColor)
317
318 if p.isViewportFocus {
319 contentStyle = contentStyle.BorderBackground(styles.Surface0)
320 }
321
322 contentFinal := contentStyle.Render(p.contentViewPort.View())
323
324 return lipgloss.JoinVertical(
325 lipgloss.Top,
326 headerContent,
327 contentFinal,
328 form,
329 )
330
331 case tools.FetchToolName:
332 pr := p.permission.Params.(tools.FetchPermissionsParams)
333 headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
334 content := p.permission.Description
335
336 renderedContent, _ := r.Render(content)
337 p.contentViewPort.Width = p.width - 2 - 2
338 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
339 p.contentViewPort.SetContent(renderedContent)
340
341 // Style the viewport
342 contentStyle := lipgloss.NewStyle().
343 MarginTop(1).
344 Padding(0, 1).
345 Border(lipgloss.RoundedBorder()).
346 BorderForeground(styles.Flamingo)
347
348 contentFinal := contentStyle.Render(p.contentViewPort.View())
349 if renderedContent == "" {
350 contentFinal = ""
351 }
352
353 return lipgloss.JoinVertical(
354 lipgloss.Top,
355 headerContent,
356 contentFinal,
357 form,
358 )
359
360 default:
361 content := p.permission.Description
362
363 renderedContent, _ := r.Render(content)
364 p.contentViewPort.Width = p.width - 2 - 2
365 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
366 p.contentViewPort.SetContent(renderedContent)
367
368 // Style the viewport
369 contentStyle := lipgloss.NewStyle().
370 MarginTop(1).
371 Padding(0, 1).
372 Border(lipgloss.RoundedBorder()).
373 BorderForeground(styles.Flamingo)
374
375 contentFinal := contentStyle.Render(p.contentViewPort.View())
376 if renderedContent == "" {
377 contentFinal = ""
378 }
379
380 return lipgloss.JoinVertical(
381 lipgloss.Top,
382 headerContent,
383 contentFinal,
384 form,
385 )
386 }
387}
388
389func (p *permissionDialogCmp) View() string {
390 return p.render()
391}
392
393func (p *permissionDialogCmp) GetSize() (int, int) {
394 return p.width, p.height
395}
396
397func (p *permissionDialogCmp) SetSize(width int, height int) {
398 p.width = width
399 p.height = height
400 p.form = p.form.WithWidth(width)
401}
402
403func (p *permissionDialogCmp) BindingKeys() []key.Binding {
404 return p.form.KeyBinds()
405}
406
407func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
408 // Create a note field for displaying the content
409
410 // Create select field for the permission options
411 selectOption := huh.NewSelect[string]().
412 Key("action").
413 Options(
414 huh.NewOption("Allow", string(PermissionAllow)),
415 huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
416 huh.NewOption("Deny", string(PermissionDeny)),
417 ).
418 Title("Select an action")
419
420 // Apply theme
421 theme := styles.HuhTheme()
422
423 // Setup form width and height
424 form := huh.NewForm(huh.NewGroup(selectOption)).
425 WithShowHelp(false).
426 WithTheme(theme).
427 WithShowErrors(false)
428
429 // Focus the form for immediate interaction
430 selectOption.Focus()
431
432 return &permissionDialogCmp{
433 permission: permission,
434 form: form,
435 selectOption: selectOption,
436 }
437}
438
439// NewPermissionDialogCmd creates a new permission dialog command
440func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
441 permDialog := newPermissionDialogCmp(permission)
442
443 // Create the dialog layout
444 dialogPane := layout.NewSinglePane(
445 permDialog.(*permissionDialogCmp),
446 layout.WithSinglePaneBordered(true),
447 layout.WithSinglePaneFocusable(true),
448 layout.WithSinglePaneActiveColor(styles.Warning),
449 layout.WithSinglePaneBorderText(map[layout.BorderPosition]string{
450 layout.TopMiddleBorder: " Permission Required ",
451 }),
452 )
453
454 // Focus the dialog
455 dialogPane.Focus()
456 widthRatio := 0.7
457 heightRatio := 0.6
458 minWidth := 100
459 minHeight := 30
460
461 // Make the dialog size more appropriate for different tools
462 switch permission.ToolName {
463 case tools.BashToolName:
464 // For bash commands, use a more compact dialog
465 widthRatio = 0.7
466 heightRatio = 0.4 // Reduced from 0.5
467 minWidth = 100
468 minHeight = 20 // Reduced from 30
469 }
470 // Return the dialog command
471 return util.CmdHandler(core.DialogMsg{
472 Content: dialogPane,
473 WidthRatio: widthRatio,
474 HeightRatio: heightRatio,
475 MinWidth: minWidth,
476 MinHeight: minHeight,
477 })
478}