From e9a8bda49a97f905a3db47b2c941591da1511244 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 29 May 2025 18:39:59 +0200 Subject: [PATCH] wip arguments dialog --- internal/tui/components/core/list/list.go | 9 + .../components/dialogs/commands/arguments.go | 174 +++++++++++++++++- .../components/dialogs/commands/commands.go | 46 ++++- .../tui/components/dialogs/commands/item.go | 6 + .../tui/components/dialogs/commands/keys.go | 92 +++++++++ internal/tui/tui.go | 10 +- 6 files changed, 325 insertions(+), 12 deletions(-) diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index e3da3bc36f78ab09197907644a3614f338a1e502..c1a678ab0a4af6fce0cd62f7c8972e7656c7e8ae 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -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 +} diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index 69e6a48dcc3b657d1587c62c1be5d3ce180c48c1..16963c22dc756483e18e616f1fa44dd425accd7d 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -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 } diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index b52ad5c6bd6e653295e9acce33dc1013b05fd99e..55cfefd5af592854cb38161f0e7e546a6e71b295 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -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 { diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go index d1395af9c2889808d8a005d0940d017558795b13..e656c1c3a6133763f7bc4bc78c438b9c84f4c3b1 100644 --- a/internal/tui/components/dialogs/commands/item.go +++ b/internal/tui/components/dialogs/commands/item.go @@ -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 diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go index cdff10da75a9b02f8657b3b60631599137203efe..92c2695f5aff71e640aeb41f165237766644210d 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/internal/tui/components/dialogs/commands/keys.go @@ -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, + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a176d895b69278252a4184fe879aca30b9cbe0ad..f2b99e0711915c402583c05ca77142d3a047af6c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -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)