Skip to content

Commit 36faca4

Browse files
authored
feat: Support named arguments in custom commands (#158)
* Allow multiple named args * fix: Fix styling in multi-arguments dialog * Remove old unused modal * Focus on only one input at a time
1 parent 1617685 commit 36faca4

File tree

6 files changed

+344
-106
lines changed

6 files changed

+344
-106
lines changed

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ OpenCode is a Go-based CLI application that brings AI assistance to your termina
1919
- **LSP Integration**: Language Server Protocol support for code intelligence
2020
- **File Change Tracking**: Track and visualize file changes during sessions
2121
- **External Editor Support**: Open your preferred editor for composing messages
22+
- **Named Arguments for Custom Commands**: Create powerful custom commands with multiple named placeholders
2223

2324
## Installation
2425

@@ -375,13 +376,22 @@ This creates a command called `user:prime-context`.
375376

376377
### Command Arguments
377378

378-
You can create commands that accept arguments by including the `$ARGUMENTS` placeholder in your command file:
379+
OpenCode supports named arguments in custom commands using placeholders in the format `$NAME` (where NAME consists of uppercase letters, numbers, and underscores, and must start with a letter).
380+
381+
For example:
379382

380383
```markdown
381-
RUN git show $ARGUMENTS
384+
# Fetch Context for Issue $ISSUE_NUMBER
385+
386+
RUN gh issue view $ISSUE_NUMBER --json title,body,comments
387+
RUN git grep --author="$AUTHOR_NAME" -n .
388+
RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
382389
```
383390

384-
When you run this command, OpenCode will prompt you to enter the text that should replace `$ARGUMENTS`.
391+
When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
392+
- Clear identification of what each argument represents
393+
- Ability to use the same argument multiple times
394+
- Better organization for commands with multiple inputs
385395

386396
### Organizing Commands
387397

Lines changed: 156 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dialog
22

33
import (
4+
"fmt"
45
"github.com/charmbracelet/bubbles/key"
56
"github.com/charmbracelet/bubbles/textinput"
67
tea "github.com/charmbracelet/bubbletea"
@@ -11,35 +12,6 @@ import (
1112
"github.com/opencode-ai/opencode/internal/tui/util"
1213
)
1314

14-
// ArgumentsDialogCmp is a component that asks the user for command arguments.
15-
type ArgumentsDialogCmp struct {
16-
width, height int
17-
textInput textinput.Model
18-
keys argumentsDialogKeyMap
19-
commandID string
20-
content string
21-
}
22-
23-
// NewArgumentsDialogCmp creates a new ArgumentsDialogCmp.
24-
func NewArgumentsDialogCmp(commandID, content string) ArgumentsDialogCmp {
25-
t := theme.CurrentTheme()
26-
ti := textinput.New()
27-
ti.Placeholder = "Enter arguments..."
28-
ti.Focus()
29-
ti.Width = 40
30-
ti.Prompt = ""
31-
ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
32-
ti.PromptStyle = ti.PromptStyle.Background(t.Background())
33-
ti.TextStyle = ti.TextStyle.Background(t.Background())
34-
35-
return ArgumentsDialogCmp{
36-
textInput: ti,
37-
keys: argumentsDialogKeyMap{},
38-
commandID: commandID,
39-
content: content,
40-
}
41-
}
42-
4315
type argumentsDialogKeyMap struct {
4416
Enter key.Binding
4517
Escape key.Binding
@@ -64,77 +36,204 @@ func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
6436
return [][]key.Binding{k.ShortHelp()}
6537
}
6638

39+
// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog.
40+
type ShowMultiArgumentsDialogMsg struct {
41+
CommandID string
42+
Content string
43+
ArgNames []string
44+
}
45+
46+
// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed.
47+
type CloseMultiArgumentsDialogMsg struct {
48+
Submit bool
49+
CommandID string
50+
Content string
51+
Args map[string]string
52+
}
53+
54+
// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments.
55+
type MultiArgumentsDialogCmp struct {
56+
width, height int
57+
inputs []textinput.Model
58+
focusIndex int
59+
keys argumentsDialogKeyMap
60+
commandID string
61+
content string
62+
argNames []string
63+
}
64+
65+
// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp.
66+
func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp {
67+
t := theme.CurrentTheme()
68+
inputs := make([]textinput.Model, len(argNames))
69+
70+
for i, name := range argNames {
71+
ti := textinput.New()
72+
ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
73+
ti.Width = 40
74+
ti.Prompt = ""
75+
ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
76+
ti.PromptStyle = ti.PromptStyle.Background(t.Background())
77+
ti.TextStyle = ti.TextStyle.Background(t.Background())
78+
79+
// Only focus the first input initially
80+
if i == 0 {
81+
ti.Focus()
82+
ti.PromptStyle = ti.PromptStyle.Foreground(t.Primary())
83+
ti.TextStyle = ti.TextStyle.Foreground(t.Primary())
84+
} else {
85+
ti.Blur()
86+
}
87+
88+
inputs[i] = ti
89+
}
90+
91+
return MultiArgumentsDialogCmp{
92+
inputs: inputs,
93+
keys: argumentsDialogKeyMap{},
94+
commandID: commandID,
95+
content: content,
96+
argNames: argNames,
97+
focusIndex: 0,
98+
}
99+
}
100+
67101
// Init implements tea.Model.
68-
func (m ArgumentsDialogCmp) Init() tea.Cmd {
69-
return tea.Batch(
70-
textinput.Blink,
71-
m.textInput.Focus(),
72-
)
102+
func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
103+
// Make sure only the first input is focused
104+
for i := range m.inputs {
105+
if i == 0 {
106+
m.inputs[i].Focus()
107+
} else {
108+
m.inputs[i].Blur()
109+
}
110+
}
111+
112+
return textinput.Blink
73113
}
74114

75115
// Update implements tea.Model.
76-
func (m ArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77-
var cmd tea.Cmd
116+
func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78117
var cmds []tea.Cmd
118+
t := theme.CurrentTheme()
79119

80120
switch msg := msg.(type) {
81121
case tea.KeyMsg:
82122
switch {
83123
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
84-
return m, util.CmdHandler(CloseArgumentsDialogMsg{})
85-
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
86-
return m, util.CmdHandler(CloseArgumentsDialogMsg{
87-
Submit: true,
124+
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
125+
Submit: false,
88126
CommandID: m.commandID,
89127
Content: m.content,
90-
Arguments: m.textInput.Value(),
128+
Args: nil,
91129
})
130+
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
131+
// If we're on the last input, submit the form
132+
if m.focusIndex == len(m.inputs)-1 {
133+
args := make(map[string]string)
134+
for i, name := range m.argNames {
135+
args[name] = m.inputs[i].Value()
136+
}
137+
return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
138+
Submit: true,
139+
CommandID: m.commandID,
140+
Content: m.content,
141+
Args: args,
142+
})
143+
}
144+
// Otherwise, move to the next input
145+
m.inputs[m.focusIndex].Blur()
146+
m.focusIndex++
147+
m.inputs[m.focusIndex].Focus()
148+
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
149+
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
150+
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
151+
// Move to the next input
152+
m.inputs[m.focusIndex].Blur()
153+
m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
154+
m.inputs[m.focusIndex].Focus()
155+
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
156+
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
157+
case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
158+
// Move to the previous input
159+
m.inputs[m.focusIndex].Blur()
160+
m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs)
161+
m.inputs[m.focusIndex].Focus()
162+
m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
163+
m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
92164
}
93165
case tea.WindowSizeMsg:
94166
m.width = msg.Width
95167
m.height = msg.Height
96168
}
97169

98-
m.textInput, cmd = m.textInput.Update(msg)
170+
// Update the focused input
171+
var cmd tea.Cmd
172+
m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
99173
cmds = append(cmds, cmd)
100174

101175
return m, tea.Batch(cmds...)
102176
}
103177

104178
// View implements tea.Model.
105-
func (m ArgumentsDialogCmp) View() string {
179+
func (m MultiArgumentsDialogCmp) View() string {
106180
t := theme.CurrentTheme()
107181
baseStyle := styles.BaseStyle()
108182

109183
// Calculate width needed for content
110184
maxWidth := 60 // Width for explanation text
111185

112-
title := baseStyle.
186+
title := lipgloss.NewStyle().
113187
Foreground(t.Primary()).
114188
Bold(true).
115189
Width(maxWidth).
116190
Padding(0, 1).
191+
Background(t.Background()).
117192
Render("Command Arguments")
118193

119-
explanation := baseStyle.
194+
explanation := lipgloss.NewStyle().
120195
Foreground(t.Text()).
121196
Width(maxWidth).
122197
Padding(0, 1).
123-
Render("This command requires arguments. Please enter the text to replace $ARGUMENTS with:")
198+
Background(t.Background()).
199+
Render("This command requires multiple arguments. Please enter values for each:")
124200

125-
inputField := baseStyle.
126-
Foreground(t.Text()).
127-
Width(maxWidth).
128-
Padding(1, 1).
129-
Render(m.textInput.View())
201+
// Create input fields for each argument
202+
inputFields := make([]string, len(m.inputs))
203+
for i, input := range m.inputs {
204+
// Highlight the label of the focused input
205+
labelStyle := lipgloss.NewStyle().
206+
Width(maxWidth).
207+
Padding(1, 1, 0, 1).
208+
Background(t.Background())
209+
210+
if i == m.focusIndex {
211+
labelStyle = labelStyle.Foreground(t.Primary()).Bold(true)
212+
} else {
213+
labelStyle = labelStyle.Foreground(t.TextMuted())
214+
}
215+
216+
label := labelStyle.Render(m.argNames[i] + ":")
217+
218+
field := lipgloss.NewStyle().
219+
Foreground(t.Text()).
220+
Width(maxWidth).
221+
Padding(0, 1).
222+
Background(t.Background()).
223+
Render(input.View())
224+
225+
inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
226+
}
130227

131228
maxWidth = min(maxWidth, m.width-10)
132229

230+
// Join all elements vertically
231+
elements := []string{title, explanation}
232+
elements = append(elements, inputFields...)
233+
133234
content := lipgloss.JoinVertical(
134235
lipgloss.Left,
135-
title,
136-
explanation,
137-
inputField,
236+
elements...,
138237
)
139238

140239
return baseStyle.Padding(1, 2).
@@ -147,27 +246,12 @@ func (m ArgumentsDialogCmp) View() string {
147246
}
148247

149248
// SetSize sets the size of the component.
150-
func (m *ArgumentsDialogCmp) SetSize(width, height int) {
249+
func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
151250
m.width = width
152251
m.height = height
153252
}
154253

155254
// Bindings implements layout.Bindings.
156-
func (m ArgumentsDialogCmp) Bindings() []key.Binding {
255+
func (m MultiArgumentsDialogCmp) Bindings() []key.Binding {
157256
return m.keys.ShortHelp()
158-
}
159-
160-
// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
161-
type CloseArgumentsDialogMsg struct {
162-
Submit bool
163-
CommandID string
164-
Content string
165-
Arguments string
166-
}
167-
168-
// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
169-
type ShowArgumentsDialogMsg struct {
170-
CommandID string
171-
Content string
172-
}
173-
257+
}

0 commit comments

Comments
 (0)