Skip to content

Commit 58e0328

Browse files
Fix: allow tab auto-complete without executing
1 parent dc8062b commit 58e0328

File tree

7 files changed

+194
-14
lines changed

7 files changed

+194
-14
lines changed

pkg/a2a/adapter.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ func runDockerAgent(ctx agent.InvocationContext, t *team.Team, agentName string,
102102

103103
case *runtime.StreamStoppedEvent:
104104
// Send final complete event with all accumulated content
105-
106105
if contentBuilder.Len() > 0 {
107106
finalEvent := &adksession.Event{
108107
Author: agentName,

pkg/tui/components/completion/completion.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ type QueryMsg struct {
5252
}
5353

5454
type SelectedMsg struct {
55-
Value string
56-
Execute func() tea.Cmd
55+
Value string
56+
Execute func() tea.Cmd
57+
AutoSubmit bool
5758
}
5859

5960
// SelectionChangedMsg is sent when the selected item changes (for preview in editor)
@@ -88,6 +89,7 @@ type completionKeyMap struct {
8889
Up key.Binding
8990
Down key.Binding
9091
Enter key.Binding
92+
Tab key.Binding
9193
Escape key.Binding
9294
}
9395

@@ -103,8 +105,12 @@ func defaultCompletionKeyMap() completionKeyMap {
103105
key.WithHelp("↓", "down"),
104106
),
105107
Enter: key.NewBinding(
106-
key.WithKeys("enter", "tab"),
107-
key.WithHelp("enter/tab", "select"),
108+
key.WithKeys("enter"),
109+
key.WithHelp("enter", "select"),
110+
),
111+
Tab: key.NewBinding(
112+
key.WithKeys("tab"),
113+
key.WithHelp("tab", "autocomplete"),
108114
),
109115
Escape: key.NewBinding(
110116
key.WithKeys("esc"),
@@ -255,8 +261,23 @@ func (c *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
255261
selectedItem := c.filteredItems[c.selected]
256262
return c, tea.Sequence(
257263
core.CmdHandler(SelectedMsg{
258-
Value: selectedItem.Value,
259-
Execute: selectedItem.Execute,
264+
Value: selectedItem.Value,
265+
Execute: selectedItem.Execute,
266+
AutoSubmit: true,
267+
}),
268+
core.CmdHandler(ClosedMsg{}),
269+
)
270+
case key.Matches(msg, c.keyMap.Tab):
271+
c.visible = false
272+
if len(c.filteredItems) == 0 || c.selected >= len(c.filteredItems) {
273+
return c, core.CmdHandler(ClosedMsg{})
274+
}
275+
selectedItem := c.filteredItems[c.selected]
276+
return c, tea.Sequence(
277+
core.CmdHandler(SelectedMsg{
278+
Value: selectedItem.Value,
279+
Execute: selectedItem.Execute,
280+
AutoSubmit: false,
260281
}),
261282
core.CmdHandler(ClosedMsg{}),
262283
)

pkg/tui/components/completion/completion_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package completion
22

33
import (
4+
"reflect"
45
"testing"
56

7+
tea "charm.land/bubbletea/v2"
68
"github.com/stretchr/testify/assert"
79
)
810

@@ -335,3 +337,79 @@ func TestCompletionManagerPinnedItems(t *testing.T) {
335337
assert.Equal(t, "main.go", m.filteredItems[1].Label, "matching item should be second")
336338
})
337339
}
340+
341+
// extractSequenceCmds extracts the slice of commands from a tea.SequenceMsg using reflection,
342+
// since tea.sequenceMsg is unexported.
343+
func extractSequenceCmds(c tea.Cmd) []tea.Cmd {
344+
if c == nil {
345+
return nil
346+
}
347+
seqMsg := c()
348+
v := reflect.ValueOf(seqMsg)
349+
var cmds []tea.Cmd
350+
if v.Kind() == reflect.Slice {
351+
for i := range v.Len() {
352+
cmd, ok := v.Index(i).Interface().(tea.Cmd)
353+
if ok {
354+
cmds = append(cmds, cmd)
355+
}
356+
}
357+
}
358+
return cmds
359+
}
360+
361+
func TestCompletionManagerAutoSubmit(t *testing.T) {
362+
t.Parallel()
363+
364+
t.Run("enter triggers auto submit", func(t *testing.T) {
365+
t.Parallel()
366+
367+
m := New().(*manager)
368+
369+
m.Update(OpenMsg{
370+
Items: []Item{
371+
{Label: "option", Value: "/option"},
372+
},
373+
})
374+
375+
_, c := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
376+
377+
cmds := extractSequenceCmds(c)
378+
379+
assert.False(t, m.visible, "completion view should close")
380+
assert.Len(t, cmds, 2, "should return a sequence of 2 commands")
381+
382+
if len(cmds) > 0 {
383+
msg0 := cmds[0]()
384+
selectedMsg, ok := msg0.(SelectedMsg)
385+
assert.True(t, ok, "first message should be SelectedMsg")
386+
assert.True(t, selectedMsg.AutoSubmit, "should have auto submit true")
387+
}
388+
})
389+
390+
t.Run("tab disables auto submit", func(t *testing.T) {
391+
t.Parallel()
392+
393+
m := New().(*manager)
394+
395+
m.Update(OpenMsg{
396+
Items: []Item{
397+
{Label: "option", Value: "/option"},
398+
},
399+
})
400+
401+
_, c := m.Update(tea.KeyPressMsg{Code: tea.KeyTab})
402+
403+
cmds := extractSequenceCmds(c)
404+
405+
assert.False(t, m.visible, "completion view should close")
406+
assert.Len(t, cmds, 2, "should return a sequence of 2 commands")
407+
408+
if len(cmds) > 0 {
409+
msg0 := cmds[0]()
410+
selectedMsg, ok := msg0.(SelectedMsg)
411+
assert.True(t, ok, "first message should be SelectedMsg")
412+
assert.False(t, selectedMsg.AutoSubmit, "should have auto submit false")
413+
}
414+
})
415+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package editor
2+
3+
import (
4+
"testing"
5+
6+
tea "charm.land/bubbletea/v2"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/docker/docker-agent/pkg/tui/components/completion"
11+
"github.com/docker/docker-agent/pkg/tui/messages"
12+
)
13+
14+
func TestEditorHandlesAutoSubmit(t *testing.T) {
15+
t.Parallel()
16+
17+
t.Run("AutoSubmit false inserts value", func(t *testing.T) {
18+
t.Parallel()
19+
20+
e := newTestEditor("/he", "he")
21+
22+
msg := completion.SelectedMsg{
23+
Value: "/hello",
24+
AutoSubmit: false,
25+
}
26+
27+
_, cmd := e.Update(msg)
28+
29+
// Command should be nil because AutoSubmit is false
30+
assert.Nil(t, cmd)
31+
32+
// Value should have trigger replaced with selected value and a space appended
33+
assert.Equal(t, "/hello ", e.textarea.Value())
34+
})
35+
36+
t.Run("AutoSubmit true sends message", func(t *testing.T) {
37+
t.Parallel()
38+
39+
e := newTestEditor("/he", "he")
40+
41+
msg := completion.SelectedMsg{
42+
Value: "/hello",
43+
AutoSubmit: true,
44+
}
45+
46+
_, cmd := e.Update(msg)
47+
require.NotNil(t, cmd)
48+
49+
// Find SendMsg
50+
found := false
51+
for _, m := range collectMsgs(cmd) {
52+
if sm, ok := m.(messages.SendMsg); ok {
53+
assert.Equal(t, "/hello", sm.Content)
54+
found = true
55+
break
56+
}
57+
}
58+
assert.True(t, found, "should return SendMsg")
59+
})
60+
61+
t.Run("AutoSubmit true with Execute runs execute command", func(t *testing.T) {
62+
t.Parallel()
63+
64+
e := newTestEditor("/he", "he")
65+
66+
type testMsg struct{}
67+
msg := completion.SelectedMsg{
68+
Value: "/hello",
69+
AutoSubmit: true,
70+
Execute: func() tea.Cmd {
71+
return func() tea.Msg { return testMsg{} }
72+
},
73+
}
74+
75+
_, cmd := e.Update(msg)
76+
require.NotNil(t, cmd)
77+
78+
// Execute should return the provided command
79+
msgs := collectMsgs(cmd)
80+
require.Len(t, msgs, 1)
81+
_, ok := msgs[0].(testMsg)
82+
assert.True(t, ok, "should return the command from Execute")
83+
84+
// It should also clear the trigger and completion word from textarea
85+
assert.Empty(t, e.textarea.Value(), "should clear the trigger and completion word")
86+
})
87+
}

pkg/tui/components/editor/completions/command.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ func NewCommandCompletion(a *app.App) Completion {
2020
}
2121
}
2222

23-
func (c *commandCompletion) AutoSubmit() bool {
24-
return true // Commands auto-submit: selecting inserts command text and sends it
25-
}
26-
2723
func (c *commandCompletion) RequiresEmptyEditor() bool {
2824
return true
2925
}

pkg/tui/components/editor/completions/completion.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
type Completion interface {
1111
Trigger() string
1212
Items() []completion.Item
13-
AutoSubmit() bool
1413
RequiresEmptyEditor() bool
1514
// MatchMode returns how items should be filtered (fuzzy or prefix)
1615
MatchMode() completion.MatchMode

pkg/tui/components/editor/editor.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,7 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
639639

640640
case completion.SelectedMsg:
641641
// If the item has an Execute function, run it instead of inserting text
642-
if msg.Execute != nil {
642+
if msg.Execute != nil && msg.AutoSubmit {
643643
// Remove the trigger character and any typed completion word from the textarea
644644
// before executing. For example, typing "@" then selecting "Browse files..."
645645
// 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) {
654654
e.clearSuggestion()
655655
return e, msg.Execute()
656656
}
657-
if e.currentCompletion.AutoSubmit() {
657+
if msg.AutoSubmit {
658658
// For auto-submit completions (like commands), use the selected
659659
// command value (e.g., "/exit") instead of what the user typed
660660
// (e.g., "/e"). Append any extra text after the trigger word

0 commit comments

Comments
 (0)