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/diff"
 13	"github.com/kujtimiihoxha/termai/internal/llm/tools"
 14	"github.com/kujtimiihoxha/termai/internal/permission"
 15	"github.com/kujtimiihoxha/termai/internal/tui/components/core"
 16	"github.com/kujtimiihoxha/termai/internal/tui/layout"
 17	"github.com/kujtimiihoxha/termai/internal/tui/styles"
 18	"github.com/kujtimiihoxha/termai/internal/tui/util"
 19
 20	"github.com/charmbracelet/huh"
 21)
 22
 23type PermissionAction string
 24
 25// Permission responses
 26const (
 27	PermissionAllow           PermissionAction = "allow"
 28	PermissionAllowForSession PermissionAction = "allow_session"
 29	PermissionDeny            PermissionAction = "deny"
 30)
 31
 32// PermissionResponseMsg represents the user's response to a permission request
 33type PermissionResponseMsg struct {
 34	Permission permission.PermissionRequest
 35	Action     PermissionAction
 36}
 37
 38// PermissionDialog interface for permission dialog component
 39type PermissionDialog interface {
 40	tea.Model
 41	layout.Sizeable
 42	layout.Bindings
 43}
 44
 45type keyMap struct {
 46	ChangeFocus key.Binding
 47}
 48
 49var keyMapValue = keyMap{
 50	ChangeFocus: key.NewBinding(
 51		key.WithKeys("tab"),
 52		key.WithHelp("tab", "change focus"),
 53	),
 54}
 55
 56// permissionDialogCmp is the implementation of PermissionDialog
 57type permissionDialogCmp struct {
 58	form            *huh.Form
 59	width           int
 60	height          int
 61	permission      permission.PermissionRequest
 62	windowSize      tea.WindowSizeMsg
 63	r               *glamour.TermRenderer
 64	contentViewPort viewport.Model
 65	isViewportFocus bool
 66	selectOption    *huh.Select[string]
 67}
 68
 69// formatDiff formats a diff string with colors for additions and deletions
 70func formatDiff(diffText string) string {
 71	lines := strings.Split(diffText, "\n")
 72	var formattedLines []string
 73
 74	// Define styles for different line types
 75	addStyle := lipgloss.NewStyle().Foreground(styles.Green)
 76	removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
 77	headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
 78	contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
 79
 80	// Process each line
 81	for _, line := range lines {
 82		if strings.HasPrefix(line, "+") {
 83			formattedLines = append(formattedLines, addStyle.Render(line))
 84		} else if strings.HasPrefix(line, "-") {
 85			formattedLines = append(formattedLines, removeStyle.Render(line))
 86		} else if strings.HasPrefix(line, "Changes:") || strings.HasPrefix(line, "  ...") {
 87			formattedLines = append(formattedLines, headerStyle.Render(line))
 88		} else if strings.HasPrefix(line, "  ") {
 89			formattedLines = append(formattedLines, contextStyle.Render(line))
 90		} else {
 91			formattedLines = append(formattedLines, line)
 92		}
 93	}
 94
 95	// Join all formatted lines
 96	return strings.Join(formattedLines, "\n")
 97}
 98
 99func (p *permissionDialogCmp) Init() tea.Cmd {
100	return nil
101}
102
103func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
104	var cmds []tea.Cmd
105
106	switch msg := msg.(type) {
107	case tea.WindowSizeMsg:
108		p.windowSize = msg
109	case tea.KeyMsg:
110		if key.Matches(msg, keyMapValue.ChangeFocus) {
111			p.isViewportFocus = !p.isViewportFocus
112			if p.isViewportFocus {
113				p.selectOption.Blur()
114				// Add a visual indicator for focus change
115				cmds = append(cmds, tea.Batch(
116					util.ReportInfo("Viewing content - use arrow keys to scroll"),
117				))
118			} else {
119				p.selectOption.Focus()
120				// Add a visual indicator for focus change
121				cmds = append(cmds, tea.Batch(
122					util.CmdHandler(util.ReportInfo("Select an action")),
123				))
124			}
125			return p, tea.Batch(cmds...)
126		}
127	}
128
129	if p.isViewportFocus {
130		viewPort, cmd := p.contentViewPort.Update(msg)
131		p.contentViewPort = viewPort
132		cmds = append(cmds, cmd)
133	} else {
134		form, cmd := p.form.Update(msg)
135		if f, ok := form.(*huh.Form); ok {
136			p.form = f
137			cmds = append(cmds, cmd)
138		}
139
140		if p.form.State == huh.StateCompleted {
141			// Get the selected action
142			action := p.form.GetString("action")
143
144			// Close the dialog and return the response
145			return p, tea.Batch(
146				util.CmdHandler(core.DialogCloseMsg{}),
147				util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
148			)
149		}
150	}
151	return p, tea.Batch(cmds...)
152}
153
154func (p *permissionDialogCmp) render() string {
155	keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
156	valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
157
158	form := p.form.View()
159
160	headerParts := []string{
161		lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
162		" ",
163		lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
164		" ",
165	}
166
167	// Create the header content first so it can be used in all cases
168	headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
169
170	r, _ := glamour.NewTermRenderer(
171		glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
172		glamour.WithWordWrap(p.width-10),
173		glamour.WithEmoji(),
174	)
175
176	// Handle different tool types
177	switch p.permission.ToolName {
178	case tools.BashToolName:
179		pr := p.permission.Params.(tools.BashPermissionsParams)
180		headerParts = append(headerParts, keyStyle.Render("Command:"))
181		content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
182
183		renderedContent, _ := r.Render(content)
184		p.contentViewPort.Width = p.width - 2 - 2
185
186		// Calculate content height dynamically based on content
187		contentLines := len(strings.Split(renderedContent, "\n"))
188		// Set a reasonable min/max for the viewport height
189		minContentHeight := 3
190		maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
191
192		// Add some padding to the content lines
193		contentHeight := contentLines + 2
194		contentHeight = max(contentHeight, minContentHeight)
195		contentHeight = min(contentHeight, maxContentHeight)
196		p.contentViewPort.Height = contentHeight
197
198		p.contentViewPort.SetContent(renderedContent)
199
200		// Style the viewport
201		var contentBorder lipgloss.Border
202		var borderColor lipgloss.TerminalColor
203
204		if p.isViewportFocus {
205			contentBorder = lipgloss.DoubleBorder()
206			borderColor = styles.Blue
207		} else {
208			contentBorder = lipgloss.RoundedBorder()
209			borderColor = styles.Flamingo
210		}
211
212		contentStyle := lipgloss.NewStyle().
213			MarginTop(1).
214			Padding(0, 1).
215			Border(contentBorder).
216			BorderForeground(borderColor)
217
218		if p.isViewportFocus {
219			contentStyle = contentStyle.BorderBackground(styles.Surface0)
220		}
221
222		contentFinal := contentStyle.Render(p.contentViewPort.View())
223
224		return lipgloss.JoinVertical(
225			lipgloss.Top,
226			headerContent,
227			contentFinal,
228			form,
229		)
230
231	case tools.EditToolName:
232		pr := p.permission.Params.(tools.EditPermissionsParams)
233		headerParts = append(headerParts, keyStyle.Render("Update"))
234		// Recreate header content with the updated headerParts
235		headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
236
237		// Format the diff with colors
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		diff, err := diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
246		if err != nil {
247			diff = fmt.Sprintf("Error formatting diff: %v", err)
248		}
249		p.contentViewPort.SetContent(diff)
250
251		// Style the viewport
252		var contentBorder lipgloss.Border
253		var borderColor lipgloss.TerminalColor
254
255		if p.isViewportFocus {
256			contentBorder = lipgloss.DoubleBorder()
257			borderColor = styles.Blue
258		} else {
259			contentBorder = lipgloss.RoundedBorder()
260			borderColor = styles.Flamingo
261		}
262
263		contentStyle := lipgloss.NewStyle().
264			MarginTop(1).
265			Padding(0, 1).
266			Border(contentBorder).
267			BorderForeground(borderColor)
268
269		if p.isViewportFocus {
270			contentStyle = contentStyle.BorderBackground(styles.Surface0)
271		}
272
273		contentFinal := contentStyle.Render(p.contentViewPort.View())
274
275		return lipgloss.JoinVertical(
276			lipgloss.Top,
277			headerContent,
278			contentFinal,
279			form,
280		)
281
282	case tools.WriteToolName:
283		pr := p.permission.Params.(tools.WritePermissionsParams)
284		headerParts = append(headerParts, keyStyle.Render("Content"))
285		// Recreate header content with the updated headerParts
286		headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
287
288		// Set up viewport for the content
289		p.contentViewPort.Width = p.width - 2 - 2
290
291		// Calculate content height dynamically based on window size
292		maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
293		p.contentViewPort.Height = maxContentHeight
294		diff, err := diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
295		if err != nil {
296			diff = fmt.Sprintf("Error formatting diff: %v", err)
297		}
298		p.contentViewPort.SetContent(diff)
299
300		// Style the viewport
301		var contentBorder lipgloss.Border
302		var borderColor lipgloss.TerminalColor
303
304		if p.isViewportFocus {
305			contentBorder = lipgloss.DoubleBorder()
306			borderColor = styles.Blue
307		} else {
308			contentBorder = lipgloss.RoundedBorder()
309			borderColor = styles.Flamingo
310		}
311
312		contentStyle := lipgloss.NewStyle().
313			MarginTop(1).
314			Padding(0, 1).
315			Border(contentBorder).
316			BorderForeground(borderColor)
317
318		if p.isViewportFocus {
319			contentStyle = contentStyle.BorderBackground(styles.Surface0)
320		}
321
322		contentFinal := contentStyle.Render(p.contentViewPort.View())
323
324		return lipgloss.JoinVertical(
325			lipgloss.Top,
326			headerContent,
327			contentFinal,
328			form,
329		)
330
331	case tools.FetchToolName:
332		pr := p.permission.Params.(tools.FetchPermissionsParams)
333		headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
334		content := p.permission.Description
335
336		renderedContent, _ := r.Render(content)
337		p.contentViewPort.Width = p.width - 2 - 2
338		p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
339		p.contentViewPort.SetContent(renderedContent)
340
341		// Style the viewport
342		contentStyle := lipgloss.NewStyle().
343			MarginTop(1).
344			Padding(0, 1).
345			Border(lipgloss.RoundedBorder()).
346			BorderForeground(styles.Flamingo)
347
348		contentFinal := contentStyle.Render(p.contentViewPort.View())
349		if renderedContent == "" {
350			contentFinal = ""
351		}
352
353		return lipgloss.JoinVertical(
354			lipgloss.Top,
355			headerContent,
356			contentFinal,
357			form,
358		)
359
360	default:
361		content := p.permission.Description
362
363		renderedContent, _ := r.Render(content)
364		p.contentViewPort.Width = p.width - 2 - 2
365		p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
366		p.contentViewPort.SetContent(renderedContent)
367
368		// Style the viewport
369		contentStyle := lipgloss.NewStyle().
370			MarginTop(1).
371			Padding(0, 1).
372			Border(lipgloss.RoundedBorder()).
373			BorderForeground(styles.Flamingo)
374
375		contentFinal := contentStyle.Render(p.contentViewPort.View())
376		if renderedContent == "" {
377			contentFinal = ""
378		}
379
380		return lipgloss.JoinVertical(
381			lipgloss.Top,
382			headerContent,
383			contentFinal,
384			form,
385		)
386	}
387}
388
389func (p *permissionDialogCmp) View() string {
390	return p.render()
391}
392
393func (p *permissionDialogCmp) GetSize() (int, int) {
394	return p.width, p.height
395}
396
397func (p *permissionDialogCmp) SetSize(width int, height int) {
398	p.width = width
399	p.height = height
400	p.form = p.form.WithWidth(width)
401}
402
403func (p *permissionDialogCmp) BindingKeys() []key.Binding {
404	return p.form.KeyBinds()
405}
406
407func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
408	// Create a note field for displaying the content
409
410	// Create select field for the permission options
411	selectOption := huh.NewSelect[string]().
412		Key("action").
413		Options(
414			huh.NewOption("Allow", string(PermissionAllow)),
415			huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
416			huh.NewOption("Deny", string(PermissionDeny)),
417		).
418		Title("Select an action")
419
420	// Apply theme
421	theme := styles.HuhTheme()
422
423	// Setup form width and height
424	form := huh.NewForm(huh.NewGroup(selectOption)).
425		WithShowHelp(false).
426		WithTheme(theme).
427		WithShowErrors(false)
428
429	// Focus the form for immediate interaction
430	selectOption.Focus()
431
432	return &permissionDialogCmp{
433		permission:   permission,
434		form:         form,
435		selectOption: selectOption,
436	}
437}
438
439// NewPermissionDialogCmd creates a new permission dialog command
440func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
441	permDialog := newPermissionDialogCmp(permission)
442
443	// Create the dialog layout
444	dialogPane := layout.NewSinglePane(
445		permDialog.(*permissionDialogCmp),
446		layout.WithSinglePaneBordered(true),
447		layout.WithSinglePaneFocusable(true),
448		layout.WithSinglePaneActiveColor(styles.Warning),
449		layout.WithSinglePaneBorderText(map[layout.BorderPosition]string{
450			layout.TopMiddleBorder: " Permission Required ",
451		}),
452	)
453
454	// Focus the dialog
455	dialogPane.Focus()
456	widthRatio := 0.7
457	heightRatio := 0.6
458	minWidth := 100
459	minHeight := 30
460
461	// Make the dialog size more appropriate for different tools
462	switch permission.ToolName {
463	case tools.BashToolName:
464		// For bash commands, use a more compact dialog
465		widthRatio = 0.7
466		heightRatio = 0.4 // Reduced from 0.5
467		minWidth = 100
468		minHeight = 20 // Reduced from 30
469	}
470	// Return the dialog command
471	return util.CmdHandler(core.DialogMsg{
472		Content:     dialogPane,
473		WidthRatio:  widthRatio,
474		HeightRatio: heightRatio,
475		MinWidth:    minWidth,
476		MinHeight:   minHeight,
477	})
478}