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:
co-authored by
Claude Opus 5
parent
d8a5d95db4
commit
655b7b790e
@@ -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(
|
||||
§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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user