Files
claude-cloak/src/keymap.rs
Jonas H 15b20a8af1 Put every app key behind one prefix
The keybindings had no red thread because there were three focus states
and every key had to ask "is this mine or the child's" — hence ctrl-q,
because plain q was forwarded. Replace the whole model: unprefixed keys
belong to the embedded claude, always, and everything cloak owns sits
behind ctrl-space (CT_PREFIX to change), tmux-style, in one table that
the which-key popup, the footer hint and the dispatch all read.

Esc is the only key with a rule of its own, and it is about reachability
rather than modes: it closes the topmost overlay, and with nothing open
it goes to the child, so interrupt and Esc-Esc rewind keep working. View
state — filters, the lane the feed shows — is a setting, not a mode, and
is deliberately not escapable.

That removes the focus model entirely, which lets the two feeds collapse
into one: the feed renders whichever lane feed_lane names, at full
width, and the stream picker chooses it. Subagents used to live in a
modal popup holding a second draw_feed with its own cache and scroll
model, rendering the same thing twice. The sessions panel loses its half
of the screen the same way. Both pickers become bottom strips with the
feed readable above them, so walking the list previews each row — which
is the view-without-resuming that /resume cannot do, and frees enter to
attach the pane.

Adds a find bar with in-place highlighting, scrolling to the matching
line rather than the containing entry, and drops the two keys that
served the old layout.
2026-09-10 14:16:34 +02:00

298 lines
11 KiB
Rust

//! 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<Self> {
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::<u8>().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);
}
}