on main help sign in
wip arguments dialog
e9a8bdaKujtim Hoxha
internal/tui/components/core/list/list.go+9
@@ -38,6 +38,7 @@ type ListModel interface {
38 UpdateItem(int, util.Model) // Replace an item at the specified index
38 UpdateItem(int, util.Model) // Replace an item at the specified index
39 ResetView() // Clear rendering cache and reset scroll position
39 ResetView() // Clear rendering cache and reset scroll position
40 Items() []util.Model // Get all items in the list
40 Items() []util.Model // Get all items in the list
41+ SelectedIndex() int // Get the index of the currently selected item
41}
42}
42
43
43// HasAnim interface identifies items that support animation.
44// HasAnim interface identifies items that support animation.
@@ -1258,3 +1259,11 @@ func (m *model) filterSection(sect section, search string) *section {
1258
1259
1259 return nil
1260 return nil
1260}
1261}
1262+
1263+// SelectedIndex returns the index of the currently selected item.
1264+func (m *model) SelectedIndex() int {
1265+ if m.selectionState.selectedIndex < 0 || m.selectionState.selectedIndex >= len(m.filteredItems) {
1266+ return NoSelection
1267+ }
1268+ return m.selectionState.selectedIndex
1269+}
internal/tui/components/dialogs/commands/arguments.go+168−6
@@ -1,11 +1,18 @@
1package commands
1package commands
2
2
3import (
3import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/bubbles/v2/help"
8+ "github.com/charmbracelet/bubbles/v2/key"
9+ "github.com/charmbracelet/bubbles/v2/textinput"
4 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
5 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/lipgloss/v2"
6 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
12 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
7 "github.com/opencode-ai/opencode/internal/tui/styles"
13 "github.com/opencode-ai/opencode/internal/tui/styles"
8 "github.com/opencode-ai/opencode/internal/tui/theme"
14 "github.com/opencode-ai/opencode/internal/tui/theme"
15+ "github.com/opencode-ai/opencode/internal/tui/util"
9)
16)
10
17
11const (
18const (
@@ -36,10 +43,55 @@ type commandArgumentsDialogCmp struct {
36 width int
43 width int
37 wWidth int // Width of the terminal window
44 wWidth int // Width of the terminal window
38 wHeight int // Height of the terminal window
45 wHeight int // Height of the terminal window
46+
47+ inputs []textinput.Model
48+ focusIndex int
49+ keys ArgumentsDialogKeyMap
50+ commandID string
51+ content string
52+ argNames []string
53+ help help.Model
39}
54}
40
55
41−func NewCommandArgumentsDialog() CommandArgumentsDialog {
56+func NewCommandArgumentsDialog(commandID, content string, argNames []string) CommandArgumentsDialog {
57+ t := theme.CurrentTheme()
58+ inputs := make([]textinput.Model, len(argNames))
59+
60+ for i, name := range argNames {
61+ ti := textinput.New()
62+ ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
63+ ti.SetWidth(40)
64+ ti.SetVirtualCursor(false)
65+ ti.Prompt = ""
66+ ds := ti.Styles()
67+
68+ ds.Blurred.Placeholder = ds.Blurred.Placeholder.Background(t.Background()).Foreground(t.TextMuted())
69+ ds.Blurred.Prompt = ds.Blurred.Prompt.Background(t.Background()).Foreground(t.TextMuted())
70+ ds.Blurred.Text = ds.Blurred.Text.Background(t.Background()).Foreground(t.TextMuted())
71+ ds.Focused.Placeholder = ds.Blurred.Placeholder.Background(t.Background()).Foreground(t.TextMuted())
72+ ds.Focused.Prompt = ds.Blurred.Prompt.Background(t.Background()).Foreground(t.Text())
73+ ds.Focused.Text = ds.Blurred.Text.Background(t.Background()).Foreground(t.Text())
74+ ti.SetStyles(ds)
75+ // Only focus the first input initially
76+ if i == 0 {
77+ ti.Focus()
78+ } else {
79+ ti.Blur()
80+ }
81+
82+ inputs[i] = ti
83+ }
84+
42− return &commandArgumentsDialogCmp{}
85+ return &commandArgumentsDialogCmp{
86+ inputs: inputs,
87+ keys: DefaultArgumentsDialogKeyMap(),
88+ commandID: commandID,
89+ content: content,
90+ argNames: argNames,
91+ focusIndex: 0,
92+ width: 60,
93+ help: help.New(),
94+ }
43}
95}
44
96
45// Init implements CommandArgumentsDialog.
97// Init implements CommandArgumentsDialog.
@@ -48,20 +100,130 @@ func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
48}
100}
49
101
50// Update implements CommandArgumentsDialog.
102// Update implements CommandArgumentsDialog.
51−func (c *commandArgumentsDialogCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
103+func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
104+ switch msg := msg.(type) {
105+ case tea.WindowSizeMsg:
106+ c.wWidth = msg.Width
107+ c.wHeight = msg.Height
108+ case tea.KeyPressMsg:
109+ switch {
110+ case key.Matches(msg, c.keys.Confirm):
111+ if c.focusIndex == len(c.inputs)-1 {
112+ content := c.content
113+ for i, name := range c.argNames {
114+ value := c.inputs[i].Value()
115+ placeholder := "$" + name
116+ content = strings.ReplaceAll(content, placeholder, value)
117+ }
118+ return c, tea.Sequence(
119+ util.CmdHandler(dialogs.CloseDialogMsg{}),
120+ util.CmdHandler(CommandRunCustomMsg{
121+ Content: content,
122+ }),
123+ )
124+ }
125+ // Otherwise, move to the next input
126+ c.inputs[c.focusIndex].Blur()
127+ c.focusIndex++
128+ c.inputs[c.focusIndex].Focus()
129+ case key.Matches(msg, c.keys.Next):
130+ // Move to the next input
131+ c.inputs[c.focusIndex].Blur()
132+ c.focusIndex = (c.focusIndex + 1) % len(c.inputs)
133+ c.inputs[c.focusIndex].Focus()
134+ case key.Matches(msg, c.keys.Previous):
135+ // Move to the previous input
136+ c.inputs[c.focusIndex].Blur()
137+ c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
138+ c.inputs[c.focusIndex].Focus()
139+
140+ default:
141+ var cmd tea.Cmd
142+ c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
143+ return c, cmd
144+ }
145+ }
52 return c, nil
146 return c, nil
53}
147}
54
148
55// View implements CommandArgumentsDialog.
149// View implements CommandArgumentsDialog.
56func (c *commandArgumentsDialogCmp) View() tea.View {
150func (c *commandArgumentsDialogCmp) View() tea.View {
151+ t := theme.CurrentTheme()
152+ baseStyle := styles.BaseStyle()
153+
154+ title := lipgloss.NewStyle().
155+ Foreground(t.Primary()).
156+ Bold(true).
157+ Padding(0, 1).
158+ Background(t.Background()).
159+ Render("Command Arguments")
160+
161+ explanation := lipgloss.NewStyle().
162+ Foreground(t.Text()).
163+ Padding(0, 1).
164+ Background(t.Background()).
165+ Render("This command requires arguments.")
166+
167+ // Create input fields for each argument
168+ inputFields := make([]string, len(c.inputs))
169+ for i, input := range c.inputs {
170+ // Highlight the label of the focused input
171+ labelStyle := lipgloss.NewStyle().
172+ Padding(1, 1, 0, 1).
173+ Background(t.Background())
174+
175+ if i == c.focusIndex {
176+ labelStyle = labelStyle.Foreground(t.Text()).Bold(true)
177+ } else {
178+ labelStyle = labelStyle.Foreground(t.TextMuted())
179+ }
180+
181+ label := labelStyle.Render(c.argNames[i] + ":")
182+
183+ field := lipgloss.NewStyle().
184+ Foreground(t.Text()).
185+ Padding(0, 1).
186+ Background(t.Background()).
187+ Render(input.View())
188+
189+ inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
190+ }
191+
192+ // Join all elements vertically
193+ elements := []string{title, explanation}
194+ elements = append(elements, inputFields...)
195+
196+ c.help.ShowAll = false
197+ helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys))
198+ elements = append(elements, "", helpText)
199+
200+ content := lipgloss.JoinVertical(
201+ lipgloss.Left,
202+ elements...,
203+ )
204+
57− return tea.NewView("")
205+ view := tea.NewView(
206+ baseStyle.Padding(1, 1, 0, 1).
207+ Border(lipgloss.RoundedBorder()).
208+ BorderBackground(t.Background()).
209+ BorderForeground(t.TextMuted()).
210+ Background(t.Background()).
211+ Width(c.width).
212+ Render(content),
213+ )
214+ cursor := c.inputs[c.focusIndex].Cursor()
215+ if cursor != nil {
216+ cursor = c.moveCursor(cursor)
217+ }
218+ view.SetCursor(cursor)
219+ return view
58}
220}
59
221
60func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
222func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
61− offset := 10 + 1
223+ offset := 13 + (1+c.focusIndex)*3
62 cursor.Y += offset
224 cursor.Y += offset
63 _, col := c.Position()
225 _, col := c.Position()
64− cursor.X = cursor.X + col + 2
226+ cursor.X = cursor.X + col + 3
65 return cursor
227 return cursor
66}
228}
67
229
internal/tui/components/dialogs/commands/commands.go+41−5
@@ -1,6 +1,7 @@
1package commands
1package commands
2
2
3import (
3import (
4+ "github.com/charmbracelet/bubbles/v2/key"
4 tea "github.com/charmbracelet/bubbletea/v2"
5 tea "github.com/charmbracelet/bubbletea/v2"
5 "github.com/charmbracelet/lipgloss/v2"
6 "github.com/charmbracelet/lipgloss/v2"
6
7
@@ -37,14 +38,31 @@ type commandDialogCmp struct {
37 wHeight int // Height of the terminal window
38 wHeight int // Height of the terminal window
38
39
39 commandList list.ListModel
40 commandList list.ListModel
41+ commands []Command
42+ keyMap CommandsDialogKeyMap
40}
43}
41
44
42func NewCommandDialog() CommandsDialog {
45func NewCommandDialog() CommandsDialog {
46+ listKeyMap := list.DefaultKeyMap()
47+ keyMap := DefaultCommandsDialogKeyMap()
48+
49+ listKeyMap.Down.SetEnabled(false)
50+ listKeyMap.Up.SetEnabled(false)
51+ listKeyMap.NDown.SetEnabled(false)
52+ listKeyMap.NUp.SetEnabled(false)
53+ listKeyMap.HalfPageDown.SetEnabled(false)
54+ listKeyMap.HalfPageUp.SetEnabled(false)
55+ listKeyMap.Home.SetEnabled(false)
56+ listKeyMap.End.SetEnabled(false)
57+
58+ listKeyMap.DownOneItem = keyMap.Next
59+ listKeyMap.UpOneItem = keyMap.Previous
60+
43− commandList := list.New(list.WithFilterable(true))
61+ commandList := list.New(list.WithFilterable(true), list.WithKeyMap(listKeyMap))
44−
45 return &commandDialogCmp{
62 return &commandDialogCmp{
46 commandList: commandList,
63 commandList: commandList,
47 width: defaultWidth,
64 width: defaultWidth,
65+ keyMap: DefaultCommandsDialogKeyMap(),
48 }
66 }
49}
67}
50
68
@@ -53,6 +71,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
53 if err != nil {
71 if err != nil {
54 return util.ReportError(err)
72 return util.ReportError(err)
55 }
73 }
74+ c.commands = commands
56
75
57 commandItems := []util.Model{}
76 commandItems := []util.Model{}
58 if len(commands) > 0 {
77 if len(commands) > 0 {
@@ -65,6 +84,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
65 commandItems = append(commandItems, NewItemSection("Default"))
84 commandItems = append(commandItems, NewItemSection("Default"))
66
85
67 for _, cmd := range c.defaultCommands() {
86 for _, cmd := range c.defaultCommands() {
87+ c.commands = append(c.commands, cmd)
68 commandItems = append(commandItems, NewCommandItem(cmd))
88 commandItems = append(commandItems, NewCommandItem(cmd))
69 }
89 }
70
90
@@ -78,10 +98,26 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78 c.wWidth = msg.Width
98 c.wWidth = msg.Width
79 c.wHeight = msg.Height
99 c.wHeight = msg.Height
80 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
100 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
101+ case tea.KeyPressMsg:
102+ switch {
103+ case key.Matches(msg, c.keyMap.Select):
104+ selectedItemInx := c.commandList.SelectedIndex()
105+ if selectedItemInx == list.NoSelection {
106+ return c, nil // No item selected, do nothing
107+ }
108+ items := c.commandList.Items()
109+ selectedItem := items[selectedItemInx].(CommandItem).Command()
110+ return c, tea.Sequence(
111+ util.CmdHandler(dialogs.CloseDialogMsg{}),
112+ selectedItem.Handler(selectedItem),
113+ )
114+ default:
115+ u, cmd := c.commandList.Update(msg)
116+ c.commandList = u.(list.ListModel)
117+ return c, cmd
118+ }
81 }
119 }
82− u, cmd := c.commandList.Update(msg)
83− c.commandList = u.(list.ListModel)
84− return c, cmd
120+ return c, nil
85}
121}
86
122
87func (c *commandDialogCmp) View() tea.View {
123func (c *commandDialogCmp) View() tea.View {
internal/tui/components/dialogs/commands/item.go+6
@@ -18,6 +18,7 @@ type CommandItem interface {
18 util.Model
18 util.Model
19 layout.Focusable
19 layout.Focusable
20 layout.Sizeable
20 layout.Sizeable
21+ Command() Command
21}
22}
22
23
23type commandItem struct {
24type commandItem struct {
@@ -72,6 +73,11 @@ func (c *commandItem) View() tea.View {
72 return tea.NewView(text)
73 return tea.NewView(text)
73}
74}
74
75
76+// Command implements CommandItem.
77+func (c *commandItem) Command() Command {
78+ return c.command
79+}
80+
75// Blur implements CommandItem.
81// Blur implements CommandItem.
76func (c *commandItem) Blur() tea.Cmd {
82func (c *commandItem) Blur() tea.Cmd {
77 c.focus = false
83 c.focus = false
internal/tui/components/dialogs/commands/keys.go+92
@@ -1 +1,93 @@
1package commands
1package commands
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/opencode-ai/opencode/internal/tui/layout"
6+)
7+
8+type CommandsDialogKeyMap struct {
9+ Select key.Binding
10+ Next key.Binding
11+ Previous key.Binding
12+}
13+
14+func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
15+ return CommandsDialogKeyMap{
16+ Select: key.NewBinding(
17+ key.WithKeys("enter"),
18+ key.WithHelp("enter", "confirm"),
19+ ),
20+ Next: key.NewBinding(
21+ key.WithKeys("tab", "down"),
22+ key.WithHelp("tab/↓", "next"),
23+ ),
24+ Previous: key.NewBinding(
25+ key.WithKeys("shift+tab", "up"),
26+ key.WithHelp("shift+tab/↑", "previous"),
27+ ),
28+ }
29+}
30+
31+// FullHelp implements help.KeyMap.
32+func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding {
33+ m := [][]key.Binding{}
34+ slice := layout.KeyMapToSlice(k)
35+ for i := 0; i < len(slice); i += 4 {
36+ end := min(i+4, len(slice))
37+ m = append(m, slice[i:end])
38+ }
39+ return m
40+}
41+
42+// ShortHelp implements help.KeyMap.
43+func (k CommandsDialogKeyMap) ShortHelp() []key.Binding {
44+ return []key.Binding{
45+ k.Select,
46+ k.Next,
47+ k.Previous,
48+ }
49+}
50+
51+type ArgumentsDialogKeyMap struct {
52+ Confirm key.Binding
53+ Next key.Binding
54+ Previous key.Binding
55+}
56+
57+func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
58+ return ArgumentsDialogKeyMap{
59+ Confirm: key.NewBinding(
60+ key.WithKeys("enter"),
61+ key.WithHelp("enter", "confirm"),
62+ ),
63+
64+ Next: key.NewBinding(
65+ key.WithKeys("tab", "down"),
66+ key.WithHelp("tab/↓", "next"),
67+ ),
68+ Previous: key.NewBinding(
69+ key.WithKeys("shift+tab", "up"),
70+ key.WithHelp("shift+tab/↑", "previous"),
71+ ),
72+ }
73+}
74+
75+// FullHelp implements help.KeyMap.
76+func (k ArgumentsDialogKeyMap) FullHelp() [][]key.Binding {
77+ m := [][]key.Binding{}
78+ slice := layout.KeyMapToSlice(k)
79+ for i := 0; i < len(slice); i += 4 {
80+ end := min(i+4, len(slice))
81+ m = append(m, slice[i:end])
82+ }
83+ return m
84+}
85+
86+// ShortHelp implements help.KeyMap.
87+func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
88+ return []key.Binding{
89+ k.Confirm,
90+ k.Next,
91+ k.Previous,
92+ }
93+}
internal/tui/tui.go+9−1
@@ -58,7 +58,15 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
58 a.dialog = u.(dialogs.DialogCmp)
58 a.dialog = u.(dialogs.DialogCmp)
59 return a, dialogCmd
59 return a, dialogCmd
60 case commands.ShowArgumentsDialogMsg:
60 case commands.ShowArgumentsDialogMsg:
61−
61+ return a, util.CmdHandler(
62+ dialogs.OpenDialogMsg{
63+ Model: commands.NewCommandArgumentsDialog(
64+ msg.CommandID,
65+ msg.Content,
66+ msg.ArgNames,
67+ ),
68+ },
69+ )
62 // Page change messages
70 // Page change messages
63 case page.PageChangeMsg:
71 case page.PageChangeMsg:
64 return a, a.moveToPage(msg.ID)
72 return a, a.moveToPage(msg.ID)