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/layout"
 16	"github.com/kujtimiihoxha/termai/internal/tui/styles"
 17	"github.com/kujtimiihoxha/termai/internal/tui/util"
 18)
 19
 20type PermissionAction string
 21
 22// Permission responses
 23const (
 24	PermissionAllow           PermissionAction = "allow"
 25	PermissionAllowForSession PermissionAction = "allow_session"
 26	PermissionDeny            PermissionAction = "deny"
 27)
 28
 29// PermissionResponseMsg represents the user's response to a permission request
 30type PermissionResponseMsg struct {
 31	Permission permission.PermissionRequest
 32	Action     PermissionAction
 33}
 34
 35// PermissionDialogCmp interface for permission dialog component
 36type PermissionDialogCmp interface {
 37	tea.Model
 38	layout.Bindings
 39	SetPermissions(permission permission.PermissionRequest)
 40}
 41
 42type permissionsMapping struct {
 43	LeftRight    key.Binding
 44	EnterSpace   key.Binding
 45	Allow        key.Binding
 46	AllowSession key.Binding
 47	Deny         key.Binding
 48	Tab          key.Binding
 49}
 50
 51var permissionsKeys = permissionsMapping{
 52	LeftRight: key.NewBinding(
 53		key.WithKeys("left", "right"),
 54		key.WithHelp("←/→", "switch options"),
 55	),
 56	EnterSpace: key.NewBinding(
 57		key.WithKeys("enter", " "),
 58		key.WithHelp("enter/space", "confirm"),
 59	),
 60	Allow: key.NewBinding(
 61		key.WithKeys("a"),
 62		key.WithHelp("a", "allow"),
 63	),
 64	AllowSession: key.NewBinding(
 65		key.WithKeys("A"),
 66		key.WithHelp("A", "allow for session"),
 67	),
 68	Deny: key.NewBinding(
 69		key.WithKeys("d"),
 70		key.WithHelp("d", "deny"),
 71	),
 72	Tab: key.NewBinding(
 73		key.WithKeys("tab"),
 74		key.WithHelp("tab", "switch options"),
 75	),
 76}
 77
 78// permissionDialogCmp is the implementation of PermissionDialog
 79type permissionDialogCmp struct {
 80	width           int
 81	height          int
 82	permission      permission.PermissionRequest
 83	windowSize      tea.WindowSizeMsg
 84	contentViewPort viewport.Model
 85	selectedOption  int // 0: Allow, 1: Allow for session, 2: Deny
 86
 87	diffCache     map[string]string
 88	markdownCache map[string]string
 89}
 90
 91func (p *permissionDialogCmp) Init() tea.Cmd {
 92	return p.contentViewPort.Init()
 93}
 94
 95func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 96	var cmds []tea.Cmd
 97
 98	switch msg := msg.(type) {
 99	case tea.WindowSizeMsg:
100		p.windowSize = msg
101		p.SetSize()
102		p.markdownCache = make(map[string]string)
103		p.diffCache = make(map[string]string)
104	case tea.KeyMsg:
105		switch {
106		case key.Matches(msg, permissionsKeys.LeftRight) || key.Matches(msg, permissionsKeys.Tab):
107			// Change selected option
108			p.selectedOption = (p.selectedOption + 1) % 3
109			return p, nil
110		case key.Matches(msg, permissionsKeys.EnterSpace):
111			// Select current option
112			return p, p.selectCurrentOption()
113		case key.Matches(msg, permissionsKeys.Allow):
114			// Select Allow
115			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
116		case key.Matches(msg, permissionsKeys.AllowSession):
117			// Select Allow for session
118			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
119		case key.Matches(msg, permissionsKeys.Deny):
120			// Select Deny
121			return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
122		default:
123			// Pass other keys to viewport
124			viewPort, cmd := p.contentViewPort.Update(msg)
125			p.contentViewPort = viewPort
126			cmds = append(cmds, cmd)
127		}
128	}
129
130	return p, tea.Batch(cmds...)
131}
132
133func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
134	var action PermissionAction
135
136	switch p.selectedOption {
137	case 0:
138		action = PermissionAllow
139	case 1:
140		action = PermissionAllowForSession
141	case 2:
142		action = PermissionDeny
143	}
144
145	return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
146}
147
148func (p *permissionDialogCmp) renderButtons() string {
149	allowStyle := styles.BaseStyle
150	allowSessionStyle := styles.BaseStyle
151	denyStyle := styles.BaseStyle
152	spacerStyle := styles.BaseStyle.Background(styles.Background)
153
154	// Style the selected button
155	switch p.selectedOption {
156	case 0:
157		allowStyle = allowStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
158		allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
159		denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
160	case 1:
161		allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
162		allowSessionStyle = allowSessionStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
163		denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
164	case 2:
165		allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
166		allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
167		denyStyle = denyStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
168	}
169
170	allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
171	allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (A)")
172	denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
173
174	content := lipgloss.JoinHorizontal(
175		lipgloss.Left,
176		allowButton,
177		spacerStyle.Render("  "),
178		allowSessionButton,
179		spacerStyle.Render("  "),
180		denyButton,
181		spacerStyle.Render("  "),
182	)
183
184	remainingWidth := p.width - lipgloss.Width(content)
185	if remainingWidth > 0 {
186		content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
187	}
188	return content
189}
190
191func (p *permissionDialogCmp) renderHeader() string {
192	toolKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Tool")
193	toolValue := styles.BaseStyle.
194		Foreground(styles.Forground).
195		Width(p.width - lipgloss.Width(toolKey)).
196		Render(fmt.Sprintf(": %s", p.permission.ToolName))
197
198	pathKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Path")
199	pathValue := styles.BaseStyle.
200		Foreground(styles.Forground).
201		Width(p.width - lipgloss.Width(pathKey)).
202		Render(fmt.Sprintf(": %s", p.permission.Path))
203
204	headerParts := []string{
205		lipgloss.JoinHorizontal(
206			lipgloss.Left,
207			toolKey,
208			toolValue,
209		),
210		styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
211		lipgloss.JoinHorizontal(
212			lipgloss.Left,
213			pathKey,
214			pathValue,
215		),
216		styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
217	}
218
219	// Add tool-specific header information
220	switch p.permission.ToolName {
221	case tools.BashToolName:
222		headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Command"))
223	case tools.EditToolName:
224		headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
225	case tools.WriteToolName:
226		headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
227	case tools.FetchToolName:
228		headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("URL"))
229	}
230
231	return lipgloss.NewStyle().Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
232}
233
234func (p *permissionDialogCmp) renderBashContent() string {
235	if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
236		content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
237
238		// Use the cache for markdown rendering
239		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
240			r, _ := glamour.NewTermRenderer(
241				glamour.WithStyles(styles.MarkdownTheme(true)),
242				glamour.WithWordWrap(p.width-10),
243			)
244			s, err := r.Render(content)
245			return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
246		})
247
248		finalContent := styles.BaseStyle.
249			Width(p.contentViewPort.Width).
250			Render(renderedContent)
251		p.contentViewPort.SetContent(finalContent)
252		return p.styleViewport()
253	}
254	return ""
255}
256
257func (p *permissionDialogCmp) renderEditContent() string {
258	if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
259		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
260			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
261		})
262
263		p.contentViewPort.SetContent(diff)
264		return p.styleViewport()
265	}
266	return ""
267}
268
269func (p *permissionDialogCmp) renderWriteContent() string {
270	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
271		// Use the cache for diff rendering
272		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
273			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
274		})
275
276		p.contentViewPort.SetContent(diff)
277		return p.styleViewport()
278	}
279	return ""
280}
281
282func (p *permissionDialogCmp) renderFetchContent() string {
283	if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
284		content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
285
286		// Use the cache for markdown rendering
287		renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
288			r, _ := glamour.NewTermRenderer(
289				glamour.WithStyles(styles.MarkdownTheme(true)),
290				glamour.WithWordWrap(p.width-10),
291			)
292			s, err := r.Render(content)
293			return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
294		})
295
296		p.contentViewPort.SetContent(renderedContent)
297		return p.styleViewport()
298	}
299	return ""
300}
301
302func (p *permissionDialogCmp) renderDefaultContent() string {
303	content := p.permission.Description
304
305	// Use the cache for markdown rendering
306	renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
307		r, _ := glamour.NewTermRenderer(
308			glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
309			glamour.WithWordWrap(p.width-10),
310		)
311		s, err := r.Render(content)
312		return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
313	})
314
315	p.contentViewPort.SetContent(renderedContent)
316
317	if renderedContent == "" {
318		return ""
319	}
320
321	return p.styleViewport()
322}
323
324func (p *permissionDialogCmp) styleViewport() string {
325	contentStyle := lipgloss.NewStyle().
326		Background(styles.Background)
327
328	return contentStyle.Render(p.contentViewPort.View())
329}
330
331func (p *permissionDialogCmp) render() string {
332	title := styles.BaseStyle.
333		Bold(true).
334		Width(p.width - 4).
335		Foreground(styles.PrimaryColor).
336		Render("Permission Required")
337	// Render header
338	headerContent := p.renderHeader()
339	// Render buttons
340	buttons := p.renderButtons()
341
342	// Calculate content height dynamically based on window size
343	p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
344	p.contentViewPort.Width = p.width - 4
345
346	// Render content based on tool type
347	var contentFinal string
348	switch p.permission.ToolName {
349	case tools.BashToolName:
350		contentFinal = p.renderBashContent()
351	case tools.EditToolName:
352		contentFinal = p.renderEditContent()
353	case tools.WriteToolName:
354		contentFinal = p.renderWriteContent()
355	case tools.FetchToolName:
356		contentFinal = p.renderFetchContent()
357	default:
358		contentFinal = p.renderDefaultContent()
359	}
360
361	content := lipgloss.JoinVertical(
362		lipgloss.Top,
363		title,
364		styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
365		headerContent,
366		contentFinal,
367		buttons,
368	)
369
370	return styles.BaseStyle.
371		Padding(1, 0, 0, 1).
372		Border(lipgloss.RoundedBorder()).
373		BorderBackground(styles.Background).
374		BorderForeground(styles.ForgroundDim).
375		Width(p.width).
376		Height(p.height).
377		Render(
378			content,
379		)
380}
381
382func (p *permissionDialogCmp) View() string {
383	return p.render()
384}
385
386func (p *permissionDialogCmp) BindingKeys() []key.Binding {
387	return layout.KeyMapToSlice(helpKeys)
388}
389
390func (p *permissionDialogCmp) SetSize() {
391	if p.permission.ID == "" {
392		return
393	}
394	switch p.permission.ToolName {
395	case tools.BashToolName:
396		p.width = int(float64(p.windowSize.Width) * 0.4)
397		p.height = int(float64(p.windowSize.Height) * 0.3)
398	case tools.EditToolName:
399		p.width = int(float64(p.windowSize.Width) * 0.8)
400		p.height = int(float64(p.windowSize.Height) * 0.8)
401	case tools.WriteToolName:
402		p.width = int(float64(p.windowSize.Width) * 0.8)
403		p.height = int(float64(p.windowSize.Height) * 0.8)
404	case tools.FetchToolName:
405		p.width = int(float64(p.windowSize.Width) * 0.4)
406		p.height = int(float64(p.windowSize.Height) * 0.3)
407	default:
408		p.width = int(float64(p.windowSize.Width) * 0.7)
409		p.height = int(float64(p.windowSize.Height) * 0.5)
410	}
411}
412
413func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) {
414	p.permission = permission
415	p.SetSize()
416}
417
418// Helper to get or set cached diff content
419func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
420	if cached, ok := c.diffCache[key]; ok {
421		return cached
422	}
423
424	content, err := generator()
425	if err != nil {
426		return fmt.Sprintf("Error formatting diff: %v", err)
427	}
428
429	c.diffCache[key] = content
430
431	return content
432}
433
434// Helper to get or set cached markdown content
435func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
436	if cached, ok := c.markdownCache[key]; ok {
437		return cached
438	}
439
440	content, err := generator()
441	if err != nil {
442		return fmt.Sprintf("Error rendering markdown: %v", err)
443	}
444
445	c.markdownCache[key] = content
446
447	return content
448}
449
450func NewPermissionDialogCmp() PermissionDialogCmp {
451	// Create viewport for content
452	contentViewport := viewport.New(0, 0)
453
454	return &permissionDialogCmp{
455		contentViewPort: contentViewport,
456		selectedOption:  0, // Default to "Allow"
457		diffCache:       make(map[string]string),
458		markdownCache:   make(map[string]string),
459	}
460}