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// SetStyles updates the styles used when rendering completion items.
114// Existing items are not restyled; subsequent SetItems calls pick up the
115// new styles.
116func (c *Completions) SetStyles(normalStyle, focusedStyle, matchStyle lipgloss.Style) {
117	c.normalStyle = normalStyle
118	c.focusedStyle = focusedStyle
119	c.matchStyle = matchStyle
120}
121
122// IsOpen returns whether the completions popup is open.
123func (c *Completions) IsOpen() bool {
124	return c.open
125}
126
127// Query returns the current filter query.
128func (c *Completions) Query() string {
129	return c.query
130}
131
132// Size returns the visible size of the popup.
133func (c *Completions) Size() (width, height int) {
134	visible := len(c.filtered)
135	return c.width, min(visible, c.height)
136}
137
138// KeyMap returns the key bindings.
139func (c *Completions) KeyMap() KeyMap {
140	return c.keyMap
141}
142
143// Open opens the completions with file items from the filesystem.
144func (c *Completions) Open(depth, limit int) tea.Cmd {
145	return func() tea.Msg {
146		var msg CompletionItemsLoadedMsg
147		var wg sync.WaitGroup
148		wg.Go(func() {
149			msg.Files = loadFiles(depth, limit)
150		})
151		wg.Go(func() {
152			msg.Resources = loadMCPResources()
153		})
154		wg.Wait()
155		return msg
156	}
157}
158
159// SetItems sets the files and MCP resources and rebuilds the merged list.
160func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
161	items := make([]list.FilterableItem, 0, len(files)+len(resources))
162
163	// Add files first.
164	for _, file := range files {
165		item := NewCompletionItem(
166			file.Path,
167			file,
168			c.normalStyle,
169			c.focusedStyle,
170			c.matchStyle,
171		)
172		items = append(items, item)
173	}
174
175	// Add MCP resources.
176	for _, resource := range resources {
177		item := NewCompletionItem(
178			resource.MCPName+"/"+cmp.Or(resource.Title, resource.URI),
179			resource,
180			c.normalStyle,
181			c.focusedStyle,
182			c.matchStyle,
183		)
184		items = append(items, item)
185	}
186
187	c.open = true
188	c.query = ""
189	c.allItems = items
190	c.filtered = append([]list.FilterableItem(nil), items...)
191	c.list.SetItems(c.filtered...)
192	c.list.SetFilter("")
193	c.list.Focus()
194
195	c.width = maxWidth
196	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
197	c.list.SetSize(c.width, c.height)
198	c.list.SelectFirst()
199	c.list.ScrollToSelected()
200
201	c.updateSize()
202}
203
204// Close closes the completions popup.
205func (c *Completions) Close() {
206	c.open = false
207}
208
209// Filter filters the completions with the given query.
210func (c *Completions) Filter(query string) {
211	if !c.open {
212		return
213	}
214
215	if query == c.query {
216		return
217	}
218
219	c.query = query
220	c.applyNamePriorityFilter(query)
221
222	c.updateSize()
223}
224
225func (c *Completions) applyNamePriorityFilter(query string) {
226	if query == "" {
227		c.filtered = append([]list.FilterableItem(nil), c.allItems...)
228		c.list.SetItems(c.filtered...)
229		return
230	}
231
232	c.list.SetItems(c.allItems...)
233	c.list.SetFilter(query)
234	raw := c.list.FilteredItems()
235	filtered := make([]list.FilterableItem, 0, len(raw))
236	for _, item := range raw {
237		filterable, ok := item.(list.FilterableItem)
238		if !ok {
239			continue
240		}
241		filtered = append(filtered, filterable)
242	}
243
244	queryLower := strings.ToLower(strings.TrimSpace(query))
245	slices.SortStableFunc(filtered, func(a, b list.FilterableItem) int {
246		return namePriorityTier(a.Filter(), queryLower) - namePriorityTier(b.Filter(), queryLower)
247	})
248	c.filtered = filtered
249	c.list.SetItems(c.filtered...)
250}
251
252func namePriorityTier(path, queryLower string) int {
253	if queryLower == "" {
254		return tierFallback
255	}
256
257	pathLower := strings.ToLower(path)
258	baseLower := strings.ToLower(filepath.Base(strings.ReplaceAll(path, `\`, `/`)))
259	stemLower := strings.TrimSuffix(baseLower, filepath.Ext(baseLower))
260	for _, rule := range namePriorityRules {
261		if rule.match(pathLower, baseLower, stemLower, queryLower) {
262			return rule.tier
263		}
264	}
265	return tierFallback
266}
267
268func hasPathSegment(pathLower, queryLower string) bool {
269	return slices.Contains(strings.FieldsFunc(pathLower, func(r rune) bool {
270		return r == '/' || r == '\\'
271	}), queryLower)
272}
273
274func (c *Completions) updateSize() {
275	items := c.filtered
276	start, end := c.list.VisibleItemIndices()
277	width := 0
278	for i := start; i <= end; i++ {
279		item := c.list.ItemAt(i)
280		if item == nil {
281			continue
282		}
283		s := item.(interface{ Text() string }).Text()
284		width = max(width, ansi.StringWidth(s))
285	}
286	c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
287	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
288	c.list.SetSize(c.width, c.height)
289	c.list.SelectFirst()
290	c.list.ScrollToSelected()
291}
292
293// HasItems returns whether there are visible items.
294func (c *Completions) HasItems() bool {
295	return len(c.filtered) > 0
296}
297
298// Update handles key events for the completions.
299func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
300	if !c.open {
301		return nil, false
302	}
303
304	switch {
305	case key.Matches(msg, c.keyMap.Up):
306		c.selectPrev()
307		return nil, true
308
309	case key.Matches(msg, c.keyMap.Down):
310		c.selectNext()
311		return nil, true
312
313	case key.Matches(msg, c.keyMap.UpInsert):
314		c.selectPrev()
315		return c.selectCurrent(true), true
316
317	case key.Matches(msg, c.keyMap.DownInsert):
318		c.selectNext()
319		return c.selectCurrent(true), true
320
321	case key.Matches(msg, c.keyMap.Select):
322		return c.selectCurrent(false), true
323
324	case key.Matches(msg, c.keyMap.Cancel):
325		c.Close()
326		return ClosedMsg{}, true
327	}
328
329	return nil, false
330}
331
332// selectPrev selects the previous item with circular navigation.
333func (c *Completions) selectPrev() {
334	items := c.filtered
335	if len(items) == 0 {
336		return
337	}
338	if !c.list.SelectPrev() {
339		c.list.WrapToEnd()
340	}
341	c.list.ScrollToSelected()
342}
343
344// selectNext selects the next item with circular navigation.
345func (c *Completions) selectNext() {
346	items := c.filtered
347	if len(items) == 0 {
348		return
349	}
350	if !c.list.SelectNext() {
351		c.list.WrapToStart()
352	}
353	c.list.ScrollToSelected()
354}
355
356// selectCurrent returns a command with the currently selected item.
357func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
358	items := c.filtered
359	if len(items) == 0 {
360		return nil
361	}
362
363	selected := c.list.Selected()
364	if selected < 0 || selected >= len(items) {
365		return nil
366	}
367
368	item, ok := items[selected].(*CompletionItem)
369	if !ok {
370		return nil
371	}
372
373	if !keepOpen {
374		c.open = false
375	}
376
377	switch item := item.Value().(type) {
378	case ResourceCompletionValue:
379		return SelectionMsg[ResourceCompletionValue]{
380			Value:    item,
381			KeepOpen: keepOpen,
382		}
383	case FileCompletionValue:
384		return SelectionMsg[FileCompletionValue]{
385			Value:    item,
386			KeepOpen: keepOpen,
387		}
388	default:
389		return nil
390	}
391}
392
393// Render renders the completions popup.
394func (c *Completions) Render() string {
395	if !c.open {
396		return ""
397	}
398
399	items := c.filtered
400	if len(items) == 0 {
401		return ""
402	}
403
404	return c.list.List.Render()
405}
406
407func loadFiles(depth, limit int) []FileCompletionValue {
408	files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
409	slices.Sort(files)
410	result := make([]FileCompletionValue, 0, len(files))
411	for _, file := range files {
412		result = append(result, FileCompletionValue{
413			Path: strings.TrimPrefix(file, "./"),
414		})
415	}
416	return result
417}
418
419func loadMCPResources() []ResourceCompletionValue {
420	var resources []ResourceCompletionValue
421	for mcpName, mcpResources := range mcp.Resources() {
422		for _, r := range mcpResources {
423			resources = append(resources, ResourceCompletionValue{
424				MCPName:  mcpName,
425				URI:      r.URI,
426				Title:    r.Name,
427				MIMEType: r.MIMEType,
428			})
429		}
430	}
431	return resources
432}