.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