Detailed changes
@@ -38,6 +38,7 @@ type ListModel interface {
UpdateItem(int, util.Model) // Replace an item at the specified index
ResetView() // Clear rendering cache and reset scroll position
Items() []util.Model // Get all items in the list
+ SelectedIndex() int // Get the index of the currently selected item
}
// HasAnim interface identifies items that support animation.
@@ -1258,3 +1259,11 @@ func (m *model) filterSection(sect section, search string) *section {
return nil
}
+
+// SelectedIndex returns the index of the currently selected item.
+func (m *model) SelectedIndex() int {
+ if m.selectionState.selectedIndex < 0 || m.selectionState.selectedIndex >= len(m.filteredItems) {
+ return NoSelection
+ }
+ return m.selectionState.selectedIndex
+}
@@ -1,11 +1,18 @@
package commands
import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/v2/help"
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
+ "github.com/opencode-ai/opencode/internal/tui/util"
)
const (
@@ -36,10 +43,55 @@ type commandArgumentsDialogCmp struct {
width int
wWidth int // Width of the terminal window
wHeight int // Height of the terminal window
+
+ inputs []textinput.Model
+ focusIndex int
+ keys ArgumentsDialogKeyMap
+ commandID string
+ content string
+ argNames []string
+ help help.Model
}
-func NewCommandArgumentsDialog() CommandArgumentsDialog {
- return &commandArgumentsDialogCmp{}
+func NewCommandArgumentsDialog(commandID, content string, argNames []string) CommandArgumentsDialog {
+ t := theme.CurrentTheme()
+ inputs := make([]textinput.Model, len(argNames))
+
+ for i, name := range argNames {
+ ti := textinput.New()
+ ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
+ ti.SetWidth(40)
+ ti.SetVirtualCursor(false)
+ ti.Prompt = ""
+ ds := ti.Styles()
+
+ ds.Blurred.Placeholder = ds.Blurred.Placeholder.Background(t.Background()).Foreground(t.TextMuted())
+ ds.Blurred.Prompt = ds.Blurred.Prompt.Background(t.Background()).Foreground(t.TextMuted())
+ ds.Blurred.Text = ds.Blurred.Text.Background(t.Background()).Foreground(t.TextMuted())
+ ds.Focused.Placeholder = ds.Blurred.Placeholder.Background(t.Background()).Foreground(t.TextMuted())
+ ds.Focused.Prompt = ds.Blurred.Prompt.Background(t.Background()).Foreground(t.Text())
+ ds.Focused.Text = ds.Blurred.Text.Background(t.Background()).Foreground(t.Text())
+ ti.SetStyles(ds)
+ // Only focus the first input initially
+ if i == 0 {
+ ti.Focus()
+ } else {
+ ti.Blur()
+ }
+
+ inputs[i] = ti
+ }
+
+ return &commandArgumentsDialogCmp{
+ inputs: inputs,
+ keys: DefaultArgumentsDialogKeyMap(),
+ commandID: commandID,
+ content: content,
+ argNames: argNames,
+ focusIndex: 0,
+ width: 60,
+ help: help.New(),
+ }
}
// Init implements CommandArgumentsDialog.
@@ -48,20 +100,130 @@ func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
}
// Update implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ c.wWidth = msg.Width
+ c.wHeight = msg.Height
+ case tea.KeyPressMsg:
+ switch {
+ case key.Matches(msg, c.keys.Confirm):
+ if c.focusIndex == len(c.inputs)-1 {
+ content := c.content
+ for i, name := range c.argNames {
+ value := c.inputs[i].Value()
+ placeholder := "$" + name
+ content = strings.ReplaceAll(content, placeholder, value)
+ }
+ return c, tea.Sequence(
+ util.CmdHandler(dialogs.CloseDialogMsg{}),
+ util.CmdHandler(CommandRunCustomMsg{
+ Content: content,
+ }),
+ )
+ }
+ // Otherwise, move to the next input
+ c.inputs[c.focusIndex].Blur()
+ c.focusIndex++
+ c.inputs[c.focusIndex].Focus()
+ case key.Matches(msg, c.keys.Next):
+ // Move to the next input
+ c.inputs[c.focusIndex].Blur()
+ c.focusIndex = (c.focusIndex + 1) % len(c.inputs)
+ c.inputs[c.focusIndex].Focus()
+ case key.Matches(msg, c.keys.Previous):
+ // Move to the previous input
+ c.inputs[c.focusIndex].Blur()
+ c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
+ c.inputs[c.focusIndex].Focus()
+
+ default:
+ var cmd tea.Cmd
+ c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
+ return c, cmd
+ }
+ }
return c, nil
}
// View implements CommandArgumentsDialog.
func (c *commandArgumentsDialogCmp) View() tea.View {
- return tea.NewView("")
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ title := lipgloss.NewStyle().
+ Foreground(t.Primary()).
+ Bold(true).
+ Padding(0, 1).
+ Background(t.Background()).
+ Render("Command Arguments")
+
+ explanation := lipgloss.NewStyle().
+ Foreground(t.Text()).
+ Padding(0, 1).
+ Background(t.Background()).
+ Render("This command requires arguments.")
+
+ // Create input fields for each argument
+ inputFields := make([]string, len(c.inputs))
+ for i, input := range c.inputs {
+ // Highlight the label of the focused input
+ labelStyle := lipgloss.NewStyle().
+ Padding(1, 1, 0, 1).
+ Background(t.Background())
+
+ if i == c.focusIndex {
+ labelStyle = labelStyle.Foreground(t.Text()).Bold(true)
+ } else {
+ labelStyle = labelStyle.Foreground(t.TextMuted())
+ }
+
+ label := labelStyle.Render(c.argNames[i] + ":")
+
+ field := lipgloss.NewStyle().
+ Foreground(t.Text()).
+ Padding(0, 1).
+ Background(t.Background()).
+ Render(input.View())
+
+ inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
+ }
+
+ // Join all elements vertically
+ elements := []string{title, explanation}
+ elements = append(elements, inputFields...)
+
+ c.help.ShowAll = false
+ helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys))
+ elements = append(elements, "", helpText)
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ elements...,
+ )
+
+ view := tea.NewView(
+ baseStyle.Padding(1, 1, 0, 1).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(t.Background()).
+ BorderForeground(t.TextMuted()).
+ Background(t.Background()).
+ Width(c.width).
+ Render(content),
+ )
+ cursor := c.inputs[c.focusIndex].Cursor()
+ if cursor != nil {
+ cursor = c.moveCursor(cursor)
+ }
+ view.SetCursor(cursor)
+ return view
}
func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
- offset := 10 + 1
+ offset := 13 + (1+c.focusIndex)*3
cursor.Y += offset
_, col := c.Position()
- cursor.X = cursor.X + col + 2
+ cursor.X = cursor.X + col + 3
return cursor
}
@@ -1,6 +1,7 @@
package commands
import (
+ "github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
@@ -37,14 +38,31 @@ type commandDialogCmp struct {
wHeight int // Height of the terminal window
commandList list.ListModel
+ commands []Command
+ keyMap CommandsDialogKeyMap
}
func NewCommandDialog() CommandsDialog {
- commandList := list.New(list.WithFilterable(true))
-
+ listKeyMap := list.DefaultKeyMap()
+ keyMap := DefaultCommandsDialogKeyMap()
+
+ listKeyMap.Down.SetEnabled(false)
+ listKeyMap.Up.SetEnabled(false)
+ listKeyMap.NDown.SetEnabled(false)
+ listKeyMap.NUp.SetEnabled(false)
+ listKeyMap.HalfPageDown.SetEnabled(false)
+ listKeyMap.HalfPageUp.SetEnabled(false)
+ listKeyMap.Home.SetEnabled(false)
+ listKeyMap.End.SetEnabled(false)
+
+ listKeyMap.DownOneItem = keyMap.Next
+ listKeyMap.UpOneItem = keyMap.Previous
+
+ commandList := list.New(list.WithFilterable(true), list.WithKeyMap(listKeyMap))
return &commandDialogCmp{
commandList: commandList,
width: defaultWidth,
+ keyMap: DefaultCommandsDialogKeyMap(),
}
}
@@ -53,6 +71,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
if err != nil {
return util.ReportError(err)
}
+ c.commands = commands
commandItems := []util.Model{}
if len(commands) > 0 {
@@ -65,6 +84,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
commandItems = append(commandItems, NewItemSection("Default"))
for _, cmd := range c.defaultCommands() {
+ c.commands = append(c.commands, cmd)
commandItems = append(commandItems, NewCommandItem(cmd))
}
@@ -78,10 +98,26 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.wWidth = msg.Width
c.wHeight = msg.Height
return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
+ case tea.KeyPressMsg:
+ switch {
+ case key.Matches(msg, c.keyMap.Select):
+ selectedItemInx := c.commandList.SelectedIndex()
+ if selectedItemInx == list.NoSelection {
+ return c, nil // No item selected, do nothing
+ }
+ items := c.commandList.Items()
+ selectedItem := items[selectedItemInx].(CommandItem).Command()
+ return c, tea.Sequence(
+ util.CmdHandler(dialogs.CloseDialogMsg{}),
+ selectedItem.Handler(selectedItem),
+ )
+ default:
+ u, cmd := c.commandList.Update(msg)
+ c.commandList = u.(list.ListModel)
+ return c, cmd
+ }
}
- u, cmd := c.commandList.Update(msg)
- c.commandList = u.(list.ListModel)
- return c, cmd
+ return c, nil
}
func (c *commandDialogCmp) View() tea.View {
@@ -18,6 +18,7 @@ type CommandItem interface {
util.Model
layout.Focusable
layout.Sizeable
+ Command() Command
}
type commandItem struct {
@@ -72,6 +73,11 @@ func (c *commandItem) View() tea.View {
return tea.NewView(text)
}
+// Command implements CommandItem.
+func (c *commandItem) Command() Command {
+ return c.command
+}
+
// Blur implements CommandItem.
func (c *commandItem) Blur() tea.Cmd {
c.focus = false
@@ -1 +1,93 @@
package commands
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+)
+
+type CommandsDialogKeyMap struct {
+ Select key.Binding
+ Next key.Binding
+ Previous key.Binding
+}
+
+func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
+ return CommandsDialogKeyMap{
+ Select: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "confirm"),
+ ),
+ Next: key.NewBinding(
+ key.WithKeys("tab", "down"),
+ key.WithHelp("tab/↓", "next"),
+ ),
+ Previous: key.NewBinding(
+ key.WithKeys("shift+tab", "up"),
+ key.WithHelp("shift+tab/↑", "previous"),
+ ),
+ }
+}
+
+// FullHelp implements help.KeyMap.
+func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding {
+ m := [][]key.Binding{}
+ slice := layout.KeyMapToSlice(k)
+ for i := 0; i < len(slice); i += 4 {
+ end := min(i+4, len(slice))
+ m = append(m, slice[i:end])
+ }
+ return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k CommandsDialogKeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{
+ k.Select,
+ k.Next,
+ k.Previous,
+ }
+}
+
+type ArgumentsDialogKeyMap struct {
+ Confirm key.Binding
+ Next key.Binding
+ Previous key.Binding
+}
+
+func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
+ return ArgumentsDialogKeyMap{
+ Confirm: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "confirm"),
+ ),
+
+ Next: key.NewBinding(
+ key.WithKeys("tab", "down"),
+ key.WithHelp("tab/↓", "next"),
+ ),
+ Previous: key.NewBinding(
+ key.WithKeys("shift+tab", "up"),
+ key.WithHelp("shift+tab/↑", "previous"),
+ ),
+ }
+}
+
+// FullHelp implements help.KeyMap.
+func (k ArgumentsDialogKeyMap) FullHelp() [][]key.Binding {
+ m := [][]key.Binding{}
+ slice := layout.KeyMapToSlice(k)
+ for i := 0; i < len(slice); i += 4 {
+ end := min(i+4, len(slice))
+ m = append(m, slice[i:end])
+ }
+ return m
+}
+
+// ShortHelp implements help.KeyMap.
+func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{
+ k.Confirm,
+ k.Next,
+ k.Previous,
+ }
+}
@@ -58,7 +58,15 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.dialog = u.(dialogs.DialogCmp)
return a, dialogCmd
case commands.ShowArgumentsDialogMsg:
-
+ return a, util.CmdHandler(
+ dialogs.OpenDialogMsg{
+ Model: commands.NewCommandArgumentsDialog(
+ msg.CommandID,
+ msg.Content,
+ msg.ArgNames,
+ ),
+ },
+ )
// Page change messages
case page.PageChangeMsg:
return a, a.moveToPage(msg.ID)