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	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}