Skip to content
Merged
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
89 changes: 89 additions & 0 deletions pkg/tui/external_editor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package tui

import (
"errors"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/tui/components/notification"
"github.com/docker/docker-agent/pkg/tui/messages"
)

// valueRecordingEditor records SetValue calls so tests can assert what the
// external-editor callback wrote back into the input editor.
type valueRecordingEditor struct {
mockEditor

value string
}

func (e *valueRecordingEditor) SetValue(v string) { e.value = v }
func (e *valueRecordingEditor) Value() string { return e.value }

func writeTempPromptFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "prompt.md")
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
return path
}

// TestExternalEditorCallback_RefocusesEditor pins the fix for the "edited a
// question in vi with ctrl+g but Enter did nothing" bug: ctrl+g works from
// any panel, so after the external editor exits the callback must request
// focus back on the input editor. Otherwise, when the content panel was
// focused (e.g. while watching a running turn), Enter is routed to the
// transcript and the edited question is never sent or queued.
func TestExternalEditorCallback_RefocusesEditor(t *testing.T) {
ed := &valueRecordingEditor{}
path := writeTempPromptFile(t, "What is 2+2?\n")

msg := externalEditorCallback(ed, path)(nil)

assert.Equal(t, "What is 2+2?", ed.value, "edited content must land in the editor, without the trailing newline")
assert.Equal(t, messages.RequestFocusMsg{Target: messages.PanelEditor}, msg,
"the callback must refocus the editor so Enter sends the edited content")

_, err := os.Stat(path)
assert.True(t, os.IsNotExist(err), "the temp file must be removed")
}

func TestExternalEditorCallback_EmptyContentClearsEditor(t *testing.T) {
ed := &valueRecordingEditor{value: "old content"}
path := writeTempPromptFile(t, " \n\n")

msg := externalEditorCallback(ed, path)(nil)

assert.Empty(t, ed.value, "blank-only content must clear the editor")
assert.Equal(t, messages.RequestFocusMsg{Target: messages.PanelEditor}, msg)
}

func TestExternalEditorCallback_EditorError(t *testing.T) {
ed := &valueRecordingEditor{value: "untouched"}
path := writeTempPromptFile(t, "ignored")

msg := externalEditorCallback(ed, path)(errors.New("exit status 1"))

show, ok := msg.(notification.ShowMsg)
require.True(t, ok, "an editor error must surface as an error notification")
assert.Equal(t, notification.TypeError, show.Type)
assert.Equal(t, "untouched", ed.value, "the editor content must not change on error")

_, err := os.Stat(path)
assert.True(t, os.IsNotExist(err), "the temp file must be removed on error")
}

func TestExternalEditorCallback_ReadError(t *testing.T) {
ed := &valueRecordingEditor{value: "untouched"}
path := filepath.Join(t.TempDir(), "missing.md")

msg := externalEditorCallback(ed, path)(nil)

show, ok := msg.(notification.ShowMsg)
require.True(t, ok, "a read failure must surface as an error notification")
assert.Equal(t, notification.TypeError, show.Type)
assert.Equal(t, "untouched", ed.value, "the editor content must not change when the temp file cannot be read")
}
15 changes: 12 additions & 3 deletions pkg/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -3006,7 +3006,13 @@ func (m *appModel) openExternalEditor() (tea.Model, tea.Cmd) {
cmd := exec.Command(parts[0], args...) //nolint:noctx // owned by tea.ExecProcess

ed := m.editor
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
return m, tea.ExecProcess(cmd, externalEditorCallback(ed, tmpPath))
}

// externalEditorCallback builds the tea.ExecProcess callback that reads the
// edited temp file back into the editor once the external editor exits.
func externalEditorCallback(ed editor.Editor, tmpPath string) func(error) tea.Msg {
return func(err error) tea.Msg {
if err != nil {
os.Remove(tmpPath)
return notification.ShowMsg{Text: fmt.Sprintf("Editor error: %v", err), Type: notification.TypeError}
Expand All @@ -3028,8 +3034,11 @@ func (m *appModel) openExternalEditor() (tea.Model, tea.Cmd) {
ed.SetValue(c)
}

return nil
})
// Ctrl+g works from any panel, so make sure the editor has focus:
// otherwise Enter goes to the content panel and the edited text is
// never sent (or queued while the agent is working).
return messages.RequestFocusMsg{Target: messages.PanelEditor}
}
}

func toFullscreenView(content, windowTitle string, working, leanMode bool) tea.View {
Expand Down
Loading