@@ -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...",