1package commands
2
3import (
4 "github.com/charmbracelet/bubbles/v2/help"
5 "github.com/charmbracelet/bubbles/v2/key"
6 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/lipgloss/v2"
8
9 "github.com/opencode-ai/opencode/internal/tui/components/chat"
10 "github.com/opencode-ai/opencode/internal/tui/components/completions"
11 "github.com/opencode-ai/opencode/internal/tui/components/core"
12 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
13 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
14 "github.com/opencode-ai/opencode/internal/tui/styles"
15 "github.com/opencode-ai/opencode/internal/tui/util"
16)
17
18const (
19 commandsDialogID dialogs.DialogID = "commands"
20
21 defaultWidth int = 70
22)
23
24const (
25 SystemCommands int = iota
26 UserCommands
27)
28
29// Command represents a command that can be executed
30type Command struct {
31 ID string
32 Title string
33 Description string
34 Handler func(cmd Command) tea.Cmd
35}
36
37// CommandsDialog represents the commands dialog.
38type CommandsDialog interface {
39 dialogs.DialogModel
40}
41
42type commandDialogCmp struct {
43 width int
44 wWidth int // Width of the terminal window
45 wHeight int // Height of the terminal window
46
47 commandList list.ListModel
48 keyMap CommandsDialogKeyMap
49 help help.Model
50 commandType int // SystemCommands or UserCommands
51 userCommands []Command // User-defined commands
52}
53
54type (
55 SwitchSessionsMsg struct{}
56 SwitchModelMsg struct{}
57)
58
59func NewCommandDialog() CommandsDialog {
60 listKeyMap := list.DefaultKeyMap()
61 keyMap := DefaultCommandsDialogKeyMap()
62
63 listKeyMap.Down.SetEnabled(false)
64 listKeyMap.Up.SetEnabled(false)
65 listKeyMap.NDown.SetEnabled(false)
66 listKeyMap.NUp.SetEnabled(false)
67 listKeyMap.HalfPageDown.SetEnabled(false)
68 listKeyMap.HalfPageUp.SetEnabled(false)
69 listKeyMap.Home.SetEnabled(false)
70 listKeyMap.End.SetEnabled(false)
71
72 listKeyMap.DownOneItem = keyMap.Next
73 listKeyMap.UpOneItem = keyMap.Previous
74
75 t := styles.CurrentTheme()
76 commandList := list.New(
77 list.WithFilterable(true),
78 list.WithKeyMap(listKeyMap),
79 list.WithWrapNavigation(true),
80 )
81 help := help.New()
82 help.Styles = t.S().Help
83 return &commandDialogCmp{
84 commandList: commandList,
85 width: defaultWidth,
86 keyMap: DefaultCommandsDialogKeyMap(),
87 help: help,
88 commandType: SystemCommands,
89 }
90}
91
92func (c *commandDialogCmp) Init() tea.Cmd {
93 commands, err := LoadCustomCommands()
94 if err != nil {
95 return util.ReportError(err)
96 }
97
98 c.userCommands = commands
99 c.SetCommandType(c.commandType)
100 return c.commandList.Init()
101}
102
103func (c *commandDialogCmp) 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 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
109 case tea.KeyPressMsg:
110 switch {
111 case key.Matches(msg, c.keyMap.Select):
112 selectedItemInx := c.commandList.SelectedIndex()
113 if selectedItemInx == list.NoSelection {
114 return c, nil // No item selected, do nothing
115 }
116 items := c.commandList.Items()
117 selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
118 return c, tea.Sequence(
119 util.CmdHandler(dialogs.CloseDialogMsg{}),
120 selectedItem.Handler(selectedItem),
121 )
122 case key.Matches(msg, c.keyMap.Tab):
123 // Toggle command type between System and User commands
124 if c.commandType == SystemCommands {
125 return c, c.SetCommandType(UserCommands)
126 } else {
127 return c, c.SetCommandType(SystemCommands)
128 }
129 default:
130 u, cmd := c.commandList.Update(msg)
131 c.commandList = u.(list.ListModel)
132 return c, cmd
133 }
134 }
135 return c, nil
136}
137
138func (c *commandDialogCmp) View() tea.View {
139 t := styles.CurrentTheme()
140 listView := c.commandList.View()
141 radio := c.commandTypeRadio()
142 content := lipgloss.JoinVertical(
143 lipgloss.Left,
144 t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
145 listView.String(),
146 "",
147 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
148 )
149 v := tea.NewView(c.style().Render(content))
150 if listView.Cursor() != nil {
151 c := c.moveCursor(listView.Cursor())
152 v.SetCursor(c)
153 }
154 return v
155}
156
157func (c *commandDialogCmp) commandTypeRadio() string {
158 t := styles.CurrentTheme()
159 choices := []string{"System", "User"}
160 iconSelected := "◉"
161 iconUnselected := "○"
162 if c.commandType == SystemCommands {
163 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
164 }
165 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
166}
167
168func (c *commandDialogCmp) listWidth() int {
169 return defaultWidth - 2 // 4 for padding
170}
171
172func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
173 c.commandType = commandType
174
175 var commands []Command
176 if c.commandType == SystemCommands {
177 commands = c.defaultCommands()
178 } else {
179 commands = c.userCommands
180 }
181
182 commandItems := []util.Model{}
183 for _, cmd := range commands {
184 commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
185 }
186 return c.commandList.SetItems(commandItems)
187}
188
189func (c *commandDialogCmp) listHeight() int {
190 listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
191 return min(listHeigh, c.wHeight/2)
192}
193
194func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
195 row, col := c.Position()
196 offset := row + 3
197 cursor.Y += offset
198 cursor.X = cursor.X + col + 2
199 return cursor
200}
201
202func (c *commandDialogCmp) style() lipgloss.Style {
203 t := styles.CurrentTheme()
204 return t.S().Base.
205 Width(c.width).
206 Border(lipgloss.RoundedBorder()).
207 BorderForeground(t.BorderFocus)
208}
209
210func (c *commandDialogCmp) Position() (int, int) {
211 row := c.wHeight/4 - 2 // just a bit above the center
212 col := c.wWidth / 2
213 col -= c.width / 2
214 return row, col
215}
216
217func (c *commandDialogCmp) defaultCommands() []Command {
218 return []Command{
219 {
220 ID: "init",
221 Title: "Initialize Project",
222 Description: "Create/Update the OpenCode.md memory file",
223 Handler: func(cmd Command) tea.Cmd {
224 prompt := `Please analyze this codebase and create a OpenCode.md file containing:
225 1. Build/lint/test commands - especially for running a single test
226 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
227
228 The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
229 If there's already a opencode.md, improve it.
230 If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
231 return tea.Batch(
232 util.CmdHandler(chat.SendMsg{
233 Text: prompt,
234 }),
235 )
236 },
237 },
238 {
239 ID: "compact",
240 Title: "Compact Session",
241 Description: "Summarize the current session and create a new one with the summary",
242 Handler: func(cmd Command) tea.Cmd {
243 return func() tea.Msg {
244 // TODO: implement compact message
245 return ""
246 }
247 },
248 },
249 {
250 ID: "switch_session",
251 Title: "Switch Session",
252 Description: "Switch to a different session",
253 Handler: func(cmd Command) tea.Cmd {
254 return func() tea.Msg {
255 return SwitchSessionsMsg{}
256 }
257 },
258 },
259 {
260 ID: "switch_model",
261 Title: "Switch Model",
262 Description: "Switch to a different model",
263 Handler: func(cmd Command) tea.Cmd {
264 return func() tea.Msg {
265 return SwitchModelMsg{}
266 }
267 },
268 },
269 }
270}
271
272func (c *commandDialogCmp) ID() dialogs.DialogID {
273 return commandsDialogID
274}