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