diff --git a/pkg/a2a/adapter.go b/pkg/a2a/adapter.go index 894252111..9753b4225 100644 --- a/pkg/a2a/adapter.go +++ b/pkg/a2a/adapter.go @@ -102,7 +102,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..3e4d4f375 --- /dev/null +++ b/pkg/tui/components/editor/completion_autosubmit_test.go @@ -0,0 +1,87 @@ +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") + }) +} 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..6d49a370d 100644 --- a/pkg/tui/components/editor/editor.go +++ b/pkg/tui/components/editor/editor.go @@ -639,7 +639,7 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case completion.SelectedMsg: // If the item has an Execute function, run it instead of inserting text - if msg.Execute != nil { + if msg.Execute != nil && msg.AutoSubmit { // 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 "@@". @@ -654,7 +654,7 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) { e.clearSuggestion() return e, msg.Execute() } - if e.currentCompletion.AutoSubmit() { + if 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