diff --git a/pkg/tui/components/completion/autocomplete_test.go b/pkg/tui/components/completion/autocomplete_test.go new file mode 100644 index 000000000..94ed43703 --- /dev/null +++ b/pkg/tui/components/completion/autocomplete_test.go @@ -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") + }) +} diff --git a/pkg/tui/components/completion/completion.go b/pkg/tui/components/completion/completion.go index 6573bae19..fd40e37eb 100644 --- a/pkg/tui/components/completion/completion.go +++ b/pkg/tui/components/completion/completion.go @@ -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) @@ -88,6 +91,7 @@ type completionKeyMap struct { Up key.Binding Down key.Binding Enter key.Binding + Tab key.Binding Escape key.Binding } @@ -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"), @@ -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{}) diff --git a/pkg/tui/components/editor/editor.go b/pkg/tui/components/editor/editor.go index d9528d68f..d9f93c596 100644 --- a/pkg/tui/components/editor/editor.go +++ b/pkg/tui/components/editor/editor.go @@ -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..." @@ -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 @@ -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 {