completions.go

  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}