diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 7134a9d..25e8e70 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -56,6 +56,10 @@ pub enum Action { /// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line /// over the sessions, ranked by the shared matcher. OpenCommandBar, + /// Escape hatch (#156): leader then `ctrl-k` sends the literal `0x0B` + /// byte to the focused pane instead of opening the command bar, giving + /// readline's kill-to-end-of-line back. + SendLiteralCtrlK, ToggleSidebar, ShowHelp, Quit, @@ -98,6 +102,7 @@ impl Action { Action::KillHighlighted => "Kill selected session".into(), Action::RenameHighlighted => "Rename selected session".into(), Action::OpenCommandBar => "Go to a session".into(), + Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(), Action::ToggleSidebar => "Show or hide sidebar".into(), Action::ShowHelp => "Show help".into(), Action::Quit => "Quit Maestro".into(), @@ -125,6 +130,7 @@ impl Action { "KillHighlighted" => return Ok(Action::KillHighlighted), "RenameHighlighted" => return Ok(Action::RenameHighlighted), "OpenCommandBar" => return Ok(Action::OpenCommandBar), + "SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK), "ToggleSidebar" => return Ok(Action::ToggleSidebar), "ShowHelp" => return Ok(Action::ShowHelp), "Quit" => return Ok(Action::Quit), @@ -256,11 +262,26 @@ mod tests { assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session"); } + /// The escape hatch (#156) is an ordinary action like any other. + #[test] + fn send_literal_ctrl_k_round_trips() { + assert_eq!( + Action::parse("SendLiteralCtrlK").unwrap(), + Action::SendLiteralCtrlK + ); + assert_eq!(Action::SendLiteralCtrlK.to_string(), "SendLiteralCtrlK"); + assert_eq!( + Action::SendLiteralCtrlK.help_label(), + "Send literal Ctrl-K to session" + ); + } + #[test] fn display_round_trips() { for a in [ Action::EnterInsert, Action::OpenCommandBar, + Action::SendLiteralCtrlK, Action::RenameHighlighted, Action::Quit, Action::OpenSpawnModal, diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index d864582..f283bd3 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -306,6 +306,7 @@ impl App { return Ok(true); } } + self.chord.take_replay(); return Ok(false); } @@ -325,32 +326,46 @@ impl App { return Ok(false); } - let known = self.cfg.keymap.known_bindings(self.state.mode); - match self.chord.feed(&key, &known) { - ChordResult::Pending => Ok(false), - ChordResult::Resolved(canon) => { - let act = self.cfg.keymap.resolve(self.state.mode, &canon); - if matches!(act, Some(Action::Quit)) { - return Ok(true); + // A key bumped out of an abandoned chord (#156) is replayed as its + // own keystroke, against the mode dispatch may have just changed. + let mut current_key = key; + let mut result = self.chord.feed( + ¤t_key, + &self.cfg.keymap.known_bindings(self.state.mode), + ); + loop { + let canon = match result { + ChordResult::Pending => return Ok(false), + ChordResult::Resolved(canon) => canon, + }; + 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(¤t_key).into_iter().collect(); + self.perform(effects).await; } - 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; - } - Some(act) => { - let effects = self.dispatch(act); - self.perform(effects).await; - } - None => {} + Some(act) => { + let effects = self.dispatch(act); + self.perform(effects).await; } - Ok(false) + None => {} + } + match self.chord.take_replay() { + Some(replay_key) => { + let known = self.cfg.keymap.known_bindings(self.state.mode); + result = self.chord.feed(&replay_key, &known); + current_key = replay_key; + } + None => return Ok(false), } } } @@ -546,6 +561,10 @@ impl App { self.state.show_overlay(OverlayKind::Help); } Action::OpenCommandBar => effects.extend(self.open_command_bar()), + // Escape hatch (#156): the literal byte, not the bar. + Action::SendLiteralCtrlK => { + effects.extend(self.forward_to_session(&InputKey::ctrl_char('k'))); + } Action::OpenSpawnModal => { self.open_spawn_modal(); effects.push(Effect::LoadDraft); @@ -2912,6 +2931,55 @@ mod tests { CtEvent::Key(crossterm::event::KeyEvent::new(code, mods)) } + fn ctrl_key_event(c: char) -> CtEvent { + key_event( + crossterm::event::KeyCode::Char(c), + crossterm::event::KeyModifiers::CONTROL, + ) + } + + /// Drive a sequence of key events through an app in Insert mode with a + /// session attached, and report the app afterwards plus every chunk of + /// bytes that reached that session, in order. + async fn drive_insert_sequence( + section: &TuiSection, + events: &[CtEvent], + ) -> (App, Vec>) { + 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(8); + 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; + for ev in events { + app.handle_event(ev.clone()).await.unwrap(); + } + let mut sent = Vec::new(); + while let Ok(SessionInput::Bytes(bytes)) = rx.try_recv() { + sent.push(bytes); + } + (app, sent) + } + /// 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. @@ -2930,19 +2998,74 @@ mod tests { /// `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. + /// + /// Uses `ctrl-x`, not the leader: the leader is also a chord prefix + /// (#156), so a single fed keystroke on it resolves as `Pending`, not + /// `Resolved` — a different mechanism, covered separately below. #[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()); + section.keymap_insert.insert("ctrl-x".into(), "Noop".into()); let sent = bytes_sent_for( §ion, key_event( - crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyCode::Char('x'), crossterm::event::KeyModifiers::CONTROL, ), ) .await; - assert_eq!(sent, Some(vec![0x01])); + assert_eq!(sent, Some(vec![0x18])); + } + + /// Leader then `ctrl-k` sends the literal byte and never opens the bar + /// (#156) — the default leader, `ctrl-a`. + #[tokio::test] + async fn leader_then_ctrl_k_sends_the_literal_byte_and_does_not_open_the_bar() { + let (app, sent) = drive_insert_sequence( + &TuiSection::default(), + &[ctrl_key_event('a'), ctrl_key_event('k')], + ) + .await; + assert_eq!(sent, vec![vec![0x0b]]); + assert_eq!(app.state.overlay(), None); + assert_eq!(app.state.mode, Mode::Insert, "still in Insert, not left"); + } + + /// The escape hatch is built from the configured leader, not a hardcoded + /// `ctrl-a` — rebinding `[tui].leader` rebinds it too (#156). + #[tokio::test] + async fn a_rebound_leader_still_reaches_the_literal_ctrl_k_escape() { + let section = TuiSection { + leader: Some("ctrl-b".into()), + ..Default::default() + }; + let (app, sent) = + drive_insert_sequence(§ion, &[ctrl_key_event('b'), ctrl_key_event('k')]).await; + assert_eq!(sent, vec![vec![0x0b]]); + assert_eq!(app.state.overlay(), None); + } + + /// Leader alone, followed by an ordinary key, still leaves Insert mode — + /// the escape hatch chord doesn't cost the leader its existing meaning + /// (#156). + #[tokio::test] + async fn leader_alone_still_leaves_insert_when_followed_by_another_key() { + let (app, sent) = drive_insert_sequence( + &TuiSection::default(), + &[ + ctrl_key_event('a'), + key_event( + crossterm::event::KeyCode::Char('j'), + crossterm::event::KeyModifiers::NONE, + ), + ], + ) + .await; + assert!( + sent.is_empty(), + "no bytes for the leader chord itself: {sent:?}" + ); + assert_eq!(app.state.mode, Mode::Normal, "leader still leaves Insert"); } #[test] diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 507024f..3362c23 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -146,6 +146,15 @@ impl TuiConfig { keymap .set(Mode::Insert, &leader.canonical(), "LeaveInsert") .expect("leader canonical form is valid by construction"); + // Leader, ctrl-k → the literal-Ctrl-K escape hatch (#156), built from + // the same leader spec so rebinding leader rebinds this chord too. + keymap + .set( + Mode::Insert, + &format!("{} ctrl-k", leader.canonical()), + "SendLiteralCtrlK", + ) + .expect("leader canonical form is valid by construction"); for (input, action) in §ion.keymap_normal { keymap.set(Mode::Normal, input, action).map_err(|source| { @@ -278,6 +287,33 @@ mod tests { assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None); } + /// The literal-Ctrl-K escape hatch (#156) is chorded off the default + /// leader, `ctrl-a ctrl-k` — not the command bar. + #[test] + fn default_leader_escapes_a_literal_ctrl_k() { + let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); + assert_eq!( + cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), + Some(Action::SendLiteralCtrlK), + ); + } + + /// Rebinding the leader must rebind the literal-Ctrl-K chord with it, not + /// just the Insert escape — proving the chord is built from the + /// configured leader rather than a hardcoded `ctrl-a`. + #[test] + fn rebinding_leader_moves_the_literal_ctrl_k_chord() { + let mut section = TuiSection::default(); + section.leader = Some("ctrl-b".into()); + let cfg = TuiConfig::load(§ion).unwrap(); + assert_eq!( + cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-k"), + Some(Action::SendLiteralCtrlK), + ); + // The old leader's chord is gone along with its Insert escape. + assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None); + } + #[test] fn user_normal_override_replaces_default() { let mut section = TuiSection::default(); diff --git a/crates/maestro-tui/src/input.rs b/crates/maestro-tui/src/input.rs index 760ffdc..a167101 100644 --- a/crates/maestro-tui/src/input.rs +++ b/crates/maestro-tui/src/input.rs @@ -387,6 +387,9 @@ pub struct ChordState { held: Vec, last_press: Option, timeout: Duration, + /// A key bumped out of an abandoned chord (see [`Self::feed`]), waiting + /// to be replayed as a fresh keystroke by the caller. + replay: Option, } impl ChordState { @@ -395,6 +398,7 @@ impl ChordState { held: Vec::new(), last_press: None, timeout, + replay: None, } } @@ -404,6 +408,13 @@ impl ChordState { /// binding; wait for the next key. /// * `Resolved(s)` — `s` is the canonical chord (or single key) to look /// up in the keymap. The held buffer is cleared. + /// + /// A held prefix that is itself directly bound (e.g. a leader key bound + /// alone as well as chorded, like `ctrl-a` and `ctrl-a ctrl-k`) resolves + /// to that binding the moment the next key fails to extend it, rather + /// than swallowing the held key and losing its meaning — the new key + /// never belonged to the chord and is handed back via [`Self::take_replay`] + /// so the caller can process it as its own keystroke. pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet) -> ChordResult { if let Some(t) = self.last_press { if t.elapsed() > self.timeout { @@ -428,11 +439,31 @@ impl ChordState { if is_prefix { self.held.push(key_s); - ChordResult::Pending - } else { - self.held.clear(); - ChordResult::Resolved(candidate) + return ChordResult::Pending; } + + if known_bindings.contains(&candidate) { + self.held.clear(); + return ChordResult::Resolved(candidate); + } + + if !self.held.is_empty() { + let held_str = self.held.join(" "); + self.held.clear(); + if known_bindings.contains(&held_str) { + self.replay = Some(key.clone()); + return ChordResult::Resolved(held_str); + } + } + + ChordResult::Resolved(candidate) + } + + /// Take the key bumped out of an abandoned chord by the last [`Self::feed`] + /// call, if any. The caller feeds it through again as a brand new + /// keystroke. + pub fn take_replay(&mut self) -> Option { + self.replay.take() } } @@ -733,6 +764,41 @@ mod tests { assert_eq!(r, ChordResult::Resolved("g x".into())); } + /// A key bound both alone and as a chord prefix (the leader: `ctrl-a` and + /// `ctrl-a ctrl-k`) must still fire its own binding when the next key + /// doesn't complete the chord — the held key's meaning isn't lost, and + /// the new key comes back via `take_replay` for the caller to process on + /// its own (#156). + #[test] + fn a_bound_prefix_resolves_itself_and_replays_the_breaking_key() { + let mut chord = ChordState::new(Duration::from_secs(10)); + let known: HashSet = ["ctrl-a".into(), "ctrl-a ctrl-k".into()] + .into_iter() + .collect(); + + let r = chord.feed(&InputKey::ctrl_char('a'), &known); + assert_eq!(r, ChordResult::Pending); + + let r = chord.feed(&InputKey::char('j'), &known); + assert_eq!(r, ChordResult::Resolved("ctrl-a".into())); + assert_eq!(chord.take_replay(), Some(InputKey::char('j'))); + } + + /// The full chord still resolves normally when the next key completes + /// it — no replay, no early fire of the shorter binding. + #[test] + fn a_completed_chord_off_a_bound_prefix_has_no_replay() { + let mut chord = ChordState::new(Duration::from_secs(10)); + let known: HashSet = ["ctrl-a".into(), "ctrl-a ctrl-k".into()] + .into_iter() + .collect(); + + chord.feed(&InputKey::ctrl_char('a'), &known); + let r = chord.feed(&InputKey::ctrl_char('k'), &known); + assert_eq!(r, ChordResult::Resolved("ctrl-a ctrl-k".into())); + assert_eq!(chord.take_replay(), None); + } + #[test] fn chord_state_lone_key_resolves_immediately() { let mut chord = ChordState::new(Duration::from_secs(10));