diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index d8ce619..2360c30 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -291,16 +291,26 @@ impl App { match self.chord.feed(&key, &known) { ChordResult::Pending => Ok(false), ChordResult::Resolved(canon) => { - if let Some(act) = self.cfg.keymap.resolve(self.state.mode, &canon) { - if matches!(act, Action::Quit) { - return Ok(true); + let act = self.cfg.keymap.resolve(self.state.mode, &canon); + if matches!(act, Some(Action::Quit)) { + return Ok(true); + } + let pty_owns_keys = + self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane); + match act { + // `Noop` suppresses a binding, and a key with no binding in + // Insert mode is the session's — so a key bound to `Noop` + // reaches the pty too. That is the escape hatch for taking + // a default binding back. + Some(Action::Noop) | None if pty_owns_keys => { + let effects = self.forward_to_session(&key).into_iter().collect(); + self.perform(effects).await; } - let effects = self.dispatch(act); - self.perform(effects).await; - } else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane) - { - let effects = self.forward_to_session(&key).into_iter().collect(); - self.perform(effects).await; + Some(act) => { + let effects = self.dispatch(act); + self.perform(effects).await; + } + None => {} } Ok(false) } @@ -2245,6 +2255,78 @@ mod tests { assert!(app.forward_to_session(&InputKey::Enter).is_none()); } + /// Drive one key event through an app in Insert mode with a session + /// attached, and report the bytes that reached that session. + async fn bytes_sent_for(section: &TuiSection, ev: CtEvent) -> Option> { + use crate::session_task::{SessionHandle, SessionState}; + use std::sync::atomic::{AtomicU64, AtomicU8}; + use std::sync::{Arc, Mutex}; + use tokio::sync::mpsc; + + let sid = Uuid::from_u128(41); + let mut app = App::new( + TuiConfig::load(section).unwrap(), + CatalogStore::default(), + DaemonClient::new("http://127.0.0.1:7070"), + DaemonLink::default(), + ); + let (tx, mut rx) = mpsc::channel(4); + app.emulators.insert( + sid, + SessionHandle { + tx, + screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))), + state: Arc::new(AtomicU8::new(SessionState::Live as u8)), + revision: Arc::new(AtomicU64::new(0)), + }, + ); + app.state.attach_to_focused(sid); + app.state.mode = Mode::Insert; + app.state.focus = Focus::Pane; + app.handle_event(ev).await.unwrap(); + match rx.try_recv() { + Ok(SessionInput::Bytes(bytes)) => Some(bytes), + _ => None, + } + } + + fn key_event(code: crossterm::event::KeyCode, mods: crossterm::event::KeyModifiers) -> CtEvent { + CtEvent::Key(crossterm::event::KeyEvent::new(code, mods)) + } + + /// Escape belongs to the session: full-screen apps (vim, Claude Code) read + /// it constantly, so no Insert-mode default may swallow it. The leader is + /// the way back to the sidebar. + #[tokio::test] + async fn escape_reaches_the_attached_session() { + let sent = bytes_sent_for( + &TuiSection::default(), + key_event( + crossterm::event::KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + ), + ) + .await; + assert_eq!(sent, Some(vec![0x1b])); + } + + /// `Noop` suppresses a binding, and in Insert mode a key with no binding is + /// the session's — so binding a key to `Noop` hands it to the pty. + #[tokio::test] + async fn a_noop_insert_binding_forwards_the_key_to_the_session() { + let mut section = TuiSection::default(); + section.keymap_insert.insert("ctrl-a".into(), "Noop".into()); + let sent = bytes_sent_for( + §ion, + key_event( + crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyModifiers::CONTROL, + ), + ) + .await; + assert_eq!(sent, Some(vec![0x01])); + } + #[test] fn pane_scrollback_moves_and_user_input_returns_to_live_output() { use crate::session_task::{SessionHandle, SessionState}; diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 77ebea9..fe9a4b8 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -75,10 +75,13 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("mouse:dblclick(sidebar-row)", "AttachHighlighted"), ]; -/// Default Insert-mode bindings. The leader (`LeaveInsert`) is inserted -/// dynamically by [`TuiConfig::load`] so rebinding it rebinds its Insert -/// escape automatically; `esc` is a second, fixed way back to the sidebar. -const DEFAULT_INSERT: &[(&str, &str)] = &[("esc", "LeaveInsert")]; +/// Default Insert-mode bindings — deliberately empty. Every key typed in +/// Insert mode belongs to the session, `esc` included: full-screen apps read +/// it constantly, so binding it here would make it unreachable. The only way +/// back to the sidebar is the leader (`LeaveInsert`), which +/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds +/// the Insert escape with it. +const DEFAULT_INSERT: &[(&str, &str)] = &[]; impl TuiConfig { /// Build a runtime config from a [`TuiSection`] (typically the `tui` @@ -232,7 +235,7 @@ mod tests { /// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal /// mode only exists while the sidebar is focused, so neither can leak - /// into a pty; `esc` is a second way out of Insert alongside the leader. + /// into a pty; `esc` stays unbound in Insert so it reaches the session. #[test] fn aoe_style_defaults_are_bound() { let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); @@ -241,10 +244,7 @@ mod tests { cfg.keymap.resolve(Mode::Normal, "tab"), Some(Action::AttachHighlighted), ); - assert_eq!( - cfg.keymap.resolve(Mode::Insert, "esc"), - Some(Action::LeaveInsert), - ); + assert_eq!(cfg.keymap.resolve(Mode::Insert, "esc"), None); } #[test]