summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/components/dialog
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/components/dialog')
-rw-r--r--internal/tui/components/dialog/commands.go247
-rw-r--r--internal/tui/components/dialog/help.go182
-rw-r--r--internal/tui/components/dialog/init.go191
-rw-r--r--internal/tui/components/dialog/permission.go710
-rw-r--r--internal/tui/components/dialog/quit.go162
-rw-r--r--internal/tui/components/dialog/session.go226
6 files changed, 1300 insertions, 418 deletions
diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go
new file mode 100644
index 000000000..7b25caeb0
--- /dev/null
+++ b/internal/tui/components/dialog/commands.go
@@ -0,0 +1,247 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
+)
+
+// Command represents a command that can be executed
+type Command struct {
+ ID string
+ Title string
+ Description string
+ Handler func(cmd Command) tea.Cmd
+}
+
+// CommandSelectedMsg is sent when a command is selected
+type CommandSelectedMsg struct {
+ Command Command
+}
+
+// CloseCommandDialogMsg is sent when the command dialog is closed
+type CloseCommandDialogMsg struct{}
+
+// CommandDialog interface for the command selection dialog
+type CommandDialog interface {
+ tea.Model
+ layout.Bindings
+ SetCommands(commands []Command)
+ SetSelectedCommand(commandID string)
+}
+
+type commandDialogCmp struct {
+ commands []Command
+ selectedIdx int
+ width int
+ height int
+ selectedCommandID string
+}
+
+type commandKeyMap struct {
+ Up key.Binding
+ Down key.Binding
+ Enter key.Binding
+ Escape key.Binding
+ J key.Binding
+ K key.Binding
+}
+
+var commandKeys = commandKeyMap{
+ Up: key.NewBinding(
+ key.WithKeys("up"),
+ key.WithHelp("↑", "previous command"),
+ ),
+ Down: key.NewBinding(
+ key.WithKeys("down"),
+ key.WithHelp("↓", "next command"),
+ ),
+ Enter: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "select command"),
+ ),
+ Escape: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "close"),
+ ),
+ J: key.NewBinding(
+ key.WithKeys("j"),
+ key.WithHelp("j", "next command"),
+ ),
+ K: key.NewBinding(
+ key.WithKeys("k"),
+ key.WithHelp("k", "previous command"),
+ ),
+}
+
+func (c *commandDialogCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
+ if c.selectedIdx > 0 {
+ c.selectedIdx--
+ }
+ return c, nil
+ case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
+ if c.selectedIdx < len(c.commands)-1 {
+ c.selectedIdx++
+ }
+ return c, nil
+ case key.Matches(msg, commandKeys.Enter):
+ if len(c.commands) > 0 {
+ return c, util.CmdHandler(CommandSelectedMsg{
+ Command: c.commands[c.selectedIdx],
+ })
+ }
+ case key.Matches(msg, commandKeys.Escape):
+ return c, util.CmdHandler(CloseCommandDialogMsg{})
+ }
+ case tea.WindowSizeMsg:
+ c.width = msg.Width
+ c.height = msg.Height
+ }
+ return c, nil
+}
+
+func (c *commandDialogCmp) View() string {
+ if len(c.commands) == 0 {
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(40).
+ Render("No commands available")
+ }
+
+ // Calculate max width needed for command titles
+ maxWidth := 40 // Minimum width
+ for _, cmd := range c.commands {
+ if len(cmd.Title) > maxWidth-4 { // Account for padding
+ maxWidth = len(cmd.Title) + 4
+ }
+ if len(cmd.Description) > maxWidth-4 {
+ maxWidth = len(cmd.Description) + 4
+ }
+ }
+
+ // Limit height to avoid taking up too much screen space
+ maxVisibleCommands := min(10, len(c.commands))
+
+ // Build the command list
+ commandItems := make([]string, 0, maxVisibleCommands)
+ startIdx := 0
+
+ // If we have more commands than can be displayed, adjust the start index
+ if len(c.commands) > maxVisibleCommands {
+ // Center the selected item when possible
+ halfVisible := maxVisibleCommands / 2
+ if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
+ startIdx = c.selectedIdx - halfVisible
+ } else if c.selectedIdx >= len(c.commands)-halfVisible {
+ startIdx = len(c.commands) - maxVisibleCommands
+ }
+ }
+
+ endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
+
+ for i := startIdx; i < endIdx; i++ {
+ cmd := c.commands[i]
+ itemStyle := styles.BaseStyle.Width(maxWidth)
+ descStyle := styles.BaseStyle.Width(maxWidth).Foreground(styles.ForgroundDim)
+
+ if i == c.selectedIdx {
+ itemStyle = itemStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background).
+ Bold(true)
+ descStyle = descStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background)
+ }
+
+ title := itemStyle.Padding(0, 1).Render(cmd.Title)
+ description := ""
+ if cmd.Description != "" {
+ description = descStyle.Padding(0, 1).Render(cmd.Description)
+ commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
+ } else {
+ commandItems = append(commandItems, title)
+ }
+ }
+
+ title := styles.BaseStyle.
+ Foreground(styles.PrimaryColor).
+ Bold(true).
+ Width(maxWidth).
+ Padding(0, 1).
+ Render("Commands")
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ title,
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ styles.BaseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ styles.BaseStyle.Width(maxWidth).Padding(0, 1).Foreground(styles.ForgroundDim).Render("↑/k: up ↓/j: down enter: select esc: cancel"),
+ )
+
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(lipgloss.Width(content) + 4).
+ Render(content)
+}
+
+func (c *commandDialogCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(commandKeys)
+}
+
+func (c *commandDialogCmp) SetCommands(commands []Command) {
+ c.commands = commands
+
+ // If we have a selected command ID, find its index
+ if c.selectedCommandID != "" {
+ for i, cmd := range commands {
+ if cmd.ID == c.selectedCommandID {
+ c.selectedIdx = i
+ return
+ }
+ }
+ }
+
+ // Default to first command if selected not found
+ c.selectedIdx = 0
+}
+
+func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
+ c.selectedCommandID = commandID
+
+ // Update the selected index if commands are already loaded
+ if len(c.commands) > 0 {
+ for i, cmd := range c.commands {
+ if cmd.ID == commandID {
+ c.selectedIdx = i
+ return
+ }
+ }
+ }
+}
+
+// NewCommandDialogCmp creates a new command selection dialog
+func NewCommandDialogCmp() CommandDialog {
+ return &commandDialogCmp{
+ commands: []Command{},
+ selectedIdx: 0,
+ selectedCommandID: "",
+ }
+}
+
diff --git a/internal/tui/components/dialog/help.go b/internal/tui/components/dialog/help.go
new file mode 100644
index 000000000..644b294cb
--- /dev/null
+++ b/internal/tui/components/dialog/help.go
@@ -0,0 +1,182 @@
+package dialog
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+)
+
+type helpCmp struct {
+ width int
+ height int
+ keys []key.Binding
+}
+
+func (h *helpCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (h *helpCmp) SetBindings(k []key.Binding) {
+ h.keys = k
+}
+
+func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ h.width = 90
+ h.height = msg.Height
+ }
+ return h, nil
+}
+
+func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
+ seen := make(map[string]struct{})
+ result := make([]key.Binding, 0, len(bindings))
+
+ // Process bindings in reverse order
+ for i := len(bindings) - 1; i >= 0; i-- {
+ b := bindings[i]
+ k := strings.Join(b.Keys(), " ")
+ if _, ok := seen[k]; ok {
+ // duplicate, skip
+ continue
+ }
+ seen[k] = struct{}{}
+ // Add to the beginning of result to maintain original order
+ result = append([]key.Binding{b}, result...)
+ }
+
+ return result
+}
+
+func (h *helpCmp) render() string {
+ helpKeyStyle := styles.Bold.Background(styles.Background).Foreground(styles.Forground).Padding(0, 1, 0, 0)
+ helpDescStyle := styles.Regular.Background(styles.Background).Foreground(styles.ForgroundMid)
+ // Compile list of bindings to render
+ bindings := removeDuplicateBindings(h.keys)
+ // Enumerate through each group of bindings, populating a series of
+ // pairs of columns, one for keys, one for descriptions
+ var (
+ pairs []string
+ width int
+ rows = 14 - 2
+ )
+ for i := 0; i < len(bindings); i += rows {
+ var (
+ keys []string
+ descs []string
+ )
+ for j := i; j < min(i+rows, len(bindings)); j++ {
+ keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
+ descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
+ }
+ // Render pair of columns; beyond the first pair, render a three space
+ // left margin, in order to visually separate the pairs.
+ var cols []string
+ if len(pairs) > 0 {
+ cols = []string{styles.BaseStyle.Render(" ")}
+ }
+
+ maxDescWidth := 0
+ for _, desc := range descs {
+ if maxDescWidth < lipgloss.Width(desc) {
+ maxDescWidth = lipgloss.Width(desc)
+ }
+ }
+ for i := range descs {
+ remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
+ if remainingWidth > 0 {
+ descs[i] = descs[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
+ }
+ }
+ maxKeyWidth := 0
+ for _, key := range keys {
+ if maxKeyWidth < lipgloss.Width(key) {
+ maxKeyWidth = lipgloss.Width(key)
+ }
+ }
+ for i := range keys {
+ remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
+ if remainingWidth > 0 {
+ keys[i] = keys[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
+ }
+ }
+
+ cols = append(cols,
+ strings.Join(keys, "\n"),
+ strings.Join(descs, "\n"),
+ )
+
+ pair := styles.BaseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
+ // check whether it exceeds the maximum width avail (the width of the
+ // terminal, subtracting 2 for the borders).
+ width += lipgloss.Width(pair)
+ if width > h.width-2 {
+ break
+ }
+ pairs = append(pairs, pair)
+ }
+
+ // https://github.com/charmbracelet/lipgloss/issues/209
+ if len(pairs) > 1 {
+ prefix := pairs[:len(pairs)-1]
+ lastPair := pairs[len(pairs)-1]
+ prefix = append(prefix, lipgloss.Place(
+ lipgloss.Width(lastPair), // width
+ lipgloss.Height(prefix[0]), // height
+ lipgloss.Left, // x
+ lipgloss.Top, // y
+ lastPair, // content
+ lipgloss.WithWhitespaceBackground(styles.Background), // background
+ ))
+ content := styles.BaseStyle.Width(h.width).Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Top,
+ prefix...,
+ ),
+ )
+ return content
+ }
+ // Join pairs of columns and enclose in a border
+ content := styles.BaseStyle.Width(h.width).Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Top,
+ pairs...,
+ ),
+ )
+ return content
+}
+
+func (h *helpCmp) View() string {
+ content := h.render()
+ header := styles.BaseStyle.
+ Bold(true).
+ Width(lipgloss.Width(content)).
+ Foreground(styles.PrimaryColor).
+ Render("Keyboard Shortcuts")
+
+ return styles.BaseStyle.Padding(1).
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(styles.ForgroundDim).
+ Width(h.width).
+ BorderBackground(styles.Background).
+ Render(
+ lipgloss.JoinVertical(lipgloss.Center,
+ header,
+ styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
+ content,
+ ),
+ )
+}
+
+type HelpCmp interface {
+ tea.Model
+ SetBindings([]key.Binding)
+}
+
+func NewHelpCmp() HelpCmp {
+ return &helpCmp{}
+}
diff --git a/internal/tui/components/dialog/init.go b/internal/tui/components/dialog/init.go
new file mode 100644
index 000000000..6098ca755
--- /dev/null
+++ b/internal/tui/components/dialog/init.go
@@ -0,0 +1,191 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
+)
+
+// InitDialogCmp is a component that asks the user if they want to initialize the project.
+type InitDialogCmp struct {
+ width, height int
+ selected int
+ keys initDialogKeyMap
+}
+
+// NewInitDialogCmp creates a new InitDialogCmp.
+func NewInitDialogCmp() InitDialogCmp {
+ return InitDialogCmp{
+ selected: 0,
+ keys: initDialogKeyMap{},
+ }
+}
+
+type initDialogKeyMap struct {
+ Tab key.Binding
+ Left key.Binding
+ Right key.Binding
+ Enter key.Binding
+ Escape key.Binding
+ Y key.Binding
+ N key.Binding
+}
+
+// ShortHelp implements key.Map.
+func (k initDialogKeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{
+ key.NewBinding(
+ key.WithKeys("tab", "left", "right"),
+ key.WithHelp("tab/←/→", "toggle selection"),
+ ),
+ key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "confirm"),
+ ),
+ key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
+ key.NewBinding(
+ key.WithKeys("y", "n"),
+ key.WithHelp("y/n", "yes/no"),
+ ),
+ }
+}
+
+// FullHelp implements key.Map.
+func (k initDialogKeyMap) FullHelp() [][]key.Binding {
+ return [][]key.Binding{k.ShortHelp()}
+}
+
+// Init implements tea.Model.
+func (m InitDialogCmp) Init() tea.Cmd {
+ return nil
+}
+
+// Update implements tea.Model.
+func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
+ case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
+ m.selected = (m.selected + 1) % 2
+ return m, nil
+ case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
+ case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
+ case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
+ }
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ }
+ return m, nil
+}
+
+// View implements tea.Model.
+func (m InitDialogCmp) View() string {
+ // Calculate width needed for content
+ maxWidth := 60 // Width for explanation text
+
+ title := styles.BaseStyle.
+ Foreground(styles.PrimaryColor).
+ Bold(true).
+ Width(maxWidth).
+ Padding(0, 1).
+ Render("Initialize Project")
+
+ explanation := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(maxWidth).
+ Padding(0, 1).
+ Render("Initialization generates a new OpenCode.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
+
+ question := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(maxWidth).
+ Padding(1, 1).
+ Render("Would you like to initialize this project?")
+
+ yesStyle := styles.BaseStyle
+ noStyle := styles.BaseStyle
+
+ if m.selected == 0 {
+ yesStyle = yesStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background).
+ Bold(true)
+ noStyle = noStyle.
+ Background(styles.Background).
+ Foreground(styles.PrimaryColor)
+ } else {
+ noStyle = noStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background).
+ Bold(true)
+ yesStyle = yesStyle.
+ Background(styles.Background).
+ Foreground(styles.PrimaryColor)
+ }
+
+ yes := yesStyle.Padding(0, 3).Render("Yes")
+ no := noStyle.Padding(0, 3).Render("No")
+
+ buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, styles.BaseStyle.Render(" "), no)
+ buttons = styles.BaseStyle.
+ Width(maxWidth).
+ Padding(1, 0).
+ Render(buttons)
+
+ help := styles.BaseStyle.
+ Width(maxWidth).
+ Padding(0, 1).
+ Foreground(styles.ForgroundDim).
+ Render("tab/←/→: toggle y/n: yes/no enter: confirm esc: cancel")
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ title,
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ explanation,
+ question,
+ buttons,
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ help,
+ )
+
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(lipgloss.Width(content) + 4).
+ Render(content)
+}
+
+// SetSize sets the size of the component.
+func (m *InitDialogCmp) SetSize(width, height int) {
+ m.width = width
+ m.height = height
+}
+
+// Bindings implements layout.Bindings.
+func (m InitDialogCmp) Bindings() []key.Binding {
+ return m.keys.ShortHelp()
+}
+
+// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
+type CloseInitDialogMsg struct {
+ Initialize bool
+}
+
+// ShowInitDialogMsg is a message that is sent to show the init dialog.
+type ShowInitDialogMsg struct {
+ Show bool
+}
diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go
index 465f475d5..16b63815c 100644
--- a/internal/tui/components/dialog/permission.go
+++ b/internal/tui/components/dialog/permission.go
@@ -9,14 +9,12 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/llm/tools"
- "github.com/kujtimiihoxha/termai/internal/permission"
- "github.com/kujtimiihoxha/termai/internal/tui/components/core"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
-
- "github.com/charmbracelet/huh"
+ "github.com/kujtimiihoxha/opencode/internal/diff"
+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
+ "github.com/kujtimiihoxha/opencode/internal/permission"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
)
type PermissionAction string
@@ -34,69 +32,69 @@ type PermissionResponseMsg struct {
Action PermissionAction
}
-// PermissionDialog interface for permission dialog component
-type PermissionDialog interface {
+// PermissionDialogCmp interface for permission dialog component
+type PermissionDialogCmp interface {
tea.Model
- layout.Sizeable
layout.Bindings
+ SetPermissions(permission permission.PermissionRequest) tea.Cmd
}
-type keyMap struct {
- ChangeFocus key.Binding
+type permissionsMapping struct {
+ Left key.Binding
+ Right key.Binding
+ EnterSpace key.Binding
+ Allow key.Binding
+ AllowSession key.Binding
+ Deny key.Binding
+ Tab key.Binding
}
-var keyMapValue = keyMap{
- ChangeFocus: key.NewBinding(
+var permissionsKeys = permissionsMapping{
+ 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"),
+ key.WithHelp("a", "allow"),
+ ),
+ AllowSession: key.NewBinding(
+ key.WithKeys("A"),
+ key.WithHelp("A", "allow for session"),
+ ),
+ Deny: key.NewBinding(
+ key.WithKeys("d"),
+ key.WithHelp("d", "deny"),
+ ),
+ Tab: key.NewBinding(
key.WithKeys("tab"),
- key.WithHelp("tab", "change focus"),
+ key.WithHelp("tab", "switch options"),
),
}
// permissionDialogCmp is the implementation of PermissionDialog
type permissionDialogCmp struct {
- form *huh.Form
width int
height int
permission permission.PermissionRequest
windowSize tea.WindowSizeMsg
- r *glamour.TermRenderer
contentViewPort viewport.Model
- isViewportFocus bool
- selectOption *huh.Select[string]
-}
+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
-// formatDiff formats a diff string with colors for additions and deletions
-func formatDiff(diffText string) string {
- lines := strings.Split(diffText, "\n")
- var formattedLines []string
-
- // Define styles for different line types
- addStyle := lipgloss.NewStyle().Foreground(styles.Green)
- removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
- headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
- contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
-
- // Process each line
- for _, line := range lines {
- if strings.HasPrefix(line, "+") {
- formattedLines = append(formattedLines, addStyle.Render(line))
- } else if strings.HasPrefix(line, "-") {
- formattedLines = append(formattedLines, removeStyle.Render(line))
- } else if strings.HasPrefix(line, "Changes:") || strings.HasPrefix(line, " ...") {
- formattedLines = append(formattedLines, headerStyle.Render(line))
- } else if strings.HasPrefix(line, " ") {
- formattedLines = append(formattedLines, contextStyle.Render(line))
- } else {
- formattedLines = append(formattedLines, line)
- }
- }
-
- // Join all formatted lines
- return strings.Join(formattedLines, "\n")
+ diffCache map[string]string
+ markdownCache map[string]string
}
func (p *permissionDialogCmp) Init() tea.Cmd {
- return nil
+ return p.contentViewPort.Init()
}
func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -105,369 +103,381 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
case tea.KeyMsg:
- if key.Matches(msg, keyMapValue.ChangeFocus) {
- p.isViewportFocus = !p.isViewportFocus
- if p.isViewportFocus {
- p.selectOption.Blur()
- // Add a visual indicator for focus change
- cmds = append(cmds, tea.Batch(
- util.ReportInfo("Viewing content - use arrow keys to scroll"),
- ))
- } else {
- p.selectOption.Focus()
- // Add a visual indicator for focus change
- cmds = append(cmds, tea.Batch(
- util.CmdHandler(util.ReportInfo("Select an action")),
- ))
- }
- return p, tea.Batch(cmds...)
+ switch {
+ case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
+ p.selectedOption = (p.selectedOption + 1) % 3
+ return p, nil
+ 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
+ cmds = append(cmds, cmd)
}
}
- if p.isViewportFocus {
- viewPort, cmd := p.contentViewPort.Update(msg)
- p.contentViewPort = viewPort
- cmds = append(cmds, cmd)
- } else {
- form, cmd := p.form.Update(msg)
- if f, ok := form.(*huh.Form); ok {
- p.form = f
- cmds = append(cmds, cmd)
- }
+ return p, tea.Batch(cmds...)
+}
- if p.form.State == huh.StateCompleted {
- // Get the selected action
- action := p.form.GetString("action")
+func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
+ var action PermissionAction
- // Close the dialog and return the response
- return p, tea.Batch(
- util.CmdHandler(core.DialogCloseMsg{}),
- util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
- )
- }
+ switch p.selectedOption {
+ case 0:
+ action = PermissionAllow
+ case 1:
+ action = PermissionAllowForSession
+ case 2:
+ action = PermissionDeny
}
- return p, tea.Batch(cmds...)
+
+ return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
}
-func (p *permissionDialogCmp) render() string {
- keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
- valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
+func (p *permissionDialogCmp) renderButtons() string {
+ allowStyle := styles.BaseStyle
+ allowSessionStyle := styles.BaseStyle
+ denyStyle := styles.BaseStyle
+ spacerStyle := styles.BaseStyle.Background(styles.Background)
+
+ // Style the selected button
+ switch p.selectedOption {
+ case 0:
+ allowStyle = allowStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ case 1:
+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ allowSessionStyle = allowSessionStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ case 2:
+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ denyStyle = denyStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ }
- form := p.form.View()
+ allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
+ allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (A)")
+ denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
+
+ content := lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ allowButton,
+ spacerStyle.Render(" "),
+ allowSessionButton,
+ spacerStyle.Render(" "),
+ denyButton,
+ spacerStyle.Render(" "),
+ )
- headerParts := []string{
- lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
- " ",
- lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
- " ",
+ remainingWidth := p.width - lipgloss.Width(content)
+ if remainingWidth > 0 {
+ content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
}
+ return content
+}
- // Create the header content first so it can be used in all cases
- headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
+func (p *permissionDialogCmp) renderHeader() string {
+ toolKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Tool")
+ toolValue := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(p.width - lipgloss.Width(toolKey)).
+ Render(fmt.Sprintf(": %s", p.permission.ToolName))
- r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
- glamour.WithWordWrap(p.width-10),
- glamour.WithEmoji(),
- )
+ pathKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Path")
+ pathValue := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(p.width - lipgloss.Width(pathKey)).
+ Render(fmt.Sprintf(": %s", p.permission.Path))
- // Handle different tool types
+ headerParts := []string{
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ toolKey,
+ toolValue,
+ ),
+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ pathKey,
+ pathValue,
+ ),
+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
+ }
+
+ // Add tool-specific header information
switch p.permission.ToolName {
case tools.BashToolName:
- pr := p.permission.Params.(tools.BashPermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("Command:"))
- content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
-
- renderedContent, _ := r.Render(content)
- p.contentViewPort.Width = p.width - 2 - 2
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Command"))
+ case tools.EditToolName:
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
+ case tools.WriteToolName:
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
+ case tools.FetchToolName:
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("URL"))
+ }
- // Calculate content height dynamically based on content
- contentLines := len(strings.Split(renderedContent, "\n"))
- // Set a reasonable min/max for the viewport height
- minContentHeight := 3
- maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
+ return lipgloss.NewStyle().Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
+}
- // Add some padding to the content lines
- contentHeight := contentLines + 2
- contentHeight = max(contentHeight, minContentHeight)
- contentHeight = min(contentHeight, maxContentHeight)
- p.contentViewPort.Height = contentHeight
+func (p *permissionDialogCmp) renderBashContent() string {
+ if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
+ content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
- p.contentViewPort.SetContent(renderedContent)
+ // Use the cache for markdown rendering
+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(true)),
+ glamour.WithWordWrap(p.width-10),
+ )
+ s, err := r.Render(content)
+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
+ })
+
+ finalContent := styles.BaseStyle.
+ Width(p.contentViewPort.Width).
+ Render(renderedContent)
+ p.contentViewPort.SetContent(finalContent)
+ return p.styleViewport()
+ }
+ return ""
+}
- // Style the viewport
- var contentBorder lipgloss.Border
- var borderColor lipgloss.TerminalColor
+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))
+ })
- if p.isViewportFocus {
- contentBorder = lipgloss.DoubleBorder()
- borderColor = styles.Blue
- } else {
- contentBorder = lipgloss.RoundedBorder()
- borderColor = styles.Flamingo
- }
+ p.contentViewPort.SetContent(diff)
+ return p.styleViewport()
+ }
+ return ""
+}
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(contentBorder).
- BorderForeground(borderColor)
+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))
+ })
- if p.isViewportFocus {
- contentStyle = contentStyle.BorderBackground(styles.Surface0)
- }
+ p.contentViewPort.SetContent(diff)
+ return p.styleViewport()
+ }
+ return ""
+}
- contentFinal := contentStyle.Render(p.contentViewPort.View())
+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))
+ })
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
- )
+ p.contentViewPort.SetContent(diff)
+ return p.styleViewport()
+ }
+ return ""
+}
- case tools.EditToolName:
- pr := p.permission.Params.(tools.EditPermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("Update"))
- // Recreate header content with the updated headerParts
- headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-
- // Format the diff with colors
- formattedDiff := formatDiff(pr.Diff)
-
- // Set up viewport for the diff content
- p.contentViewPort.Width = p.width - 2 - 2
-
- // Calculate content height dynamically based on window size
- maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.Height = maxContentHeight
- p.contentViewPort.SetContent(formattedDiff)
-
- // Style the viewport
- var contentBorder lipgloss.Border
- var borderColor lipgloss.TerminalColor
-
- if p.isViewportFocus {
- contentBorder = lipgloss.DoubleBorder()
- borderColor = styles.Blue
- } else {
- contentBorder = lipgloss.RoundedBorder()
- borderColor = styles.Flamingo
- }
+func (p *permissionDialogCmp) renderFetchContent() string {
+ if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
+ content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(contentBorder).
- BorderForeground(borderColor)
+ // Use the cache for markdown rendering
+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(true)),
+ glamour.WithWordWrap(p.width-10),
+ )
+ s, err := r.Render(content)
+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
+ })
- if p.isViewportFocus {
- contentStyle = contentStyle.BorderBackground(styles.Surface0)
- }
+ p.contentViewPort.SetContent(renderedContent)
+ return p.styleViewport()
+ }
+ return ""
+}
- contentFinal := contentStyle.Render(p.contentViewPort.View())
+func (p *permissionDialogCmp) renderDefaultContent() string {
+ content := p.permission.Description
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
+ // Use the cache for markdown rendering
+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
+ glamour.WithWordWrap(p.width-10),
)
+ s, err := r.Render(content)
+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
+ })
- case tools.WriteToolName:
- pr := p.permission.Params.(tools.WritePermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("Content"))
- // Recreate header content with the updated headerParts
- headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-
- // Format the diff with colors
- formattedDiff := formatDiff(pr.Content)
-
- // Set up viewport for the content
- p.contentViewPort.Width = p.width - 2 - 2
-
- // Calculate content height dynamically based on window size
- maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.Height = maxContentHeight
- p.contentViewPort.SetContent(formattedDiff)
-
- // Style the viewport
- var contentBorder lipgloss.Border
- var borderColor lipgloss.TerminalColor
-
- if p.isViewportFocus {
- contentBorder = lipgloss.DoubleBorder()
- borderColor = styles.Blue
- } else {
- contentBorder = lipgloss.RoundedBorder()
- borderColor = styles.Flamingo
- }
+ p.contentViewPort.SetContent(renderedContent)
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(contentBorder).
- BorderForeground(borderColor)
+ if renderedContent == "" {
+ return ""
+ }
- if p.isViewportFocus {
- contentStyle = contentStyle.BorderBackground(styles.Surface0)
- }
+ return p.styleViewport()
+}
- contentFinal := contentStyle.Render(p.contentViewPort.View())
+func (p *permissionDialogCmp) styleViewport() string {
+ contentStyle := lipgloss.NewStyle().
+ Background(styles.Background)
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
- )
+ return contentStyle.Render(p.contentViewPort.View())
+}
+func (p *permissionDialogCmp) render() string {
+ title := styles.BaseStyle.
+ Bold(true).
+ Width(p.width - 4).
+ Foreground(styles.PrimaryColor).
+ 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:
- pr := p.permission.Params.(tools.FetchPermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
- content := p.permission.Description
-
- renderedContent, _ := r.Render(content)
- p.contentViewPort.Width = p.width - 2 - 2
- p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.SetContent(renderedContent)
-
- // Style the viewport
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(styles.Flamingo)
-
- contentFinal := contentStyle.Render(p.contentViewPort.View())
- if renderedContent == "" {
- contentFinal = ""
- }
-
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
- )
-
+ contentFinal = p.renderFetchContent()
default:
- content := p.permission.Description
-
- renderedContent, _ := r.Render(content)
- p.contentViewPort.Width = p.width - 2 - 2
- p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.SetContent(renderedContent)
-
- // Style the viewport
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(styles.Flamingo)
+ contentFinal = p.renderDefaultContent()
+ }
- contentFinal := contentStyle.Render(p.contentViewPort.View())
- if renderedContent == "" {
- contentFinal = ""
- }
+ // Add help text
+ helpText := styles.BaseStyle.Width(p.width - 4).Padding(0, 1).Foreground(styles.ForgroundDim).Render("←/→/tab: switch options a: allow A: allow for session d: deny enter/space: confirm")
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Top,
+ title,
+ styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
+ headerContent,
+ contentFinal,
+ buttons,
+ styles.BaseStyle.Render(strings.Repeat(" ", p.width - 4)),
+ helpText,
+ )
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
+ return styles.BaseStyle.
+ Padding(1, 0, 0, 1).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(p.width).
+ Height(p.height).
+ Render(
+ content,
)
- }
}
func (p *permissionDialogCmp) View() string {
return p.render()
}
-func (p *permissionDialogCmp) GetSize() (int, int) {
- return p.width, p.height
+func (p *permissionDialogCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(permissionsKeys)
}
-func (p *permissionDialogCmp) SetSize(width int, height int) {
- p.width = width
- p.height = height
- p.form = p.form.WithWidth(width)
+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
}
-func (p *permissionDialogCmp) BindingKeys() []key.Binding {
- return p.form.KeyBinds()
+func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
+ p.permission = permission
+ return p.SetSize()
}
-func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
- // Create a note field for displaying the content
+// 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
+ }
- // Create select field for the permission options
- selectOption := huh.NewSelect[string]().
- Key("action").
- Options(
- huh.NewOption("Allow", string(PermissionAllow)),
- huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
- huh.NewOption("Deny", string(PermissionDeny)),
- ).
- Title("Select an action")
+ content, err := generator()
+ if err != nil {
+ return fmt.Sprintf("Error formatting diff: %v", err)
+ }
- // Apply theme
- theme := styles.HuhTheme()
+ c.diffCache[key] = content
- // Setup form width and height
- form := huh.NewForm(huh.NewGroup(selectOption)).
- WithShowHelp(false).
- WithTheme(theme).
- WithShowErrors(false)
+ return content
+}
- // Focus the form for immediate interaction
- selectOption.Focus()
+// 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
+ }
- return &permissionDialogCmp{
- permission: permission,
- form: form,
- selectOption: selectOption,
+ content, err := generator()
+ if err != nil {
+ return fmt.Sprintf("Error rendering markdown: %v", err)
}
-}
-// NewPermissionDialogCmd creates a new permission dialog command
-func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
- permDialog := newPermissionDialogCmp(permission)
-
- // Create the dialog layout
- dialogPane := layout.NewSinglePane(
- permDialog.(*permissionDialogCmp),
- layout.WithSinglePaneBordered(true),
- layout.WithSinglePaneFocusable(true),
- layout.WithSinglePaneActiveColor(styles.Warning),
- layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
- layout.TopMiddleBorder: " Permission Required ",
- }),
- )
+ c.markdownCache[key] = content
- // Focus the dialog
- dialogPane.Focus()
- widthRatio := 0.7
- heightRatio := 0.6
- minWidth := 100
- minHeight := 30
+ return content
+}
- // Make the dialog size more appropriate for different tools
- switch permission.ToolName {
- case tools.BashToolName:
- // For bash commands, use a more compact dialog
- widthRatio = 0.7
- heightRatio = 0.4 // Reduced from 0.5
- minWidth = 100
- minHeight = 20 // Reduced from 30
+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),
}
- // Return the dialog command
- return util.CmdHandler(core.DialogMsg{
- Content: dialogPane,
- WidthRatio: widthRatio,
- HeightRatio: heightRatio,
- MinWidth: minWidth,
- MinHeight: minHeight,
- })
}
diff --git a/internal/tui/components/dialog/quit.go b/internal/tui/components/dialog/quit.go
index 60c1fc0d2..5bbe6696c 100644
--- a/internal/tui/components/dialog/quit.go
+++ b/internal/tui/components/dialog/quit.go
@@ -1,28 +1,58 @@
package dialog
import (
+ "strings"
+
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
- "github.com/kujtimiihoxha/termai/internal/tui/components/core"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
-
- "github.com/charmbracelet/huh"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
)
const question = "Are you sure you want to quit?"
+type CloseQuitMsg struct{}
+
type QuitDialog interface {
tea.Model
- layout.Sizeable
layout.Bindings
}
type quitDialogCmp struct {
- form *huh.Form
- width int
- height int
+ selectedNo bool
+}
+
+type helpMapping struct {
+ LeftRight key.Binding
+ EnterSpace key.Binding
+ Yes key.Binding
+ No key.Binding
+ Tab key.Binding
+}
+
+var helpKeys = helpMapping{
+ LeftRight: key.NewBinding(
+ key.WithKeys("left", "right"),
+ key.WithHelp("←/→", "switch options"),
+ ),
+ EnterSpace: key.NewBinding(
+ key.WithKeys("enter", " "),
+ key.WithHelp("enter/space", "confirm"),
+ ),
+ Yes: key.NewBinding(
+ key.WithKeys("y", "Y"),
+ key.WithHelp("y/Y", "yes"),
+ ),
+ No: key.NewBinding(
+ key.WithKeys("n", "N"),
+ key.WithHelp("n/N", "no"),
+ ),
+ Tab: key.NewBinding(
+ key.WithKeys("tab"),
+ key.WithHelp("tab", "switch options"),
+ ),
}
func (q *quitDialogCmp) Init() tea.Cmd {
@@ -30,77 +60,73 @@ func (q *quitDialogCmp) Init() tea.Cmd {
}
func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- form, cmd := q.form.Update(msg)
- if f, ok := form.(*huh.Form); ok {
- q.form = f
- cmds = append(cmds, cmd)
- }
-
- if q.form.State == huh.StateCompleted {
- v := q.form.GetBool("quit")
- if v {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
+ q.selectedNo = !q.selectedNo
+ return q, nil
+ case key.Matches(msg, helpKeys.EnterSpace):
+ if !q.selectedNo {
+ return q, tea.Quit
+ }
+ return q, util.CmdHandler(CloseQuitMsg{})
+ case key.Matches(msg, helpKeys.Yes):
return q, tea.Quit
+ case key.Matches(msg, helpKeys.No):
+ return q, util.CmdHandler(CloseQuitMsg{})
}
- cmds = append(cmds, util.CmdHandler(core.DialogCloseMsg{}))
}
-
- return q, tea.Batch(cmds...)
+ return q, nil
}
func (q *quitDialogCmp) View() string {
- return q.form.View()
-}
+ yesStyle := styles.BaseStyle
+ noStyle := styles.BaseStyle
+ spacerStyle := styles.BaseStyle.Background(styles.Background)
+
+ if q.selectedNo {
+ noStyle = noStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ yesStyle = yesStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ } else {
+ yesStyle = yesStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ noStyle = noStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ }
-func (q *quitDialogCmp) GetSize() (int, int) {
- return q.width, q.height
-}
+ yesButton := yesStyle.Padding(0, 1).Render("Yes")
+ noButton := noStyle.Padding(0, 1).Render("No")
+
+ buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
+
+ width := lipgloss.Width(question)
+ remainingWidth := width - lipgloss.Width(buttons)
+ if remainingWidth > 0 {
+ buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
+ }
-func (q *quitDialogCmp) SetSize(width int, height int) {
- q.width = width
- q.height = height
- q.form = q.form.WithWidth(width).WithHeight(height)
+ content := styles.BaseStyle.Render(
+ lipgloss.JoinVertical(
+ lipgloss.Center,
+ question,
+ "",
+ buttons,
+ ),
+ )
+
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(lipgloss.Width(content) + 4).
+ Render(content)
}
func (q *quitDialogCmp) BindingKeys() []key.Binding {
- return q.form.KeyBinds()
+ return layout.KeyMapToSlice(helpKeys)
}
-func newQuitDialogCmp() QuitDialog {
- confirm := huh.NewConfirm().
- Title(question).
- Affirmative("Yes!").
- Key("quit").
- Negative("No.")
-
- theme := styles.HuhTheme()
- theme.Focused.FocusedButton = theme.Focused.FocusedButton.Background(styles.Warning)
- theme.Blurred.FocusedButton = theme.Blurred.FocusedButton.Background(styles.Warning)
- form := huh.NewForm(huh.NewGroup(confirm)).
- WithShowHelp(false).
- WithWidth(0).
- WithHeight(0).
- WithTheme(theme).
- WithShowErrors(false)
- confirm.Focus()
+func NewQuitCmp() QuitDialog {
return &quitDialogCmp{
- form: form,
+ selectedNo: true,
}
}
-
-func NewQuitDialogCmd() tea.Cmd {
- content := layout.NewSinglePane(
- newQuitDialogCmp().(*quitDialogCmp),
- layout.WithSinglePaneBordered(true),
- layout.WithSinglePaneFocusable(true),
- layout.WithSinglePaneActiveColor(styles.Warning),
- )
- content.Focus()
- return util.CmdHandler(core.DialogMsg{
- Content: content,
- WidthRatio: 0.2,
- HeightRatio: 0.1,
- MinWidth: 40,
- MinHeight: 5,
- })
-}
diff --git a/internal/tui/components/dialog/session.go b/internal/tui/components/dialog/session.go
new file mode 100644
index 000000000..060875f91
--- /dev/null
+++ b/internal/tui/components/dialog/session.go
@@ -0,0 +1,226 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
+)
+
+// SessionSelectedMsg is sent when a session is selected
+type SessionSelectedMsg struct {
+ Session session.Session
+}
+
+// CloseSessionDialogMsg is sent when the session dialog is closed
+type CloseSessionDialogMsg struct{}
+
+// SessionDialog interface for the session switching dialog
+type SessionDialog interface {
+ tea.Model
+ layout.Bindings
+ SetSessions(sessions []session.Session)
+ SetSelectedSession(sessionID string)
+}
+
+type sessionDialogCmp struct {
+ sessions []session.Session
+ selectedIdx int
+ width int
+ height int
+ selectedSessionID string
+}
+
+type sessionKeyMap struct {
+ Up key.Binding
+ Down key.Binding
+ Enter key.Binding
+ Escape key.Binding
+ J key.Binding
+ K key.Binding
+}
+
+var sessionKeys = sessionKeyMap{
+ Up: key.NewBinding(
+ key.WithKeys("up"),
+ key.WithHelp("↑", "previous session"),
+ ),
+ Down: key.NewBinding(
+ key.WithKeys("down"),
+ key.WithHelp("↓", "next session"),
+ ),
+ Enter: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "select session"),
+ ),
+ Escape: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "close"),
+ ),
+ J: key.NewBinding(
+ key.WithKeys("j"),
+ key.WithHelp("j", "next session"),
+ ),
+ K: key.NewBinding(
+ key.WithKeys("k"),
+ key.WithHelp("k", "previous session"),
+ ),
+}
+
+func (s *sessionDialogCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
+ if s.selectedIdx > 0 {
+ s.selectedIdx--
+ }
+ return s, nil
+ case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J):
+ if s.selectedIdx < len(s.sessions)-1 {
+ s.selectedIdx++
+ }
+ return s, nil
+ case key.Matches(msg, sessionKeys.Enter):
+ if len(s.sessions) > 0 {
+ return s, util.CmdHandler(SessionSelectedMsg{
+ Session: s.sessions[s.selectedIdx],
+ })
+ }
+ case key.Matches(msg, sessionKeys.Escape):
+ return s, util.CmdHandler(CloseSessionDialogMsg{})
+ }
+ case tea.WindowSizeMsg:
+ s.width = msg.Width
+ s.height = msg.Height
+ }
+ return s, nil
+}
+
+func (s *sessionDialogCmp) View() string {
+ if len(s.sessions) == 0 {
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(40).
+ Render("No sessions available")
+ }
+
+ // Calculate max width needed for session titles
+ maxWidth := 40 // Minimum width
+ for _, sess := range s.sessions {
+ if len(sess.Title) > maxWidth-4 { // Account for padding
+ maxWidth = len(sess.Title) + 4
+ }
+ }
+
+ // Limit height to avoid taking up too much screen space
+ maxVisibleSessions := min(10, len(s.sessions))
+
+ // Build the session list
+ sessionItems := make([]string, 0, maxVisibleSessions)
+ startIdx := 0
+
+ // If we have more sessions than can be displayed, adjust the start index
+ if len(s.sessions) > maxVisibleSessions {
+ // Center the selected item when possible
+ halfVisible := maxVisibleSessions / 2
+ if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible {
+ startIdx = s.selectedIdx - halfVisible
+ } else if s.selectedIdx >= len(s.sessions)-halfVisible {
+ startIdx = len(s.sessions) - maxVisibleSessions
+ }
+ }
+
+ endIdx := min(startIdx+maxVisibleSessions, len(s.sessions))
+
+ for i := startIdx; i < endIdx; i++ {
+ sess := s.sessions[i]
+ itemStyle := styles.BaseStyle.Width(maxWidth)
+
+ if i == s.selectedIdx {
+ itemStyle = itemStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background).
+ Bold(true)
+ }
+
+ sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
+ }
+
+ title := styles.BaseStyle.
+ Foreground(styles.PrimaryColor).
+ Bold(true).
+ Width(maxWidth).
+ Padding(0, 1).
+ Render("Switch Session")
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ title,
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ styles.BaseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ styles.BaseStyle.Width(maxWidth).Padding(0, 1).Foreground(styles.ForgroundDim).Render("↑/k: up ↓/j: down enter: select esc: cancel"),
+ )
+
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(lipgloss.Width(content) + 4).
+ Render(content)
+}
+
+func (s *sessionDialogCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(sessionKeys)
+}
+
+func (s *sessionDialogCmp) SetSessions(sessions []session.Session) {
+ s.sessions = sessions
+
+ // If we have a selected session ID, find its index
+ if s.selectedSessionID != "" {
+ for i, sess := range sessions {
+ if sess.ID == s.selectedSessionID {
+ s.selectedIdx = i
+ return
+ }
+ }
+ }
+
+ // Default to first session if selected not found
+ s.selectedIdx = 0
+}
+
+func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
+ s.selectedSessionID = sessionID
+
+ // Update the selected index if sessions are already loaded
+ if len(s.sessions) > 0 {
+ for i, sess := range s.sessions {
+ if sess.ID == sessionID {
+ s.selectedIdx = i
+ return
+ }
+ }
+ }
+}
+
+// NewSessionDialogCmp creates a new session switching dialog
+func NewSessionDialogCmp() SessionDialog {
+ return &sessionDialogCmp{
+ sessions: []session.Session{},
+ selectedIdx: 0,
+ selectedSessionID: "",
+ }
+}
+