diff --git a/pkg/tui/external_editor_test.go b/pkg/tui/external_editor_test.go new file mode 100644 index 0000000000..bf58c03e59 --- /dev/null +++ b/pkg/tui/external_editor_test.go @@ -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") +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index ae07e3f29e..1d68071997 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -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} @@ -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 {