diff --git a/pkg/a2a/adapter.go b/pkg/a2a/adapter.go index 4c2c017c2..fc892f605 100644 --- a/pkg/a2a/adapter.go +++ b/pkg/a2a/adapter.go @@ -103,7 +103,6 @@ func runDockerAgent(ctx agent.InvocationContext, t *team.Team, agentName string, case *runtime.StreamStoppedEvent: // Send final complete event with all accumulated content - if contentBuilder.Len() > 0 { finalEvent := &adksession.Event{ Author: agentName, diff --git a/pkg/tui/components/completion/completion.go b/pkg/tui/components/completion/completion.go index 6573bae19..e6ac8fc81 100644 --- a/pkg/tui/components/completion/completion.go +++ b/pkg/tui/components/completion/completion.go @@ -52,8 +52,9 @@ type QueryMsg struct { } type SelectedMsg struct { - Value string - Execute func() tea.Cmd + Value string + Execute func() tea.Cmd + AutoSubmit bool } // SelectionChangedMsg is sent when the selected item changes (for preview in editor) @@ -88,6 +89,7 @@ type completionKeyMap struct { Up key.Binding Down key.Binding Enter key.Binding + Tab key.Binding Escape key.Binding } @@ -103,8 +105,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,8 +261,23 @@ 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, + }), + 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, }), core.CmdHandler(ClosedMsg{}), ) diff --git a/pkg/tui/components/completion/completion_test.go b/pkg/tui/components/completion/completion_test.go index 8dc42685e..fc4c24f10 100644 --- a/pkg/tui/components/completion/completion_test.go +++ b/pkg/tui/components/completion/completion_test.go @@ -1,8 +1,10 @@ package completion import ( + "reflect" "testing" + tea "charm.land/bubbletea/v2" "github.com/stretchr/testify/assert" ) @@ -335,3 +337,79 @@ func TestCompletionManagerPinnedItems(t *testing.T) { assert.Equal(t, "main.go", m.filteredItems[1].Label, "matching item should be second") }) } + +// extractSequenceCmds extracts the slice of commands from a tea.SequenceMsg using reflection, +// since tea.sequenceMsg is unexported. +func extractSequenceCmds(c tea.Cmd) []tea.Cmd { + if c == nil { + return nil + } + seqMsg := c() + v := reflect.ValueOf(seqMsg) + var cmds []tea.Cmd + if v.Kind() == reflect.Slice { + for i := range v.Len() { + cmd, ok := v.Index(i).Interface().(tea.Cmd) + if ok { + cmds = append(cmds, cmd) + } + } + } + return cmds +} + +func TestCompletionManagerAutoSubmit(t *testing.T) { + t.Parallel() + + t.Run("enter triggers auto submit", func(t *testing.T) { + t.Parallel() + + m := New().(*manager) + + m.Update(OpenMsg{ + Items: []Item{ + {Label: "option", Value: "/option"}, + }, + }) + + _, c := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + cmds := extractSequenceCmds(c) + + assert.False(t, m.visible, "completion view should close") + assert.Len(t, cmds, 2, "should return a sequence of 2 commands") + + if len(cmds) > 0 { + msg0 := cmds[0]() + selectedMsg, ok := msg0.(SelectedMsg) + assert.True(t, ok, "first message should be SelectedMsg") + assert.True(t, selectedMsg.AutoSubmit, "should have auto submit true") + } + }) + + t.Run("tab disables auto submit", func(t *testing.T) { + t.Parallel() + + m := New().(*manager) + + m.Update(OpenMsg{ + Items: []Item{ + {Label: "option", Value: "/option"}, + }, + }) + + _, c := m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + + cmds := extractSequenceCmds(c) + + assert.False(t, m.visible, "completion view should close") + assert.Len(t, cmds, 2, "should return a sequence of 2 commands") + + if len(cmds) > 0 { + msg0 := cmds[0]() + selectedMsg, ok := msg0.(SelectedMsg) + assert.True(t, ok, "first message should be SelectedMsg") + assert.False(t, selectedMsg.AutoSubmit, "should have auto submit false") + } + }) +} diff --git a/pkg/tui/components/editor/completion_autosubmit_test.go b/pkg/tui/components/editor/completion_autosubmit_test.go new file mode 100644 index 000000000..97f22d0c3 --- /dev/null +++ b/pkg/tui/components/editor/completion_autosubmit_test.go @@ -0,0 +1,185 @@ +package editor + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tui/components/completion" + "github.com/docker/docker-agent/pkg/tui/messages" +) + +func TestEditorHandlesAutoSubmit(t *testing.T) { + t.Parallel() + + t.Run("AutoSubmit false inserts value", func(t *testing.T) { + t.Parallel() + + e := newTestEditor("/he", "he") + + msg := completion.SelectedMsg{ + Value: "/hello", + AutoSubmit: false, + } + + _, cmd := e.Update(msg) + + // Command should be nil because AutoSubmit is false + assert.Nil(t, cmd) + + // Value should have trigger replaced with selected value and a space appended + assert.Equal(t, "/hello ", e.textarea.Value()) + }) + + t.Run("AutoSubmit true sends message", func(t *testing.T) { + t.Parallel() + + e := newTestEditor("/he", "he") + + msg := completion.SelectedMsg{ + Value: "/hello", + AutoSubmit: true, + } + + _, cmd := e.Update(msg) + require.NotNil(t, cmd) + + // Find SendMsg + found := false + for _, m := range collectMsgs(cmd) { + if sm, ok := m.(messages.SendMsg); ok { + assert.Equal(t, "/hello", sm.Content) + found = true + break + } + } + assert.True(t, found, "should return SendMsg") + }) + + t.Run("AutoSubmit true with Execute runs execute command", func(t *testing.T) { + t.Parallel() + + e := newTestEditor("/he", "he") + + type testMsg struct{} + msg := completion.SelectedMsg{ + Value: "/hello", + AutoSubmit: true, + Execute: func() tea.Cmd { + return func() tea.Msg { return testMsg{} } + }, + } + + _, cmd := e.Update(msg) + require.NotNil(t, cmd) + + // Execute should return the provided command + msgs := collectMsgs(cmd) + require.Len(t, msgs, 1) + _, ok := msgs[0].(testMsg) + assert.True(t, ok, "should return the command from Execute") + + // It should also clear the trigger and completion word from textarea + assert.Empty(t, e.textarea.Value(), "should clear the trigger and completion word") + }) + + t.Run("@ completion inserts value even if AutoSubmit is true", func(t *testing.T) { + t.Parallel() + + e := newTestEditor("@he", "he") + e.currentCompletion = &mockCompletion{trigger: "@"} + + msg := completion.SelectedMsg{ + Value: "@hello", + AutoSubmit: true, + } + + _, cmd := e.Update(msg) + + // Command should be nil because atCompletion is true, preventing AutoSubmit behavior + assert.Nil(t, cmd) + + // Value should have trigger replaced with selected value and a space appended + assert.Equal(t, "@hello ", e.textarea.Value()) + }) + + t.Run("@ completion adds file attachment", func(t *testing.T) { + t.Parallel() + + e := newTestEditor("@main.go", "main.go") + e.currentCompletion = &mockCompletion{trigger: "@"} + + // Use a real file that exists + msg := completion.SelectedMsg{ + Value: "@editor.go", + AutoSubmit: false, + } + + _, cmd := e.Update(msg) + assert.Nil(t, cmd) + + // Value should have trigger replaced with selected value and a space appended + assert.Equal(t, "@editor.go ", e.textarea.Value()) + + // File should be tracked as attachment + require.Len(t, e.attachments, 1) + assert.Equal(t, "@editor.go", e.attachments[0].placeholder) + assert.False(t, e.attachments[0].isTemp) + }) + + t.Run("@ completion with Execute runs execute command even if AutoSubmit is false", func(t *testing.T) { + t.Parallel() + + e := newTestEditor("@he", "he") + e.currentCompletion = &mockCompletion{trigger: "@"} + + type testMsg struct{} + msg := completion.SelectedMsg{ + Value: "@hello", + AutoSubmit: false, + Execute: func() tea.Cmd { + return func() tea.Msg { return testMsg{} } + }, + } + + _, cmd := e.Update(msg) + require.NotNil(t, cmd) + + // Execute should return the provided command + msgs := collectMsgs(cmd) + require.Len(t, msgs, 1) + _, ok := msgs[0].(testMsg) + assert.True(t, ok, "should return the command from Execute") + + // It should also clear the trigger and completion word from textarea + assert.Empty(t, e.textarea.Value(), "should clear the trigger and completion word") + }) + + t.Run("@paste- completion sends message if AutoSubmit is true", func(t *testing.T) { + t.Parallel() + + e := newTestEditor("@paste", "paste") + e.currentCompletion = &mockCompletion{trigger: "@"} + + msg := completion.SelectedMsg{ + Value: "@paste-1", + AutoSubmit: true, + } + + _, cmd := e.Update(msg) + require.NotNil(t, cmd) + + // Find SendMsg + found := false + for _, m := range collectMsgs(cmd) { + if sm, ok := m.(messages.SendMsg); ok { + assert.Equal(t, "@paste-1", sm.Content) + found = true + break + } + } + assert.True(t, found, "should return SendMsg") + }) +} diff --git a/pkg/tui/components/editor/completions/command.go b/pkg/tui/components/editor/completions/command.go index fe628d3a3..0e0bd4307 100644 --- a/pkg/tui/components/editor/completions/command.go +++ b/pkg/tui/components/editor/completions/command.go @@ -20,10 +20,6 @@ func NewCommandCompletion(a *app.App) Completion { } } -func (c *commandCompletion) AutoSubmit() bool { - return true // Commands auto-submit: selecting inserts command text and sends it -} - func (c *commandCompletion) RequiresEmptyEditor() bool { return true } diff --git a/pkg/tui/components/editor/completions/completion.go b/pkg/tui/components/editor/completions/completion.go index e07e2e522..084bf0ee7 100644 --- a/pkg/tui/components/editor/completions/completion.go +++ b/pkg/tui/components/editor/completions/completion.go @@ -10,7 +10,6 @@ import ( type Completion interface { Trigger() string Items() []completion.Item - AutoSubmit() bool RequiresEmptyEditor() bool // MatchMode returns how items should be filtered (fuzzy or prefix) MatchMode() completion.MatchMode diff --git a/pkg/tui/components/editor/editor.go b/pkg/tui/components/editor/editor.go index d9528d68f..c92ca47fe 100644 --- a/pkg/tui/components/editor/editor.go +++ b/pkg/tui/components/editor/editor.go @@ -638,48 +638,50 @@ 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 - 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..." - // should remove the "@" so AttachFile doesn't produce a double "@@". - if e.currentCompletion != nil { - triggerWord := e.currentCompletion.Trigger() + e.completionWord - currentValue := e.textarea.Value() - if idx := strings.LastIndex(currentValue, triggerWord); idx >= 0 { - e.textarea.SetValue(currentValue[:idx] + currentValue[idx+len(triggerWord):]) - e.textarea.MoveToEnd() - } + if e.currentCompletion == nil { + return e, nil + } + + atCompletion := e.currentCompletion.Trigger() == "@" && !strings.HasPrefix(msg.Value, "@paste-") + triggerWord := e.currentCompletion.Trigger() + e.completionWord + currentValue := e.textarea.Value() + idx := strings.LastIndex(currentValue, triggerWord) + + // Handle Execute functions (e.g., "Browse files...") + // There is an execute function AND you hit enter, or there is an @ directive + if msg.Execute != nil && (msg.AutoSubmit || atCompletion) { + if idx >= 0 { + e.textarea.SetValue(currentValue[:idx] + currentValue[idx+len(triggerWord):]) + e.textarea.MoveToEnd() } e.clearSuggestion() return e, msg.Execute() } - if e.currentCompletion.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 - // to preserve arguments (e.g., "/export /tmp/file"). - triggerWord := e.currentCompletion.Trigger() + e.completionWord + + // Handle Auto-Submit items (e.g., commands like "/exit") + if msg.AutoSubmit && !atCompletion { extraText := "" - if _, after, found := strings.Cut(e.textarea.Value(), triggerWord); found { - extraText = after + if idx >= 0 { + extraText = currentValue[idx+len(triggerWord):] } 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):] + + // Insert standard completions (e.g., file paths or text pastes) + if idx >= 0 { + 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-") { + + // Track valid file references + if atCompletion { if err := e.addFileAttachment(msg.Value); err != nil { slog.Warn("failed to add file attachment from completion", "value", msg.Value, "error", err) } } + e.clearSuggestion() return e, nil case completion.ClosedMsg: