.gitignore 🔗
@@ -48,3 +48,4 @@ Thumbs.db
 
 manpages/
 completions/
+!internal/tui/components/completions/
  Carlos Alexandro Becker created
* fix: hide completions tui when no results
* fix: gitignore
* Revert "fix(tui): completions should not close on no results (#198)"
This reverts commit 833eede1c10e1dcfacfcc6c6e529d3d8b7e7f838.
* fix: completions
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* fix: accept
* fix: improvements
* chore(deps): update bubbles
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* fix: improvements
* fix: accept
---------
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
  
  
  
.gitignore                                                          |  1 
go.mod                                                              |  2 
go.sum                                                              |  4 
internal/tui/components/chat/editor/editor.go                       | 65 
internal/tui/components/completions/completions.go                  | 50 
vendor/github.com/charmbracelet/bubbles/v2/filepicker/filepicker.go |  6 
vendor/github.com/charmbracelet/bubbles/v2/textarea/textarea.go     | 35 
vendor/modules.txt                                                  |  2 
8 files changed, 113 insertions(+), 52 deletions(-)
@@ -48,3 +48,4 @@ Thumbs.db
 
 manpages/
 completions/
+!internal/tui/components/completions/
  @@ -16,7 +16,7 @@ require (
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.8.1
 	github.com/charlievieth/fastwalk v1.0.11
-	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198
+	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
 	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1
 	github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
  @@ -68,8 +68,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
 github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
 github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198 h1:CkMS9Ah9ac1Ego5JDC5NJyZyAAqu23Z+O0yDwsa3IxM=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
 github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250716142813-5d1379f56ba2 h1:Gj/vSk7h96TxUU/GSuwbYkr9H0ze+ElAQjcl25wB0+U=
 github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250716142813-5d1379f56ba2/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw=
 github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
  @@ -171,6 +171,8 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		m.attachments = append(m.attachments, msg.Attachment)
 		return m, nil
+	case completions.CompletionsOpenedMsg:
+		m.isCompletionsOpen = true
 	case completions.CompletionsClosedMsg:
 		m.isCompletionsOpen = false
 		m.currentQuery = ""
@@ -183,9 +185,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			// If the selected item is a file, insert its path into the textarea
 			value := m.textarea.Value()
 			value = value[:m.completionsStartIndex]
-			if len(value) > 0 && value[len(value)-1] != ' ' {
-				value += " "
-			}
 			value += item.Path
 			m.textarea.SetValue(value)
 			m.isCompletionsOpen = false
@@ -199,37 +198,15 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyPressMsg:
 		switch {
 		// Completions
-		case msg.String() == "/" && !m.isCompletionsOpen:
+		case msg.String() == "/" && !m.isCompletionsOpen &&
+			// only show if beginning of prompt, or if previous char is a space:
+			(len(m.textarea.Value()) == 0 || m.textarea.Value()[len(m.textarea.Value())-1] == ' '):
 			m.isCompletionsOpen = true
 			m.currentQuery = ""
-			cmds = append(cmds, m.startCompletions)
 			m.completionsStartIndex = len(m.textarea.Value())
-		case msg.String() == "space" && m.isCompletionsOpen:
-			m.isCompletionsOpen = false
-			m.currentQuery = ""
-			m.completionsStartIndex = 0
-			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
+			cmds = append(cmds, m.startCompletions)
 		case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
 			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
-		case msg.String() == "backspace" && m.isCompletionsOpen:
-			if len(m.currentQuery) > 0 {
-				m.currentQuery = m.currentQuery[:len(m.currentQuery)-1]
-				cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
-					Query: m.currentQuery,
-				}))
-			} else {
-				m.isCompletionsOpen = false
-				m.currentQuery = ""
-				m.completionsStartIndex = 0
-				cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
-			}
-		default:
-			if m.isCompletionsOpen {
-				m.currentQuery += msg.String()
-				cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
-					Query: m.currentQuery,
-				}))
-			}
 		}
 		if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
 			m.deleteMode = true
@@ -281,6 +258,36 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	m.textarea, cmd = m.textarea.Update(msg)
 	cmds = append(cmds, cmd)
+
+	if m.textarea.Focused() {
+		kp, ok := msg.(tea.KeyPressMsg)
+		if ok {
+			if kp.String() == "space" || m.textarea.Value() == "" {
+				m.isCompletionsOpen = false
+				m.currentQuery = ""
+				m.completionsStartIndex = 0
+				cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
+			} else {
+				word := m.textarea.Word()
+				if strings.HasPrefix(word, "/") {
+					// XXX: wont' work if editing in the middle of the field.
+					m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
+					m.currentQuery = word[1:]
+					m.isCompletionsOpen = true
+					cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
+						Query:  m.currentQuery,
+						Reopen: m.isCompletionsOpen,
+					}))
+				} else {
+					m.isCompletionsOpen = false
+					m.currentQuery = ""
+					m.completionsStartIndex = 0
+					cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
+				}
+			}
+		}
+	}
+
 	return m, tea.Batch(cmds...)
 }
 
  @@ -1,6 +1,8 @@
 package completions
 
 import (
+	"strings"
+
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/list"
@@ -23,11 +25,14 @@ type OpenCompletionsMsg struct {
 }
 
 type FilterCompletionsMsg struct {
-	Query string // The query to filter completions
+	Query  string // The query to filter completions
+	Reopen bool
 }
 
 type CompletionsClosedMsg struct{}
 
+type CompletionsOpenedMsg struct{}
+
 type CloseCompletionsMsg struct{}
 
 type SelectCompletionMsg struct {
@@ -126,11 +131,7 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case CloseCompletionsMsg:
 		c.open = false
-		c.query = ""
-		return c, tea.Batch(
-			c.list.SetItems([]util.Model{}),
-			util.CmdHandler(CompletionsClosedMsg{}),
-		)
+		return c, util.CmdHandler(CompletionsClosedMsg{})
 	case OpenCompletionsMsg:
 		c.open = true
 		c.query = ""
@@ -143,21 +144,41 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			items = append(items, item)
 		}
 		c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
-		cmds := []tea.Cmd{
+		return c, tea.Batch(
 			c.list.SetSize(c.width, c.height),
 			c.list.SetItems(items),
-		}
-		return c, tea.Batch(cmds...)
+			util.CmdHandler(CompletionsOpenedMsg{}),
+		)
 	case FilterCompletionsMsg:
-		c.query = msg.Query
-		if !c.open {
-			return c, nil // If completions are not open, do nothing
+		if !c.open && !msg.Reopen {
+			return c, nil
+		}
+		if msg.Query == c.query {
+			// PERF: if same query, don't need to filter again
+			return c, nil
+		}
+		if len(c.list.Items()) == 0 &&
+			len(msg.Query) > len(c.query) &&
+			strings.HasPrefix(msg.Query, c.query) {
+			// PERF: if c.query didn't match anything,
+			// AND msg.Query is longer than c.query,
+			// AND msg.Query is prefixed with c.query - which means
+			//		that the user typed more chars after a 0 match,
+			// it won't match anything, so return earlier.
+			return c, nil
 		}
+		c.query = msg.Query
 		var cmds []tea.Cmd
 		cmds = append(cmds, c.list.Filter(msg.Query))
 		itemsLen := len(c.list.Items())
 		c.height = max(min(maxCompletionsHeight, itemsLen), 1)
 		cmds = append(cmds, c.list.SetSize(c.width, c.height))
+		if itemsLen == 0 {
+			cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
+		} else if msg.Reopen {
+			c.open = true
+			cmds = append(cmds, util.CmdHandler(CompletionsOpenedMsg{}))
+		}
 		return c, tea.Batch(cmds...)
 	}
 	return c, nil
@@ -165,12 +186,9 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 // View implements Completions.
 func (c *completionsCmp) View() string {
-	if !c.open {
+	if !c.open || len(c.list.Items()) == 0 {
 		return ""
 	}
-	if len(c.list.Items()) == 0 {
-		return c.style().Render("No completions found")
-	}
 
 	return c.style().Render(c.list.View())
 }
  @@ -518,9 +518,9 @@ func (m Model) canSelect(file string) bool {
 }
 
 // HighlightedPath returns the path of the currently highlighted file or directory.
-func (M Model) HighlightedPath() string {
-	if len(M.files) == 0 || M.selected < 0 || M.selected >= len(M.files) {
+func (m Model) HighlightedPath() string {
+	if len(m.files) == 0 || m.selected < 0 || m.selected >= len(m.files) {
 		return ""
 	}
-	return filepath.Join(M.CurrentDirectory, M.files[M.selected].Name())
+	return filepath.Join(m.CurrentDirectory, m.files[m.selected].Name())
 }
  @@ -722,6 +722,41 @@ func (m *Model) Reset() {
 	m.SetCursorColumn(0)
 }
 
+// Word returns the word at the cursor position.
+// A word is delimited by spaces or line-breaks.
+func (m *Model) Word() string {
+	line := m.value[m.row]
+	col := m.col - 1
+
+	if col < 0 {
+		return ""
+	}
+
+	// If cursor is beyond the line, return empty string
+	if col >= len(line) {
+		return ""
+	}
+
+	// If cursor is on a space, return empty string
+	if unicode.IsSpace(line[col]) {
+		return ""
+	}
+
+	// Find the start of the word by moving left
+	start := col
+	for start > 0 && !unicode.IsSpace(line[start-1]) {
+		start--
+	}
+
+	// Find the end of the word by moving right
+	end := col
+	for end < len(line) && !unicode.IsSpace(line[end]) {
+		end++
+	}
+
+	return string(line[start:end])
+}
+
 // san initializes or retrieves the rune sanitizer.
 func (m *Model) san() runeutil.Sanitizer {
 	if m.rsan == nil {
  @@ -241,7 +241,7 @@ github.com/bmatcuk/doublestar/v4
 github.com/charlievieth/fastwalk
 github.com/charlievieth/fastwalk/internal/dirent
 github.com/charlievieth/fastwalk/internal/fmtdirent
-# github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198
+# github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
 ## explicit; go 1.23.0
 github.com/charmbracelet/bubbles/v2/cursor
 github.com/charmbracelet/bubbles/v2/filepicker