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	"git.secluded.site/crush/internal/agent/tools/mcp"
 13	"git.secluded.site/crush/internal/fsext"
 14	"git.secluded.site/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		if item == nil {
186			continue
187		}
188		s := item.(interface{ Text() string }).Text()
189		width = max(width, ansi.StringWidth(s))
190	}
191	c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
192	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
193	c.list.SetSize(c.width, c.height)
194	c.list.SelectFirst()
195	c.list.ScrollToSelected()
196}
197
198// HasItems returns whether there are visible items.
199func (c *Completions) HasItems() bool {
200	return len(c.list.FilteredItems()) > 0
201}
202
203// Update handles key events for the completions.
204func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
205	if !c.open {
206		return nil, false
207	}
208
209	switch {
210	case key.Matches(msg, c.keyMap.Up):
211		c.selectPrev()
212		return nil, true
213
214	case key.Matches(msg, c.keyMap.Down):
215		c.selectNext()
216		return nil, true
217
218	case key.Matches(msg, c.keyMap.UpInsert):
219		c.selectPrev()
220		return c.selectCurrent(true), true
221
222	case key.Matches(msg, c.keyMap.DownInsert):
223		c.selectNext()
224		return c.selectCurrent(true), true
225
226	case key.Matches(msg, c.keyMap.Select):
227		return c.selectCurrent(false), true
228
229	case key.Matches(msg, c.keyMap.Cancel):
230		c.Close()
231		return ClosedMsg{}, true
232	}
233
234	return nil, false
235}
236
237// selectPrev selects the previous item with circular navigation.
238func (c *Completions) selectPrev() {
239	items := c.list.FilteredItems()
240	if len(items) == 0 {
241		return
242	}
243	if !c.list.SelectPrev() {
244		c.list.WrapToEnd()
245	}
246	c.list.ScrollToSelected()
247}
248
249// selectNext selects the next item with circular navigation.
250func (c *Completions) selectNext() {
251	items := c.list.FilteredItems()
252	if len(items) == 0 {
253		return
254	}
255	if !c.list.SelectNext() {
256		c.list.WrapToStart()
257	}
258	c.list.ScrollToSelected()
259}
260
261// selectCurrent returns a command with the currently selected item.
262func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
263	items := c.list.FilteredItems()
264	if len(items) == 0 {
265		return nil
266	}
267
268	selected := c.list.Selected()
269	if selected < 0 || selected >= len(items) {
270		return nil
271	}
272
273	item, ok := items[selected].(*CompletionItem)
274	if !ok {
275		return nil
276	}
277
278	if !keepOpen {
279		c.open = false
280	}
281
282	switch item := item.Value().(type) {
283	case ResourceCompletionValue:
284		return SelectionMsg[ResourceCompletionValue]{
285			Value:    item,
286			KeepOpen: keepOpen,
287		}
288	case FileCompletionValue:
289		return SelectionMsg[FileCompletionValue]{
290			Value:    item,
291			KeepOpen: keepOpen,
292		}
293	default:
294		return nil
295	}
296}
297
298// Render renders the completions popup.
299func (c *Completions) Render() string {
300	if !c.open {
301		return ""
302	}
303
304	items := c.list.FilteredItems()
305	if len(items) == 0 {
306		return ""
307	}
308
309	return c.list.Render()
310}
311
312func loadFiles(depth, limit int) []FileCompletionValue {
313	files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
314	slices.Sort(files)
315	result := make([]FileCompletionValue, 0, len(files))
316	for _, file := range files {
317		result = append(result, FileCompletionValue{
318			Path: strings.TrimPrefix(file, "./"),
319		})
320	}
321	return result
322}
323
324func loadMCPResources() []ResourceCompletionValue {
325	var resources []ResourceCompletionValue
326	for mcpName, mcpResources := range mcp.Resources() {
327		for _, r := range mcpResources {
328			resources = append(resources, ResourceCompletionValue{
329				MCPName:  mcpName,
330				URI:      r.URI,
331				Title:    r.Name,
332				MIMEType: r.MIMEType,
333			})
334		}
335	}
336	return resources
337}