1package completions
2
3import (
4 "image/color"
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/util"
13 "github.com/rivo/uniseg"
14)
15
16type CompletionItem interface {
17 util.Model
18 layout.Focusable
19 layout.Sizeable
20 list.HasMatchIndexes
21 list.HasFilterValue
22 Value() any
23}
24
25type completionItemCmp struct {
26 width int
27 text string
28 value any
29 focus bool
30 matchIndexes []int
31 bgColor color.Color
32}
33
34type completionOptions func(*completionItemCmp)
35
36func WithBackgroundColor(c color.Color) completionOptions {
37 return func(cmp *completionItemCmp) {
38 cmp.bgColor = c
39 }
40}
41
42func WithMatchIndexes(indexes ...int) completionOptions {
43 return func(cmp *completionItemCmp) {
44 cmp.matchIndexes = indexes
45 }
46}
47
48func NewCompletionItem(text string, value any, opts ...completionOptions) CompletionItem {
49 c := &completionItemCmp{
50 text: text,
51 value: value,
52 }
53
54 for _, opt := range opts {
55 opt(c)
56 }
57 return c
58}
59
60// Init implements CommandItem.
61func (c *completionItemCmp) Init() tea.Cmd {
62 return nil
63}
64
65// Update implements CommandItem.
66func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
67 return c, nil
68}
69
70// View implements CommandItem.
71func (c *completionItemCmp) View() tea.View {
72 t := styles.CurrentTheme()
73
74 titleStyle := t.S().Text.Padding(0, 1).Width(c.width)
75 titleMatchStyle := t.S().Text.Underline(true)
76 if c.bgColor != nil {
77 titleStyle = titleStyle.Background(c.bgColor)
78 titleMatchStyle = titleMatchStyle.Background(c.bgColor)
79 }
80
81 if c.focus {
82 titleStyle = t.S().TextSelected.Padding(0, 1).Width(c.width)
83 titleMatchStyle = t.S().TextSelected.Underline(true)
84 }
85
86 var truncatedTitle string
87 var adjustedMatchIndexes []int
88
89 availableWidth := c.width - 2 // Account for padding
90 if len(c.matchIndexes) > 0 && len(c.text) > availableWidth {
91 // Smart truncation: ensure the last matching part is visible
92 truncatedTitle, adjustedMatchIndexes = c.smartTruncate(c.text, availableWidth, c.matchIndexes)
93 } else {
94 // No matches, use regular truncation
95 truncatedTitle = ansi.Truncate(c.text, availableWidth, "…")
96 adjustedMatchIndexes = c.matchIndexes
97 }
98
99 text := titleStyle.Render(truncatedTitle)
100 if len(adjustedMatchIndexes) > 0 {
101 var ranges []lipgloss.Range
102 for _, rng := range matchedRanges(adjustedMatchIndexes) {
103 // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
104 // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
105 // so we need to adjust it here:
106 start, stop := bytePosToVisibleCharPos(text, rng)
107 ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
108 }
109 text = lipgloss.StyleRanges(text, ranges...)
110 }
111 return tea.NewView(text)
112}
113
114// Blur implements CommandItem.
115func (c *completionItemCmp) Blur() tea.Cmd {
116 c.focus = false
117 return nil
118}
119
120// Focus implements CommandItem.
121func (c *completionItemCmp) Focus() tea.Cmd {
122 c.focus = true
123 return nil
124}
125
126// GetSize implements CommandItem.
127func (c *completionItemCmp) GetSize() (int, int) {
128 return c.width, 1
129}
130
131// IsFocused implements CommandItem.
132func (c *completionItemCmp) IsFocused() bool {
133 return c.focus
134}
135
136// SetSize implements CommandItem.
137func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
138 c.width = width
139 return nil
140}
141
142func (c *completionItemCmp) MatchIndexes(indexes []int) {
143 c.matchIndexes = indexes
144 for i := range c.matchIndexes {
145 c.matchIndexes[i] += 1 // Adjust for the padding we add in View
146 }
147}
148
149func (c *completionItemCmp) FilterValue() string {
150 return c.text
151}
152
153func (c *completionItemCmp) Value() any {
154 return c.value
155}
156
157// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
158func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) (string, []int) {
159 if width <= 0 {
160 return "", []int{}
161 }
162
163 textLen := ansi.StringWidth(text)
164 if textLen <= width {
165 return text, matchIndexes
166 }
167
168 if len(matchIndexes) == 0 {
169 return ansi.Truncate(text, width, "…"), []int{}
170 }
171
172 // Find the last match position
173 lastMatchPos := matchIndexes[len(matchIndexes)-1]
174
175 // Convert byte position to visual width position
176 lastMatchVisualPos := 0
177 bytePos := 0
178 gr := uniseg.NewGraphemes(text)
179 for bytePos < lastMatchPos && gr.Next() {
180 bytePos += len(gr.Str())
181 lastMatchVisualPos += max(1, gr.Width())
182 }
183
184 // Calculate how much space we need for the ellipsis
185 ellipsisWidth := 1 // "…" character width
186 availableWidth := width - ellipsisWidth
187
188 // If the last match is within the available width, truncate from the end
189 if lastMatchVisualPos < availableWidth {
190 return ansi.Truncate(text, width, "…"), matchIndexes
191 }
192
193 // Calculate the start position to ensure the last match is visible
194 // We want to show some context before the last match if possible
195 startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
196
197 // Convert visual position back to byte position
198 startBytePos := 0
199 currentVisualPos := 0
200 gr = uniseg.NewGraphemes(text)
201 for currentVisualPos < startVisualPos && gr.Next() {
202 startBytePos += len(gr.Str())
203 currentVisualPos += max(1, gr.Width())
204 }
205
206 // Extract the substring starting from startBytePos
207 truncatedText := text[startBytePos:]
208
209 // Truncate to fit width with ellipsis
210 truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
211 truncatedText = "…" + truncatedText
212
213 // Adjust match indexes for the new truncated string
214 adjustedIndexes := []int{}
215 for _, idx := range matchIndexes {
216 if idx >= startBytePos {
217 newIdx := idx - startBytePos + 1 //
218 // Check if this match is still within the truncated string
219 if newIdx < len(truncatedText) {
220 adjustedIndexes = append(adjustedIndexes, newIdx)
221 }
222 }
223 }
224
225 return truncatedText, adjustedIndexes
226}
227
228func matchedRanges(in []int) [][2]int {
229 if len(in) == 0 {
230 return [][2]int{}
231 }
232 current := [2]int{in[0], in[0]}
233 if len(in) == 1 {
234 return [][2]int{current}
235 }
236 var out [][2]int
237 for i := 1; i < len(in); i++ {
238 if in[i] == current[1]+1 {
239 current[1] = in[i]
240 } else {
241 out = append(out, current)
242 current = [2]int{in[i], in[i]}
243 }
244 }
245 out = append(out, current)
246 return out
247}
248
249func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
250 bytePos, byteStart, byteStop := 0, rng[0], rng[1]
251 pos, start, stop := 0, 0, 0
252 gr := uniseg.NewGraphemes(str)
253 for byteStart > bytePos {
254 if !gr.Next() {
255 break
256 }
257 bytePos += len(gr.Str())
258 pos += max(1, gr.Width())
259 }
260 start = pos
261 for byteStop > bytePos {
262 if !gr.Next() {
263 break
264 }
265 bytePos += len(gr.Str())
266 pos += max(1, gr.Width())
267 }
268 stop = pos
269 return start, stop
270}