1package commands
2
3import (
4 "github.com/charmbracelet/bubbles/v2/key"
5 tea "github.com/charmbracelet/bubbletea/v2"
6 "github.com/charmbracelet/lipgloss/v2"
7
8 "github.com/opencode-ai/opencode/internal/tui/components/chat"
9 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
10 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
11 "github.com/opencode-ai/opencode/internal/tui/styles"
12 "github.com/opencode-ai/opencode/internal/tui/theme"
13 "github.com/opencode-ai/opencode/internal/tui/util"
14)
15
16const (
17 commandsDialogID dialogs.DialogID = "commands"
18
19 defaultWidth int = 60
20)
21
22// Command represents a command that can be executed
23type Command struct {
24 ID string
25 Title string
26 Description string
27 Handler func(cmd Command) tea.Cmd
28}
29
30// CommandsDialog represents the commands dialog.
31type CommandsDialog interface {
32 dialogs.DialogModel
33}
34
35type commandDialogCmp struct {
36 width int
37 wWidth int // Width of the terminal window
38 wHeight int // Height of the terminal window
39
40 commandList list.ListModel
41 commands []Command
42 keyMap CommandsDialogKeyMap
43}
44
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
61 commandList := list.New(list.WithFilterable(true), list.WithKeyMap(listKeyMap))
62 return &commandDialogCmp{
63 commandList: commandList,
64 width: defaultWidth,
65 keyMap: DefaultCommandsDialogKeyMap(),
66 }
67}
68
69func (c *commandDialogCmp) Init() tea.Cmd {
70 commands, err := LoadCustomCommands()
71 if err != nil {
72 return util.ReportError(err)
73 }
74 c.commands = commands
75
76 commandItems := []util.Model{}
77 if len(commands) > 0 {
78 commandItems = append(commandItems, NewItemSection("Custom"))
79 for _, cmd := range commands {
80 commandItems = append(commandItems, NewCommandItem(cmd))
81 }
82 }
83
84 commandItems = append(commandItems, NewItemSection("Default"))
85
86 for _, cmd := range c.defaultCommands() {
87 c.commands = append(c.commands, cmd)
88 commandItems = append(commandItems, NewCommandItem(cmd))
89 }
90
91 c.commandList.SetItems(commandItems)
92 return c.commandList.Init()
93}
94
95func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
96 switch msg := msg.(type) {
97 case tea.WindowSizeMsg:
98 c.wWidth = msg.Width
99 c.wHeight = msg.Height
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 }
119 }
120 return c, nil
121}
122
123func (c *commandDialogCmp) View() tea.View {
124 listView := c.commandList.View()
125 v := tea.NewView(c.style().Render(listView.String()))
126 if listView.Cursor() != nil {
127 c := c.moveCursor(listView.Cursor())
128 v.SetCursor(c)
129 }
130 return v
131}
132
133func (c *commandDialogCmp) listWidth() int {
134 return defaultWidth - 4 // 4 for padding
135}
136
137func (c *commandDialogCmp) listHeight() int {
138 listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
139 return min(listHeigh, c.wHeight/2)
140}
141
142func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
143 offset := 10 + 1
144 cursor.Y += offset
145 _, col := c.Position()
146 cursor.X = cursor.X + col + 2
147 return cursor
148}
149
150func (c *commandDialogCmp) style() lipgloss.Style {
151 t := theme.CurrentTheme()
152 return styles.BaseStyle().
153 Width(c.width).
154 Padding(0, 1, 1, 1).
155 Border(lipgloss.RoundedBorder()).
156 BorderBackground(t.Background()).
157 BorderForeground(t.TextMuted())
158}
159
160func (q *commandDialogCmp) Position() (int, int) {
161 row := 10
162 col := q.wWidth / 2
163 col -= q.width / 2
164 return row, col
165}
166
167func (c *commandDialogCmp) defaultCommands() []Command {
168 return []Command{
169 {
170 ID: "init",
171 Title: "Initialize Project",
172 Description: "Create/Update the OpenCode.md memory file",
173 Handler: func(cmd Command) tea.Cmd {
174 prompt := `Please analyze this codebase and create a OpenCode.md file containing:
175 1. Build/lint/test commands - especially for running a single test
176 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
177
178 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.
179 If there's already a opencode.md, improve it.
180 If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
181 return tea.Batch(
182 util.CmdHandler(chat.SendMsg{
183 Text: prompt,
184 }),
185 )
186 },
187 },
188 {
189 ID: "compact",
190 Title: "Compact Session",
191 Description: "Summarize the current session and create a new one with the summary",
192 Handler: func(cmd Command) tea.Cmd {
193 return func() tea.Msg {
194 // TODO: implement compact message
195 return ""
196 }
197 },
198 },
199 }
200}
201
202func (c *commandDialogCmp) ID() dialogs.DialogID {
203 return commandsDialogID
204}