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 if contentHeight < minContentHeight {
203 contentHeight = minContentHeight
204 }
205 if contentHeight > maxContentHeight {
206 contentHeight = maxContentHeight
207 }
208 p.contentViewPort.Height = contentHeight
209
210 p.contentViewPort.SetContent(renderedContent)
211
212 // Style the viewport
213 var contentBorder lipgloss.Border
214 var borderColor lipgloss.TerminalColor
215
216 if p.isViewportFocus {
217 contentBorder = lipgloss.DoubleBorder()
218 borderColor = styles.Blue
219 } else {
220 contentBorder = lipgloss.RoundedBorder()
221 borderColor = styles.Flamingo
222 }
223
224 contentStyle := lipgloss.NewStyle().
225 MarginTop(1).
226 Padding(0, 1).
227 Border(contentBorder).
228 BorderForeground(borderColor)
229
230 if p.isViewportFocus {
231 contentStyle = contentStyle.BorderBackground(styles.Surface0)
232 }
233
234 contentFinal := contentStyle.Render(p.contentViewPort.View())
235
236 return lipgloss.JoinVertical(
237 lipgloss.Top,
238 headerContent,
239 contentFinal,
240 form,
241 )
242
243 case tools.EditToolName:
244 pr := p.permission.Params.(tools.EditPermissionsParams)
245 headerParts = append(headerParts, keyStyle.Render("Update"))
246 // Recreate header content with the updated headerParts
247 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
248 // Format the diff with colors instead of using markdown code block
249 formattedDiff := formatDiff(pr.Diff)
250 return lipgloss.JoinVertical(
251 lipgloss.Top,
252 headerContent,
253 formattedDiff,
254 form,
255 )
256
257 case tools.WriteToolName:
258 pr := p.permission.Params.(tools.WritePermissionsParams)
259 headerParts = append(headerParts, keyStyle.Render("Content"))
260 // Recreate header content with the updated headerParts
261 headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
262 // Format the diff with colors instead of using markdown code block
263 formattedDiff := formatDiff(pr.Content)
264 return lipgloss.JoinVertical(
265 lipgloss.Top,
266 headerContent,
267 formattedDiff,
268 form,
269 )
270
271 case tools.FetchToolName:
272 pr := p.permission.Params.(tools.FetchPermissionsParams)
273 headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
274 content := p.permission.Description
275
276 renderedContent, _ := r.Render(content)
277 p.contentViewPort.Width = p.width - 2 - 2
278 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
279 p.contentViewPort.SetContent(renderedContent)
280
281 // Style the viewport
282 contentStyle := lipgloss.NewStyle().
283 MarginTop(1).
284 Padding(0, 1).
285 Border(lipgloss.RoundedBorder()).
286 BorderForeground(styles.Flamingo)
287
288 contentFinal := contentStyle.Render(p.contentViewPort.View())
289 if renderedContent == "" {
290 contentFinal = ""
291 }
292
293 return lipgloss.JoinVertical(
294 lipgloss.Top,
295 headerContent,
296 contentFinal,
297 form,
298 )
299
300 default:
301 content := p.permission.Description
302
303 renderedContent, _ := r.Render(content)
304 p.contentViewPort.Width = p.width - 2 - 2
305 p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
306 p.contentViewPort.SetContent(renderedContent)
307
308 // Style the viewport
309 contentStyle := lipgloss.NewStyle().
310 MarginTop(1).
311 Padding(0, 1).
312 Border(lipgloss.RoundedBorder()).
313 BorderForeground(styles.Flamingo)
314
315 contentFinal := contentStyle.Render(p.contentViewPort.View())
316 if renderedContent == "" {
317 contentFinal = ""
318 }
319
320 return lipgloss.JoinVertical(
321 lipgloss.Top,
322 headerContent,
323 contentFinal,
324 form,
325 )
326 }
327}
328
329func (p *permissionDialogCmp) View() string {
330 return p.render()
331}
332
333func (p *permissionDialogCmp) GetSize() (int, int) {
334 return p.width, p.height
335}
336
337func (p *permissionDialogCmp) SetSize(width int, height int) {
338 p.width = width
339 p.height = height
340 p.form = p.form.WithWidth(width)
341}
342
343func (p *permissionDialogCmp) BindingKeys() []key.Binding {
344 return p.form.KeyBinds()
345}
346
347func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
348 // Create a note field for displaying the content
349
350 // Create select field for the permission options
351 selectOption := huh.NewSelect[string]().
352 Key("action").
353 Options(
354 huh.NewOption("Allow", string(PermissionAllow)),
355 huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
356 huh.NewOption("Deny", string(PermissionDeny)),
357 ).
358 Title("Select an action")
359
360 // Apply theme
361 theme := styles.HuhTheme()
362
363 // Setup form width and height
364 form := huh.NewForm(huh.NewGroup(selectOption)).
365 WithShowHelp(false).
366 WithTheme(theme).
367 WithShowErrors(false)
368
369 // Focus the form for immediate interaction
370 selectOption.Focus()
371
372 return &permissionDialogCmp{
373 permission: permission,
374 form: form,
375 selectOption: selectOption,
376 }
377}
378
379// NewPermissionDialogCmd creates a new permission dialog command
380func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
381 permDialog := newPermissionDialogCmp(permission)
382
383 // Create the dialog layout
384 dialogPane := layout.NewSinglePane(
385 permDialog.(*permissionDialogCmp),
386 layout.WithSinglePaneBordered(true),
387 layout.WithSinglePaneFocusable(true),
388 layout.WithSinglePaneActiveColor(styles.Warning),
389 layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
390 layout.TopMiddleBorder: " Permission Required ",
391 }),
392 )
393
394 // Focus the dialog
395 dialogPane.Focus()
396 widthRatio := 0.7
397 heightRatio := 0.6
398 minWidth := 100
399 minHeight := 30
400
401 // Make the dialog size more appropriate for different tools
402 switch permission.ToolName {
403 case tools.BashToolName:
404 // For bash commands, use a more compact dialog
405 widthRatio = 0.7
406 heightRatio = 0.4 // Reduced from 0.5
407 minWidth = 100
408 minHeight = 20 // Reduced from 30
409 }
410 // Return the dialog command
411 return util.CmdHandler(core.DialogMsg{
412 Content: dialogPane,
413 WidthRatio: widthRatio,
414 HeightRatio: heightRatio,
415 MinWidth: minWidth,
416 MinHeight: minHeight,
417 })
418}