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}