on main help sign in
feat: completions menu (#1781)

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

commit23e0b06d0054203cf4deb033b18e48037ed99e5d authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateThu 8 Jan 2026, 12:03 (9 months ago) parente38ffc3
8 files changed+809−24
.gitignore+1−2 internal/ui/completions/completions.go+271 internal/ui/completions/item.go+185 internal/ui/completions/keys.go+74 internal/ui/list/filterable.go+2 internal/ui/list/list.go+80−15 internal/ui/model/ui.go+184−7 internal/ui/styles/styles.go+12
.gitignore+1−2
@@ -48,6 +48,5 @@ Thumbs.db
4848/tmp/
4949
5050manpages/
51−completions/
52−!internal/tui/components/completions/
51+completions/crush.*sh
5352.prettierignore
internal/ui/completions/completions.gonew file+271
@@ -0,0 +1,271 @@
1+package completions
2+
3+import (
4+ "slices"
5+ "strings"
6+
7+ "charm.land/bubbles/v2/key"
8+ tea "charm.land/bubbletea/v2"
9+ "charm.land/lipgloss/v2"
10+ "github.com/charmbracelet/crush/internal/fsext"
11+ "github.com/charmbracelet/crush/internal/ui/list"
12+ "github.com/charmbracelet/x/ansi"
13+ "github.com/charmbracelet/x/exp/ordered"
14+)
15+
16+const (
17+ minHeight = 1
18+ maxHeight = 10
19+ minWidth = 10
20+ maxWidth = 100
21+)
22+
23+// SelectionMsg is sent when a completion is selected.
24+type SelectionMsg struct {
25+ Value any
26+ Insert bool // If true, insert without closing.
27+}
28+
29+// ClosedMsg is sent when the completions are closed.
30+type ClosedMsg struct{}
31+
32+// FilesLoadedMsg is sent when files have been loaded for completions.
33+type FilesLoadedMsg struct {
34+ Files []string
35+}
36+
37+// Completions represents the completions popup component.
38+type Completions struct {
39+ // Popup dimensions
40+ width int
41+ height int
42+
43+ // State
44+ open bool
45+ query string
46+
47+ // Key bindings
48+ keyMap KeyMap
49+
50+ // List component
51+ list *list.FilterableList
52+
53+ // Styling
54+ normalStyle lipgloss.Style
55+ focusedStyle lipgloss.Style
56+ matchStyle lipgloss.Style
57+}
58+
59+// New creates a new completions component.
60+func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
61+ l := list.NewFilterableList()
62+ l.SetGap(0)
63+ l.SetReverse(true)
64+
65+ return &Completions{
66+ keyMap: DefaultKeyMap(),
67+ list: l,
68+ normalStyle: normalStyle,
69+ focusedStyle: focusedStyle,
70+ matchStyle: matchStyle,
71+ }
72+}
73+
74+// IsOpen returns whether the completions popup is open.
75+func (c *Completions) IsOpen() bool {
76+ return c.open
77+}
78+
79+// Query returns the current filter query.
80+func (c *Completions) Query() string {
81+ return c.query
82+}
83+
84+// Size returns the visible size of the popup.
85+func (c *Completions) Size() (width, height int) {
86+ visible := len(c.list.VisibleItems())
87+ return c.width, min(visible, c.height)
88+}
89+
90+// KeyMap returns the key bindings.
91+func (c *Completions) KeyMap() KeyMap {
92+ return c.keyMap
93+}
94+
95+// OpenWithFiles opens the completions with file items from the filesystem.
96+func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd {
97+ return func() tea.Msg {
98+ files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
99+ slices.Sort(files)
100+ return FilesLoadedMsg{Files: files}
101+ }
102+}
103+
104+// SetFiles sets the file items on the completions popup.
105+func (c *Completions) SetFiles(files []string) {
106+ items := make([]list.FilterableItem, 0, len(files))
107+ width := 0
108+ for _, file := range files {
109+ file = strings.TrimPrefix(file, "./")
110+ item := NewCompletionItem(
111+ file,
112+ FileCompletionValue{Path: file},
113+ c.normalStyle,
114+ c.focusedStyle,
115+ c.matchStyle,
116+ )
117+
118+ width = max(width, ansi.StringWidth(file))
119+ items = append(items, item)
120+ }
121+
122+ c.open = true
123+ c.query = ""
124+ c.list.SetItems(items...)
125+ c.list.SetFilter("") // Clear any previous filter.
126+ c.list.Focus()
127+
128+ c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
129+ c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
130+ c.list.SetSize(c.width, c.height)
131+ c.list.SelectFirst()
132+ c.list.ScrollToSelected()
133+}
134+
135+// Close closes the completions popup.
136+func (c *Completions) Close() tea.Cmd {
137+ c.open = false
138+ return func() tea.Msg {
139+ return ClosedMsg{}
140+ }
141+}
142+
143+// Filter filters the completions with the given query.
144+func (c *Completions) Filter(query string) {
145+ if !c.open {
146+ return
147+ }
148+
149+ if query == c.query {
150+ return
151+ }
152+
153+ c.query = query
154+ c.list.SetFilter(query)
155+
156+ items := c.list.VisibleItems()
157+ width := 0
158+ for _, item := range items {
159+ width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text()))
160+ }
161+ c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
162+ c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
163+ c.list.SetSize(c.width, c.height)
164+ c.list.SelectFirst()
165+ c.list.ScrollToSelected()
166+}
167+
168+// HasItems returns whether there are visible items.
169+func (c *Completions) HasItems() bool {
170+ return len(c.list.VisibleItems()) > 0
171+}
172+
173+// Update handles key events for the completions.
174+func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Cmd, bool) {
175+ if !c.open {
176+ return nil, false
177+ }
178+
179+ switch {
180+ case key.Matches(msg, c.keyMap.Up):
181+ c.selectPrev()
182+ return nil, true
183+
184+ case key.Matches(msg, c.keyMap.Down):
185+ c.selectNext()
186+ return nil, true
187+
188+ case key.Matches(msg, c.keyMap.UpInsert):
189+ c.selectPrev()
190+ return c.selectCurrent(true), true
191+
192+ case key.Matches(msg, c.keyMap.DownInsert):
193+ c.selectNext()
194+ return c.selectCurrent(true), true
195+
196+ case key.Matches(msg, c.keyMap.Select):
197+ return c.selectCurrent(false), true
198+
199+ case key.Matches(msg, c.keyMap.Cancel):
200+ return c.Close(), true
201+ }
202+
203+ return nil, false
204+}
205+
206+// selectPrev selects the previous item with circular navigation.
207+func (c *Completions) selectPrev() {
208+ items := c.list.VisibleItems()
209+ if len(items) == 0 {
210+ return
211+ }
212+ if !c.list.SelectPrev() {
213+ c.list.WrapToEnd()
214+ }
215+ c.list.ScrollToSelected()
216+}
217+
218+// selectNext selects the next item with circular navigation.
219+func (c *Completions) selectNext() {
220+ items := c.list.VisibleItems()
221+ if len(items) == 0 {
222+ return
223+ }
224+ if !c.list.SelectNext() {
225+ c.list.WrapToStart()
226+ }
227+ c.list.ScrollToSelected()
228+}
229+
230+// selectCurrent returns a command with the currently selected item.
231+func (c *Completions) selectCurrent(insert bool) tea.Cmd {
232+ items := c.list.VisibleItems()
233+ if len(items) == 0 {
234+ return nil
235+ }
236+
237+ selected := c.list.Selected()
238+ if selected < 0 || selected >= len(items) {
239+ return nil
240+ }
241+
242+ item, ok := items[selected].(*CompletionItem)
243+ if !ok {
244+ return nil
245+ }
246+
247+ if !insert {
248+ c.open = false
249+ }
250+
251+ return func() tea.Msg {
252+ return SelectionMsg{
253+ Value: item.Value(),
254+ Insert: insert,
255+ }
256+ }
257+}
258+
259+// Render renders the completions popup.
260+func (c *Completions) Render() string {
261+ if !c.open {
262+ return ""
263+ }
264+
265+ items := c.list.VisibleItems()
266+ if len(items) == 0 {
267+ return ""
268+ }
269+
270+ return c.list.Render()
271+}
internal/ui/completions/item.gonew file+185
@@ -0,0 +1,185 @@
1+package completions
2+
3+import (
4+ "charm.land/lipgloss/v2"
5+ "github.com/charmbracelet/crush/internal/ui/list"
6+ "github.com/charmbracelet/x/ansi"
7+ "github.com/rivo/uniseg"
8+ "github.com/sahilm/fuzzy"
9+)
10+
11+// FileCompletionValue represents a file path completion value.
12+type FileCompletionValue struct {
13+ Path string
14+}
15+
16+// CompletionItem represents an item in the completions list.
17+type CompletionItem struct {
18+ text string
19+ value any
20+ match fuzzy.Match
21+ focused bool
22+ cache map[int]string
23+
24+ // Styles
25+ normalStyle lipgloss.Style
26+ focusedStyle lipgloss.Style
27+ matchStyle lipgloss.Style
28+}
29+
30+// NewCompletionItem creates a new completion item.
31+func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem {
32+ return &CompletionItem{
33+ text: text,
34+ value: value,
35+ normalStyle: normalStyle,
36+ focusedStyle: focusedStyle,
37+ matchStyle: matchStyle,
38+ }
39+}
40+
41+// Text returns the display text of the item.
42+func (c *CompletionItem) Text() string {
43+ return c.text
44+}
45+
46+// Value returns the value of the item.
47+func (c *CompletionItem) Value() any {
48+ return c.value
49+}
50+
51+// Filter implements [list.FilterableItem].
52+func (c *CompletionItem) Filter() string {
53+ return c.text
54+}
55+
56+// SetMatch implements [list.MatchSettable].
57+func (c *CompletionItem) SetMatch(m fuzzy.Match) {
58+ c.cache = nil
59+ c.match = m
60+}
61+
62+// SetFocused implements [list.Focusable].
63+func (c *CompletionItem) SetFocused(focused bool) {
64+ if c.focused != focused {
65+ c.cache = nil
66+ }
67+ c.focused = focused
68+}
69+
70+// Render implements [list.Item].
71+func (c *CompletionItem) Render(width int) string {
72+ return renderItem(
73+ c.normalStyle,
74+ c.focusedStyle,
75+ c.matchStyle,
76+ c.text,
77+ c.focused,
78+ width,
79+ c.cache,
80+ &c.match,
81+ )
82+}
83+
84+func renderItem(
85+ normalStyle, focusedStyle, matchStyle lipgloss.Style,
86+ text string,
87+ focused bool,
88+ width int,
89+ cache map[int]string,
90+ match *fuzzy.Match,
91+) string {
92+ if cache == nil {
93+ cache = make(map[int]string)
94+ }
95+
96+ cached, ok := cache[width]
97+ if ok {
98+ return cached
99+ }
100+
101+ innerWidth := width - 2 // Account for padding
102+ // Truncate if needed.
103+ if ansi.StringWidth(text) > innerWidth {
104+ text = ansi.Truncate(text, innerWidth, "…")
105+ }
106+
107+ // Select base style.
108+ style := normalStyle
109+ matchStyle = matchStyle.Background(style.GetBackground())
110+ if focused {
111+ style = focusedStyle
112+ matchStyle = matchStyle.Background(style.GetBackground())
113+ }
114+
115+ // Render full-width text with background.
116+ content := style.Padding(0, 1).Width(width).Render(text)
117+
118+ // Apply match highlighting using StyleRanges.
119+ if len(match.MatchedIndexes) > 0 {
120+ var ranges []lipgloss.Range
121+ for _, rng := range matchedRanges(match.MatchedIndexes) {
122+ start, stop := bytePosToVisibleCharPos(text, rng)
123+ // Offset by 1 for the padding space.
124+ ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle))
125+ }
126+ content = lipgloss.StyleRanges(content, ranges...)
127+ }
128+
129+ cache[width] = content
130+ return content
131+}
132+
133+// matchedRanges converts a list of match indexes into contiguous ranges.
134+func matchedRanges(in []int) [][2]int {
135+ if len(in) == 0 {
136+ return [][2]int{}
137+ }
138+ current := [2]int{in[0], in[0]}
139+ if len(in) == 1 {
140+ return [][2]int{current}
141+ }
142+ var out [][2]int
143+ for i := 1; i < len(in); i++ {
144+ if in[i] == current[1]+1 {
145+ current[1] = in[i]
146+ } else {
147+ out = append(out, current)
148+ current = [2]int{in[i], in[i]}
149+ }
150+ }
151+ out = append(out, current)
152+ return out
153+}
154+
155+// bytePosToVisibleCharPos converts byte positions to visible character positions.
156+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
157+ bytePos, byteStart, byteStop := 0, rng[0], rng[1]
158+ pos, start, stop := 0, 0, 0
159+ gr := uniseg.NewGraphemes(str)
160+ for byteStart > bytePos {
161+ if !gr.Next() {
162+ break
163+ }
164+ bytePos += len(gr.Str())
165+ pos += max(1, gr.Width())
166+ }
167+ start = pos
168+ for byteStop > bytePos {
169+ if !gr.Next() {
170+ break
171+ }
172+ bytePos += len(gr.Str())
173+ pos += max(1, gr.Width())
174+ }
175+ stop = pos
176+ return start, stop
177+}
178+
179+// Ensure CompletionItem implements the required interfaces.
180+var (
181+ _ list.Item = (*CompletionItem)(nil)
182+ _ list.FilterableItem = (*CompletionItem)(nil)
183+ _ list.MatchSettable = (*CompletionItem)(nil)
184+ _ list.Focusable = (*CompletionItem)(nil)
185+)
internal/ui/completions/keys.gonew file+74
@@ -0,0 +1,74 @@
1+package completions
2+
3+import (
4+ "charm.land/bubbles/v2/key"
5+)
6+
7+// KeyMap defines the key bindings for the completions component.
8+type KeyMap struct {
9+ Down,
10+ Up,
11+ Select,
12+ Cancel key.Binding
13+ DownInsert,
14+ UpInsert key.Binding
15+}
16+
17+// DefaultKeyMap returns the default key bindings for completions.
18+func DefaultKeyMap() KeyMap {
19+ return KeyMap{
20+ Down: key.NewBinding(
21+ key.WithKeys("down"),
22+ key.WithHelp("down", "move down"),
23+ ),
24+ Up: key.NewBinding(
25+ key.WithKeys("up"),
26+ key.WithHelp("up", "move up"),
27+ ),
28+ Select: key.NewBinding(
29+ key.WithKeys("enter", "tab", "ctrl+y"),
30+ key.WithHelp("enter", "select"),
31+ ),
32+ Cancel: key.NewBinding(
33+ key.WithKeys("esc", "alt+esc"),
34+ key.WithHelp("esc", "cancel"),
35+ ),
36+ DownInsert: key.NewBinding(
37+ key.WithKeys("ctrl+n"),
38+ key.WithHelp("ctrl+n", "insert next"),
39+ ),
40+ UpInsert: key.NewBinding(
41+ key.WithKeys("ctrl+p"),
42+ key.WithHelp("ctrl+p", "insert previous"),
43+ ),
44+ }
45+}
46+
47+// KeyBindings returns all key bindings as a slice.
48+func (k KeyMap) KeyBindings() []key.Binding {
49+ return []key.Binding{
50+ k.Down,
51+ k.Up,
52+ k.Select,
53+ k.Cancel,
54+ }
55+}
56+
57+// FullHelp returns the full help for the key bindings.
58+func (k KeyMap) FullHelp() [][]key.Binding {
59+ m := [][]key.Binding{}
60+ slice := k.KeyBindings()
61+ for i := 0; i < len(slice); i += 4 {
62+ end := min(i+4, len(slice))
63+ m = append(m, slice[i:end])
64+ }
65+ return m
66+}
67+
68+// ShortHelp returns the short help for the key bindings.
69+func (k KeyMap) ShortHelp() []key.Binding {
70+ return []key.Binding{
71+ k.Up,
72+ k.Down,
73+ }
74+}
internal/ui/list/filterable.go+2
@@ -68,6 +68,8 @@ func (f *FilterableList) PrependItems(items ...FilterableItem) {
6868// SetFilter sets the filter query and updates the list items.
6969func (f *FilterableList) SetFilter(q string) {
7070 f.query = q
71+ f.List.SetItems(f.VisibleItems()...)
72+ f.ScrollToTop()
7173}
7274
7375// FilterableItemsSource is a type that implements [fuzzy.Source] for filtering
internal/ui/list/list.go+80−15
@@ -17,6 +17,9 @@ type List struct {
1717 // Gap between items (0 or less means no gap)
1818 gap int
1919
20+ // show list in reverse order
21+ reverse bool
22+
2023 // Focus and selection state
2124 focused bool
2225 selectedIdx int // The current selected index -1 means no selection
@@ -63,6 +66,11 @@ func (l *List) SetGap(gap int) {
6366 l.gap = gap
6467}
6568
69+// SetReverse shows the list in reverse order.
70+func (l *List) SetReverse(reverse bool) {
71+ l.reverse = reverse
72+}
73+
6674// Width returns the width of the list viewport.
6775func (l *List) Width() int {
6876 return l.width
@@ -126,6 +134,10 @@ func (l *List) ScrollBy(lines int) {
126134 return
127135 }
128136
137+ if l.reverse {
138+ lines = -lines
139+ }
140+
129141 if lines > 0 {
130142 // Scroll down
131143 // Calculate from the bottom how many lines needed to anchor the last
@@ -269,6 +281,13 @@ func (l *List) Render() string {
269281 lines = lines[:l.height]
270282 }
271283
284+ if l.reverse {
285+ // Reverse the lines so the list renders bottom-to-top.
286+ for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
287+ lines[i], lines[j] = lines[j], lines[i]
288+ }
289+ }
290+
272291 return strings.Join(lines, "\n")
273292}
274293
@@ -440,12 +459,21 @@ func (l *List) IsSelectedLast() bool {
440459 return l.selectedIdx == len(l.items)-1
441460}
442461
443−// SelectPrev selects the previous item in the list.
462+// SelectPrev selects the visually previous item (moves toward visual top).
444463// It returns whether the selection changed.
445464func (l *List) SelectPrev() bool {
446− if l.selectedIdx > 0 {
447− l.selectedIdx--
448− return true
465+ if l.reverse {
466+ // In reverse, visual up = higher index
467+ if l.selectedIdx < len(l.items)-1 {
468+ l.selectedIdx++
469+ return true
470+ }
471+ } else {
472+ // Normal: visual up = lower index
473+ if l.selectedIdx > 0 {
474+ l.selectedIdx--
475+ return true
476+ }
449477 }
450478 return false
451479}
@@ -453,9 +481,18 @@ func (l *List) SelectPrev() bool {
453481// SelectNext selects the next item in the list.
454482// It returns whether the selection changed.
455483func (l *List) SelectNext() bool {
456− if l.selectedIdx < len(l.items)-1 {
457− l.selectedIdx++
458− return true
484+ if l.reverse {
485+ // In reverse, visual down = lower index
486+ if l.selectedIdx > 0 {
487+ l.selectedIdx--
488+ return true
489+ }
490+ } else {
491+ // Normal: visual down = higher index
492+ if l.selectedIdx < len(l.items)-1 {
493+ l.selectedIdx++
494+ return true
495+ }
459496 }
460497 return false
461498}
@@ -463,21 +500,49 @@ func (l *List) SelectNext() bool {
463500// SelectFirst selects the first item in the list.
464501// It returns whether the selection changed.
465502func (l *List) SelectFirst() bool {
466− if len(l.items) > 0 {
467− l.selectedIdx = 0
468− return true
503+ if len(l.items) == 0 {
504+ return false
469505 }
470− return false
506+ l.selectedIdx = 0
507+ return true
471508}
472509
473−// SelectLast selects the last item in the list.
510+// SelectLast selects the last item in the list (highest index).
474511// It returns whether the selection changed.
475512func (l *List) SelectLast() bool {
476− if len(l.items) > 0 {
513+ if len(l.items) == 0 {
514+ return false
515+ }
516+ l.selectedIdx = len(l.items) - 1
517+ return true
518+}
519+
520+// WrapToStart wraps selection to the visual start (for circular navigation).
521+// In normal mode, this is index 0. In reverse mode, this is the highest index.
522+func (l *List) WrapToStart() bool {
523+ if len(l.items) == 0 {
524+ return false
525+ }
526+ if l.reverse {
477527 l.selectedIdx = len(l.items) - 1
478− return true
528+ } else {
529+ l.selectedIdx = 0
479530 }
480− return false
531+ return true
532+}
533+
534+// WrapToEnd wraps selection to the visual end (for circular navigation).
535+// In normal mode, this is the highest index. In reverse mode, this is index 0.
536+func (l *List) WrapToEnd() bool {
537+ if len(l.items) == 0 {
538+ return false
539+ }
540+ if l.reverse {
541+ l.selectedIdx = 0
542+ } else {
543+ l.selectedIdx = len(l.items) - 1
544+ }
545+ return true
481546}
482547
483548// SelectedItem returns the currently selected item. It may be nil if no item
internal/ui/model/ui.go+184−7
@@ -30,6 +30,7 @@ import (
3030 "github.com/charmbracelet/crush/internal/ui/anim"
3131 "github.com/charmbracelet/crush/internal/ui/chat"
3232 "github.com/charmbracelet/crush/internal/ui/common"
33+ "github.com/charmbracelet/crush/internal/ui/completions"
3334 "github.com/charmbracelet/crush/internal/ui/dialog"
3435 "github.com/charmbracelet/crush/internal/ui/logo"
3536 "github.com/charmbracelet/crush/internal/ui/styles"
@@ -103,6 +104,13 @@ type UI struct {
103104 readyPlaceholder string
104105 workingPlaceholder string
105106
107+ // Completions state
108+ completions *completions.Completions
109+ completionsOpen bool
110+ completionsStartIndex int
111+ completionsQuery string
112+ completionsPositionStart image.Point // x,y where user typed '@'
113+
106114 // Chat components
107115 chat *Chat
108116
@@ -133,14 +141,22 @@ func New(com *common.Common) *UI {
133141
134142 ch := NewChat(com)
135143
144+ // Completions component
145+ comp := completions.New(
146+ com.Styles.Completions.Normal,
147+ com.Styles.Completions.Focused,
148+ com.Styles.Completions.Match,
149+ )
150+
136151 ui := &UI{
137− com: com,
138− dialog: dialog.NewOverlay(),
139− keyMap: DefaultKeyMap(),
140− focus: uiFocusNone,
141− state: uiConfigure,
142− textarea: ta,
143− chat: ch,
152+ com: com,
153+ dialog: dialog.NewOverlay(),
154+ keyMap: DefaultKeyMap(),
155+ focus: uiFocusNone,
156+ state: uiConfigure,
157+ textarea: ta,
158+ chat: ch,
159+ completions: comp,
144160 }
145161
146162 status := NewStatus(com, ui)
@@ -335,6 +351,21 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
335351 cmds = append(cmds, cmd)
336352 }
337353 }
354+ case completions.SelectionMsg:
355+ // Handle file completion selection.
356+ if item, ok := msg.Value.(completions.FileCompletionValue); ok {
357+ m.insertFileCompletion(item.Path)
358+ }
359+ if !msg.Insert {
360+ m.closeCompletions()
361+ }
362+ case completions.FilesLoadedMsg:
363+ // Handle async file loading for completions.
364+ if m.completionsOpen {
365+ m.completions.SetFiles(msg.Files)
366+ }
367+ case completions.ClosedMsg:
368+ m.completionsOpen = false
338369 case tea.KeyPressMsg:
339370 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
340371 cmds = append(cmds, cmd)
@@ -775,6 +806,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
775806 case uiChat, uiLanding, uiChatCompact:
776807 switch m.focus {
777808 case uiFocusEditor:
809+ // Handle completions if open.
810+ if m.completionsOpen {
811+ if cmd, ok := m.completions.Update(msg); ok {
812+ cmds = append(cmds, cmd)
813+ return tea.Batch(cmds...)
814+ }
815+ }
816+
778817 switch {
779818 case key.Matches(msg, m.keyMap.Editor.SendMessage):
780819 value := m.textarea.Value()
@@ -823,15 +862,57 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
823862 cmds = append(cmds, m.openEditor(m.textarea.Value()))
824863 case key.Matches(msg, m.keyMap.Editor.Newline):
825864 m.textarea.InsertRune('\n')
865+ m.closeCompletions()
826866 default:
827867 if handleGlobalKeys(msg) {
828868 // Handle global keys first before passing to textarea.
829869 break
830870 }
831871
872+ // Check for @ trigger before passing to textarea.
873+ curValue := m.textarea.Value()
874+ curIdx := len(curValue)
875+
876+ // Trigger completions on @.
877+ if msg.String() == "@" && !m.completionsOpen {
878+ // Only show if beginning of prompt or after whitespace.
879+ if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
880+ m.completionsOpen = true
881+ m.completionsQuery = ""
882+ m.completionsStartIndex = curIdx
883+ m.completionsPositionStart = m.completionsPosition()
884+ depth, limit := m.com.Config().Options.TUI.Completions.Limits()
885+ cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
886+ }
887+ }
888+
832889 ta, cmd := m.textarea.Update(msg)
833890 m.textarea = ta
834891 cmds = append(cmds, cmd)
892+
893+ // After updating textarea, check if we need to filter completions.
894+ // Skip filtering on the initial @ keystroke since items are loading async.
895+ if m.completionsOpen && msg.String() != "@" {
896+ newValue := m.textarea.Value()
897+ newIdx := len(newValue)
898+
899+ // Close completions if cursor moved before start.
900+ if newIdx <= m.completionsStartIndex {
901+ m.closeCompletions()
902+ } else if msg.String() == "space" {
903+ // Close on space.
904+ m.closeCompletions()
905+ } else {
906+ // Extract current word and filter.
907+ word := m.textareaWord()
908+ if strings.HasPrefix(word, "@") {
909+ m.completionsQuery = word[1:]
910+ m.completions.Filter(m.completionsQuery)
911+ } else if m.completionsOpen {
912+ m.closeCompletions()
913+ }
914+ }
915+ }
835916 }
836917 case uiFocusMain:
837918 switch {
@@ -982,6 +1063,26 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
9821063 // Add status and help layer
9831064 m.status.Draw(scr, layout.status)
9841065
1066+ // Draw completions popup if open
1067+ if m.completionsOpen && m.completions.HasItems() {
1068+ w, h := m.completions.Size()
1069+ x := m.completionsPositionStart.X
1070+ y := m.completionsPositionStart.Y - h
1071+
1072+ screenW := area.Dx()
1073+ if x+w > screenW {
1074+ x = screenW - w
1075+ }
1076+ x = max(0, x)
1077+ y = max(0, y)
1078+
1079+ completionsView := uv.NewStyledString(m.completions.Render())
1080+ completionsView.Draw(scr, image.Rectangle{
1081+ Min: image.Pt(x, y),
1082+ Max: image.Pt(x+w, y+h),
1083+ })
1084+ }
1085+
9851086 // Debugging rendering (visually see when the tui rerenders)
9861087 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
9871088 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
@@ -1489,6 +1590,82 @@ func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
14891590 return t.EditorPromptYoloDotsBlurred.Render()
14901591}
14911592
1593+// closeCompletions closes the completions popup and resets state.
1594+func (m *UI) closeCompletions() {
1595+ m.completionsOpen = false
1596+ m.completionsQuery = ""
1597+ m.completionsStartIndex = 0
1598+ m.completions.Close()
1599+}
1600+
1601+// insertFileCompletion inserts the selected file path into the textarea,
1602+// replacing the @query, and adds the file as an attachment.
1603+func (m *UI) insertFileCompletion(path string) {
1604+ value := m.textarea.Value()
1605+ word := m.textareaWord()
1606+
1607+ // Find the @ and query to replace.
1608+ if m.completionsStartIndex > len(value) {
1609+ return
1610+ }
1611+
1612+ // Build the new value: everything before @, the path, everything after query.
1613+ endIdx := m.completionsStartIndex + len(word)
1614+ if endIdx > len(value) {
1615+ endIdx = len(value)
1616+ }
1617+
1618+ newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
1619+ m.textarea.SetValue(newValue)
1620+ // XXX: This will always move the cursor to the end of the textarea.
1621+ m.textarea.MoveToEnd()
1622+
1623+ // Add file as attachment.
1624+ content, err := os.ReadFile(path)
1625+ if err != nil {
1626+ // If it fails, let the LLM handle it later.
1627+ return
1628+ }
1629+
1630+ m.attachments = append(m.attachments, message.Attachment{
1631+ FilePath: path,
1632+ FileName: filepath.Base(path),
1633+ MimeType: mimeOf(content),
1634+ Content: content,
1635+ })
1636+}
1637+
1638+// completionsPosition returns the X and Y position for the completions popup.
1639+func (m *UI) completionsPosition() image.Point {
1640+ cur := m.textarea.Cursor()
1641+ if cur == nil {
1642+ return image.Point{
1643+ X: m.layout.editor.Min.X,
1644+ Y: m.layout.editor.Min.Y,
1645+ }
1646+ }
1647+ return image.Point{
1648+ X: cur.X + m.layout.editor.Min.X,
1649+ Y: m.layout.editor.Min.Y + cur.Y,
1650+ }
1651+}
1652+
1653+// textareaWord returns the current word at the cursor position.
1654+func (m *UI) textareaWord() string {
1655+ return m.textarea.Word()
1656+}
1657+
1658+// isWhitespace returns true if the byte is a whitespace character.
1659+func isWhitespace(b byte) bool {
1660+ return b == ' ' || b == '\t' || b == '\n' || b == '\r'
1661+}
1662+
1663+// mimeOf detects the MIME type of the given content.
1664+func mimeOf(content []byte) string {
1665+ mimeBufferSize := min(512, len(content))
1666+ return http.DetectContentType(content[:mimeBufferSize])
1667+}
1668+
14921669var readyPlaceholders = [...]string{
14931670 "Ready!",
14941671 "Ready...",
internal/ui/styles/styles.go+12
@@ -330,6 +330,13 @@ type Styles struct {
330330 UpdateMessage lipgloss.Style
331331 SuccessMessage lipgloss.Style
332332 }
333+
334+ // Completions popup styles
335+ Completions struct {
336+ Normal lipgloss.Style
337+ Focused lipgloss.Style
338+ Match lipgloss.Style
339+ }
333340}
334341
335342// ChromaTheme converts the current markdown chroma styles to a chroma
@@ -1160,6 +1167,11 @@ func DefaultStyles() Styles {
11601167 s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning)
11611168 s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark)
11621169
1170+ // Completions styles
1171+ s.Completions.Normal = base.Background(bgSubtle).Foreground(fgBase)
1172+ s.Completions.Focused = base.Background(primary).Foreground(white)
1173+ s.Completions.Match = base.Underline(true)
1174+
11631175 return s
11641176}
11651177