1package completions
2
3import (
4 "cmp"
5 "path/filepath"
6 "slices"
7 "strings"
8 "sync"
9
10 "charm.land/bubbles/v2/key"
11 tea "charm.land/bubbletea/v2"
12 "charm.land/lipgloss/v2"
13 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/ui/list"
16 "github.com/charmbracelet/x/ansi"
17 "github.com/charmbracelet/x/exp/ordered"
18)
19
20const (
21 minHeight = 1
22 maxHeight = 10
23 minWidth = 10
24 maxWidth = 100
25
26 tierExactName = iota
27 tierPrefixName
28 tierPathSegment
29 tierFallback
30)
31
32// SelectionMsg is sent when a completion is selected.
33type SelectionMsg[T any] struct {
34 Value T
35 KeepOpen bool // If true, insert without closing.
36}
37
38// ClosedMsg is sent when the completions are closed.
39type ClosedMsg struct{}
40
41// CompletionItemsLoadedMsg is sent when files have been loaded for completions.
42type CompletionItemsLoadedMsg struct {
43 Files []FileCompletionValue
44 Resources []ResourceCompletionValue
45}
46
47// Completions represents the completions popup component.
48type Completions struct {
49 // Popup dimensions
50 width int
51 height int
52
53 // State
54 open bool
55 query string
56
57 // Key bindings
58 keyMap KeyMap
59
60 // List component
61 list *list.FilterableList
62
63 // Styling
64 normalStyle lipgloss.Style
65 focusedStyle lipgloss.Style
66 matchStyle lipgloss.Style
67
68 allItems []list.FilterableItem
69 filtered []list.FilterableItem
70}
71
72type namePriorityRule struct {
73 tier int
74 match func(pathLower, baseLower, stemLower, queryLower string) bool
75}
76
77var namePriorityRules = []namePriorityRule{
78 {
79 tier: tierExactName,
80 match: func(_ string, baseLower, stemLower, queryLower string) bool {
81 return baseLower == queryLower || stemLower == queryLower
82 },
83 },
84 {
85 tier: tierPrefixName,
86 match: func(_ string, baseLower, _ string, queryLower string) bool {
87 return strings.HasPrefix(baseLower, queryLower)
88 },
89 },
90 {
91 tier: tierPathSegment,
92 match: func(pathLower, _ string, _ string, queryLower string) bool {
93 return hasPathSegment(pathLower, queryLower)
94 },
95 },
96}
97
98// New creates a new completions component.
99func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
100 l := list.NewFilterableList()
101 l.SetGap(0)
102 l.SetReverse(true)
103
104 return &Completions{
105 keyMap: DefaultKeyMap(),
106 list: l,
107 normalStyle: normalStyle,
108 focusedStyle: focusedStyle,
109 matchStyle: matchStyle,
110 }
111}
112
113// IsOpen returns whether the completions popup is open.
114func (c *Completions) IsOpen() bool {
115 return c.open
116}
117
118// Query returns the current filter query.
119func (c *Completions) Query() string {
120 return c.query
121}
122
123// Size returns the visible size of the popup.
124func (c *Completions) Size() (width, height int) {
125 visible := len(c.filtered)
126 return c.width, min(visible, c.height)
127}
128
129// KeyMap returns the key bindings.
130func (c *Completions) KeyMap() KeyMap {
131 return c.keyMap
132}
133
134// Open opens the completions with file items from the filesystem.
135func (c *Completions) Open(depth, limit int) tea.Cmd {
136 return func() tea.Msg {
137 var msg CompletionItemsLoadedMsg
138 var wg sync.WaitGroup
139 wg.Go(func() {
140 msg.Files = loadFiles(depth, limit)
141 })
142 wg.Go(func() {
143 msg.Resources = loadMCPResources()
144 })
145 wg.Wait()
146 return msg
147 }
148}
149
150// SetItems sets the files and MCP resources and rebuilds the merged list.
151func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
152 items := make([]list.FilterableItem, 0, len(files)+len(resources))
153
154 // Add files first.
155 for _, file := range files {
156 item := NewCompletionItem(
157 file.Path,
158 file,
159 c.normalStyle,
160 c.focusedStyle,
161 c.matchStyle,
162 )
163 items = append(items, item)
164 }
165
166 // Add MCP resources.
167 for _, resource := range resources {
168 item := NewCompletionItem(
169 resource.MCPName+"/"+cmp.Or(resource.Title, resource.URI),
170 resource,
171 c.normalStyle,
172 c.focusedStyle,
173 c.matchStyle,
174 )
175 items = append(items, item)
176 }
177
178 c.open = true
179 c.query = ""
180 c.allItems = items
181 c.filtered = append([]list.FilterableItem(nil), items...)
182 c.list.SetItems(c.filtered...)
183 c.list.SetFilter("")
184 c.list.Focus()
185
186 c.width = maxWidth
187 c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
188 c.list.SetSize(c.width, c.height)
189 c.list.SelectFirst()
190 c.list.ScrollToSelected()
191
192 c.updateSize()
193}
194
195// Close closes the completions popup.
196func (c *Completions) Close() {
197 c.open = false
198}
199
200// Filter filters the completions with the given query.
201func (c *Completions) Filter(query string) {
202 if !c.open {
203 return
204 }
205
206 if query == c.query {
207 return
208 }
209
210 c.query = query
211 c.applyNamePriorityFilter(query)
212
213 c.updateSize()
214}
215
216func (c *Completions) applyNamePriorityFilter(query string) {
217 if query == "" {
218 c.filtered = append([]list.FilterableItem(nil), c.allItems...)
219 c.list.SetItems(c.filtered...)
220 return
221 }
222
223 c.list.SetItems(c.allItems...)
224 c.list.SetFilter(query)
225 raw := c.list.FilteredItems()
226 filtered := make([]list.FilterableItem, 0, len(raw))
227 for _, item := range raw {
228 filterable, ok := item.(list.FilterableItem)
229 if !ok {
230 continue
231 }
232 filtered = append(filtered, filterable)
233 }
234
235 queryLower := strings.ToLower(strings.TrimSpace(query))
236 slices.SortStableFunc(filtered, func(a, b list.FilterableItem) int {
237 return namePriorityTier(a.Filter(), queryLower) - namePriorityTier(b.Filter(), queryLower)
238 })
239 c.filtered = filtered
240 c.list.SetItems(c.filtered...)
241}
242
243func namePriorityTier(path, queryLower string) int {
244 if queryLower == "" {
245 return tierFallback
246 }
247
248 pathLower := strings.ToLower(path)
249 baseLower := strings.ToLower(filepath.Base(strings.ReplaceAll(path, `\`, `/`)))
250 stemLower := strings.TrimSuffix(baseLower, filepath.Ext(baseLower))
251 for _, rule := range namePriorityRules {
252 if rule.match(pathLower, baseLower, stemLower, queryLower) {
253 return rule.tier
254 }
255 }
256 return tierFallback
257}
258
259func hasPathSegment(pathLower, queryLower string) bool {
260 for _, part := range strings.FieldsFunc(pathLower, func(r rune) bool {
261 return r == '/' || r == '\\'
262 }) {
263 if part == queryLower {
264 return true
265 }
266 }
267 return false
268}
269
270func (c *Completions) updateSize() {
271 items := c.filtered
272 start, end := c.list.VisibleItemIndices()
273 width := 0
274 for i := start; i <= end; i++ {
275 item := c.list.ItemAt(i)
276 if item == nil {
277 continue
278 }
279 s := item.(interface{ Text() string }).Text()
280 width = max(width, ansi.StringWidth(s))
281 }
282 c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
283 c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
284 c.list.SetSize(c.width, c.height)
285 c.list.SelectFirst()
286 c.list.ScrollToSelected()
287}
288
289// HasItems returns whether there are visible items.
290func (c *Completions) HasItems() bool {
291 return len(c.filtered) > 0
292}
293
294// Update handles key events for the completions.
295func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
296 if !c.open {
297 return nil, false
298 }
299
300 switch {
301 case key.Matches(msg, c.keyMap.Up):
302 c.selectPrev()
303 return nil, true
304
305 case key.Matches(msg, c.keyMap.Down):
306 c.selectNext()
307 return nil, true
308
309 case key.Matches(msg, c.keyMap.UpInsert):
310 c.selectPrev()
311 return c.selectCurrent(true), true
312
313 case key.Matches(msg, c.keyMap.DownInsert):
314 c.selectNext()
315 return c.selectCurrent(true), true
316
317 case key.Matches(msg, c.keyMap.Select):
318 return c.selectCurrent(false), true
319
320 case key.Matches(msg, c.keyMap.Cancel):
321 c.Close()
322 return ClosedMsg{}, true
323 }
324
325 return nil, false
326}
327
328// selectPrev selects the previous item with circular navigation.
329func (c *Completions) selectPrev() {
330 items := c.filtered
331 if len(items) == 0 {
332 return
333 }
334 if !c.list.SelectPrev() {
335 c.list.WrapToEnd()
336 }
337 c.list.ScrollToSelected()
338}
339
340// selectNext selects the next item with circular navigation.
341func (c *Completions) selectNext() {
342 items := c.filtered
343 if len(items) == 0 {
344 return
345 }
346 if !c.list.SelectNext() {
347 c.list.WrapToStart()
348 }
349 c.list.ScrollToSelected()
350}
351
352// selectCurrent returns a command with the currently selected item.
353func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
354 items := c.filtered
355 if len(items) == 0 {
356 return nil
357 }
358
359 selected := c.list.Selected()
360 if selected < 0 || selected >= len(items) {
361 return nil
362 }
363
364 item, ok := items[selected].(*CompletionItem)
365 if !ok {
366 return nil
367 }
368
369 if !keepOpen {
370 c.open = false
371 }
372
373 switch item := item.Value().(type) {
374 case ResourceCompletionValue:
375 return SelectionMsg[ResourceCompletionValue]{
376 Value: item,
377 KeepOpen: keepOpen,
378 }
379 case FileCompletionValue:
380 return SelectionMsg[FileCompletionValue]{
381 Value: item,
382 KeepOpen: keepOpen,
383 }
384 default:
385 return nil
386 }
387}
388
389// Render renders the completions popup.
390func (c *Completions) Render() string {
391 if !c.open {
392 return ""
393 }
394
395 items := c.filtered
396 if len(items) == 0 {
397 return ""
398 }
399
400 return c.list.List.Render()
401}
402
403func loadFiles(depth, limit int) []FileCompletionValue {
404 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
405 slices.Sort(files)
406 result := make([]FileCompletionValue, 0, len(files))
407 for _, file := range files {
408 result = append(result, FileCompletionValue{
409 Path: strings.TrimPrefix(file, "./"),
410 })
411 }
412 return result
413}
414
415func loadMCPResources() []ResourceCompletionValue {
416 var resources []ResourceCompletionValue
417 for mcpName, mcpResources := range mcp.Resources() {
418 for _, r := range mcpResources {
419 resources = append(resources, ResourceCompletionValue{
420 MCPName: mcpName,
421 URI: r.URI,
422 Title: r.Name,
423 MIMEType: r.MIMEType,
424 })
425 }
426 }
427 return resources
428}