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