diff --git a/cli/internal/tui/chat.go b/cli/internal/tui/chat.go index e31db3d4..82536159 100644 --- a/cli/internal/tui/chat.go +++ b/cli/internal/tui/chat.go @@ -1168,7 +1168,12 @@ func (m ChatModel) update(msg tea.Msg) (ChatModel, tea.Cmd) { return m, cmd } case "enter": - if trimmed := strings.TrimSpace(m.TextArea.Value()); trimmed != "" { + trimmed := strings.TrimSpace(m.TextArea.Value()) + // a draft typed while connecting waits in the composer + if m.connecting() && !(strings.HasPrefix(trimmed, "/") && m.isRecognizedCommand(trimmed)) { + return m, nil + } + if trimmed != "" { m.TextArea.Reset() m.syncLayout() m.submitInput(trimmed, &cmds) @@ -2051,12 +2056,16 @@ func (m ChatModel) statusLine() string { } return "thinking" } - if m.Status.Phase == nil { + if m.connecting() { return pick(m.Client != nil, "connecting…", "opening session…") } return pick(m.Flags.Tools, "ready", "") } +func (m ChatModel) connecting() bool { + return m.Status.Phase == nil && !(m.Status.Running && !m.Status.Idle) +} + // phaseMood is the face class for the phase statusLine names. func (m ChatModel) phaseMood() mood { switch { diff --git a/cli/internal/tui/prompt_multiline_test.go b/cli/internal/tui/prompt_multiline_test.go index eddbf4e6..0574f0c9 100644 --- a/cli/internal/tui/prompt_multiline_test.go +++ b/cli/internal/tui/prompt_multiline_test.go @@ -57,6 +57,15 @@ func TestPromptMultilineWrapsScrollsAndSubmits(t *testing.T) { altEnter := tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt} m := composer(t, width, height) + // enter while connecting keeps the draft instead of sending it. + m = typePrompt(m, "too early") + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.TextArea.Value() != "too early" || len(m.pendingUsers) != 0 { + t.Fatalf("enter while connecting sent %q (pending %d), want the draft kept", m.TextArea.Value(), len(m.pendingUsers)) + } + m.TextArea.Reset() + m.Status.Phase = &phaseResting + if m.promptHeight() != 1 { t.Fatalf("initial promptHeight %d, want 1", m.promptHeight()) } diff --git a/cli/test/e2e/tui_folders_test.go b/cli/test/e2e/tui_folders_test.go index 5cccff88..bebda4d4 100644 --- a/cli/test/e2e/tui_folders_test.go +++ b/cli/test/e2e/tui_folders_test.go @@ -79,6 +79,7 @@ func TestTUIMissingWorkspaceSendsTheTurnFromThePickedFolder(t *testing.T) { t.Fatal(err) } + d.connected() d.App.Chat.TextArea.SetValue("hello from nowhere") d.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter}) if view := d.View(); d.App.State != tui.AppStateFolderPicker || !strings.Contains(view, "not found") { diff --git a/cli/test/e2e/tui_harness_test.go b/cli/test/e2e/tui_harness_test.go index 5be6493b..f97c92bd 100644 --- a/cli/test/e2e/tui_harness_test.go +++ b/cli/test/e2e/tui_harness_test.go @@ -5,6 +5,7 @@ package e2e import ( "context" "reflect" + "strings" "testing" "albedo/cli/internal/config" @@ -124,6 +125,17 @@ func (d *tuiDriver) results(cmd tea.Cmd) []tea.Msg { } } +// connected answers the chat's status poll once, as the event loop would +// before the composer lets a message through. +func (d *tuiDriver) connected() { + d.t.Helper() + chat := d.App.Chat + d.Update(d.Update(tui.ChatStatusPollMsg{SessionID: chat.SessionID, Generation: chat.Generation})()) + if view := d.View(); strings.Contains(view, "connecting…") { + d.t.Fatalf("the session still reads as connecting after its status answered:\n%s", view) + } +} + // View returns the rendered terminal contents without styling. func (d *tuiDriver) View() string { d.t.Helper() diff --git a/cli/test/e2e/tui_notices_test.go b/cli/test/e2e/tui_notices_test.go index 07624241..3672f77f 100644 --- a/cli/test/e2e/tui_notices_test.go +++ b/cli/test/e2e/tui_notices_test.go @@ -24,6 +24,7 @@ func TestTUINoticeMovesToANewSessionAndClearsOnSend(t *testing.T) { t.Fatalf("notice did not follow the real new session:\n%s", d.View()) } + d.connected() // Enter batches the send with the spinner; only the send reaches the // daemon. d.App.Chat.TextArea.SetValue("clear the notice")