From 1c624c6552e90e7cf8f2caff0fca054dd73597c3 Mon Sep 17 00:00:00 2001
From: lucascouts <lucascs@protonmail.com>
Date: Tue, 6 Oct 2026 16:18:41 -0300
Subject: [PATCH] agent_ui: Enter does nothing in the composer while a question
 is pending

While the agent waits on an answer to a question it asked -- the
AskUserQuestion card of 0005/0006/0027 -- Enter in the composer sent a
message. The keystroke was usually meant for the card, so the message
went out over an open question, which the agent then had to reconcile
with an answer it never got. Now Enter and the send chords
(ctrl-enter, cmd-enter on macOS, and the modifier-send chord when
use_modifier_to_send is on) do nothing in the composer until the
question is answered, declined or cancelled. The operator answers on
the card -- Tab to its field or a button, Enter there -- or with the
mouse.

BY CHOICE, the Send button still sends while the question is open: the
block lives in the key binding, not in ThreadView::send, which the
button's click listener reaches as well. A click on Send is never
accidental, and keeping it is the operator's decision of 2026-10-05.
ctrl-shift-enter (SendImmediately) is left bound for the same reason --
it is a deliberate chord, not a stray Enter.

How it works:

- MessageEditorAddon puts `question_pending` in the composer's key
  context, read from a Cell shared with MessageEditor
  (set_question_pending).
- ThreadView sets it on every render from
  Conversation::has_pending_elicitation -- the predicate 0030's sidebar
  status reads, so the composer stops sending exactly while the sidebar
  says "Waiting for Your Answer". Reading it per render instead of on
  the elicitation events means every way a question ends clears the
  flag without a path of its own.
- The Linux, macOS and Windows default keymaps bind enter and the send chord in
  `AcpThread > Editor && question_pending` to
  agent::IgnoreSendWhileQuestionPending, a no-op the composer handles.
  Not `null`: Enter carries key_char "\n", and a keystroke no handled
  action consumes falls through to the editor's text input, so an
  unbound Enter would still insert a newline. The action is hidden from
  the command palette -- there is nothing to run from there.
- vim.json gets the same block for `MessageEditor > Editor && VimControl`:
  vim binds Enter to agent::Chat in normal mode, in a keymap loaded after
  the default one, so it would otherwise outrank the block above.

The card's own Enter is untouched: its `Elicitation` context is only
on the dispatch path while the card has focus, and the composer's
context only while the composer does.

Tests (conversation_view::tests, all named *question_pending*): Enter
and ctrl-enter send nothing and insert no newline while the question
is open, and Enter sends again once it is cancelled; the same with
use_modifier_to_send; Enter in vim's normal mode; send() -- the Send
button's path -- still sends;
Tab then Enter from the card's field answers the question.

Story 017. Reads 0027 (the pinned card) and 0030 (the pending state).

Tests added 2026-10-09 (0.2.2_p20261009). A URL elicitation -- open a link, finish in the
browser -- blocks Enter like a form question, pinned on purpose: the reason above
holds for it too, even though it has no card and is drawn inline. Whether a URL
question should block at all is the operator's call; this keeps today's behaviour
from changing unnoticed. And 0005's multi-line field: Enter in the card is
menu::Confirm, so Shift-Enter is the only newline -- it must insert one and leave
the question pending, and Enter still answers. Here rather than in 0005 because it
uses this patch's composer setup.

(0.2.3_p20261009): a swallowed Enter is no longer silent. The composer shows "The agent is
waiting on your answer -- answer the question, or click Send to send anyway." while
the question is pending, and drops it when the question ends; the rule above is
unchanged. Tests: the hint, every default keymap's question_pending block (macOS
and Windows are never loaded by Linux tests), and 0031's stop_turn-once test,
here because it sits beside this patch's tests.
---

diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index 430ddf1..8cdb1cd 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -379,6 +379,17 @@
       "enter": "editor::Newline",
     },
   },
+  {
+    // While the agent waits on an answer to its question, Enter and the send
+    // chords do nothing in the composer -- no send, no newline. Answer on the
+    // question card, or click Send to send anyway.
+    "context": "AcpThread > Editor && question_pending",
+    "use_key_equivalents": true,
+    "bindings": {
+      "enter": "agent::IgnoreSendWhileQuestionPending",
+      "ctrl-enter": "agent::IgnoreSendWhileQuestionPending",
+    },
+  },
   {
     "context": "Elicitation || (Elicitation > Editor)",
     "use_key_equivalents": true,
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index de2a333..60d141c 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -413,6 +413,17 @@
       "enter": "editor::Newline",
     },
   },
+  {
+    // While the agent waits on an answer to its question, Enter and the send
+    // chords do nothing in the composer -- no send, no newline. Answer on the
+    // question card, or click Send to send anyway.
+    "context": "AcpThread > Editor && question_pending",
+    "use_key_equivalents": true,
+    "bindings": {
+      "enter": "agent::IgnoreSendWhileQuestionPending",
+      "cmd-enter": "agent::IgnoreSendWhileQuestionPending",
+    },
+  },
   {
     "context": "Elicitation || (Elicitation > Editor)",
     "use_key_equivalents": true,
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index bef5a8a..7b4173e 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -382,6 +382,17 @@
       "enter": "editor::Newline",
     },
   },
+  {
+    // While the agent waits on an answer to its question, Enter and the send
+    // chords do nothing in the composer -- no send, no newline. Answer on the
+    // question card, or click Send to send anyway.
+    "context": "AcpThread > Editor && question_pending",
+    "use_key_equivalents": true,
+    "bindings": {
+      "enter": "agent::IgnoreSendWhileQuestionPending",
+      "ctrl-enter": "agent::IgnoreSendWhileQuestionPending",
+    },
+  },
   {
     "context": "Elicitation || (Elicitation > Editor)",
     "use_key_equivalents": true,
diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json
index d35f0fb..4518548 100644
--- a/assets/keymaps/vim.json
+++ b/assets/keymaps/vim.json
@@ -1211,6 +1211,14 @@
       "enter": "agent::Chat",
     },
   },
+  {
+    // Outranks the binding above while the agent waits on an answer to its
+    // question, as the default keymaps' `question_pending` block does.
+    "context": "MessageEditor > Editor && VimControl && question_pending",
+    "bindings": {
+      "enter": "agent::IgnoreSendWhileQuestionPending",
+    },
+  },
 
   {
     "context": "SettingsWindow > NavigationMenu && !search",
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 4b98f0a..63fc567 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -339,6 +339,11 @@ actions!(
         RenameSelectedThread,
         /// Starts a chat conversation with follow-up enabled.
         ChatWithFollow,
+        /// Does nothing. Bound to Enter and the send chords in the composer
+        /// while the agent waits on an answer to its question: a handled action
+        /// is what keeps the keystroke from reaching the editor as a newline,
+        /// which an unbound (`null`) Enter would.
+        IgnoreSendWhileQuestionPending,
         /// Cycles to the next inline assist suggestion.
         CycleNextInlineAssist,
         /// Cycles to the previous inline assist suggestion.
@@ -993,6 +998,8 @@ fn update_command_palette_filter(cx: &mut App) {
         // Skills are surfaced through the settings UI now, so this command
         // should never appear in the palette.
         filter.hide_action_types(&manage_skills_action);
+        // A no-op that exists only to be bound; nothing to run from the palette.
+        filter.hide_action_types(&[TypeId::of::<IgnoreSendWhileQuestionPending>()]);
         if !disable_ai {
             filter.show_action_types(skill_creator_actions.iter());
         } else {
diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs
index 718025b..99cb0c0 100644
--- a/crates/agent_ui/src/conversation_view.rs
+++ b/crates/agent_ui/src/conversation_view.rs
@@ -7584,9 +7584,6 @@ pub(crate) mod tests {
     #[gpui::test]
     async fn test_preloaded_question_counts_as_pending(cx: &mut TestAppContext) {
         init_test(cx);
-        cx.update(|cx| {
-            cx.update_flags(true, vec![AcpBetaFeatureFlag::NAME.to_string()]);
-        });
 
         // The question is asked before the conversation registers the thread, so it never
         // sees `ElicitationRequested` -- the case `register_thread` seeds for.
@@ -7616,6 +7613,338 @@ pub(crate) mod tests {
         });
     }
 
+    /// A composer with a question from the agent still open, the default keymap
+    /// bound, and "hello" typed and focused. Returns the thread view and the
+    /// question's id.
+    async fn setup_question_pending_composer(
+        use_modifier_to_send: bool,
+        vim_mode: bool,
+        cx: &mut TestAppContext,
+    ) -> (
+        Entity<ThreadView>,
+        ElicitationEntryId,
+        &mut VisualTestContext,
+    ) {
+        init_test(cx);
+        cx.update(|cx| {
+            let bindings = settings::KeymapFile::load_asset_allow_partial_failure(
+                "keymaps/default-linux.json",
+                cx,
+            )
+            .expect("default keymap should load");
+            cx.bind_keys(bindings);
+            cx.update_global::<SettingsStore, _>(|store, cx| {
+                store.update_user_settings(cx, |settings| {
+                    settings.agent.get_or_insert_default().use_modifier_to_send =
+                        Some(use_modifier_to_send);
+                    settings.vim_mode = Some(vim_mode);
+                });
+            });
+            if vim_mode {
+                vim::init(cx);
+                let mut vim_bindings =
+                    settings::KeymapFile::load_asset_allow_partial_failure("keymaps/vim.json", cx)
+                        .expect("vim keymap should load");
+                for binding in &mut vim_bindings {
+                    binding.set_meta(settings::KeybindSource::Vim.meta());
+                }
+                cx.bind_keys(vim_bindings);
+            }
+        });
+
+        let connection = PreloadedElicitationConnection::default();
+        let elicitation_id = connection.elicitation_id.clone();
+        let (conversation_view, cx) =
+            setup_conversation_view(StubAgentServer::new(connection), cx).await;
+        add_to_workspace_with_size(conversation_view.clone(), true, cx);
+        let elicitation_id = elicitation_id
+            .lock()
+            .clone()
+            .expect("connection should preload an elicitation");
+
+        let thread_view = active_thread(&conversation_view, cx);
+        thread_view.update_in(cx, |view, window, cx| {
+            view.message_editor.update(cx, |editor, cx| {
+                editor.set_text("hello", window, cx);
+            });
+            let focus = view.message_editor.focus_handle(cx);
+            window.focus(&focus, cx);
+        });
+        cx.run_until_parked();
+        (thread_view, elicitation_id, cx)
+    }
+
+    fn user_message_count(thread_view: &Entity<ThreadView>, cx: &VisualTestContext) -> usize {
+        thread_view.read_with(cx, |view, cx| {
+            view.thread
+                .read(cx)
+                .entries()
+                .iter()
+                .filter(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
+                .count()
+        })
+    }
+
+    fn composer_text(thread_view: &Entity<ThreadView>, cx: &VisualTestContext) -> String {
+        thread_view.read_with(cx, |view, cx| view.message_editor.read(cx).text(cx))
+    }
+
+    // Story 017: Enter in the composer must not send a message over a question the agent
+    // is still waiting on -- and must not insert a newline either.
+    // Story 017 on every platform: Linux tests load only default-linux.json, so the
+    // macOS and Windows blocks are read here -- each must swallow Enter and its send
+    // chord while a question is pending.
+    #[test]
+    fn test_question_pending_block_in_every_default_keymap() {
+        for (file, chord) in [
+            ("keymaps/default-linux.json", "ctrl-enter"),
+            ("keymaps/default-macos.json", "cmd-enter"),
+            ("keymaps/default-windows.json", "ctrl-enter"),
+        ] {
+            let content = util::asset_str::<settings::SettingsAssets>(file);
+            let keymap = settings::KeymapFile::parse(&content)
+                .unwrap_or_else(|error| panic!("{file} parses: {error}"));
+            let section = keymap
+                .sections()
+                .find(|section| section.context == "AcpThread > Editor && question_pending")
+                .unwrap_or_else(|| panic!("{file} has the question_pending block"));
+            for key in ["enter", chord] {
+                let action = section
+                    .bindings()
+                    .find(|(keystroke, _)| keystroke.as_str() == key)
+                    .and_then(|(_, action)| {
+                        settings::KeymapFile::parse_action(action).ok().flatten()
+                    })
+                    .map(|(name, _)| name.clone());
+                assert_eq!(
+                    action.as_deref(),
+                    Some("agent::IgnoreSendWhileQuestionPending"),
+                    "{file}: {key}"
+                );
+            }
+        }
+    }
+
+    #[gpui::test]
+    async fn test_question_pending_enter_sends_nothing(cx: &mut TestAppContext) {
+        let (thread_view, elicitation_id, cx) =
+            setup_question_pending_composer(false, false, cx).await;
+
+        cx.simulate_keystrokes("enter");
+        cx.run_until_parked();
+        assert_eq!(user_message_count(&thread_view, cx), 0);
+        assert_eq!(composer_text(&thread_view, cx), "hello");
+
+        // `ctrl-enter` (send and follow) is a send too.
+        cx.simulate_keystrokes("ctrl-enter");
+        cx.run_until_parked();
+        assert_eq!(user_message_count(&thread_view, cx), 0);
+        assert_eq!(composer_text(&thread_view, cx), "hello");
+
+        // The swallowed Enter is explained, not silent (2026-10-09).
+        assert!(thread_view.read_with(cx, |view, cx| {
+            view.message_editor.read(cx).shows_swallowed_send_hint()
+        }));
+
+        // Once the question is answered, Enter sends again.
+        let thread = thread_view.read_with(cx, |view, _| view.thread.clone());
+        thread.update(cx, |thread, cx| {
+            thread.cancel_elicitation(&elicitation_id, cx)
+        });
+        cx.run_until_parked();
+        assert!(
+            !thread_view.read_with(cx, |view, cx| {
+                view.message_editor.read(cx).shows_swallowed_send_hint()
+            }),
+            "the hint ends with the question"
+        );
+        cx.simulate_keystrokes("enter");
+        cx.run_until_parked();
+        assert_eq!(user_message_count(&thread_view, cx), 1);
+        assert_eq!(composer_text(&thread_view, cx), "");
+    }
+
+    // Pinned on purpose, not by accident: a URL elicitation (open a link, finish in the
+    // browser) blocks Enter like a form question. 0043's reason holds for both -- a message
+    // sent over an open question is one the agent must reconcile with an answer it never
+    // got -- even though a URL question has no card to type into, and is drawn inline.
+    #[gpui::test]
+    async fn test_question_pending_url_elicitation_blocks_enter_too(cx: &mut TestAppContext) {
+        let (thread_view, form_id, cx) = setup_question_pending_composer(false, false, cx).await;
+        let thread = thread_view.read_with(cx, |view, _| view.thread.clone());
+        thread.update(cx, |thread, cx| thread.cancel_elicitation(&form_id, cx));
+        cx.run_until_parked();
+
+        let session_id = thread.read_with(cx, |thread, _| thread.session_id().clone());
+        let _response = thread.update(cx, |thread, cx| {
+            thread
+                .request_elicitation(
+                    acp_v2::CreateElicitationRequest::new(
+                        acp_v2::ElicitationUrlMode::new(
+                            acp_v2::ElicitationSessionScope::new(session_id),
+                            acp_v2::ElicitationId::new("url-1"),
+                            "https://example.com/complete",
+                        ),
+                        "Complete this in the browser",
+                    ),
+                    cx,
+                )
+                .expect("a URL elicitation should be accepted")
+        });
+        cx.run_until_parked();
+        let url_id = thread.read_with(cx, |thread, _| {
+            thread
+                .entries()
+                .iter()
+                .rev()
+                .find_map(|entry| match entry {
+                    AgentThreadEntry::Elicitation(id) => Some(id.clone()),
+                    _ => None,
+                })
+                .expect("the URL elicitation is an entry")
+        });
+
+        cx.simulate_keystrokes("enter");
+        cx.run_until_parked();
+        assert_eq!(user_message_count(&thread_view, cx), 0);
+        assert_eq!(composer_text(&thread_view, cx), "hello");
+
+        thread.update(cx, |thread, cx| thread.cancel_elicitation(&url_id, cx));
+        cx.run_until_parked();
+        cx.simulate_keystrokes("enter");
+        cx.run_until_parked();
+        assert_eq!(user_message_count(&thread_view, cx), 1);
+    }
+
+    #[gpui::test]
+    async fn test_question_pending_swallows_the_modifier_send(cx: &mut TestAppContext) {
+        let (thread_view, _, cx) = setup_question_pending_composer(true, false, cx).await;
+
+        cx.simulate_keystrokes("ctrl-enter");
+        cx.run_until_parked();
+        assert_eq!(user_message_count(&thread_view, cx), 0);
+        cx.simulate_keystrokes("enter");
+        cx.run_until_parked();
+        assert_eq!(user_message_count(&thread_view, cx), 0);
+        assert_eq!(composer_text(&thread_view, cx), "hello");
+    }
+
+    // Vim's normal mode binds Enter to `agent::Chat` in a keymap loaded after the default
+    // one, so it needs its own `question_pending` block to be outranked.
+    #[gpui::test]
+    async fn test_question_pending_swallows_vim_normal_mode_enter(cx: &mut TestAppContext) {
+        let (thread_view, _, cx) = setup_question_pending_composer(false, true, cx).await;
+
+        // The composer opens in vim's normal mode -- and Escape there would cancel the
+        // turn, and the question with it.
+        cx.update(|window, _cx| {
+            assert!(
+                window
+                    .context_stack()
+                    .last()
+                    .is_some_and(|context| context.contains("VimControl")),
+                "the composer should start in vim's normal mode"
+            );
+        });
+        cx.simulate_keystrokes("enter");
+        cx.run_until_parked();
+        assert_eq!(user_message_count(&thread_view, cx), 0);
+        assert_eq!(composer_text(&thread_view, cx), "hello");
+    }
+
+    // The block lives in the key binding, so the Send button -- which calls the same
+    // `send` its click listener does -- still sends while the question is open.
+    #[gpui::test]
+    async fn test_question_pending_send_button_still_sends(cx: &mut TestAppContext) {
+        let (thread_view, _, cx) = setup_question_pending_composer(false, false, cx).await;
+
+        thread_view.update_in(cx, |view, window, cx| view.send(window, cx));
+        cx.run_until_parked();
+        assert_eq!(user_message_count(&thread_view, cx), 1);
+    }
+
+    // The card's own Enter is untouched: Tab from its field to Submit, then Enter, answers.
+    #[gpui::test]
+    async fn test_question_pending_tab_enter_answers_the_card(cx: &mut TestAppContext) {
+        let (thread_view, elicitation_id, cx) =
+            setup_question_pending_composer(false, false, cx).await;
+
+        let field = thread_view.read_with(cx, |view, _| {
+            view.elicitation_text_field(&elicitation_id, "name")
+                .expect("the card should have a text field named `name`")
+        });
+        cx.update(|window, cx| {
+            field.update(cx, |editor, cx| editor.set_text("Ada", window, cx));
+            window.focus(&field.focus_handle(cx), cx);
+        });
+        cx.run_until_parked();
+        for key in ["tab", "enter"] {
+            cx.simulate_keystrokes(key);
+            cx.simulate_event(gpui::KeyUpEvent {
+                keystroke: gpui::Keystroke::parse(key).expect("valid keystroke"),
+            });
+            cx.run_until_parked();
+        }
+
+        thread_view.read_with(cx, |view, cx| {
+            let thread = view.thread.read(cx);
+            let (_, elicitation) = thread
+                .elicitation(&elicitation_id)
+                .expect("the question should still be in the thread");
+            assert!(
+                matches!(elicitation.status, ElicitationStatus::Accepted),
+                "Tab then Enter should have answered the question"
+            );
+        });
+        assert_eq!(user_message_count(&thread_view, cx), 0);
+    }
+
+    // 0005: a text answer can span lines. Enter in the card's field is `menu::Confirm`, so
+    // Shift-Enter -- the auto-height editor's newline -- is the only way to type a line
+    // break, and it must neither answer nor drop the question.
+    #[gpui::test]
+    async fn test_elicitation_text_field_shift_enter_inserts_a_newline(cx: &mut TestAppContext) {
+        let (thread_view, elicitation_id, cx) =
+            setup_question_pending_composer(false, false, cx).await;
+
+        let field = thread_view.read_with(cx, |view, _| {
+            view.elicitation_text_field(&elicitation_id, "name")
+                .expect("the card should have a text field named `name`")
+        });
+        cx.update(|window, cx| {
+            field.update(cx, |editor, cx| editor.set_text("first", window, cx));
+            field.update(cx, |editor, cx| {
+                editor.move_to_end(&editor::actions::MoveToEnd, window, cx)
+            });
+            window.focus(&field.focus_handle(cx), cx);
+        });
+        cx.run_until_parked();
+        cx.simulate_keystrokes("shift-enter");
+        cx.simulate_input("second");
+        cx.run_until_parked();
+
+        let text = cx.update(|_, cx| field.read(cx).text(cx));
+        assert_eq!(text, "first\nsecond");
+        let pending = |thread_view: &Entity<ThreadView>, cx: &VisualTestContext| {
+            thread_view.read_with(cx, |view, cx| {
+                view.thread
+                    .read(cx)
+                    .elicitation(&elicitation_id)
+                    .is_some_and(|(_, elicitation)| {
+                        matches!(elicitation.status, ElicitationStatus::Pending { .. })
+                    })
+            })
+        };
+        assert!(
+            pending(&thread_view, cx),
+            "Shift-Enter must not answer the question"
+        );
+
+        cx.simulate_keystrokes("enter");
+        cx.run_until_parked();
+        assert!(!pending(&thread_view, cx), "Enter still answers it");
+    }
+
     #[gpui::test]
     async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) {
         init_test(cx);
@@ -15116,6 +15445,30 @@ pub(crate) mod tests {
         }
     }
 
+    // 0031: a turn's time is added to the session once. stop_turn can be reached twice for
+    // one turn (a stop and an error both end it); the second must add nothing.
+    #[gpui::test]
+    async fn test_stop_turn_adds_a_turn_to_the_session_time_once(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (conversation_view, cx) =
+            setup_conversation_view(StubAgentServer::default_response(), cx).await;
+        add_to_workspace(conversation_view.clone(), cx);
+        let thread_view = active_thread(&conversation_view, cx);
+
+        thread_view.update(cx, |view, cx| {
+            let generation = view.start_turn(cx);
+            std::thread::sleep(std::time::Duration::from_millis(5));
+            view.stop_turn(generation, cx);
+            let once = view.turn_fields.session_duration;
+            assert!(
+                once > std::time::Duration::ZERO,
+                "the turn's time was added"
+            );
+            view.stop_turn(generation, cx);
+            assert_eq!(view.turn_fields.session_duration, once, "not added twice");
+        });
+    }
+
     // 0037: entries pushed within ONE thread update reach the view as one NewEntry event
     // each, all flushed after the last push. Upstream's handler synced only the last index
     // per event, so the view fell behind the thread and the next sync panicked on an index
diff --git a/crates/agent_ui/src/conversation_view/elicitation.rs b/crates/agent_ui/src/conversation_view/elicitation.rs
index 9d09ecb..9f6c872 100644
--- a/crates/agent_ui/src/conversation_view/elicitation.rs
+++ b/crates/agent_ui/src/conversation_view/elicitation.rs
@@ -62,6 +62,14 @@ pub(crate) struct ElicitationFormState {
 }
 
 impl ElicitationFormState {
+    #[cfg(test)]
+    pub(crate) fn text_field_editor(&self, field_name: &str) -> Option<Entity<Editor>> {
+        match self.fields.get(field_name)? {
+            ElicitationFieldState::Text(editor) => Some(editor.clone()),
+            _ => None,
+        }
+    }
+
     pub(crate) fn new(schema: &acp::ElicitationSchema, window: &mut Window, cx: &mut App) -> Self {
         let required = schema.required.as_deref().unwrap_or_default();
         let mut fields = HashMap::default();
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index 75946c6..4a92417 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -3550,6 +3550,17 @@ impl ThreadView {
         self.elicitation_form_states.contains_key(id)
     }
 
+    #[cfg(test)]
+    pub(crate) fn elicitation_text_field(
+        &self,
+        id: &ElicitationEntryId,
+        field_name: &str,
+    ) -> Option<Entity<Editor>> {
+        self.elicitation_form_states
+            .get(id)?
+            .text_field_editor(field_name)
+    }
+
     fn submit_elicitation(
         &mut self,
         elicitation_id: ElicitationEntryId,
@@ -8743,6 +8754,19 @@ impl ThreadView {
         self.message_editor.read(cx).set_local_commands(commands);
     }
 
+    /// Mirrors the conversation's pending question into the composer's key
+    /// context. Read on every render rather than on the elicitation events, so
+    /// every way a question ends -- answered, declined, cancelled, the turn
+    /// stopped -- clears it without a path of its own. The conversation-wide
+    /// predicate is the one 0030's sidebar status reads, so the composer stops
+    /// sending exactly while the sidebar says "Waiting for Your Answer".
+    fn sync_question_pending(&self, cx: &App) {
+        let question_pending = self.conversation.read(cx).has_pending_elicitation();
+        self.message_editor
+            .read(cx)
+            .set_question_pending(question_pending);
+    }
+
     /// Whether this elicitation is drawn by the pinned card above the message
     /// editor rather than inline, in the conversation.
     ///
@@ -14436,6 +14460,7 @@ impl Render for ThreadView {
         // current availability of feedback/sharing, which can change between
         // renders (settings, connection state, feature flags).
         self.sync_local_commands(cx);
+        self.sync_question_pending(cx);
 
         let has_messages = self.has_visible_conversation(cx);
         let list_state = self.list_state.clone();
diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs
index 4ec9710..b20db69 100644
--- a/crates/agent_ui/src/message_editor.rs
+++ b/crates/agent_ui/src/message_editor.rs
@@ -1,7 +1,7 @@
 use crate::DEFAULT_THREAD_TITLE;
 use crate::SendImmediately;
 use crate::{
-    ChatWithFollow,
+    ChatWithFollow, IgnoreSendWhileQuestionPending,
     completion_provider::{
         AgentContextSelection, AvailableCommand, AvailableSkill, PromptCompletionProvider,
         PromptCompletionProviderDelegate, PromptContextAction, PromptContextType,
@@ -37,7 +37,7 @@ use project::{
 };
 use rope::Point;
 use settings::Settings;
-use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc};
+use std::{cell::Cell, cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc};
 use text::LineEnding;
 use theme_settings::ThemeSettings;
 use ui::{ContextMenu, Tooltip, WithScrollbar, prelude::*};
@@ -221,6 +221,12 @@ pub struct MessageEditor {
     workspace: WeakEntity<Workspace>,
     session_capabilities: SharedSessionCapabilities,
     local_commands: SharedLocalCommands,
+    /// Shared with the editor's [`MessageEditorAddon`], which reads it into the
+    /// key context as `question_pending`.
+    question_pending: Rc<Cell<bool>>,
+    /// Set when Enter (or a send chord) was swallowed because a question is
+    /// pending; the composer then says why until the question ends.
+    swallowed_send: bool,
     agent_id: AgentId,
     thread_store: Option<Entity<ThreadStore>>,
     /// The rendered draft, while the preview is showing; `None` while editing.
@@ -499,6 +505,7 @@ impl MessageEditor {
             .upgrade()
             .map(|project| project.read(cx).languages().clone());
 
+        let question_pending = Rc::new(Cell::new(false));
         let editor = cx.new(|cx| {
             let buffer = cx.new(|cx| {
                 let buffer = Buffer::local("", cx);
@@ -522,7 +529,7 @@ impl MessageEditor {
                 max_entries_visible: 12,
                 placement: None,
             });
-            editor.register_addon(MessageEditorAddon::new());
+            editor.register_addon(MessageEditorAddon::new(question_pending.clone()));
 
             editor.set_custom_context_menu(|editor, _point, window, cx| {
                 let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
@@ -645,6 +652,8 @@ impl MessageEditor {
             workspace,
             session_capabilities,
             local_commands,
+            question_pending,
+            swallowed_send: false,
             agent_id,
             thread_store,
             preview: None,
@@ -658,6 +667,35 @@ impl MessageEditor {
         *self.local_commands.write() = commands;
     }
 
+    /// Whether the agent is waiting on an answer to a question it asked. While
+    /// it is, the keymap binds Enter and the send chords to nothing here, so a
+    /// keystroke meant for the question card cannot send a message over it.
+    /// Only the key binding is blocked: the Send button still sends.
+    pub fn set_question_pending(&self, question_pending: bool) {
+        self.question_pending.set(question_pending);
+    }
+
+    /// Whether the composer is explaining a swallowed Enter: one was pressed
+    /// while a question was pending, and the question is still pending.
+    pub fn shows_swallowed_send_hint(&self) -> bool {
+        self.swallowed_send && self.question_pending.get()
+    }
+
+    fn render_swallowed_send_hint(&mut self) -> Option<impl IntoElement + use<>> {
+        if !self.question_pending.get() {
+            // The question ended -- answered, declined, cancelled -- so the next
+            // swallowed Enter, under a new question, starts the hint afresh.
+            self.swallowed_send = false;
+        }
+        self.shows_swallowed_send_hint().then(|| {
+            Label::new(
+                "The agent is waiting on your answer \u{2014} answer the question, or click Send to send anyway.",
+            )
+            .size(LabelSize::Small)
+            .color(Color::Muted)
+        })
+    }
+
     pub fn set_session_capabilities(
         &mut self,
         session_capabilities: SharedSessionCapabilities,
@@ -2217,6 +2255,14 @@ impl Render for MessageEditor {
             .on_action(cx.listener(Self::chat))
             .on_action(cx.listener(Self::send_immediately))
             .on_action(cx.listener(Self::chat_with_follow))
+            .on_action(
+                cx.listener(|this, _: &IgnoreSendWhileQuestionPending, _, cx| {
+                    // Still a no-op as far as sending goes (0043); it only makes the
+                    // composer say why nothing happened.
+                    this.swallowed_send = true;
+                    cx.notify();
+                }),
+            )
             .on_action(cx.listener(Self::cancel))
             .capture_action(cx.listener(Self::copy))
             .capture_action(cx.listener(Self::cut))
@@ -2226,6 +2272,7 @@ impl Render for MessageEditor {
                 this.toggle_preview(window, cx)
             }))
             .flex_1()
+            .children(self.render_swallowed_send_hint())
             .children(self.render_preview(window, cx))
             .child(div().flex_1().child({
                 let settings = ThemeSettings::get_global(cx);
@@ -2256,11 +2303,13 @@ impl Render for MessageEditor {
     }
 }
 
-pub struct MessageEditorAddon {}
+pub struct MessageEditorAddon {
+    question_pending: Rc<Cell<bool>>,
+}
 
 impl MessageEditorAddon {
-    pub fn new() -> Self {
-        Self {}
+    pub fn new(question_pending: Rc<Cell<bool>>) -> Self {
+        Self { question_pending }
     }
 }
 
@@ -2278,6 +2327,9 @@ impl Addon for MessageEditorAddon {
         if settings.use_modifier_to_send {
             key_context.add("use_modifier_to_send");
         }
+        if self.question_pending.get() {
+            key_context.add("question_pending");
+        }
     }
 }
 
