completions.go

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