mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
1 parent
f3181b7526
commit
c5c42b7d91
5 files changed
+40
-127
No files matched your search
+13
-56
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 `##` /
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user