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
1 change: 0 additions & 1 deletion pkg/a2a/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 27 additions & 6 deletions pkg/tui/components/completion/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -88,6 +89,7 @@ type completionKeyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Tab key.Binding
Escape key.Binding
}

Expand All @@ -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"),
Expand Down Expand Up @@ -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{}),
)
Expand Down
78 changes: 78 additions & 0 deletions pkg/tui/components/completion/completion_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package completion

import (
"reflect"
"testing"

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

Expand Down Expand Up @@ -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")
}
})
}
185 changes: 185 additions & 0 deletions pkg/tui/components/editor/completion_autosubmit_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
4 changes: 0 additions & 4 deletions pkg/tui/components/editor/completions/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 0 additions & 1 deletion pkg/tui/components/editor/completions/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading