@@ -4,6 +4,7 @@ import (
44 "github.com/charmbracelet/bubbles/key"
55 tea "github.com/charmbracelet/bubbletea"
66 "github.com/charmbracelet/lipgloss"
7+ utilComponents "github.com/opencode-ai/opencode/internal/tui/components/util"
78 "github.com/opencode-ai/opencode/internal/tui/layout"
89 "github.com/opencode-ai/opencode/internal/tui/styles"
910 "github.com/opencode-ai/opencode/internal/tui/theme"
@@ -18,6 +19,33 @@ type Command struct {
1819 Handler func(cmd Command) tea.Cmd
1920}
2021
22+func (ci Command) Render(selected bool, width int) string {
23+ t := theme.CurrentTheme()
24+ baseStyle := styles.BaseStyle()
25+
26+ descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
27+ itemStyle := baseStyle.Width(width).
28+ Foreground(t.Text()).
29+ Background(t.Background())
30+
31+ if selected {
32+ itemStyle = itemStyle.
33+ Background(t.Primary()).
34+ Foreground(t.Background()).
35+ Bold(true)
36+ descStyle = descStyle.
37+ Background(t.Primary()).
38+ Foreground(t.Background())
39+ }
40+
41+ title := itemStyle.Padding(0, 1).Render(ci.Title)
42+ if ci.Description != "" {
43+ description := descStyle.Padding(0, 1).Render(ci.Description)
44+ return lipgloss.JoinVertical(lipgloss.Left, title, description)
45+ }
46+ return title
47+}
48+
2149// CommandSelectedMsg is sent when a command is selected
2250type CommandSelectedMsg struct {
2351 Command Command
@@ -31,35 +59,20 @@ type CommandDialog interface {
3159 tea.Model
3260 layout.Bindings
3361 SetCommands(commands []Command)
34− SetSelectedCommand(commandID string)
3562}
3663
3764type commandDialogCmp struct {
38− commands []Command
39− selectedIdx int
40− width int
41− height int
42− selectedCommandID string
65+ listView utilComponents.SimpleList[Command]
66+ width int
67+ height int
4368}
4469
4570type commandKeyMap struct {
46− Up key.Binding
47− Down key.Binding
4871 Enter key.Binding
4972 Escape key.Binding
50− J key.Binding
51− K key.Binding
5273}
5374
5475var commandKeys = commandKeyMap{
55− Up: key.NewBinding(
56− key.WithKeys("up"),
57− key.WithHelp("↑", "previous command"),
58− ),
59− Down: key.NewBinding(
60− key.WithKeys("down"),
61− key.WithHelp("↓", "next command"),
62− ),
6376 Enter: key.NewBinding(
6477 key.WithKeys("enter"),
6578 key.WithHelp("enter", "select command"),
@@ -68,38 +81,22 @@ var commandKeys = commandKeyMap{
6881 key.WithKeys("esc"),
6982 key.WithHelp("esc", "close"),
7083 ),
71− J: key.NewBinding(
72− key.WithKeys("j"),
73− key.WithHelp("j", "next command"),
74− ),
75− K: key.NewBinding(
76− key.WithKeys("k"),
77− key.WithHelp("k", "previous command"),
78− ),
7984}
8085
8186func (c *commandDialogCmp) Init() tea.Cmd {
82− return nil
87+ return c.listView.Init()
8388}
8489
8590func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91+ var cmds []tea.Cmd
8692 switch msg := msg.(type) {
8793 case tea.KeyMsg:
8894 switch {
89− case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
90− if c.selectedIdx > 0 {
91− c.selectedIdx--
92− }
93− return c, nil
94− case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
95− if c.selectedIdx < len(c.commands)-1 {
96− c.selectedIdx++
97− }
98− return c, nil
9995 case key.Matches(msg, commandKeys.Enter):
100− if len(c.commands) > 0 {
96+ selectedItem, idx := c.listView.GetSelectedItem()
97+ if idx != -1 {
10198 return c, util.CmdHandler(CommandSelectedMsg{
102− Command: c.commands[c.selectedIdx],
99+ Command: selectedItem,
103100 })
104101 }
105102 case key.Matches(msg, commandKeys.Escape):
@@ -109,78 +106,35 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
109106 c.width = msg.Width
110107 c.height = msg.Height
111108 }
112− return c, nil
109+
110+ u, cmd := c.listView.Update(msg)
111+ c.listView = u.(utilComponents.SimpleList[Command])
112+ cmds = append(cmds, cmd)
113+
114+ return c, tea.Batch(cmds...)
113115}
114116
115117func (c *commandDialogCmp) View() string {
116118 t := theme.CurrentTheme()
117119 baseStyle := styles.BaseStyle()
118−
119− if len(c.commands) == 0 {
120− return baseStyle.Padding(1, 2).
121− Border(lipgloss.RoundedBorder()).
122− BorderBackground(t.Background()).
123− BorderForeground(t.TextMuted()).
124− Width(40).
125− Render("No commands available")
126− }
127120
128− // Calculate max width needed for command titles
129− maxWidth := 40 // Minimum width
130− for _, cmd := range c.commands {
131− if len(cmd.Title) > maxWidth-4 { // Account for padding
132− maxWidth = len(cmd.Title) + 4
133− }
134− if len(cmd.Description) > maxWidth-4 {
135− maxWidth = len(cmd.Description) + 4
136− }
137− }
121+ maxWidth := 40
138122
139− // Limit height to avoid taking up too much screen space
140− maxVisibleCommands := min(10, len(c.commands))
141−
142− // Build the command list
143− commandItems := make([]string, 0, maxVisibleCommands)
144− startIdx := 0
145−
146− // If we have more commands than can be displayed, adjust the start index
147− if len(c.commands) > maxVisibleCommands {
148− // Center the selected item when possible
149− halfVisible := maxVisibleCommands / 2
150− if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
151− startIdx = c.selectedIdx - halfVisible
152− } else if c.selectedIdx >= len(c.commands)-halfVisible {
153− startIdx = len(c.commands) - maxVisibleCommands
154− }
155− }
123+ commands := c.listView.GetItems()
156124
157− endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
158−
159− for i := startIdx; i < endIdx; i++ {
160− cmd := c.commands[i]
161− itemStyle := baseStyle.Width(maxWidth)
162− descStyle := baseStyle.Width(maxWidth).Foreground(t.TextMuted())
163−
164− if i == c.selectedIdx {
165− itemStyle = itemStyle.
166− Background(t.Primary()).
167− Foreground(t.Background()).
168− Bold(true)
169− descStyle = descStyle.
170− Background(t.Primary()).
171− Foreground(t.Background())
125+ for _, cmd := range commands {
126+ if len(cmd.Title) > maxWidth-4 {
127+ maxWidth = len(cmd.Title) + 4
172128 }
173−
174− title := itemStyle.Padding(0, 1).Render(cmd.Title)
175− description := ""
176129 if cmd.Description != "" {
177− description = descStyle.Padding(0, 1).Render(cmd.Description)
178− commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
179− } else {
180− commandItems = append(commandItems, title)
130+ if len(cmd.Description) > maxWidth-4 {
131+ maxWidth = len(cmd.Description) + 4
132+ }
181133 }
182134 }
183135
136+ c.listView.SetMaxWidth(maxWidth)
137+
184138 title := baseStyle.
185139 Foreground(t.Primary()).
186140 Bold(true).
@@ -192,7 +146,7 @@ func (c *commandDialogCmp) View() string {
192146 lipgloss.Left,
193147 title,
194148 baseStyle.Width(maxWidth).Render(""),
195− baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
149+ baseStyle.Width(maxWidth).Render(c.listView.View()),
196150 baseStyle.Width(maxWidth).Render(""),
197151 )
198152
@@ -209,41 +163,18 @@ func (c *commandDialogCmp) BindingKeys() []key.Binding {
209163}
210164
211165func (c *commandDialogCmp) SetCommands(commands []Command) {
212− c.commands = commands
213−
214− // If we have a selected command ID, find its index
215− if c.selectedCommandID != "" {
216− for i, cmd := range commands {
217− if cmd.ID == c.selectedCommandID {
218− c.selectedIdx = i
219− return
220− }
221− }
222− }
223−
224− // Default to first command if selected not found
225− c.selectedIdx = 0
226−}
227−
228−func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
229− c.selectedCommandID = commandID
230−
231− // Update the selected index if commands are already loaded
232− if len(c.commands) > 0 {
233− for i, cmd := range c.commands {
234− if cmd.ID == commandID {
235− c.selectedIdx = i
236− return
237− }
238− }
239− }
166+ c.listView.SetItems(commands)
240167}
241168
242169// NewCommandDialogCmp creates a new command selection dialog
243170func NewCommandDialogCmp() CommandDialog {
171+ listView := utilComponents.NewSimpleList[Command](
172+ []Command{},
173+ 10,
174+ "No commands available",
175+ true,
176+ )
244177 return &commandDialogCmp{
245− commands: []Command{},
246− selectedIdx: 0,
247− selectedCommandID: "",
178+ listView: listView,
248179 }
249180}