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