@@ -30,6 +30,7 @@ import (
30 "github.com/charmbracelet/crush/internal/ui/anim"
30 "github.com/charmbracelet/crush/internal/ui/anim"
31 "github.com/charmbracelet/crush/internal/ui/chat"
31 "github.com/charmbracelet/crush/internal/ui/chat"
32 "github.com/charmbracelet/crush/internal/ui/common"
32 "github.com/charmbracelet/crush/internal/ui/common"
33+ "github.com/charmbracelet/crush/internal/ui/completions"
33 "github.com/charmbracelet/crush/internal/ui/dialog"
34 "github.com/charmbracelet/crush/internal/ui/dialog"
34 "github.com/charmbracelet/crush/internal/ui/logo"
35 "github.com/charmbracelet/crush/internal/ui/logo"
35 "github.com/charmbracelet/crush/internal/ui/styles"
36 "github.com/charmbracelet/crush/internal/ui/styles"
@@ -103,6 +104,13 @@ type UI struct {
103 readyPlaceholder string
104 readyPlaceholder string
104 workingPlaceholder string
105 workingPlaceholder string
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 '@'
106 // Chat components
114 // Chat components
107 chat *Chat
115 chat *Chat
@@ -133,14 +141,22 @@ func New(com *common.Common) *UI {
134 ch := NewChat(com)
142 ch := NewChat(com)
144+ // Completions component
145+ comp := completions.New(
146+ com.Styles.Completions.Normal,
147+ com.Styles.Completions.Focused,
148+ com.Styles.Completions.Match,
136 ui := &UI{
151 ui := &UI{
137− com: com,
152+ com: com,
138− dialog: dialog.NewOverlay(),
153+ dialog: dialog.NewOverlay(),
139− keyMap: DefaultKeyMap(),
154+ keyMap: DefaultKeyMap(),
140− focus: uiFocusNone,
155+ focus: uiFocusNone,
141− state: uiConfigure,
156+ state: uiConfigure,
142− textarea: ta,
157+ textarea: ta,
143− chat: ch,
158+ chat: ch,
146 status := NewStatus(com, ui)
162 status := NewStatus(com, ui)
@@ -335,6 +351,21 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
335 cmds = append(cmds, cmd)
351 cmds = append(cmds, cmd)
354+ case completions.SelectionMsg:
355+ // Handle file completion selection.
356+ if item, ok := msg.Value.(completions.FileCompletionValue); ok {
357+ m.insertFileCompletion(item.Path)
360+ m.closeCompletions()
362+ case completions.FilesLoadedMsg:
363+ // Handle async file loading for completions.
364+ if m.completionsOpen {
365+ m.completions.SetFiles(msg.Files)
367+ case completions.ClosedMsg:
368+ m.completionsOpen = false
338 case tea.KeyPressMsg:
369 case tea.KeyPressMsg:
339 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
370 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
340 cmds = append(cmds, cmd)
371 cmds = append(cmds, cmd)
@@ -775,6 +806,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
775 case uiChat, uiLanding, uiChatCompact:
806 case uiChat, uiLanding, uiChatCompact:
776 switch m.focus {
807 switch m.focus {
777 case uiFocusEditor:
808 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...)
779 case key.Matches(msg, m.keyMap.Editor.SendMessage):
818 case key.Matches(msg, m.keyMap.Editor.SendMessage):
780 value := m.textarea.Value()
819 value := m.textarea.Value()
@@ -823,15 +862,57 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
823 cmds = append(cmds, m.openEditor(m.textarea.Value()))
862 cmds = append(cmds, m.openEditor(m.textarea.Value()))
824 case key.Matches(msg, m.keyMap.Editor.Newline):
863 case key.Matches(msg, m.keyMap.Editor.Newline):
825 m.textarea.InsertRune('\n')
864 m.textarea.InsertRune('\n')
865+ m.closeCompletions()
827 if handleGlobalKeys(msg) {
867 if handleGlobalKeys(msg) {
828 // Handle global keys first before passing to textarea.
868 // Handle global keys first before passing to textarea.
872+ // Check for @ trigger before passing to textarea.
873+ curValue := m.textarea.Value()
874+ curIdx := len(curValue)
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))
832 ta, cmd := m.textarea.Update(msg)
889 ta, cmd := m.textarea.Update(msg)
833 m.textarea = ta
890 m.textarea = ta
834 cmds = append(cmds, cmd)
891 cmds = append(cmds, cmd)
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)
899+ // Close completions if cursor moved before start.
900+ if newIdx <= m.completionsStartIndex {
901+ m.closeCompletions()
902+ } else if msg.String() == "space" {
904+ m.closeCompletions()
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()
836 case uiFocusMain:
917 case uiFocusMain:
@@ -982,6 +1063,26 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
982 // Add status and help layer
1063 // Add status and help layer
983 m.status.Draw(scr, layout.status)
1064 m.status.Draw(scr, layout.status)
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
1072+ screenW := area.Dx()
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),
985 // Debugging rendering (visually see when the tui rerenders)
1086 // Debugging rendering (visually see when the tui rerenders)
986 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1087 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
987 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1088 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 {
1489 return t.EditorPromptYoloDotsBlurred.Render()
1590 return t.EditorPromptYoloDotsBlurred.Render()
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()
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()
1607+ // Find the @ and query to replace.
1608+ if m.completionsStartIndex > len(value) {
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)
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()
1623+ // Add file as attachment.
1624+ content, err := os.ReadFile(path)
1626+ // If it fails, let the LLM handle it later.
1630+ m.attachments = append(m.attachments, message.Attachment{
1632+ FileName: filepath.Base(path),
1633+ MimeType: mimeOf(content),
1638+// completionsPosition returns the X and Y position for the completions popup.
1639+func (m *UI) completionsPosition() image.Point {
1640+ cur := m.textarea.Cursor()
1642+ return image.Point{
1643+ X: m.layout.editor.Min.X,
1644+ Y: m.layout.editor.Min.Y,
1647+ return image.Point{
1648+ X: cur.X + m.layout.editor.Min.X,
1649+ Y: m.layout.editor.Min.Y + cur.Y,
1653+// textareaWord returns the current word at the cursor position.
1654+func (m *UI) textareaWord() string {
1655+ return m.textarea.Word()
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'
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])
1492var readyPlaceholders = [...]string{
1669var readyPlaceholders = [...]string{
1493 "Ready!",
1670 "Ready!",
1494 "Ready...",
1671 "Ready...",