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
 68func (p *permissionDialogCmp) Init() tea.Cmd {
 69	return nil
 70}
 71
 72func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 73	var cmds []tea.Cmd
 74
 75	switch msg := msg.(type) {
 76	case tea.WindowSizeMsg:
 77		p.windowSize = msg
 78	case tea.KeyMsg:
 79		if key.Matches(msg, keyMapValue.ChangeFocus) {
 80			p.isViewportFocus = !p.isViewportFocus
 81			if p.isViewportFocus {
 82				p.selectOption.Blur()
 83				// Add a visual indicator for focus change
 84				cmds = append(cmds, tea.Batch(
 85					util.CmdHandler(util.InfoMsg("Viewing content - use arrow keys to scroll")),
 86				))
 87			} else {
 88				p.selectOption.Focus()
 89				// Add a visual indicator for focus change
 90				cmds = append(cmds, tea.Batch(
 91					util.CmdHandler(util.InfoMsg("Select an action")),
 92				))
 93			}
 94			return p, tea.Batch(cmds...)
 95		}
 96	}
 97
 98	if p.isViewportFocus {
 99		viewPort, cmd := p.contentViewPort.Update(msg)
100		p.contentViewPort = viewPort
101		cmds = append(cmds, cmd)
102	} else {
103		form, cmd := p.form.Update(msg)
104		if f, ok := form.(*huh.Form); ok {
105			p.form = f
106			cmds = append(cmds, cmd)
107		}
108
109		if p.form.State == huh.StateCompleted {
110			// Get the selected action
111			action := p.form.GetString("action")
112
113			// Close the dialog and return the response
114			return p, tea.Batch(
115				util.CmdHandler(core.DialogCloseMsg{}),
116				util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
117			)
118		}
119	}
120	return p, tea.Batch(cmds...)
121}
122
123func (p *permissionDialogCmp) render() string {
124	keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
125	valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
126
127	form := p.form.View()
128
129	headerParts := []string{
130		lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
131		" ",
132		lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
133		" ",
134	}
135	r, _ := glamour.NewTermRenderer(
136		glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
137		glamour.WithWordWrap(p.width-10),
138		glamour.WithEmoji(),
139	)
140	content := ""
141	switch p.permission.ToolName {
142	case tools.BashToolName:
143		pr := p.permission.Params.(tools.BashPermissionsParams)
144		headerParts = append(headerParts, keyStyle.Render("Command:"))
145		content = fmt.Sprintf("```bash\n%s\n```", pr.Command)
146	case tools.EditToolName:
147		pr := p.permission.Params.(tools.EditPermissionsParams)
148		headerParts = append(headerParts, keyStyle.Render("Update"))
149		content = fmt.Sprintf("```\n%s\n```", pr.Diff)
150	case tools.WriteToolName:
151		pr := p.permission.Params.(tools.WritePermissionsParams)
152		headerParts = append(headerParts, keyStyle.Render("Content"))
153		content = fmt.Sprintf("```\n%s\n```", pr.Content)
154	case tools.FetchToolName:
155		pr := p.permission.Params.(tools.FetchPermissionsParams)
156		headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
157	default:
158		content = p.permission.Description
159	}
160
161	renderedContent, _ := r.Render(content)
162	headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
163	p.contentViewPort.Width = p.width - 2 - 2
164
165	// Calculate content height dynamically based on content
166	contentLines := len(strings.Split(renderedContent, "\n"))
167	// Set a reasonable min/max for the viewport height
168	minContentHeight := 3
169	maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
170
171	// For bash commands, adjust height based on content length
172	if p.permission.ToolName == tools.BashToolName {
173		// Add some padding to the content lines
174		contentHeight := contentLines + 2
175		if contentHeight < minContentHeight {
176			contentHeight = minContentHeight
177		}
178		if contentHeight > maxContentHeight {
179			contentHeight = maxContentHeight
180		}
181		p.contentViewPort.Height = contentHeight
182	} else {
183		// For other content types, use the full available height
184		p.contentViewPort.Height = maxContentHeight
185	}
186
187	p.contentViewPort.SetContent(renderedContent)
188
189	// Make focus change more apparent with different border styles and colors
190	var contentBorder lipgloss.Border
191	var borderColor lipgloss.TerminalColor
192
193	if p.isViewportFocus {
194		contentBorder = lipgloss.DoubleBorder()
195		borderColor = styles.Blue
196	} else {
197		contentBorder = lipgloss.RoundedBorder()
198		borderColor = styles.Flamingo
199	}
200
201	contentStyle := lipgloss.NewStyle().
202		MarginTop(1).
203		Padding(0, 1).
204		Border(contentBorder).
205		BorderForeground(borderColor)
206
207	if p.isViewportFocus {
208		contentStyle = contentStyle.BorderBackground(styles.Surface0)
209	}
210
211	contentFinal := contentStyle.Render(p.contentViewPort.View())
212	if renderedContent == "" {
213		contentFinal = ""
214	}
215
216	return lipgloss.JoinVertical(
217		lipgloss.Top,
218		headerContent,
219		contentFinal,
220		form,
221	)
222}
223
224func (p *permissionDialogCmp) View() string {
225	return p.render()
226}
227
228func (p *permissionDialogCmp) GetSize() (int, int) {
229	return p.width, p.height
230}
231
232func (p *permissionDialogCmp) SetSize(width int, height int) {
233	p.width = width
234	p.height = height
235	p.form = p.form.WithWidth(width)
236}
237
238func (p *permissionDialogCmp) BindingKeys() []key.Binding {
239	return p.form.KeyBinds()
240}
241
242func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
243	// Create a note field for displaying the content
244
245	// Create select field for the permission options
246	selectOption := huh.NewSelect[string]().
247		Key("action").
248		Options(
249			huh.NewOption("Allow", string(PermissionAllow)),
250			huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
251			huh.NewOption("Deny", string(PermissionDeny)),
252		).
253		Title("Select an action")
254
255	// Apply theme
256	theme := styles.HuhTheme()
257
258	// Setup form width and height
259	form := huh.NewForm(huh.NewGroup(selectOption)).
260		WithShowHelp(false).
261		WithTheme(theme).
262		WithShowErrors(false)
263
264	// Focus the form for immediate interaction
265	selectOption.Focus()
266
267	return &permissionDialogCmp{
268		permission:   permission,
269		form:         form,
270		selectOption: selectOption,
271	}
272}
273
274// NewPermissionDialogCmd creates a new permission dialog command
275func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
276	permDialog := newPermissionDialogCmp(permission)
277
278	// Create the dialog layout
279	dialogPane := layout.NewSinglePane(
280		permDialog.(*permissionDialogCmp),
281		layout.WithSinglePaneBordered(true),
282		layout.WithSinglePaneFocusable(true),
283		layout.WithSinglePaneActiveColor(styles.Warning),
284		layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
285			layout.TopMiddleBorder: " Permission Required ",
286		}),
287	)
288
289	// Focus the dialog
290	dialogPane.Focus()
291	widthRatio := 0.7
292	heightRatio := 0.6
293	minWidth := 100
294	minHeight := 30
295
296	// Make the dialog size more appropriate for different tools
297	switch permission.ToolName {
298	case tools.BashToolName:
299		// For bash commands, use a more compact dialog
300		widthRatio = 0.7
301		heightRatio = 0.4 // Reduced from 0.5
302		minWidth = 100
303		minHeight = 20 // Reduced from 30
304	}
305	// Return the dialog command
306	return util.CmdHandler(core.DialogMsg{
307		Content:     dialogPane,
308		WidthRatio:  widthRatio,
309		HeightRatio: heightRatio,
310		MinWidth:    minWidth,
311		MinHeight:   minHeight,
312	})
313}