fix(tui): drop bare double-Enter steer; keep Shift/Ctrl+Enter

Double-tap Enter within 500ms after queueing was hard to discover and
fought the chrome copy (double-↵ vs ⇧↵). Bare Enter while streaming now
only queues; steer stays an explicit Shift+Enter or Ctrl+Enter gesture.
Composer hints no longer promise double-↵.
This commit is contained in:
Hunter B committed 2026-07-24 12:31:40 -07:00
1 parent f3181b7526
commit c5c42b7d91
5 files changed
+40 -127

No files matched your search

+13 -56
View File
@@ -2024,7 +2024,9 @@ pub struct App {
/// instead of spawning a second dispatch that could reorder ops.
pub dispatch_in_flight: bool,
/// Timestamp of the most recent Enter while the engine was busy.
/// Used by `enter_with_double_tap()` to detect a double-tap within 500 ms.
/// Retained for session layout compatibility; bare-Enter double-tap
/// steering was removed (use Shift+Enter / Ctrl+Enter instead).
#[allow(dead_code)]
pub last_enter_instant: Option<Instant>,
/// Whether the once-per-turn provider-wait incident (#3095) has already
/// been logged for the current turn.
@@ -7000,14 +7002,14 @@ impl App {
/// Decide how to route a fresh composer submit.
///
/// v0.8.68: streaming output queues. Busy-but-waiting turns steer so
/// Enter can amend the active turn before output starts. A double-tap
/// Enter can amend the active turn before output starts. Explicit Shift/Ctrl+Enter
/// Enter within 500 ms triggers Steer while streaming; Ctrl+Enter forces
/// Steer in all busy states.
///
/// Truth table:
/// offline=F, busy=F → Immediate
/// offline=F, busy=T, streaming=F → Steer
/// offline=F, busy=T, streaming=T → Queue (double-tap → Steer)
/// offline=F, busy=T, streaming=T → Queue (Shift/Ctrl+Enter steers)
/// offline=T, busy=* → Queue
#[must_use]
pub fn decide_submit_disposition(&self) -> SubmitDisposition {
@@ -7025,65 +7027,20 @@ impl App {
if self.streaming_message_index.is_none() {
return SubmitDisposition::Steer;
}
// Streaming: queue the message. Double-tap Enter within 500 ms
// triggers Steer via enter_with_double_tap(); see the ui.rs submit
// handler.
// Streaming: queue the message. Steer is an explicit gesture
// (Shift+Enter / Ctrl+Enter), not a bare double-Enter race.
SubmitDisposition::Queue
}
/// Process an Enter keypress with double-tap steering detection.
/// Resolve what bare Enter should do right now.
///
/// When the engine is busy, the first Enter queues the message. A second
/// Enter within 500 ms triggers Steer (interrupt the current turn to
/// inject the new instruction immediately). When idle, Enter submits
/// immediately.
///
/// The first Enter clears the composer, so the UI path must also call
/// [`Self::take_queued_for_double_tap_steer`] on an empty second Enter to
/// escalate the just-queued message — otherwise double-tap never fires.
/// When the engine is busy, Enter queues. When idle, Enter submits
/// immediately. Steering is only available via explicit Shift+Enter or
/// Ctrl+Enter — a second bare Enter after queueing must not interrupt.
#[must_use]
pub fn enter_with_double_tap(&mut self) -> Option<SubmitDisposition> {
let disposition = self.decide_submit_disposition();
match disposition {
SubmitDisposition::Queue => {
if let Some(instant) = self.last_enter_instant
&& instant.elapsed() < Duration::from_millis(500)
{
self.last_enter_instant = None;
return Some(SubmitDisposition::Steer);
}
self.last_enter_instant = Some(Instant::now());
Some(SubmitDisposition::Queue)
}
other => {
self.last_enter_instant = None;
Some(other)
}
}
}
/// True when a second bare Enter should escalate the most recent queued
/// follow-up into a live steer (composer already emptied by the first tap).
#[must_use]
pub fn can_double_tap_steer_queued(&self) -> bool {
if self.offline_mode || !self.is_loading || self.dispatch_in_flight {
return false;
}
if self.queued_messages.is_empty() {
return false;
}
self.last_enter_instant
.is_some_and(|instant| instant.elapsed() < Duration::from_millis(500))
}
/// Pop the most recently queued message when a double-tap steer window is
/// still open. Clears the window so a third Enter does not re-steer.
pub fn take_queued_for_double_tap_steer(&mut self) -> Option<QueuedMessage> {
if !self.can_double_tap_steer_queued() {
return None;
}
self.last_enter_instant = None;
self.queued_messages.pop_back()
// Name kept for call-site stability; the double-tap window is gone.
Some(self.decide_submit_disposition())
}
/// Mark the in-flight streaming Assistant cell as interrupted: prepend
+9 -38
View File
@@ -3787,41 +3787,30 @@ fn submit_disposition_offline_busy_queues() {
}
#[test]
fn double_enter_detects_steering() {
fn bare_enter_while_streaming_stays_queue_not_steer() {
let mut app = App::new(test_options(false), &Config::default());
// Simulate a busy engine that is already streaming so the first Enter
// queues; the second tap escalates to steer.
// Busy + streaming: every bare Enter queues. Steer is Shift/Ctrl+Enter only.
app.is_loading = true;
app.streaming_message_index = Some(0);
// First Enter → Queue (normal queueing)
let first = app.enter_with_double_tap();
assert_eq!(first, Some(SubmitDisposition::Queue));
// Second Enter within 500ms → Steer (double-tap detected)
let second = app.enter_with_double_tap();
assert_eq!(second, Some(SubmitDisposition::Steer));
assert_eq!(second, Some(SubmitDisposition::Queue));
}
#[test]
fn empty_composer_double_enter_takes_last_queued_for_steer() {
fn empty_composer_second_enter_does_not_steal_queued_for_steer() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = true;
app.streaming_message_index = Some(0);
// Mirror the live path: first Enter queues + clears the composer.
assert_eq!(app.enter_with_double_tap(), Some(SubmitDisposition::Queue));
app.queue_message(QueuedMessage::new("older queued".to_string(), None));
app.queue_message(QueuedMessage::new("just typed follow-up".to_string(), None));
assert!(app.input.is_empty());
let taken = app
.take_queued_for_double_tap_steer()
.expect("second bare Enter must escalate the just-queued message");
assert_eq!(taken.display, "just typed follow-up");
assert_eq!(app.queued_message_count(), 1);
assert!(app.last_enter_instant.is_none());
// Window consumed: a third Enter does not keep popping.
assert!(app.take_queued_for_double_tap_steer().is_none());
// Bare Enter no longer pops the queue for steer; messages stay queued.
assert_eq!(app.enter_with_double_tap(), Some(SubmitDisposition::Queue));
assert_eq!(app.queued_message_count(), 2);
}
#[test]
@@ -3835,27 +3824,9 @@ fn sticky_error_ttl_is_capped_and_clears_on_composer_activity() {
}
#[test]
fn double_enter_resets_after_timeout() {
fn bare_enter_passes_through_when_idle() {
let mut app = App::new(test_options(false), &Config::default());
app.is_loading = true;
app.streaming_message_index = Some(0);
// First Enter → Queue
let first = app.enter_with_double_tap();
assert_eq!(first, Some(SubmitDisposition::Queue));
// Simulate timeout by clearing last_enter_instant
app.last_enter_instant = None;
// Next Enter → Queue again (not Steer, because window expired)
let second = app.enter_with_double_tap();
assert_eq!(second, Some(SubmitDisposition::Queue));
}
#[test]
fn double_enter_passes_through_when_idle() {
let mut app = App::new(test_options(false), &Config::default());
// Engine idle → Immediate (not affected by double-tap)
// Engine idle → Immediate every time.
let first = app.enter_with_double_tap();
assert_eq!(first, Some(SubmitDisposition::Immediate));
let second = app.enter_with_double_tap();
-12
View File
@@ -6227,18 +6227,6 @@ async fn run_event_loop(
continue;
}
}
// First Enter while streaming queues and clears the composer.
// A second bare Enter within the double-tap window must still
// steer that just-queued message (chrome promises double-↵).
if app.input.trim().is_empty()
&& !slash_menu_open
&& !mention_menu_open
&& let Some(queued) = app.take_queued_for_double_tap_steer()
{
persist_offline_queue_state(app);
attempt_steer_with_queue_fallback(app, &engine_handle, queued).await;
continue;
}
if let Some(input) = app.handle_composer_enter() {
// `# foo` quick-add (#492) — when memory is enabled,
// a single line starting with `#` (but not `##` /
+9 -12
View File
@@ -10757,14 +10757,14 @@ async fn streaming_enter_queue_pushes_visible_toast() {
}
#[tokio::test]
async fn empty_composer_double_enter_steers_just_queued_message() {
// Live path regression: first Enter queues+clears; second bare Enter must
// still steer the just-queued body (not require retyping).
async fn empty_composer_second_enter_leaves_queued_message() {
// Bare Enter while streaming only queues. A second bare Enter must not
// steal the just-queued body for steer — use Shift+Enter / Ctrl+Enter.
let mut app = create_test_app();
app.is_loading = true;
app.streaming_message_index = Some(0);
let config = Config::default();
let mut engine = crate::core::engine::mock_engine_handle();
let engine = crate::core::engine::mock_engine_handle();
let queued = build_queued_message(&mut app, "coordinate parallel tasks".to_string());
submit_or_steer_message(&mut app, &config, &engine.handle, queued)
@@ -10772,16 +10772,13 @@ async fn empty_composer_double_enter_steers_just_queued_message() {
.expect("first enter queues while streaming");
assert_eq!(app.queued_message_count(), 1);
assert!(app.input.is_empty());
assert!(app.last_enter_instant.is_some());
let escalated = app
.take_queued_for_double_tap_steer()
.expect("second enter takes queued body");
attempt_steer_with_queue_fallback(&mut app, &engine.handle, escalated).await;
assert_eq!(app.queued_message_count(), 0);
// Second bare Enter with empty composer is a no-op for queue contents.
assert!(app.input.trim().is_empty());
assert_eq!(app.decide_submit_disposition(), crate::tui::app::SubmitDisposition::Queue);
assert_eq!(app.queued_message_count(), 1);
assert_eq!(
engine.rx_steer.recv().await.as_deref(),
app.queued_messages.front().map(|m| m.display.as_str()),
Some("coordinate parallel tasks")
);
}
+9 -9
View File
@@ -1166,38 +1166,38 @@ impl Renderable for ComposerWidget<'_> {
(Some("↵ offline queue".to_string()), palette::STATUS_WARNING)
} else if self.app.mode == crate::tui::app::AppMode::Operate {
// Ctrl+G sends an already-queued item now; with only
// composer text it stashes the draft (#440). Double-↵
// after queueing steers the just-queued task.
// composer text it stashes the draft (#440). Steer is
// an explicit Shift+Enter / Ctrl+Enter gesture.
let label = if queue_count > 0 {
format!(
"↵ queue task ({} waiting) · double-↵/⇧↵ steer · Ctrl+G send queued",
"↵ queue task ({} waiting) · ⇧↵ steer · Ctrl+G send queued",
queue_count.saturating_add(1)
)
} else {
"↵ queue task · double-↵/⇧↵ steer".to_string()
"↵ queue task · ⇧↵ steer".to_string()
};
(Some(label), palette::WHALE_INFO)
} else {
let label = if queue_count > 0 {
format!(
"↵ queue ({} waiting) · double-↵/⇧↵ steer · Ctrl+G send queued",
"↵ queue ({} waiting) · ⇧↵ steer · Ctrl+G send queued",
queue_count.saturating_add(1)
)
} else {
"↵ queue · double-↵/⇧↵ steer".to_string()
"↵ queue · ⇧↵ steer".to_string()
};
(Some(label), palette::TEXT_MUTED)
}
}
// Steer reached via double-tap Enter, Shift+Enter, or Ctrl+Enter.
// Steer is reached via Shift+Enter or Ctrl+Enter only.
SubmitDisposition::Steer => {
(Some("↵ steering".to_string()), palette::WHALE_INFO)
}
SubmitDisposition::QueueFollowUp => (
Some(if self.app.mode == crate::tui::app::AppMode::Operate {
"↵ queued task · double-↵/⇧↵ steer · Ctrl+G send queued".to_string()
"↵ queued task · ⇧↵ steer · Ctrl+G send queued".to_string()
} else {
"↵ queued · double-↵/⇧↵ steer · Ctrl+G send queued".to_string()
"↵ queued · ⇧↵ steer · Ctrl+G send queued".to_string()
}),
palette::TEXT_MUTED,
),