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