Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions pkg/tui/components/completion/autocomplete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package completion

import (
"testing"

tea "charm.land/bubbletea/v2"
"github.com/stretchr/testify/assert"
)

func TestTabVsEnterBehavior(t *testing.T) {
t.Run("Enter closes completion popup", func(t *testing.T) {
c := New().(*manager)
c.items = []Item{
{Label: "exit", Description: "Exit", Value: "/exit"},
}
c.filterItems("")
c.visible = true

// Press Enter
result, _ := c.Update(tea.KeyPressMsg{Code: tea.KeyEnter})

// Verify completion popup is closed
assert.False(t, result.(*manager).visible, "Enter should close completion popup")
})

t.Run("Tab closes completion popup", func(t *testing.T) {
c := New().(*manager)
c.items = []Item{
{Label: "exit", Description: "Exit", Value: "/exit"},
}
c.filterItems("")
c.visible = true

// Press Tab
result, _ := c.Update(tea.KeyPressMsg{Code: tea.KeyTab})

// Verify completion popup is closed
assert.False(t, result.(*manager).visible, "Tab should close completion popup")
})

t.Run("Tab does not trigger Execute function", func(t *testing.T) {
c := New().(*manager)
c.items = []Item{
{
Label: "export",
Description: "Export session",
Value: "/export",
Execute: func() tea.Cmd {
// This should not be called for Tab
t.Error("Tab should not trigger Execute function")
return nil
},
},
}
c.filterItems("")
c.visible = true

// Press Tab (should autocomplete but not execute)
c.Update(tea.KeyPressMsg{Code: tea.KeyTab})

// If we reach here without t.Error being called, the test passes
})

t.Run("Enter triggers Execute function", func(t *testing.T) {
c := New().(*manager)
c.items = []Item{
{
Label: "browse",
Description: "Browse files",
Value: "@",
Execute: func() tea.Cmd {
return nil
},
},
}
c.filterItems("")
c.visible = true

// Press Enter (should execute)
_, cmd := c.Update(tea.KeyPressMsg{Code: tea.KeyEnter})

// The Execute function is called when the command is run by the tea runtime
// For this test, we verify that a command is returned (which will call Execute when run)
assert.NotNil(t, cmd, "Enter should return a command that will execute the item")
})

t.Run("Escape closes popup without executing", func(t *testing.T) {
c := New().(*manager)
executed := false
c.items = []Item{
{
Label: "exit",
Description: "Exit",
Value: "/exit",
Execute: func() tea.Cmd {
executed = true
return nil
},
},
}
c.filterItems("")
c.visible = true

// Press Escape
c.Update(tea.KeyPressMsg{Code: tea.KeyEsc})

// Verify popup is closed and Execute was NOT called
assert.False(t, c.visible, "Escape should close completion popup")
assert.False(t, executed, "Escape should not trigger Execute function")
})
}
37 changes: 31 additions & 6 deletions pkg/tui/components/completion/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ type QueryMsg struct {
}

type SelectedMsg struct {
Value string
Execute func() tea.Cmd
Value string
Execute func() tea.Cmd
// AutoSubmit is true when Enter was pressed (should auto-submit commands)
// false when Tab was pressed (just autocomplete, don't submit)
AutoSubmit bool
}

// SelectionChangedMsg is sent when the selected item changes (for preview in editor)
Expand Down Expand Up @@ -88,6 +91,7 @@ type completionKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Tab key.Binding
Escape key.Binding
}

Expand All @@ -103,8 +107,12 @@ func defaultCompletionKeyMap() completionKeyMap {
key.WithHelp("↓", "down"),
),
Enter: key.NewBinding(
key.WithKeys("enter", "tab"),
key.WithHelp("enter/tab", "select"),
key.WithKeys("enter"),
key.WithHelp("enter", "select"),
),
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "autocomplete"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
Expand Down Expand Up @@ -255,11 +263,28 @@ func (c *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
selectedItem := c.filteredItems[c.selected]
return c, tea.Sequence(
core.CmdHandler(SelectedMsg{
Value: selectedItem.Value,
Execute: selectedItem.Execute,
Value: selectedItem.Value,
Execute: selectedItem.Execute,
AutoSubmit: true, // Enter pressed - auto-submit commands
}),
core.CmdHandler(ClosedMsg{}),
)

case key.Matches(msg, c.keyMap.Tab):
c.visible = false
if len(c.filteredItems) == 0 || c.selected >= len(c.filteredItems) {
return c, core.CmdHandler(ClosedMsg{})
}
selectedItem := c.filteredItems[c.selected]
return c, tea.Sequence(
core.CmdHandler(SelectedMsg{
Value: selectedItem.Value,
Execute: selectedItem.Execute,
AutoSubmit: false, // Tab pressed - just autocomplete, don't submit
}),
core.CmdHandler(ClosedMsg{}),
)

case key.Matches(msg, c.keyMap.Escape):
c.visible = false
return c, core.CmdHandler(ClosedMsg{})
Expand Down
35 changes: 27 additions & 8 deletions pkg/tui/components/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,8 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
return e, cmd

case completion.SelectedMsg:
// If the item has an Execute function, run it instead of inserting text
// Rule 1: Action items (like "Browse files...") ALWAYS execute, regardless of Tab/Enter
// Execute takes precedence over everything else - it's an action, not text insertion
if msg.Execute != nil {
// Remove the trigger character and any typed completion word from the textarea
// before executing. For example, typing "@" then selecting "Browse files..."
Expand All @@ -654,7 +655,16 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
e.clearSuggestion()
return e, msg.Execute()
}
if e.currentCompletion.AutoSubmit() {

// Rule 2: No value and no Execute = nothing to do (defensive)
if msg.Value == "" {
e.clearSuggestion()
return e, nil
}

// Rule 3: Slash commands (/), only submit on Enter, never on Tab
// File attachments (@) NEVER submit - they always just insert text
if e.currentCompletion.AutoSubmit() && msg.AutoSubmit {
// For auto-submit completions (like commands), use the selected
// command value (e.g., "/exit") instead of what the user typed
// (e.g., "/e"). Append any extra text after the trigger word
Expand All @@ -667,13 +677,22 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
cmd := e.resetAndSend(msg.Value + extraText)
return e, cmd
}
// For non-auto-submit completions (like file paths), replace the completion word
currentValue := e.textarea.Value()
if lastIdx := strings.LastIndex(currentValue, e.completionWord); lastIdx >= 0 {
newValue := currentValue[:lastIdx-1] + msg.Value + " " + currentValue[lastIdx+len(e.completionWord):]
e.textarea.SetValue(newValue)
e.textarea.MoveToEnd()

// Rule 4: Normal text insertion - for @ files on both Tab and Enter
// This handles: Tab on /command (inserts text), Enter on @file (inserts), Tab on @file (inserts)
// Build the active token (e.g., "@", "@rea", "/e")
if e.currentCompletion != nil {
triggerWord := e.currentCompletion.Trigger() + e.completionWord
currentValue := e.textarea.Value()
// Replace the trigger word (including trigger char) with the selected value
if idx := strings.LastIndex(currentValue, triggerWord); idx >= 0 {
// Keep text before the trigger, add selected value, then text after
newValue := currentValue[:idx] + msg.Value + " " + currentValue[idx+len(triggerWord):]
e.textarea.SetValue(newValue)
e.textarea.MoveToEnd()
}
}

// Track file references when using @ completion (but not paste placeholders)
if e.currentCompletion != nil && e.currentCompletion.Trigger() == "@" && !strings.HasPrefix(msg.Value, "@paste-") {
if err := e.addFileAttachment(msg.Value); err != nil {
Expand Down