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