Decode Windows Terminal's mapped Shift+Enter sequence (#50720)

## Why

Windows Terminal's `sendInput` mapping delivers `ESC[13;2u` as individual console key events. Decode it as `Shift+Enter` so the mapping inserts a newline in the composer.

## What changed

- Wrap the Windows event stream with a decoder for the complete Escape-prefixed sequence, including interleaved key releases.
- Use a fixed 50 ms deadline for partial sequences and replay incomplete or mismatched input unchanged. Preserve literal text, paste events, and native key events.
- Update the `crossterm` revision and dependency lockfiles.

## Testing

Add tests for repeated and fragmented mappings, deadline handling, unchanged input, error and end-of-stream replay, and composer newline rendering with a snapshot.

GitOrigin-RevId: 95ab39960510d71322824c63817710633a2fda23
This commit is contained in:
Benjamin Carlsson
2026-10-03 21:20:56 +00:00
committed by copyberry
parent b172810921
commit 447eac3b81
8 changed files with 436 additions and 23 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+17 -18
View File
@@ -254,7 +254,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -265,7 +265,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -5827,12 +5827,11 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.29.0"
source = "git+https://github.com/openai-oss-forks/crossterm?rev=efa177859fd9623d57b9fe7ae9bf491ae1ac6ec4#efa177859fd9623d57b9fe7ae9bf491ae1ac6ec4"
source = "git+https://github.com/openai-oss-forks/crossterm?rev=ed1cdab335221515706178d68495bba2aed1924f#ed1cdab335221515706178d68495bba2aed1924f"
dependencies = [
"bitflags 2.13.1",
"crossterm_winapi",
"derive_more 2.1.1",
"document-features",
"futures-core",
"mio",
"parking_lot",
@@ -6497,7 +6496,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -6854,7 +6853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -7504,7 +7503,7 @@ dependencies = [
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9617,7 +9616,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9683,7 +9682,7 @@ dependencies = [
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -10592,7 +10591,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -11309,7 +11308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -12242,7 +12241,7 @@ dependencies = [
"libc",
"log",
"socket2 0.6.3",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -13545,7 +13544,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -13604,7 +13603,7 @@ dependencies = [
"security-framework 3.5.1",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -14428,7 +14427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -15258,7 +15257,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -15314,7 +15313,7 @@ dependencies = [
"parking_lot",
"rustix 1.1.4",
"signal-hook",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -17065,7 +17064,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
+1 -1
View File
@@ -643,7 +643,7 @@ opt-level = 0
[patch.crates-io]
# Uncomment to debug local changes.
# ratatui = { path = "../../ratatui" }
crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "efa177859fd9623d57b9fe7ae9bf491ae1ac6ec4" }
crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "ed1cdab335221515706178d68495bba2aed1924f" }
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" }
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" }
+2
View File
@@ -90,6 +90,8 @@ pub(crate) mod test_support;
mod tmux;
#[cfg(any(windows, test))]
mod windows_console;
#[cfg(any(windows, test))]
mod windows_key_sequence;
/// Target frame interval for UI redraw scheduling.
pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME_INTERVAL;
+14 -3
View File
@@ -125,11 +125,22 @@ impl<S: EventSource + Default> EventBroker<S> {
}
/// Real crossterm-backed event source.
pub struct CrosstermEventSource(pub crossterm::event::EventStream);
pub struct CrosstermEventSource {
#[cfg(not(windows))]
events: crossterm::event::EventStream,
#[cfg(windows)]
events: super::windows_key_sequence::WindowsKeySequence<crossterm::event::EventStream>,
}
impl Default for CrosstermEventSource {
fn default() -> Self {
Self(crossterm::event::EventStream::new())
let events = crossterm::event::EventStream::new();
Self {
#[cfg(not(windows))]
events,
#[cfg(windows)]
events: super::windows_key_sequence::WindowsKeySequence::new(events),
}
}
}
@@ -140,7 +151,7 @@ impl EventSource for CrosstermEventSource {
#[cfg(windows)]
let _ = super::windows_console::ensure_input_record_mode();
let result = Pin::new(&mut self.get_mut().0).poll_next(cx);
let result = Pin::new(&mut self.get_mut().events).poll_next(cx);
// EventStream starts its blocking reader before returning Pending, so reassert the mode
// after that transition as well.
@@ -0,0 +1,14 @@
---
source: tui/src/tui/windows_key_sequence_tests.rs
assertion_line: 188
expression: terminal.backend()
---
" "
"› first line "
" "
" second line [13;2u "
" "
" "
" "
" "
" 100% context left "
@@ -0,0 +1,157 @@
//! Decode Windows Terminal's explicit CSI-u Shift+Enter mapping from console key events.
//!
//! Crossterm's Win32 backend delivers `sendInput` as individual keys. Only the complete
//! ESC-prefixed sequence is recognized; plain text and paste events are never scanned.
//! A fixed deadline bounds waiting for input; already-ready keys may complete a candidate.
//! Failed prefixes are replayed unchanged.
//! State belongs to the event source so dropping it on terminal handoff drops partial input.
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::task::Context;
use std::task::Poll;
use std::time::Duration;
use crossterm::event::Event;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use tokio::time::Sleep;
use tokio_stream::Stream;
use super::event_stream::EventResult;
const SEQUENCE: [KeyCode; 7] = [
KeyCode::Esc,
KeyCode::Char('['),
KeyCode::Char('1'),
KeyCode::Char('3'),
KeyCode::Char(';'),
KeyCode::Char('2'),
KeyCode::Char('u'),
];
const SEQUENCE_TIMEOUT: Duration = Duration::from_millis(/*millis*/ 50);
pub(super) struct WindowsKeySequence<S> {
inner: S,
// At most one press and one release per sequence character.
pending: Vec<KeyEvent>,
next: usize,
deadline: Option<Pin<Box<Sleep>>>,
ready: VecDeque<EventResult>,
final_release: Option<KeyEvent>,
}
impl<S> WindowsKeySequence<S> {
pub(super) fn new(inner: S) -> Self {
Self {
inner,
pending: Vec::new(),
next: 0,
deadline: None,
ready: VecDeque::new(),
final_release: None,
}
}
fn flush(&mut self) {
self.ready
.extend(self.pending.drain(..).map(|key| Ok(Event::Key(key))));
self.next = 0;
self.deadline = None;
}
fn accept(&mut self, event: Event) {
if let Some(release) = self.final_release.take()
&& event == Event::Key(release)
{
return;
}
if let Event::Key(key) = event {
if let Some(previous) = self.pending.last()
&& previous.kind == KeyEventKind::Press
&& key
== (KeyEvent {
kind: KeyEventKind::Release,
..*previous
})
{
self.pending.push(key);
return;
}
if key.code == SEQUENCE[self.next]
&& key.modifiers.is_empty()
&& key.kind == KeyEventKind::Press
{
if self.pending.is_empty() {
self.deadline = Some(Box::pin(tokio::time::sleep(SEQUENCE_TIMEOUT)));
}
self.pending.push(key);
self.next += 1;
if self.next == SEQUENCE.len() {
self.pending.clear();
self.next = 0;
self.deadline = None;
self.final_release = Some(KeyEvent {
kind: KeyEventKind::Release,
..key
});
self.ready.push_back(Ok(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::SHIFT,
))));
}
return;
}
}
self.flush();
// A mismatching Escape may start the next sequence after replaying the failed one.
if matches!(event, Event::Key(key) if key == KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
{
self.accept(event);
} else {
self.ready.push_back(Ok(event));
}
}
}
impl<S: Stream<Item = EventResult> + Unpin> Stream for WindowsKeySequence<S> {
type Item = EventResult;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
loop {
if let Some(event) = this.ready.pop_front() {
return Poll::Ready(Some(event));
}
// Each ready key advances the finite candidate or produces output, so this
// cannot drain indefinitely. Prefer queued input over an elapsed deadline.
match Pin::new(&mut this.inner).poll_next(cx) {
Poll::Ready(Some(Ok(event))) => this.accept(event),
Poll::Ready(Some(Err(error))) => {
this.flush();
this.ready.push_back(Err(error));
}
Poll::Ready(None) => {
this.flush();
return Poll::Ready(this.ready.pop_front());
}
Poll::Pending => {
if let Some(deadline) = &mut this.deadline
&& deadline.as_mut().poll(cx).is_ready()
{
this.flush();
continue;
}
return Poll::Pending;
}
}
}
}
}
#[cfg(test)]
#[path = "windows_key_sequence_tests.rs"]
mod tests;
@@ -0,0 +1,230 @@
use super::*;
use futures::FutureExt;
use pretty_assertions::assert_eq;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::UnboundedReceiverStream;
fn keys(text: &str) -> Vec<Event> {
text.chars()
.map(|ch| {
Event::Key(KeyEvent::new(
if ch == '\u{1b}' {
KeyCode::Esc
} else {
KeyCode::Char(ch)
},
KeyModifiers::NONE,
))
})
.collect()
}
async fn decoded(events: Vec<Event>) -> Vec<Event> {
WindowsKeySequence::new(tokio_stream::iter(events.into_iter().map(Ok)))
.map(Result::unwrap)
.collect()
.await
}
#[tokio::test]
async fn repeated_mappings_decode_with_or_without_key_releases() {
let expected = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
let presses = keys("\u{1b}[13;2u\u{1b}[13;2u");
assert_eq!(decoded(presses.clone()).await, vec![expected.clone(); 2]);
let records = presses
.into_iter()
.flat_map(|event| {
let Event::Key(key) = event else {
unreachable!()
};
[
Event::Key(key),
Event::Key(KeyEvent {
kind: KeyEventKind::Release,
..key
}),
]
})
.collect();
assert_eq!(decoded(records).await, vec![expected; 2]);
}
#[tokio::test(start_paused = true)]
async fn fragmented_mapping_waits_without_leaking_escape() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let mut stream = WindowsKeySequence::new(UnboundedReceiverStream::new(rx));
let events = keys("\u{1b}[13;2u");
for event in &events[..6] {
tx.send(Ok(event.clone())).unwrap();
assert!(stream.next().now_or_never().is_none());
tokio::time::advance(Duration::from_millis(/*millis*/ 5)).await;
}
tx.send(Ok(events[6].clone())).unwrap();
assert_eq!(
stream.next().await.unwrap().unwrap(),
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT))
);
}
#[tokio::test(start_paused = true)]
async fn delayed_poll_consumes_queued_suffix_without_waiting_for_more_input() {
for suffix in ["[13;2u", "[13;"] {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let mut stream = WindowsKeySequence::new(UnboundedReceiverStream::new(rx));
tx.send(Ok(keys("\u{1b}")[0].clone())).unwrap();
assert!(stream.next().now_or_never().is_none());
for event in keys(suffix) {
tx.send(Ok(event)).unwrap();
}
tokio::time::advance(SEQUENCE_TIMEOUT).await;
let expected = if suffix.ends_with('u') {
vec![Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::SHIFT,
))]
} else {
keys("\u{1b}[13;")
};
for event in expected {
assert_eq!(
stream.next().now_or_never().unwrap().unwrap().unwrap(),
event
);
}
assert!(stream.next().now_or_never().is_none());
}
}
#[tokio::test]
async fn continuously_ready_mappings_yield_one_newline_at_a_time() {
let events = keys("\u{1b}[13;2u").into_iter().cycle();
let mut stream = WindowsKeySequence::new(tokio_stream::iter(events.map(Ok)));
for _ in 0..2 {
assert_eq!(
stream.next().now_or_never().unwrap().unwrap().unwrap(),
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT))
);
}
}
#[tokio::test(start_paused = true)]
async fn standalone_escape_and_partial_prefix_wake_at_fixed_deadline() {
for text in ["\u{1b}", "\u{1b}[13;"] {
let events = keys(text);
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let mut stream = WindowsKeySequence::new(UnboundedReceiverStream::new(rx));
tx.send(Ok(events[0].clone())).unwrap();
assert!(stream.next().now_or_never().is_none());
tokio::time::advance(Duration::from_millis(/*millis*/ 40)).await;
for event in &events[1..] {
tx.send(Ok(event.clone())).unwrap();
}
assert!(stream.next().now_or_never().is_none());
// next() must be woken by the original deadline, without another input event.
let start = tokio::time::Instant::now();
assert_eq!(stream.next().await.unwrap().unwrap(), events[0]);
assert_eq!(start.elapsed(), Duration::from_millis(/*millis*/ 10));
for event in &events[1..] {
assert_eq!(stream.next().await.unwrap().unwrap(), *event);
}
drop(tx);
assert!(stream.next().await.is_none());
}
}
#[tokio::test]
async fn literal_text_paste_native_keys_and_failed_prefixes_are_unchanged() {
let mut events = keys("literal [13;2u\u{1b}[13;3u\u{1b}[x");
events.extend([
Event::Paste("literal \u{1b}[13;2u".to_string()),
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)),
Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)),
Event::Key(KeyEvent::new_with_kind(
KeyCode::Char('x'),
KeyModifiers::NONE,
KeyEventKind::Repeat,
)),
Event::Key(KeyEvent::new_with_kind(
KeyCode::Esc,
KeyModifiers::NONE,
KeyEventKind::Release,
)),
]);
assert_eq!(decoded(events.clone()).await, events);
for interrupt in [
Event::Resize(80, 24),
Event::FocusLost,
Event::Paste("[13;2u".to_string()),
] {
let mut events = keys("\u{1b}[");
events.push(interrupt);
events.extend(keys("13;2u"));
assert_eq!(decoded(events.clone()).await, events);
}
}
#[tokio::test]
async fn mismatch_can_start_another_mapping_and_eof_replays_partial_input() {
let mut expected = keys("\u{1b}[1");
expected.push(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::SHIFT,
)));
expected.extend(keys("\u{1b}["));
assert_eq!(decoded(keys("\u{1b}[1\u{1b}[13;2u\u{1b}[")).await, expected);
}
#[tokio::test]
async fn errors_follow_pending_input_and_do_not_keep_partial_state() {
let mut events: Vec<_> = keys("\u{1b}[").into_iter().map(Ok).collect();
events.push(Err(std::io::Error::other("input failed")));
events.extend(keys("13;2u").into_iter().map(Ok));
let mut stream = WindowsKeySequence::new(tokio_stream::iter(events));
for event in keys("\u{1b}[") {
assert_eq!(stream.next().await.unwrap().unwrap(), event);
}
assert_eq!(
stream.next().await.unwrap().unwrap_err().to_string(),
"input failed"
);
assert_eq!(
stream.map(Result::unwrap).collect::<Vec<_>>().await,
keys("13;2u")
);
}
#[tokio::test]
async fn mapped_shift_enter_renders_newlines_in_the_composer() {
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
use crate::render::renderable::Renderable;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
AppEventSender::new(tx),
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ true,
);
for event in decoded(keys("first line\u{1b}[13;2u\u{1b}[13;2usecond line [13;2u")).await {
let Event::Key(key) = event else {
unreachable!()
};
assert!(matches!(
composer.handle_key_event(key).0,
InputResult::None
));
}
assert_eq!(composer.current_text(), "first line\n\nsecond line [13;2u");
let mut terminal = Terminal::new(TestBackend::new(/*width*/ 60, /*height*/ 9)).unwrap();
terminal
.draw(|frame| composer.render(frame.area(), frame.buffer_mut()))
.unwrap();
insta::assert_snapshot!("windows_mapped_shift_enter", terminal.backend());
}