permission.go

  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}