permission.go

  1package dialog
  2
  3import (
  4	"fmt"
  5
  6	"github.com/charmbracelet/bubbles/key"
  7	"github.com/charmbracelet/bubbles/viewport"
  8	tea "github.com/charmbracelet/bubbletea"
  9	"github.com/charmbracelet/glamour"
 10	"github.com/charmbracelet/lipgloss"
 11	"github.com/kujtimiihoxha/termai/internal/llm/tools"
 12	"github.com/kujtimiihoxha/termai/internal/permission"
 13	"github.com/kujtimiihoxha/termai/internal/tui/components/core"
 14	"github.com/kujtimiihoxha/termai/internal/tui/layout"
 15	"github.com/kujtimiihoxha/termai/internal/tui/styles"
 16	"github.com/kujtimiihoxha/termai/internal/tui/util"
 17
 18	"github.com/charmbracelet/huh"
 19)
 20
 21type PermissionAction string
 22
 23// Permission responses
 24const (
 25	PermissionAllow           PermissionAction = "allow"
 26	PermissionAllowForSession PermissionAction = "allow_session"
 27	PermissionDeny            PermissionAction = "deny"
 28)
 29
 30// PermissionResponseMsg represents the user's response to a permission request
 31type PermissionResponseMsg struct {
 32	Permission permission.PermissionRequest
 33	Action     PermissionAction
 34}
 35
 36// PermissionDialog interface for permission dialog component
 37type PermissionDialog interface {
 38	tea.Model
 39	layout.Sizeable
 40	layout.Bindings
 41}
 42
 43type keyMap struct {
 44	ChangeFocus key.Binding
 45}
 46
 47var keyMapValue = keyMap{
 48	ChangeFocus: key.NewBinding(
 49		key.WithKeys("tab"),
 50		key.WithHelp("tab", "change focus"),
 51	),
 52}
 53
 54// permissionDialogCmp is the implementation of PermissionDialog
 55type permissionDialogCmp struct {
 56	form            *huh.Form
 57	width           int
 58	height          int
 59	permission      permission.PermissionRequest
 60	windowSize      tea.WindowSizeMsg
 61	r               *glamour.TermRenderer
 62	contentViewPort viewport.Model
 63	isViewportFocus bool
 64	selectOption    *huh.Select[string]
 65}
 66
 67func (p *permissionDialogCmp) Init() tea.Cmd {
 68	return nil
 69}
 70
 71func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 72	var cmds []tea.Cmd
 73
 74	switch msg := msg.(type) {
 75	case tea.WindowSizeMsg:
 76		p.windowSize = msg
 77	case tea.KeyMsg:
 78		if key.Matches(msg, keyMapValue.ChangeFocus) {
 79			p.isViewportFocus = !p.isViewportFocus
 80			if p.isViewportFocus {
 81				p.selectOption.Blur()
 82				// Add a visual indicator for focus change
 83				cmds = append(cmds, tea.Batch(
 84					util.CmdHandler(util.InfoMsg("Viewing content - use arrow keys to scroll")),
 85				))
 86			} else {
 87				p.selectOption.Focus()
 88				// Add a visual indicator for focus change
 89				cmds = append(cmds, tea.Batch(
 90					util.CmdHandler(util.InfoMsg("Select an action")),
 91				))
 92			}
 93			return p, tea.Batch(cmds...)
 94		}
 95	}
 96
 97	if p.isViewportFocus {
 98		viewPort, cmd := p.contentViewPort.Update(msg)
 99		p.contentViewPort = viewPort
100		cmds = append(cmds, cmd)
101	} else {
102		form, cmd := p.form.Update(msg)
103		if f, ok := form.(*huh.Form); ok {
104			p.form = f
105			cmds = append(cmds, cmd)
106		}
107
108		if p.form.State == huh.StateCompleted {
109			// Get the selected action
110			action := p.form.GetString("action")
111
112			// Close the dialog and return the response
113			return p, tea.Batch(
114				util.CmdHandler(core.DialogCloseMsg{}),
115				util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
116			)
117		}
118	}
119	return p, tea.Batch(cmds...)
120}
121
122func (p *permissionDialogCmp) render() string {
123	keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
124	valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
125
126	form := p.form.View()
127
128	headerParts := []string{
129		lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
130		" ",
131		lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
132		" ",
133	}
134	r, _ := glamour.NewTermRenderer(
135		glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
136		glamour.WithWordWrap(p.width-10),
137		glamour.WithEmoji(),
138	)
139	content := ""
140	switch p.permission.ToolName {
141	case tools.BashToolName:
142		pr := p.permission.Params.(tools.BashPermissionsParams)
143		headerParts = append(headerParts, keyStyle.Render("Command:"))
144		content = fmt.Sprintf("```bash\n%s\n```", pr.Command)
145	case tools.EditToolName:
146		pr := p.permission.Params.(tools.EditPermissionsParams)
147		headerParts = append(headerParts, keyStyle.Render("Update"))
148		content = fmt.Sprintf("```\n%s\n```", pr.Diff)
149	case tools.WriteToolName:
150		pr := p.permission.Params.(tools.WritePermissionsParams)
151		headerParts = append(headerParts, keyStyle.Render("Content"))
152		content = fmt.Sprintf("```\n%s\n```", pr.Content)
153	case tools.FetchToolName:
154		pr := p.permission.Params.(tools.FetchPermissionsParams)
155		headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
156	default:
157		content = p.permission.Description
158	}
159
160	renderedContent, _ := r.Render(content)
161	headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
162	p.contentViewPort.Width = p.width - 2 - 2
163	p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
164	p.contentViewPort.SetContent(renderedContent)
165
166	// Make focus change more apparent with different border styles and colors
167	var contentBorder lipgloss.Border
168	var borderColor lipgloss.TerminalColor
169
170	if p.isViewportFocus {
171		contentBorder = lipgloss.DoubleBorder()
172		borderColor = styles.Blue
173	} else {
174		contentBorder = lipgloss.RoundedBorder()
175		borderColor = styles.Flamingo
176	}
177
178	contentStyle := lipgloss.NewStyle().
179		MarginTop(1).
180		Padding(0, 1).
181		Border(contentBorder).
182		BorderForeground(borderColor)
183
184	if p.isViewportFocus {
185		contentStyle = contentStyle.BorderBackground(styles.Surface0)
186	}
187
188	contentFinal := contentStyle.Render(p.contentViewPort.View())
189	if renderedContent == "" {
190		contentFinal = ""
191	}
192
193	return lipgloss.JoinVertical(
194		lipgloss.Top,
195		headerContent,
196		contentFinal,
197		form,
198	)
199}
200
201func (p *permissionDialogCmp) View() string {
202	return p.render()
203}
204
205func (p *permissionDialogCmp) GetSize() (int, int) {
206	return p.width, p.height
207}
208
209func (p *permissionDialogCmp) SetSize(width int, height int) {
210	p.width = width
211	p.height = height
212	p.form = p.form.WithWidth(width)
213}
214
215func (p *permissionDialogCmp) BindingKeys() []key.Binding {
216	return p.form.KeyBinds()
217}
218
219func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
220	// Create a note field for displaying the content
221
222	// Create select field for the permission options
223	selectOption := huh.NewSelect[string]().
224		Key("action").
225		Options(
226			huh.NewOption("Allow", string(PermissionAllow)),
227			huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
228			huh.NewOption("Deny", string(PermissionDeny)),
229		).
230		Title("Select an action")
231
232	// Apply theme
233	theme := styles.HuhTheme()
234
235	// Setup form width and height
236	form := huh.NewForm(huh.NewGroup(selectOption)).
237		WithShowHelp(false).
238		WithTheme(theme).
239		WithShowErrors(false)
240
241	// Focus the form for immediate interaction
242	selectOption.Focus()
243
244	return &permissionDialogCmp{
245		permission:   permission,
246		form:         form,
247		selectOption: selectOption,
248	}
249}
250
251// NewPermissionDialogCmd creates a new permission dialog command
252func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
253	permDialog := newPermissionDialogCmp(permission)
254
255	// Create the dialog layout
256	dialogPane := layout.NewSinglePane(
257		permDialog.(*permissionDialogCmp),
258		layout.WithSinglePaneBordered(true),
259		layout.WithSinglePaneFocusable(true),
260		layout.WithSinglePaneActiveColor(styles.Warning),
261		layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
262			layout.TopMiddleBorder: " Permission Required ",
263		}),
264	)
265
266	// Focus the dialog
267	dialogPane.Focus()
268	widthRatio := 0.7
269	heightRatio := 0.6
270	minWidth := 100
271	minHeight := 30
272
273	// Make the dialog size more appropriate for bash commands
274	switch permission.ToolName {
275	case tools.BashToolName:
276		widthRatio = 0.7
277		heightRatio = 0.5
278		minWidth = 100
279		minHeight = 30
280	}
281	// Return the dialog command
282	return util.CmdHandler(core.DialogMsg{
283		Content:     dialogPane,
284		WidthRatio:  widthRatio,
285		HeightRatio: heightRatio,
286		MinWidth:    minWidth,
287		MinHeight:   minHeight,
288	})
289}