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 content := strings.Join(formattedLines, "\n")
96
97 // Create a bordered box for the content
98 contentStyle := lipgloss.NewStyle().
99 MarginTop(1).
100 Padding(0, 1).
101 Border(lipgloss.RoundedBorder()).
102 BorderForeground(styles.Flamingo)
103
104 return contentStyle.Render(content)
105}
106
107func (p *permissionDialogCmp) Init() tea.Cmd {
108 return nil
109}
110
111func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112 var cmds []tea.Cmd
113
114 switch msg := msg.(type) {
115 case tea.WindowSizeMsg:
116 p.windowSize = msg
117 case tea.KeyMsg:
118 if key.Matches(msg, keyMapValue.ChangeFocus) {
119 p.isViewportFocus = !p.isViewportFocus
120 if p.isViewportFocus {
121 p.selectOption.Blur()
122 // Add a visual indicator for focus change
123 cmds = append(cmds, tea.Batch(
124 util.CmdHandler(util.InfoMsg("Viewing content - use arrow keys to scroll")),
125 ))
126 } else {
127 p.selectOption.Focus()
128 // Add a visual indicator for focus change
129 cmds = append(cmds, tea.Batch(
130 util.CmdHandler(util.InfoMsg("Select an action")),
131 ))
132 }
133 return p, tea.Batch(cmds...)
134 }
135 }
136
137 if p.isViewportFocus {
138 viewPort, cmd := p.contentViewPort.Update(msg)
139 p.contentViewPort = viewPort
140 cmds = append(cmds, cmd)
141 } else {
142 form, cmd := p.form.Update(msg)
143 if f, ok := form.(*huh.Form); ok {
144 p.form = f
145 cmds = append(cmds, cmd)
146 }
147
148 if p.form.State == huh.StateCompleted {
149 // Get the selected action
150 action := p.form.GetString("action")
151
152 // Close the dialog and return the response
153 return p, tea.Batch(
154 util.CmdHandler(core.DialogCloseMsg{}),
155 util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
156 )
157 }
158 }
159 return p, tea.Batch(cmds...)
160}
161
162func (p *permissionDialogCmp) render() string {
163 keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
164 valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
165
166 form := p.form.View()
167
168 headerParts := []string{
169 lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
170 " ",
171 lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
172 " ",
173 }
174
175 // Create the header content first so it can be used in all cases
176 headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
177
178 r, _ := glamour.NewTermRenderer(
179 glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
180 glamour.WithWordWrap(p.width-10),
181 glamour.WithEmoji(),
182 )
183
184 // Handle different tool types
185 switch p.permission.ToolName {
186 case tools.BashToolName:
187 pr := p.permission.Params.(tools.BashPermissionsParams)
188 headerParts = append(headerParts, keyStyle.Render("Command:"))
189 content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
190
191 renderedContent, _ := r.Render(content)
192 p.contentViewPort.Width = p.width - 2 - 2
193
194 // Calculate content height dynamically based on content
195 contentLines := len(strings.Split(renderedContent, "\n"))
196 // Set a reasonable min/max for the viewport height
197 minContentHeight := 3
198 maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
199
200 // Add some padding to the content lines
201 contentHeight := contentLines + 2
202 contentHeight = max(contentHeight, minContentHeight)
203 contentHeight = min(contentHeight, maxContentHeight)
204 p.contentViewPort.Height = contentHeight
205
206 p.contentViewPort.SetContent(renderedContent)
207
208 // Style the viewport
209 var contentBorder lipgloss.Border
210 var borderColor lipgloss.TerminalColor
211
212 if p.isViewportFocus {
213 contentBorder = lipgloss.DoubleBorder()
214 borderColor = styles.Blue
215 } else {
216 contentBorder = lipgloss.RoundedBorder()
217 borderColor = styles.Flamingo
218 }
219
220 contentStyle := lipgloss.NewStyle().
221 MarginTop(1).
222 Padding(0, 1).
223 Border(contentBorder).
224 BorderForeground(borderColor)
225
226 if p.isViewportFocus {
227 contentStyle = contentStyle.BorderBackground(styles.Surface0)
228 }
229
230 contentFinal := contentStyle.Render(p.contentViewPort.View())
231
232 return lipgloss.JoinVertical(
233 lipgloss.Top,
234 headerContent,
235 contentFinal,
236 form,
237 )
238
239 case tools.EditToolName:
240 pr := p.permission.Params.(tools.EditPermissionsParams)
241 headerParts = append(headerParts, keyStyle.Render("Update"))
242 // Recreate header content with the updated headerParts
243 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
244 // Format the diff with colors instead of using markdown code block
245 formattedDiff := formatDiff(pr.Diff)
246 return lipgloss.JoinVertical(
247 lipgloss.Top,
248 headerContent,
249 formattedDiff,
250 form,
251 )
252
253 case tools.WriteToolName:
254 pr := p.permission.Params.(tools.WritePermissionsParams)
255 headerParts = append(headerParts, keyStyle.Render("Content"))
256 // Recreate header content with the updated headerParts
257 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
258 // Format the diff with colors instead of using markdown code block
259 formattedDiff := formatDiff(pr.Content)
260 return lipgloss.JoinVertical(
261 lipgloss.Top,
262 headerContent,
263 formattedDiff,
264 form,
265 )
266
267 case tools.FetchToolName:
268 pr := p.permission.Params.(tools.FetchPermissionsParams)
269 headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
270 content := p.permission.Description
271
272 renderedContent, _ := r.Render(content)
273 p.contentViewPort.Width = p.width - 2 - 2
274 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
275 p.contentViewPort.SetContent(renderedContent)
276
277 // Style the viewport
278 contentStyle := lipgloss.NewStyle().
279 MarginTop(1).
280 Padding(0, 1).
281 Border(lipgloss.RoundedBorder()).
282 BorderForeground(styles.Flamingo)
283
284 contentFinal := contentStyle.Render(p.contentViewPort.View())
285 if renderedContent == "" {
286 contentFinal = ""
287 }
288
289 return lipgloss.JoinVertical(
290 lipgloss.Top,
291 headerContent,
292 contentFinal,
293 form,
294 )
295
296 default:
297 content := p.permission.Description
298
299 renderedContent, _ := r.Render(content)
300 p.contentViewPort.Width = p.width - 2 - 2
301 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
302 p.contentViewPort.SetContent(renderedContent)
303
304 // Style the viewport
305 contentStyle := lipgloss.NewStyle().
306 MarginTop(1).
307 Padding(0, 1).
308 Border(lipgloss.RoundedBorder()).
309 BorderForeground(styles.Flamingo)
310
311 contentFinal := contentStyle.Render(p.contentViewPort.View())
312 if renderedContent == "" {
313 contentFinal = ""
314 }
315
316 return lipgloss.JoinVertical(
317 lipgloss.Top,
318 headerContent,
319 contentFinal,
320 form,
321 )
322 }
323}
324
325func (p *permissionDialogCmp) View() string {
326 return p.render()
327}
328
329func (p *permissionDialogCmp) GetSize() (int, int) {
330 return p.width, p.height
331}
332
333func (p *permissionDialogCmp) SetSize(width int, height int) {
334 p.width = width
335 p.height = height
336 p.form = p.form.WithWidth(width)
337}
338
339func (p *permissionDialogCmp) BindingKeys() []key.Binding {
340 return p.form.KeyBinds()
341}
342
343func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
344 // Create a note field for displaying the content
345
346 // Create select field for the permission options
347 selectOption := huh.NewSelect[string]().
348 Key("action").
349 Options(
350 huh.NewOption("Allow", string(PermissionAllow)),
351 huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
352 huh.NewOption("Deny", string(PermissionDeny)),
353 ).
354 Title("Select an action")
355
356 // Apply theme
357 theme := styles.HuhTheme()
358
359 // Setup form width and height
360 form := huh.NewForm(huh.NewGroup(selectOption)).
361 WithShowHelp(false).
362 WithTheme(theme).
363 WithShowErrors(false)
364
365 // Focus the form for immediate interaction
366 selectOption.Focus()
367
368 return &permissionDialogCmp{
369 permission: permission,
370 form: form,
371 selectOption: selectOption,
372 }
373}
374
375// NewPermissionDialogCmd creates a new permission dialog command
376func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
377 permDialog := newPermissionDialogCmp(permission)
378
379 // Create the dialog layout
380 dialogPane := layout.NewSinglePane(
381 permDialog.(*permissionDialogCmp),
382 layout.WithSinglePaneBordered(true),
383 layout.WithSinglePaneFocusable(true),
384 layout.WithSinglePaneActiveColor(styles.Warning),
385 layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
386 layout.TopMiddleBorder: " Permission Required ",
387 }),
388 )
389
390 // Focus the dialog
391 dialogPane.Focus()
392 widthRatio := 0.7
393 heightRatio := 0.6
394 minWidth := 100
395 minHeight := 30
396
397 // Make the dialog size more appropriate for different tools
398 switch permission.ToolName {
399 case tools.BashToolName:
400 // For bash commands, use a more compact dialog
401 widthRatio = 0.7
402 heightRatio = 0.4 // Reduced from 0.5
403 minWidth = 100
404 minHeight = 20 // Reduced from 30
405 }
406 // Return the dialog command
407 return util.CmdHandler(core.DialogMsg{
408 Content: dialogPane,
409 WidthRatio: widthRatio,
410 HeightRatio: heightRatio,
411 MinWidth: minWidth,
412 MinHeight: minHeight,
413 })
414}