//! The one binding table. //! //! Every app key lives here, once: the which-key popup renders this table, the //! footer hint summarises it, and `ui::run_act` dispatches it. A binding that //! is not in the table cannot be pressed, and one that is in it is documented //! for free — which is what stops the keymap drifting apart again. //! //! # The rule the whole model rests on //! //! **Unprefixed keys belong to the embedded `claude`. Always.** There is no //! focus model, no ctrl-↑/ctrl-↓ dance and no "is this key mine?" question: //! `q`, `j` and Esc reach Claude Code because nothing else can claim them. //! Everything cloak owns sits behind [`Prefix`], tmux-style. Two exceptions, //! both of which a real terminal also keeps for itself rather than forwarding: //! the **wheel** and **shift**+PgUp/PgDn. //! //! Esc is the one key with a rule of its own, and it is a rule about //! reachability, not about modes: *Esc closes the topmost overlay; with nothing //! open it goes to the child.* Claude Code uses Esc to interrupt and Esc-Esc to //! rewind, so eating it unconditionally would break both. View state (a filter //! set, the lane the feed shows) is deliberately **not** escapable — it is a //! setting, not a mode, and resetting it on a stray Esc would be a surprise //! rather than a rescue. use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; /// Something a key does. `Copy`, so dispatch can match on it after the table /// borrow ends. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Act { /// Session picker overlay (also the home of the turn tree and `b`ranching). Sessions, /// Stream picker overlay: the main chain, every subagent, every nested /// server-tool call. Picking one points the feed at that lane. Streams, /// Model picker → spawn a brand-new `claude --session-id …`. NewSession, /// Attach the pane to the most recent past session (`claude -c`). Continue, /// Entry-kind filter strip. Filter, /// Incremental search over the displayed lane. Search, /// Jump the feed to the next / previous user prompt. Repeatable: the menu /// stays open so `]]]` walks. NextPrompt, PrevPrompt, /// Pane fullscreen. Esc still reaches the child there — it is nothing *but* /// the pane, so nothing is covering it. ZoomPane, /// Hide the pane, feed takes the screen. This *does* cover the pane, so Esc /// leaves it. ZoomFeed, /// Back to the live main chain of the pane's session, tailing it. Undoes /// every kind of pinning at once — a picked session, a picked lane, and a /// scroll position parked by a search or a prompt jump. FollowLive, Reload, Quit, } impl Act { /// Whether the popup marks this entry as leading somewhere — an overlay /// that takes over input. Purely cosmetic (`▸`). pub fn opens(self) -> bool { matches!( self, Act::Sessions | Act::Streams | Act::NewSession | Act::Filter | Act::Search ) } /// Repeatable actions keep the menu up, so the key can be pressed again /// without re-pressing the prefix. Everything else closes it. pub fn sticky(self) -> bool { matches!(self, Act::NextPrompt | Act::PrevPrompt) } } #[derive(Debug)] pub struct Bind { pub key: char, pub label: &'static str, pub act: Act, } /// A menu level. There is one today (`ROOT`); the type exists because the /// popup renders *a* level and `EmbedUi::menu` holds the open one, not because /// nesting is planned. A submenu earns its place when a group of keys is both /// large and rarely used, and no group is either right now. #[derive(Debug)] pub struct Menu { pub title: &'static str, pub binds: &'static [Bind], } impl Menu { pub fn find(&self, c: char) -> Option<&Bind> { self.binds.iter().find(|b| b.key == c) } } /// The root menu, in reading order. The popup lays it out in columns. pub static ROOT: Menu = Menu { title: "", binds: &[ Bind { key: 's', label: "sessions", act: Act::Sessions }, Bind { key: 'a', label: "streams", act: Act::Streams }, Bind { key: 'n', label: "new", act: Act::NewSession }, Bind { key: 'c', label: "continue", act: Act::Continue }, Bind { key: 'f', label: "filter", act: Act::Filter }, Bind { key: '/', label: "search", act: Act::Search }, Bind { key: ']', label: "next prompt", act: Act::NextPrompt }, Bind { key: '[', label: "prev prompt", act: Act::PrevPrompt }, Bind { key: '.', label: "follow live", act: Act::FollowLive }, Bind { key: 'z', label: "zoom pane", act: Act::ZoomPane }, Bind { key: 'Z', label: "zoom feed", act: Act::ZoomFeed }, Bind { key: 'r', label: "reload", act: Act::Reload }, Bind { key: 'q', label: "quit", act: Act::Quit }, ], }; // --------------------------------------------------------------------------- // The prefix // --------------------------------------------------------------------------- /// The one key that opens the menu. Configurable because the *only* hard /// requirement is that the embedded Claude Code does not want it, and that is /// a property of the child's version, not of ours — so it must be changeable /// without a rebuild. `CT_PREFIX=ctrl-b`, `CT_PREFIX=ctrl-]`, `CT_PREFIX=f1`. /// /// Default `ctrl-space`. Terminals disagree about what ctrl-space *is* on the /// wire (NUL, ctrl-`@`, or a real ctrl-modified space), so that one spelling /// matches all three — see `matches`. `CT_DEBUG_KEYS=1` shows what actually /// arrives when a terminal delivers none of them. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Prefix { code: KeyCode, mods: KeyModifiers, /// True for the ctrl-space default, which needs the three-way match. ctrl_space: bool, pub label: String, } impl Default for Prefix { fn default() -> Self { Self::parse("ctrl-space").expect("the default prefix parses") } } impl Prefix { /// Read `CT_PREFIX`, falling back to the default on an unset or /// unparseable value (a typo must not leave the app with no menu key). pub fn from_env() -> Self { std::env::var("CT_PREFIX") .ok() .and_then(|s| Self::parse(&s)) .unwrap_or_default() } pub fn parse(spec: &str) -> Option { let spec = spec.trim(); let mut mods = KeyModifiers::NONE; let mut rest = spec; loop { let lower = rest.to_ascii_lowercase(); let (m, tail) = if let Some(t) = lower.strip_prefix("ctrl-") { (KeyModifiers::CONTROL, t.len()) } else if let Some(t) = lower.strip_prefix("shift-") { (KeyModifiers::SHIFT, t.len()) } else if let Some(t) = lower.strip_prefix("alt-") { (KeyModifiers::ALT, t.len()) } else { break; }; mods |= m; rest = &rest[rest.len() - tail..]; } let low = rest.to_ascii_lowercase(); let code = match low.as_str() { "space" => KeyCode::Char(' '), "tab" => KeyCode::Tab, "esc" => KeyCode::Esc, f if f.starts_with('f') && f[1..].parse::().is_ok() => { KeyCode::F(f[1..].parse().ok()?) } _ => { let mut it = rest.chars(); let c = it.next()?; if it.next().is_some() { return None; } KeyCode::Char(c.to_ascii_lowercase()) } }; let ctrl_space = code == KeyCode::Char(' ') && mods.contains(KeyModifiers::CONTROL); Some(Self { code, mods, ctrl_space, label: pretty(mods, code), }) } /// Does this event open the menu? /// /// ctrl-space is three events depending on the terminal: `Char(' ')` with /// CONTROL, `Char('@')` with CONTROL (the NUL byte decoded as its caret /// spelling), and a bare `Null`. All three mean the same keypress, so all /// three count. pub fn matches(&self, k: &KeyEvent) -> bool { if self.ctrl_space { let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); return k.code == KeyCode::Null || (ctrl && matches!(k.code, KeyCode::Char(' ') | KeyCode::Char('@'))); } // Compare only the modifiers the spec named: terminals add SHIFT of // their own accord for capitals and for some ctrl combinations. let want = self.mods & (KeyModifiers::CONTROL | KeyModifiers::ALT); let got = k.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT); let code = match k.code { KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()), other => other, }; code == self.code && got == want } } fn pretty(mods: KeyModifiers, code: KeyCode) -> String { let mut s = String::new(); if mods.contains(KeyModifiers::CONTROL) { s.push('^'); } if mods.contains(KeyModifiers::ALT) { s.push_str("alt-"); } match code { KeyCode::Char(' ') => s.push_str("space"), KeyCode::Char(c) => s.push(c), KeyCode::Tab => s.push_str("tab"), KeyCode::Esc => s.push_str("esc"), KeyCode::F(n) => s.push_str(&format!("F{n}")), other => s.push_str(&format!("{other:?}")), } s } #[cfg(test)] mod tests { use super::*; use ratatui::crossterm::event::KeyEventKind; fn ev(code: KeyCode, mods: KeyModifiers) -> KeyEvent { KeyEvent { code, modifiers: mods, kind: KeyEventKind::Press, state: ratatui::crossterm::event::KeyEventState::NONE, } } /// The default has to survive all three ways a terminal spells ctrl-space, /// because we cannot know which one the user's terminal picks. #[test] fn ctrl_space_matches_every_spelling_terminals_use() { let p = Prefix::default(); assert!(p.matches(&ev(KeyCode::Char(' '), KeyModifiers::CONTROL))); assert!(p.matches(&ev(KeyCode::Char('@'), KeyModifiers::CONTROL))); assert!(p.matches(&ev(KeyCode::Null, KeyModifiers::NONE))); assert!(!p.matches(&ev(KeyCode::Char(' '), KeyModifiers::NONE)), "plain space is the child's"); assert_eq!(p.label, "^space"); } #[test] fn prefix_specs_parse_and_reject() { let p = Prefix::parse("ctrl-b").unwrap(); assert!(p.matches(&ev(KeyCode::Char('b'), KeyModifiers::CONTROL))); assert!(!p.matches(&ev(KeyCode::Char('b'), KeyModifiers::NONE))); assert_eq!(p.label, "^b"); assert!(Prefix::parse("f1").unwrap().matches(&ev(KeyCode::F(1), KeyModifiers::NONE))); assert!(Prefix::parse("ctrl-]").unwrap().matches(&ev(KeyCode::Char(']'), KeyModifiers::CONTROL))); assert!(Prefix::parse("").is_none()); assert!(Prefix::parse("ctrl-nope").is_none()); } /// Every key in the table is unique per menu, or one of them is dead. #[test] fn no_menu_binds_a_key_twice() { fn check(m: &Menu) { let mut seen = Vec::new(); for b in m.binds { assert!(!seen.contains(&b.key), "{} binds {:?} twice", m.title, b.key); seen.push(b.key); } } check(&ROOT); } }