mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
committed by
copyberry
parent
b172810921
commit
447eac3b81
Generated
+1
-1
File diff suppressed because one or more lines are too long
Generated
+17
-18
@@ -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
@@ -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" }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
+14
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user