SuperCoder/internal/tui/components/dialog/permission.go

495 lines
14 KiB
Go
Raw Normal View History

2025-03-25 13:04:36 +01:00
package dialog
import (
2025-03-27 22:35:48 +01:00
"fmt"
2025-03-25 13:04:36 +01:00
"github.com/charmbracelet/bubbles/key"
2025-03-27 22:35:48 +01:00
"github.com/charmbracelet/bubbles/viewport"
2025-03-25 13:04:36 +01:00
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
2025-04-24 18:25:52 +02:00
"github.com/opencode-ai/opencode/internal/diff"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
2025-04-24 18:25:52 +02:00
"github.com/opencode-ai/opencode/internal/tui/util"
2025-05-03 01:53:58 +05:30
"strings"
2025-03-25 13:04:36 +01:00
)
type PermissionAction string
// Permission responses
const (
PermissionAllow PermissionAction = "allow"
PermissionAllowForSession PermissionAction = "allow_session"
PermissionDeny PermissionAction = "deny"
)
// PermissionResponseMsg represents the user's response to a permission request
type PermissionResponseMsg struct {
Permission permission.PermissionRequest
Action PermissionAction
}
// PermissionDialogCmp interface for permission dialog component
type PermissionDialogCmp interface {
2025-03-25 13:04:36 +01:00
tea.Model
layout.Bindings
SetPermissions(permission permission.PermissionRequest) tea.Cmd
2025-03-25 13:04:36 +01:00
}
type permissionsMapping struct {
2025-04-18 21:24:35 +02:00
Left key.Binding
Right key.Binding
EnterSpace key.Binding
Allow key.Binding
AllowSession key.Binding
Deny key.Binding
Tab key.Binding
2025-03-27 22:35:48 +01:00
}
var permissionsKeys = permissionsMapping{
2025-04-18 21:24:35 +02:00
Left: key.NewBinding(
key.WithKeys("left"),
key.WithHelp("←", "switch options"),
),
Right: key.NewBinding(
key.WithKeys("right"),
key.WithHelp("→", "switch options"),
),
EnterSpace: key.NewBinding(
key.WithKeys("enter", " "),
key.WithHelp("enter/space", "confirm"),
),
Allow: key.NewBinding(
key.WithKeys("a"),
2025-04-24 13:01:32 +02:00
key.WithHelp("a", "allow"),
),
AllowSession: key.NewBinding(
2025-04-23 12:08:45 +02:00
key.WithKeys("s"),
2025-04-24 13:01:32 +02:00
key.WithHelp("s", "allow for session"),
),
Deny: key.NewBinding(
key.WithKeys("d"),
2025-04-24 13:01:32 +02:00
key.WithHelp("d", "deny"),
),
Tab: key.NewBinding(
2025-03-27 22:35:48 +01:00
key.WithKeys("tab"),
key.WithHelp("tab", "switch options"),
2025-03-27 22:35:48 +01:00
),
}
2025-03-25 13:04:36 +01:00
// permissionDialogCmp is the implementation of PermissionDialog
type permissionDialogCmp struct {
2025-03-27 22:35:48 +01:00
width int
height int
permission permission.PermissionRequest
windowSize tea.WindowSizeMsg
contentViewPort viewport.Model
selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
2025-03-25 13:04:36 +01:00
diffCache map[string]string
markdownCache map[string]string
}
2025-03-25 13:04:36 +01:00
func (p *permissionDialogCmp) Init() tea.Cmd {
return p.contentViewPort.Init()
2025-03-25 13:04:36 +01:00
}
func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
2025-03-27 22:35:48 +01:00
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.windowSize = msg
cmd := p.SetSize()
cmds = append(cmds, cmd)
p.markdownCache = make(map[string]string)
p.diffCache = make(map[string]string)
2025-03-27 22:35:48 +01:00
case tea.KeyMsg:
switch {
2025-04-18 21:24:35 +02:00
case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
p.selectedOption = (p.selectedOption + 1) % 3
return p, nil
2025-04-18 21:24:35 +02:00
case key.Matches(msg, permissionsKeys.Left):
p.selectedOption = (p.selectedOption + 2) % 3
case key.Matches(msg, permissionsKeys.EnterSpace):
return p, p.selectCurrentOption()
case key.Matches(msg, permissionsKeys.Allow):
return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
case key.Matches(msg, permissionsKeys.AllowSession):
return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
case key.Matches(msg, permissionsKeys.Deny):
return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
default:
// Pass other keys to viewport
viewPort, cmd := p.contentViewPort.Update(msg)
p.contentViewPort = viewPort
2025-03-27 22:35:48 +01:00
cmds = append(cmds, cmd)
}
2025-03-25 13:04:36 +01:00
}
2025-03-25 13:04:36 +01:00
return p, tea.Batch(cmds...)
}
func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
var action PermissionAction
2025-03-27 22:35:48 +01:00
switch p.selectedOption {
case 0:
action = PermissionAllow
case 1:
action = PermissionAllowForSession
case 2:
action = PermissionDeny
2025-03-27 22:35:48 +01:00
}
2025-04-07 19:43:31 +02:00
return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
}
2025-04-07 19:43:31 +02:00
func (p *permissionDialogCmp) renderButtons() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
allowStyle := baseStyle
allowSessionStyle := baseStyle
denyStyle := baseStyle
spacerStyle := baseStyle.Background(t.Background())
// Style the selected button
switch p.selectedOption {
case 0:
allowStyle = allowStyle.Background(t.Primary()).Foreground(t.Background())
allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
case 1:
allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
allowSessionStyle = allowSessionStyle.Background(t.Primary()).Foreground(t.Background())
denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
case 2:
allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
denyStyle = denyStyle.Background(t.Primary()).Foreground(t.Background())
}
2025-04-07 19:43:31 +02:00
allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
2025-04-24 13:01:32 +02:00
allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
content := lipgloss.JoinHorizontal(
lipgloss.Left,
allowButton,
spacerStyle.Render(" "),
allowSessionButton,
spacerStyle.Render(" "),
denyButton,
spacerStyle.Render(" "),
)
2025-04-07 19:43:31 +02:00
remainingWidth := p.width - lipgloss.Width(content)
if remainingWidth > 0 {
content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
}
return content
}
2025-04-07 19:43:31 +02:00
func (p *permissionDialogCmp) renderHeader() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
toolValue := baseStyle.
Foreground(t.Text()).
Width(p.width - lipgloss.Width(toolKey)).
Render(fmt.Sprintf(": %s", p.permission.ToolName))
2025-04-07 19:43:31 +02:00
pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
pathValue := baseStyle.
Foreground(t.Text()).
Width(p.width - lipgloss.Width(pathKey)).
Render(fmt.Sprintf(": %s", p.permission.Path))
2025-04-07 19:43:31 +02:00
headerParts := []string{
lipgloss.JoinHorizontal(
lipgloss.Left,
toolKey,
toolValue,
),
baseStyle.Render(strings.Repeat(" ", p.width)),
lipgloss.JoinHorizontal(
lipgloss.Left,
pathKey,
pathValue,
),
baseStyle.Render(strings.Repeat(" ", p.width)),
}
2025-04-07 19:43:31 +02:00
// Add tool-specific header information
switch p.permission.ToolName {
case tools.BashToolName:
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
2025-03-27 22:35:48 +01:00
case tools.EditToolName:
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
case tools.WriteToolName:
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
case tools.FetchToolName:
headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
}
2025-04-07 19:43:31 +02:00
return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
}
2025-04-07 19:43:31 +02:00
func (p *permissionDialogCmp) renderBashContent() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
2025-04-07 19:43:31 +02:00
// Use the cache for markdown rendering
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
r := styles.GetMarkdownRenderer(p.width-10)
s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
})
finalContent := baseStyle.
Width(p.contentViewPort.Width).
Render(renderedContent)
p.contentViewPort.SetContent(finalContent)
return p.styleViewport()
}
return ""
}
2025-04-07 19:43:31 +02:00
func (p *permissionDialogCmp) renderEditContent() string {
if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
})
2025-04-07 19:43:31 +02:00
p.contentViewPort.SetContent(diff)
return p.styleViewport()
}
return ""
}
2025-04-07 19:43:31 +02:00
func (p *permissionDialogCmp) renderPatchContent() string {
if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
})
p.contentViewPort.SetContent(diff)
return p.styleViewport()
}
return ""
}
func (p *permissionDialogCmp) renderWriteContent() string {
if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
// Use the cache for diff rendering
diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
})
2025-04-07 19:43:31 +02:00
2025-04-12 18:45:36 +02:00
p.contentViewPort.SetContent(diff)
return p.styleViewport()
}
return ""
}
2025-04-07 19:43:31 +02:00
func (p *permissionDialogCmp) renderFetchContent() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
2025-04-07 19:43:31 +02:00
// Use the cache for markdown rendering
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
r := styles.GetMarkdownRenderer(p.width-10)
s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
})
2025-04-07 19:43:31 +02:00
finalContent := baseStyle.
Width(p.contentViewPort.Width).
Render(renderedContent)
p.contentViewPort.SetContent(finalContent)
return p.styleViewport()
}
return ""
}
2025-04-07 19:43:31 +02:00
func (p *permissionDialogCmp) renderDefaultContent() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
content := p.permission.Description
2025-04-07 19:43:31 +02:00
// Use the cache for markdown rendering
renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
r := styles.GetMarkdownRenderer(p.width-10)
s, err := r.Render(content)
return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
})
2025-04-07 19:43:31 +02:00
finalContent := baseStyle.
Width(p.contentViewPort.Width).
Render(renderedContent)
p.contentViewPort.SetContent(finalContent)
2025-04-07 19:43:31 +02:00
if renderedContent == "" {
return ""
}
2025-04-07 19:43:31 +02:00
return p.styleViewport()
}
2025-04-07 19:43:31 +02:00
func (p *permissionDialogCmp) styleViewport() string {
t := theme.CurrentTheme()
contentStyle := lipgloss.NewStyle().
Background(t.Background())
2025-04-07 19:43:31 +02:00
return contentStyle.Render(p.contentViewPort.View())
}
2025-04-07 19:43:31 +02:00
func (p *permissionDialogCmp) render() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
title := baseStyle.
Bold(true).
Width(p.width - 4).
Foreground(t.Primary()).
Render("Permission Required")
// Render header
headerContent := p.renderHeader()
// Render buttons
buttons := p.renderButtons()
// Calculate content height dynamically based on window size
p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
p.contentViewPort.Width = p.width - 4
// Render content based on tool type
var contentFinal string
switch p.permission.ToolName {
case tools.BashToolName:
contentFinal = p.renderBashContent()
case tools.EditToolName:
contentFinal = p.renderEditContent()
case tools.PatchToolName:
contentFinal = p.renderPatchContent()
case tools.WriteToolName:
contentFinal = p.renderWriteContent()
case tools.FetchToolName:
contentFinal = p.renderFetchContent()
default:
contentFinal = p.renderDefaultContent()
}
2025-04-07 19:43:31 +02:00
content := lipgloss.JoinVertical(
lipgloss.Top,
title,
baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
headerContent,
contentFinal,
buttons,
baseStyle.Render(strings.Repeat(" ", p.width-4)),
)
2025-04-07 19:43:31 +02:00
return baseStyle.
Padding(1, 0, 0, 1).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(p.width).
Height(p.height).
Render(
content,
)
2025-03-25 13:04:36 +01:00
}
2025-03-27 22:35:48 +01:00
func (p *permissionDialogCmp) View() string {
return p.render()
}
func (p *permissionDialogCmp) BindingKeys() []key.Binding {
2025-04-20 15:56:36 +02:00
return layout.KeyMapToSlice(permissionsKeys)
2025-03-25 13:04:36 +01:00
}
func (p *permissionDialogCmp) SetSize() tea.Cmd {
if p.permission.ID == "" {
return nil
}
switch p.permission.ToolName {
case tools.BashToolName:
p.width = int(float64(p.windowSize.Width) * 0.4)
p.height = int(float64(p.windowSize.Height) * 0.3)
case tools.EditToolName:
p.width = int(float64(p.windowSize.Width) * 0.8)
p.height = int(float64(p.windowSize.Height) * 0.8)
case tools.WriteToolName:
p.width = int(float64(p.windowSize.Width) * 0.8)
p.height = int(float64(p.windowSize.Height) * 0.8)
case tools.FetchToolName:
p.width = int(float64(p.windowSize.Width) * 0.4)
p.height = int(float64(p.windowSize.Height) * 0.3)
default:
p.width = int(float64(p.windowSize.Width) * 0.7)
p.height = int(float64(p.windowSize.Height) * 0.5)
}
return nil
2025-03-25 13:04:36 +01:00
}
func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
p.permission = permission
return p.SetSize()
2025-03-25 13:04:36 +01:00
}
// Helper to get or set cached diff content
func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
if cached, ok := c.diffCache[key]; ok {
return cached
}
2025-03-25 13:04:36 +01:00
content, err := generator()
if err != nil {
return fmt.Sprintf("Error formatting diff: %v", err)
}
2025-03-25 13:04:36 +01:00
c.diffCache[key] = content
2025-03-25 13:04:36 +01:00
return content
}
2025-03-25 13:04:36 +01:00
// Helper to get or set cached markdown content
func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
if cached, ok := c.markdownCache[key]; ok {
return cached
}
2025-03-25 13:04:36 +01:00
content, err := generator()
if err != nil {
return fmt.Sprintf("Error rendering markdown: %v", err)
2025-03-25 13:04:36 +01:00
}
c.markdownCache[key] = content
2025-03-25 13:04:36 +01:00
return content
}
2025-03-27 22:35:48 +01:00
func NewPermissionDialogCmp() PermissionDialogCmp {
// Create viewport for content
contentViewport := viewport.New(0, 0)
return &permissionDialogCmp{
contentViewPort: contentViewport,
selectedOption: 0, // Default to "Allow"
diffCache: make(map[string]string),
markdownCache: make(map[string]string),
2025-03-27 22:35:48 +01:00
}
2025-03-25 13:04:36 +01:00
}