fix(maestro): let Escape reach the session in the TUI

Insert mode bound `esc` to LeaveInsert, so full-screen apps in a session
(vim, Claude Code) never saw Escape — the only way out of Insert is now
the leader, which rebinding still follows.

A key bound to `Noop` in Insert mode is also forwarded to the pty now.
`Noop` documents itself as the way to suppress a default binding, and in
Insert mode an unbound key belongs to the session, so this is the escape
hatch for taking any other default back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-09-03 11:32:46 +01:00
co-authored by Claude Opus 5
parent d8a5d95db4
commit 655b7b790e
2 changed files with 100 additions and 18 deletions
+91 -9
View File
@@ -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<Vec<u8>> {
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(
&section,
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};
+9 -9
View File
@@ -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]