1package commands
2
3import (
4 "strings"
5
6 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/lipgloss/v2"
8 "github.com/charmbracelet/x/ansi"
9 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
10 "github.com/opencode-ai/opencode/internal/tui/layout"
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 "github.com/rivo/uniseg"
15)
16
17type CommandItem interface {
18 util.Model
19 layout.Focusable
20 layout.Sizeable
21 Command() Command
22}
23
24type commandItem struct {
25 width int
26 command Command
27 focus bool
28 matchIndexes []int
29}
30
31func NewCommandItem(command Command) CommandItem {
32 return &commandItem{
33 command: command,
34 matchIndexes: make([]int, 0),
35 }
36}
37
38// Init implements CommandItem.
39func (c *commandItem) Init() tea.Cmd {
40 return nil
41}
42
43// Update implements CommandItem.
44func (c *commandItem) Update(tea.Msg) (tea.Model, tea.Cmd) {
45 return c, nil
46}
47
48// View implements CommandItem.
49func (c *commandItem) View() tea.View {
50 t := theme.CurrentTheme()
51
52 baseStyle := styles.BaseStyle()
53 titleStyle := baseStyle.Width(c.width).Foreground(t.Text())
54 titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
55
56 if c.focus {
57 titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
58 titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
59 }
60 var ranges []lipgloss.Range
61 truncatedTitle := ansi.Truncate(c.command.Title, c.width, "…")
62 text := titleStyle.Render(truncatedTitle)
63 if len(c.matchIndexes) > 0 {
64 for _, rng := range matchedRanges(c.matchIndexes) {
65 // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
66 // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
67 // so we need to adjust it here:
68 start, stop := bytePosToVisibleCharPos(text, rng)
69 ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
70 }
71 text = lipgloss.StyleRanges(text, ranges...)
72 }
73 return tea.NewView(text)
74}
75
76// Command implements CommandItem.
77func (c *commandItem) Command() Command {
78 return c.command
79}
80
81// Blur implements CommandItem.
82func (c *commandItem) Blur() tea.Cmd {
83 c.focus = false
84 return nil
85}
86
87// Focus implements CommandItem.
88func (c *commandItem) Focus() tea.Cmd {
89 c.focus = true
90 return nil
91}
92
93// IsFocused implements CommandItem.
94func (c *commandItem) IsFocused() bool {
95 return c.focus
96}
97
98// GetSize implements CommandItem.
99func (c *commandItem) GetSize() (int, int) {
100 return c.width, 2
101}
102
103// SetSize implements CommandItem.
104func (c *commandItem) SetSize(width int, height int) tea.Cmd {
105 c.width = width
106 return nil
107}
108
109func (c *commandItem) FilterValue() string {
110 return c.command.Title
111}
112
113func (c *commandItem) MatchIndexes(indexes []int) {
114 c.matchIndexes = indexes
115}
116
117func matchedRanges(in []int) [][2]int {
118 if len(in) == 0 {
119 return [][2]int{}
120 }
121 current := [2]int{in[0], in[0]}
122 if len(in) == 1 {
123 return [][2]int{current}
124 }
125 var out [][2]int
126 for i := 1; i < len(in); i++ {
127 if in[i] == current[1]+1 {
128 current[1] = in[i]
129 } else {
130 out = append(out, current)
131 current = [2]int{in[i], in[i]}
132 }
133 }
134 out = append(out, current)
135 return out
136}
137
138func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
139 bytePos, byteStart, byteStop := 0, rng[0], rng[1]
140 pos, start, stop := 0, 0, 0
141 gr := uniseg.NewGraphemes(str)
142 for byteStart > bytePos {
143 if !gr.Next() {
144 break
145 }
146 bytePos += len(gr.Str())
147 pos += max(1, gr.Width())
148 }
149 start = pos
150 for byteStop > bytePos {
151 if !gr.Next() {
152 break
153 }
154 bytePos += len(gr.Str())
155 pos += max(1, gr.Width())
156 }
157 stop = pos
158 return start, stop
159}
160
161type ItemSection interface {
162 util.Model
163 layout.Sizeable
164 list.SectionHeader
165}
166type itemSectionModel struct {
167 width int
168 title string
169}
170
171func NewItemSection(title string) ItemSection {
172 return &itemSectionModel{
173 title: title,
174 }
175}
176
177func (m *itemSectionModel) Init() tea.Cmd {
178 return nil
179}
180
181func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
182 return m, nil
183}
184
185func (m *itemSectionModel) View() tea.View {
186 t := theme.CurrentTheme()
187 title := ansi.Truncate(m.title, m.width-1, "…")
188 style := styles.BaseStyle().Padding(1, 0, 0, 0).Width(m.width).Foreground(t.TextMuted()).Bold(true)
189 if len(title) < m.width {
190 remainingWidth := m.width - lipgloss.Width(title)
191 if remainingWidth > 0 {
192 title += " " + strings.Repeat("─", remainingWidth-1)
193 }
194 }
195 return tea.NewView(style.Render(title))
196}
197
198func (m *itemSectionModel) GetSize() (int, int) {
199 return m.width, 1
200}
201
202func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
203 m.width = width
204 return nil
205}
206
207func (m *itemSectionModel) IsSectionHeader() bool {
208 return true
209}