feat: completions menu (#1781)

Carlos Alexandro Becker created

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

Change summary

.gitignore                             |   3 
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               |  95 ++++++++-
internal/ui/model/ui.go                | 191 +++++++++++++++++++
internal/ui/styles/styles.go           |  12 +
8 files changed, 809 insertions(+), 24 deletions(-)

Detailed changes

.gitignore 🔗

@@ -48,6 +48,5 @@ Thumbs.db
 /tmp/
 
 manpages/
-completions/
-!internal/tui/components/completions/
+completions/crush.*sh
 .prettierignore

internal/ui/completions/completions.go 🔗

@@ -0,0 +1,271 @@
+package completions
+
+import (
+	"slices"
+	"strings"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/ordered"
+)
+
+const (
+	minHeight = 1
+	maxHeight = 10
+	minWidth  = 10
+	maxWidth  = 100
+)
+
+// SelectionMsg is sent when a completion is selected.
+type SelectionMsg struct {
+	Value  any
+	Insert bool // If true, insert without closing.
+}
+
+// ClosedMsg is sent when the completions are closed.
+type ClosedMsg struct{}
+
+// FilesLoadedMsg is sent when files have been loaded for completions.
+type FilesLoadedMsg struct {
+	Files []string
+}
+
+// Completions represents the completions popup component.
+type Completions struct {
+	// Popup dimensions
+	width  int
+	height int
+
+	// State
+	open  bool
+	query string
+
+	// Key bindings
+	keyMap KeyMap
+
+	// List component
+	list *list.FilterableList
+
+	// Styling
+	normalStyle  lipgloss.Style
+	focusedStyle lipgloss.Style
+	matchStyle   lipgloss.Style
+}
+
+// New creates a new completions component.
+func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
+	l := list.NewFilterableList()
+	l.SetGap(0)
+	l.SetReverse(true)
+
+	return &Completions{
+		keyMap:       DefaultKeyMap(),
+		list:         l,
+		normalStyle:  normalStyle,
+		focusedStyle: focusedStyle,
+		matchStyle:   matchStyle,
+	}
+}
+
+// IsOpen returns whether the completions popup is open.
+func (c *Completions) IsOpen() bool {
+	return c.open
+}
+
+// Query returns the current filter query.
+func (c *Completions) Query() string {
+	return c.query
+}
+
+// Size returns the visible size of the popup.
+func (c *Completions) Size() (width, height int) {
+	visible := len(c.list.VisibleItems())
+	return c.width, min(visible, c.height)
+}
+
+// KeyMap returns the key bindings.
+func (c *Completions) KeyMap() KeyMap {
+	return c.keyMap
+}
+
+// OpenWithFiles opens the completions with file items from the filesystem.
+func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd {
+	return func() tea.Msg {
+		files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
+		slices.Sort(files)
+		return FilesLoadedMsg{Files: files}
+	}
+}
+
+// SetFiles sets the file items on the completions popup.
+func (c *Completions) SetFiles(files []string) {
+	items := make([]list.FilterableItem, 0, len(files))
+	width := 0
+	for _, file := range files {
+		file = strings.TrimPrefix(file, "./")
+		item := NewCompletionItem(
+			file,
+			FileCompletionValue{Path: file},
+			c.normalStyle,
+			c.focusedStyle,
+			c.matchStyle,
+		)
+
+		width = max(width, ansi.StringWidth(file))
+		items = append(items, item)
+	}
+
+	c.open = true
+	c.query = ""
+	c.list.SetItems(items...)
+	c.list.SetFilter("") // Clear any previous filter.
+	c.list.Focus()
+
+	c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
+	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
+	c.list.SetSize(c.width, c.height)
+	c.list.SelectFirst()
+	c.list.ScrollToSelected()
+}
+
+// Close closes the completions popup.
+func (c *Completions) Close() tea.Cmd {
+	c.open = false
+	return func() tea.Msg {
+		return ClosedMsg{}
+	}
+}
+
+// Filter filters the completions with the given query.
+func (c *Completions) Filter(query string) {
+	if !c.open {
+		return
+	}
+
+	if query == c.query {
+		return
+	}
+
+	c.query = query
+	c.list.SetFilter(query)
+
+	items := c.list.VisibleItems()
+	width := 0
+	for _, item := range items {
+		width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text()))
+	}
+	c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
+	c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
+	c.list.SetSize(c.width, c.height)
+	c.list.SelectFirst()
+	c.list.ScrollToSelected()
+}
+
+// HasItems returns whether there are visible items.
+func (c *Completions) HasItems() bool {
+	return len(c.list.VisibleItems()) > 0
+}
+
+// Update handles key events for the completions.
+func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Cmd, bool) {
+	if !c.open {
+		return nil, false
+	}
+
+	switch {
+	case key.Matches(msg, c.keyMap.Up):
+		c.selectPrev()
+		return nil, true
+
+	case key.Matches(msg, c.keyMap.Down):
+		c.selectNext()
+		return nil, true
+
+	case key.Matches(msg, c.keyMap.UpInsert):
+		c.selectPrev()
+		return c.selectCurrent(true), true
+
+	case key.Matches(msg, c.keyMap.DownInsert):
+		c.selectNext()
+		return c.selectCurrent(true), true
+
+	case key.Matches(msg, c.keyMap.Select):
+		return c.selectCurrent(false), true
+
+	case key.Matches(msg, c.keyMap.Cancel):
+		return c.Close(), true
+	}
+
+	return nil, false
+}
+
+// selectPrev selects the previous item with circular navigation.
+func (c *Completions) selectPrev() {
+	items := c.list.VisibleItems()
+	if len(items) == 0 {
+		return
+	}
+	if !c.list.SelectPrev() {
+		c.list.WrapToEnd()
+	}
+	c.list.ScrollToSelected()
+}
+
+// selectNext selects the next item with circular navigation.
+func (c *Completions) selectNext() {
+	items := c.list.VisibleItems()
+	if len(items) == 0 {
+		return
+	}
+	if !c.list.SelectNext() {
+		c.list.WrapToStart()
+	}
+	c.list.ScrollToSelected()
+}
+
+// selectCurrent returns a command with the currently selected item.
+func (c *Completions) selectCurrent(insert bool) tea.Cmd {
+	items := c.list.VisibleItems()
+	if len(items) == 0 {
+		return nil
+	}
+
+	selected := c.list.Selected()
+	if selected < 0 || selected >= len(items) {
+		return nil
+	}
+
+	item, ok := items[selected].(*CompletionItem)
+	if !ok {
+		return nil
+	}
+
+	if !insert {
+		c.open = false
+	}
+
+	return func() tea.Msg {
+		return SelectionMsg{
+			Value:  item.Value(),
+			Insert: insert,
+		}
+	}
+}
+
+// Render renders the completions popup.
+func (c *Completions) Render() string {
+	if !c.open {
+		return ""
+	}
+
+	items := c.list.VisibleItems()
+	if len(items) == 0 {
+		return ""
+	}
+
+	return c.list.Render()
+}

internal/ui/completions/item.go 🔗

@@ -0,0 +1,185 @@
+package completions
+
+import (
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/rivo/uniseg"
+	"github.com/sahilm/fuzzy"
+)
+
+// FileCompletionValue represents a file path completion value.
+type FileCompletionValue struct {
+	Path string
+}
+
+// CompletionItem represents an item in the completions list.
+type CompletionItem struct {
+	text    string
+	value   any
+	match   fuzzy.Match
+	focused bool
+	cache   map[int]string
+
+	// Styles
+	normalStyle  lipgloss.Style
+	focusedStyle lipgloss.Style
+	matchStyle   lipgloss.Style
+}
+
+// NewCompletionItem creates a new completion item.
+func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem {
+	return &CompletionItem{
+		text:         text,
+		value:        value,
+		normalStyle:  normalStyle,
+		focusedStyle: focusedStyle,
+		matchStyle:   matchStyle,
+	}
+}
+
+// Text returns the display text of the item.
+func (c *CompletionItem) Text() string {
+	return c.text
+}
+
+// Value returns the value of the item.
+func (c *CompletionItem) Value() any {
+	return c.value
+}
+
+// Filter implements [list.FilterableItem].
+func (c *CompletionItem) Filter() string {
+	return c.text
+}
+
+// SetMatch implements [list.MatchSettable].
+func (c *CompletionItem) SetMatch(m fuzzy.Match) {
+	c.cache = nil
+	c.match = m
+}
+
+// SetFocused implements [list.Focusable].
+func (c *CompletionItem) SetFocused(focused bool) {
+	if c.focused != focused {
+		c.cache = nil
+	}
+	c.focused = focused
+}
+
+// Render implements [list.Item].
+func (c *CompletionItem) Render(width int) string {
+	return renderItem(
+		c.normalStyle,
+		c.focusedStyle,
+		c.matchStyle,
+		c.text,
+		c.focused,
+		width,
+		c.cache,
+		&c.match,
+	)
+}
+
+func renderItem(
+	normalStyle, focusedStyle, matchStyle lipgloss.Style,
+	text string,
+	focused bool,
+	width int,
+	cache map[int]string,
+	match *fuzzy.Match,
+) string {
+	if cache == nil {
+		cache = make(map[int]string)
+	}
+
+	cached, ok := cache[width]
+	if ok {
+		return cached
+	}
+
+	innerWidth := width - 2 // Account for padding
+	// Truncate if needed.
+	if ansi.StringWidth(text) > innerWidth {
+		text = ansi.Truncate(text, innerWidth, "…")
+	}
+
+	// Select base style.
+	style := normalStyle
+	matchStyle = matchStyle.Background(style.GetBackground())
+	if focused {
+		style = focusedStyle
+		matchStyle = matchStyle.Background(style.GetBackground())
+	}
+
+	// Render full-width text with background.
+	content := style.Padding(0, 1).Width(width).Render(text)
+
+	// Apply match highlighting using StyleRanges.
+	if len(match.MatchedIndexes) > 0 {
+		var ranges []lipgloss.Range
+		for _, rng := range matchedRanges(match.MatchedIndexes) {
+			start, stop := bytePosToVisibleCharPos(text, rng)
+			// Offset by 1 for the padding space.
+			ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle))
+		}
+		content = lipgloss.StyleRanges(content, ranges...)
+	}
+
+	cache[width] = content
+	return content
+}
+
+// matchedRanges converts a list of match indexes into contiguous ranges.
+func matchedRanges(in []int) [][2]int {
+	if len(in) == 0 {
+		return [][2]int{}
+	}
+	current := [2]int{in[0], in[0]}
+	if len(in) == 1 {
+		return [][2]int{current}
+	}
+	var out [][2]int
+	for i := 1; i < len(in); i++ {
+		if in[i] == current[1]+1 {
+			current[1] = in[i]
+		} else {
+			out = append(out, current)
+			current = [2]int{in[i], in[i]}
+		}
+	}
+	out = append(out, current)
+	return out
+}
+
+// bytePosToVisibleCharPos converts byte positions to visible character positions.
+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
+	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
+	pos, start, stop := 0, 0, 0
+	gr := uniseg.NewGraphemes(str)
+	for byteStart > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	start = pos
+	for byteStop > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	stop = pos
+	return start, stop
+}
+
+// Ensure CompletionItem implements the required interfaces.
+var (
+	_ list.Item           = (*CompletionItem)(nil)
+	_ list.FilterableItem = (*CompletionItem)(nil)
+	_ list.MatchSettable  = (*CompletionItem)(nil)
+	_ list.Focusable      = (*CompletionItem)(nil)
+)

internal/ui/completions/keys.go 🔗

@@ -0,0 +1,74 @@
+package completions
+
+import (
+	"charm.land/bubbles/v2/key"
+)
+
+// KeyMap defines the key bindings for the completions component.
+type KeyMap struct {
+	Down,
+	Up,
+	Select,
+	Cancel key.Binding
+	DownInsert,
+	UpInsert key.Binding
+}
+
+// DefaultKeyMap returns the default key bindings for completions.
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Down: key.NewBinding(
+			key.WithKeys("down"),
+			key.WithHelp("down", "move down"),
+		),
+		Up: key.NewBinding(
+			key.WithKeys("up"),
+			key.WithHelp("up", "move up"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter", "tab", "ctrl+y"),
+			key.WithHelp("enter", "select"),
+		),
+		Cancel: key.NewBinding(
+			key.WithKeys("esc", "alt+esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+		DownInsert: key.NewBinding(
+			key.WithKeys("ctrl+n"),
+			key.WithHelp("ctrl+n", "insert next"),
+		),
+		UpInsert: key.NewBinding(
+			key.WithKeys("ctrl+p"),
+			key.WithHelp("ctrl+p", "insert previous"),
+		),
+	}
+}
+
+// KeyBindings returns all key bindings as a slice.
+func (k KeyMap) KeyBindings() []key.Binding {
+	return []key.Binding{
+		k.Down,
+		k.Up,
+		k.Select,
+		k.Cancel,
+	}
+}
+
+// FullHelp returns the full help for the key bindings.
+func (k KeyMap) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := k.KeyBindings()
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+// ShortHelp returns the short help for the key bindings.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		k.Up,
+		k.Down,
+	}
+}

internal/ui/list/filterable.go 🔗

@@ -68,6 +68,8 @@ func (f *FilterableList) PrependItems(items ...FilterableItem) {
 // SetFilter sets the filter query and updates the list items.
 func (f *FilterableList) SetFilter(q string) {
 	f.query = q
+	f.List.SetItems(f.VisibleItems()...)
+	f.ScrollToTop()
 }
 
 // FilterableItemsSource is a type that implements [fuzzy.Source] for filtering

internal/ui/list/list.go 🔗

@@ -17,6 +17,9 @@ type List struct {
 	// Gap between items (0 or less means no gap)
 	gap int
 
+	// show list in reverse order
+	reverse bool
+
 	// Focus and selection state
 	focused     bool
 	selectedIdx int // The current selected index -1 means no selection
@@ -63,6 +66,11 @@ func (l *List) SetGap(gap int) {
 	l.gap = gap
 }
 
+// SetReverse shows the list in reverse order.
+func (l *List) SetReverse(reverse bool) {
+	l.reverse = reverse
+}
+
 // Width returns the width of the list viewport.
 func (l *List) Width() int {
 	return l.width
@@ -126,6 +134,10 @@ func (l *List) ScrollBy(lines int) {
 		return
 	}
 
+	if l.reverse {
+		lines = -lines
+	}
+
 	if lines > 0 {
 		// Scroll down
 		// Calculate from the bottom how many lines needed to anchor the last
@@ -269,6 +281,13 @@ func (l *List) Render() string {
 		lines = lines[:l.height]
 	}
 
+	if l.reverse {
+		// Reverse the lines so the list renders bottom-to-top.
+		for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
+			lines[i], lines[j] = lines[j], lines[i]
+		}
+	}
+
 	return strings.Join(lines, "\n")
 }
 
@@ -440,12 +459,21 @@ func (l *List) IsSelectedLast() bool {
 	return l.selectedIdx == len(l.items)-1
 }
 
-// SelectPrev selects the previous item in the list.
+// SelectPrev selects the visually previous item (moves toward visual top).
 // It returns whether the selection changed.
 func (l *List) SelectPrev() bool {
-	if l.selectedIdx > 0 {
-		l.selectedIdx--
-		return true
+	if l.reverse {
+		// In reverse, visual up = higher index
+		if l.selectedIdx < len(l.items)-1 {
+			l.selectedIdx++
+			return true
+		}
+	} else {
+		// Normal: visual up = lower index
+		if l.selectedIdx > 0 {
+			l.selectedIdx--
+			return true
+		}
 	}
 	return false
 }
@@ -453,9 +481,18 @@ func (l *List) SelectPrev() bool {
 // SelectNext selects the next item in the list.
 // It returns whether the selection changed.
 func (l *List) SelectNext() bool {
-	if l.selectedIdx < len(l.items)-1 {
-		l.selectedIdx++
-		return true
+	if l.reverse {
+		// In reverse, visual down = lower index
+		if l.selectedIdx > 0 {
+			l.selectedIdx--
+			return true
+		}
+	} else {
+		// Normal: visual down = higher index
+		if l.selectedIdx < len(l.items)-1 {
+			l.selectedIdx++
+			return true
+		}
 	}
 	return false
 }
@@ -463,21 +500,49 @@ func (l *List) SelectNext() bool {
 // SelectFirst selects the first item in the list.
 // It returns whether the selection changed.
 func (l *List) SelectFirst() bool {
-	if len(l.items) > 0 {
-		l.selectedIdx = 0
-		return true
+	if len(l.items) == 0 {
+		return false
 	}
-	return false
+	l.selectedIdx = 0
+	return true
 }
 
-// SelectLast selects the last item in the list.
+// SelectLast selects the last item in the list (highest index).
 // It returns whether the selection changed.
 func (l *List) SelectLast() bool {
-	if len(l.items) > 0 {
+	if len(l.items) == 0 {
+		return false
+	}
+	l.selectedIdx = len(l.items) - 1
+	return true
+}
+
+// WrapToStart wraps selection to the visual start (for circular navigation).
+// In normal mode, this is index 0. In reverse mode, this is the highest index.
+func (l *List) WrapToStart() bool {
+	if len(l.items) == 0 {
+		return false
+	}
+	if l.reverse {
 		l.selectedIdx = len(l.items) - 1
-		return true
+	} else {
+		l.selectedIdx = 0
 	}
-	return false
+	return true
+}
+
+// WrapToEnd wraps selection to the visual end (for circular navigation).
+// In normal mode, this is the highest index. In reverse mode, this is index 0.
+func (l *List) WrapToEnd() bool {
+	if len(l.items) == 0 {
+		return false
+	}
+	if l.reverse {
+		l.selectedIdx = 0
+	} else {
+		l.selectedIdx = len(l.items) - 1
+	}
+	return true
 }
 
 // SelectedItem returns the currently selected item. It may be nil if no item

internal/ui/model/ui.go 🔗

@@ -30,6 +30,7 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/completions"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
 	"github.com/charmbracelet/crush/internal/ui/logo"
 	"github.com/charmbracelet/crush/internal/ui/styles"
@@ -103,6 +104,13 @@ type UI struct {
 	readyPlaceholder   string
 	workingPlaceholder string
 
+	// Completions state
+	completions              *completions.Completions
+	completionsOpen          bool
+	completionsStartIndex    int
+	completionsQuery         string
+	completionsPositionStart image.Point // x,y where user typed '@'
+
 	// Chat components
 	chat *Chat
 
@@ -133,14 +141,22 @@ func New(com *common.Common) *UI {
 
 	ch := NewChat(com)
 
+	// Completions component
+	comp := completions.New(
+		com.Styles.Completions.Normal,
+		com.Styles.Completions.Focused,
+		com.Styles.Completions.Match,
+	)
+
 	ui := &UI{
-		com:      com,
-		dialog:   dialog.NewOverlay(),
-		keyMap:   DefaultKeyMap(),
-		focus:    uiFocusNone,
-		state:    uiConfigure,
-		textarea: ta,
-		chat:     ch,
+		com:         com,
+		dialog:      dialog.NewOverlay(),
+		keyMap:      DefaultKeyMap(),
+		focus:       uiFocusNone,
+		state:       uiConfigure,
+		textarea:    ta,
+		chat:        ch,
+		completions: comp,
 	}
 
 	status := NewStatus(com, ui)
@@ -335,6 +351,21 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, cmd)
 			}
 		}
+	case completions.SelectionMsg:
+		// Handle file completion selection.
+		if item, ok := msg.Value.(completions.FileCompletionValue); ok {
+			m.insertFileCompletion(item.Path)
+		}
+		if !msg.Insert {
+			m.closeCompletions()
+		}
+	case completions.FilesLoadedMsg:
+		// Handle async file loading for completions.
+		if m.completionsOpen {
+			m.completions.SetFiles(msg.Files)
+		}
+	case completions.ClosedMsg:
+		m.completionsOpen = false
 	case tea.KeyPressMsg:
 		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
 			cmds = append(cmds, cmd)
@@ -775,6 +806,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 	case uiChat, uiLanding, uiChatCompact:
 		switch m.focus {
 		case uiFocusEditor:
+			// Handle completions if open.
+			if m.completionsOpen {
+				if cmd, ok := m.completions.Update(msg); ok {
+					cmds = append(cmds, cmd)
+					return tea.Batch(cmds...)
+				}
+			}
+
 			switch {
 			case key.Matches(msg, m.keyMap.Editor.SendMessage):
 				value := m.textarea.Value()
@@ -823,15 +862,57 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				cmds = append(cmds, m.openEditor(m.textarea.Value()))
 			case key.Matches(msg, m.keyMap.Editor.Newline):
 				m.textarea.InsertRune('\n')
+				m.closeCompletions()
 			default:
 				if handleGlobalKeys(msg) {
 					// Handle global keys first before passing to textarea.
 					break
 				}
 
+				// Check for @ trigger before passing to textarea.
+				curValue := m.textarea.Value()
+				curIdx := len(curValue)
+
+				// Trigger completions on @.
+				if msg.String() == "@" && !m.completionsOpen {
+					// Only show if beginning of prompt or after whitespace.
+					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
+						m.completionsOpen = true
+						m.completionsQuery = ""
+						m.completionsStartIndex = curIdx
+						m.completionsPositionStart = m.completionsPosition()
+						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
+						cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
+					}
+				}
+
 				ta, cmd := m.textarea.Update(msg)
 				m.textarea = ta
 				cmds = append(cmds, cmd)
+
+				// After updating textarea, check if we need to filter completions.
+				// Skip filtering on the initial @ keystroke since items are loading async.
+				if m.completionsOpen && msg.String() != "@" {
+					newValue := m.textarea.Value()
+					newIdx := len(newValue)
+
+					// Close completions if cursor moved before start.
+					if newIdx <= m.completionsStartIndex {
+						m.closeCompletions()
+					} else if msg.String() == "space" {
+						// Close on space.
+						m.closeCompletions()
+					} else {
+						// Extract current word and filter.
+						word := m.textareaWord()
+						if strings.HasPrefix(word, "@") {
+							m.completionsQuery = word[1:]
+							m.completions.Filter(m.completionsQuery)
+						} else if m.completionsOpen {
+							m.closeCompletions()
+						}
+					}
+				}
 			}
 		case uiFocusMain:
 			switch {
@@ -982,6 +1063,26 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 	// Add status and help layer
 	m.status.Draw(scr, layout.status)
 
+	// Draw completions popup if open
+	if m.completionsOpen && m.completions.HasItems() {
+		w, h := m.completions.Size()
+		x := m.completionsPositionStart.X
+		y := m.completionsPositionStart.Y - h
+
+		screenW := area.Dx()
+		if x+w > screenW {
+			x = screenW - w
+		}
+		x = max(0, x)
+		y = max(0, y)
+
+		completionsView := uv.NewStyledString(m.completions.Render())
+		completionsView.Draw(scr, image.Rectangle{
+			Min: image.Pt(x, y),
+			Max: image.Pt(x+w, y+h),
+		})
+	}
+
 	// Debugging rendering (visually see when the tui rerenders)
 	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
 		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 {
 	return t.EditorPromptYoloDotsBlurred.Render()
 }
 
+// closeCompletions closes the completions popup and resets state.
+func (m *UI) closeCompletions() {
+	m.completionsOpen = false
+	m.completionsQuery = ""
+	m.completionsStartIndex = 0
+	m.completions.Close()
+}
+
+// insertFileCompletion inserts the selected file path into the textarea,
+// replacing the @query, and adds the file as an attachment.
+func (m *UI) insertFileCompletion(path string) {
+	value := m.textarea.Value()
+	word := m.textareaWord()
+
+	// Find the @ and query to replace.
+	if m.completionsStartIndex > len(value) {
+		return
+	}
+
+	// Build the new value: everything before @, the path, everything after query.
+	endIdx := m.completionsStartIndex + len(word)
+	if endIdx > len(value) {
+		endIdx = len(value)
+	}
+
+	newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
+	m.textarea.SetValue(newValue)
+	// XXX: This will always move the cursor to the end of the textarea.
+	m.textarea.MoveToEnd()
+
+	// Add file as attachment.
+	content, err := os.ReadFile(path)
+	if err != nil {
+		// If it fails, let the LLM handle it later.
+		return
+	}
+
+	m.attachments = append(m.attachments, message.Attachment{
+		FilePath: path,
+		FileName: filepath.Base(path),
+		MimeType: mimeOf(content),
+		Content:  content,
+	})
+}
+
+// completionsPosition returns the X and Y position for the completions popup.
+func (m *UI) completionsPosition() image.Point {
+	cur := m.textarea.Cursor()
+	if cur == nil {
+		return image.Point{
+			X: m.layout.editor.Min.X,
+			Y: m.layout.editor.Min.Y,
+		}
+	}
+	return image.Point{
+		X: cur.X + m.layout.editor.Min.X,
+		Y: m.layout.editor.Min.Y + cur.Y,
+	}
+}
+
+// textareaWord returns the current word at the cursor position.
+func (m *UI) textareaWord() string {
+	return m.textarea.Word()
+}
+
+// isWhitespace returns true if the byte is a whitespace character.
+func isWhitespace(b byte) bool {
+	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
+}
+
+// mimeOf detects the MIME type of the given content.
+func mimeOf(content []byte) string {
+	mimeBufferSize := min(512, len(content))
+	return http.DetectContentType(content[:mimeBufferSize])
+}
+
 var readyPlaceholders = [...]string{
 	"Ready!",
 	"Ready...",

internal/ui/styles/styles.go 🔗

@@ -330,6 +330,13 @@ type Styles struct {
 		UpdateMessage  lipgloss.Style
 		SuccessMessage lipgloss.Style
 	}
+
+	// Completions popup styles
+	Completions struct {
+		Normal  lipgloss.Style
+		Focused lipgloss.Style
+		Match   lipgloss.Style
+	}
 }
 
 // ChromaTheme converts the current markdown chroma styles to a chroma
@@ -1160,6 +1167,11 @@ func DefaultStyles() Styles {
 	s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning)
 	s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark)
 
+	// Completions styles
+	s.Completions.Normal = base.Background(bgSubtle).Foreground(fgBase)
+	s.Completions.Focused = base.Background(primary).Foreground(white)
+	s.Completions.Match = base.Underline(true)
+
 	return s
 }