Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ede15d2b | ||
|
|
331039df11 | ||
|
|
151438094e | ||
|
|
c1c283c0f8 | ||
|
|
1b260b5c20 | ||
|
|
f8b434dd1d |
@@ -56,6 +56,14 @@ 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,
|
||||
/// Escape hatch (#158): the leader pressed twice sends one literal
|
||||
/// leader byte to the focused pane and drops the pending chord, the way
|
||||
/// tmux resolves a doubled prefix key.
|
||||
SendLiteralLeader,
|
||||
ToggleSidebar,
|
||||
ShowHelp,
|
||||
Quit,
|
||||
@@ -98,6 +106,8 @@ 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::SendLiteralLeader => "Send literal leader key to session".into(),
|
||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||
Action::ShowHelp => "Show help".into(),
|
||||
Action::Quit => "Quit Maestro".into(),
|
||||
@@ -125,6 +135,8 @@ impl Action {
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
|
||||
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
|
||||
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
|
||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||
"Quit" => return Ok(Action::Quit),
|
||||
@@ -256,11 +268,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,
|
||||
|
||||
+347
-42
@@ -23,7 +23,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::catalog::CatalogStore;
|
||||
use crate::command_bar::{BarOutcome, BarTarget, CommandBar};
|
||||
use crate::command_bar::{BarOutcome, BarTarget, CommandBar, SessionActionId};
|
||||
use crate::config::TuiConfig;
|
||||
use crate::daemon_link::DaemonLink;
|
||||
use crate::draft::{DraftInputResult, DraftPane};
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,6 +494,25 @@ impl App {
|
||||
self.close_command_bar();
|
||||
(OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)])
|
||||
}
|
||||
// The action page raises the flow this client already has
|
||||
// — for kill that is `KillConfirm`, which the TUI puts in
|
||||
// front of every kill (#160). The bar names the target;
|
||||
// it never reimplements the flow.
|
||||
BarOutcome::Open(BarTarget::SessionAction { session, action }) => {
|
||||
self.close_command_bar();
|
||||
let effects = match action {
|
||||
SessionActionId::Kill => {
|
||||
self.confirm_kill(session);
|
||||
Vec::new()
|
||||
}
|
||||
SessionActionId::Rename => {
|
||||
self.prompt_rename(session);
|
||||
Vec::new()
|
||||
}
|
||||
SessionActionId::SpawnSimilar => self.open_spawn_similar(session),
|
||||
};
|
||||
(OverlayDecision::Consumed, effects)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(OverlayKind::Help) => {
|
||||
@@ -527,6 +561,16 @@ 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')));
|
||||
}
|
||||
// Escape hatch (#158): the leader's own byte, built from the
|
||||
// configured leader rather than a hardcoded key.
|
||||
Action::SendLiteralLeader => {
|
||||
let leader = self.cfg.leader.clone();
|
||||
effects.extend(self.forward_to_session(&leader));
|
||||
}
|
||||
Action::OpenSpawnModal => {
|
||||
self.open_spawn_modal();
|
||||
effects.push(Effect::LoadDraft);
|
||||
@@ -555,24 +599,12 @@ impl App {
|
||||
}
|
||||
Action::KillHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||
self.confirm_kill(sid);
|
||||
}
|
||||
}
|
||||
Action::RenameHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
// Pre-fill with the custom name only: a session that has
|
||||
// none starts empty rather than with its command already
|
||||
// typed in, which Enter would then pin as the name.
|
||||
self.rename_input = TextInput::with_value(
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| s.id == sid)
|
||||
.and_then(crate::sidebar::custom_name_for)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
self.state.show_overlay(OverlayKind::Rename(sid));
|
||||
self.prompt_rename(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -858,6 +890,30 @@ impl App {
|
||||
Some(None)
|
||||
}
|
||||
|
||||
/// Raise the kill confirmation for one session (`docs/WORKTREES.md`).
|
||||
/// Shared by the keymap's own kill and the command bar's action page
|
||||
/// (#155), which picks the action and hands off rather than reimplementing
|
||||
/// it; the blockers the dialog names are read at render time.
|
||||
fn confirm_kill(&mut self, sid: Uuid) {
|
||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||
}
|
||||
|
||||
/// Raise the rename prompt for one session, pre-filled with its custom
|
||||
/// name only: a session that has none starts empty rather than with its
|
||||
/// command already typed in, which Enter would then pin as the name.
|
||||
fn prompt_rename(&mut self, sid: Uuid) {
|
||||
self.rename_input = TextInput::with_value(
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| s.id == sid)
|
||||
.and_then(crate::sidebar::custom_name_for)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
self.state.show_overlay(OverlayKind::Rename(sid));
|
||||
}
|
||||
|
||||
/// Put the spawn form up with a fresh draft behind it, preselecting the
|
||||
/// project of the session the operator was last on — so `n` then Enter
|
||||
/// spawns another session there, the way "spawn similar" would. A
|
||||
@@ -871,6 +927,30 @@ impl App {
|
||||
self.state.open_spawn_modal();
|
||||
}
|
||||
|
||||
/// "Spawn similar" (#155): the spawn form seeded from one session's
|
||||
/// directory and command, rather than from whatever the operator was last
|
||||
/// looking at. Same base-repository rule as the plain seed, and the name
|
||||
/// is left out on purpose — a clone is a new session and takes its own
|
||||
/// generated handle, like `web/src/spawnSimilar.ts`.
|
||||
fn open_spawn_similar(&mut self, sid: Uuid) -> Vec<Effect> {
|
||||
let session = self
|
||||
.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| s.id == sid)
|
||||
.cloned();
|
||||
self.open_spawn_modal();
|
||||
if let (Some(session), Some(draft)) = (session, self.draft.as_mut()) {
|
||||
draft.seed_cwd = Some(match &session.worktree {
|
||||
Some(wt) => wt.base_repo.clone(),
|
||||
None => session.cwd.clone(),
|
||||
});
|
||||
draft.seed_command = (!session.command.is_empty()).then(|| session.command.clone());
|
||||
}
|
||||
vec![Effect::LoadDraft]
|
||||
}
|
||||
|
||||
/// The project of the session the operator was last on — its base
|
||||
/// repository for a worktree session, its cwd otherwise. Shared by the
|
||||
/// plain spawn modal seed and fast-forward's project pick (#67 review):
|
||||
@@ -2857,6 +2937,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<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(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.
|
||||
@@ -2875,19 +3004,120 @@ 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");
|
||||
}
|
||||
|
||||
/// Leader pressed twice sends one literal leader byte, tmux-style, and
|
||||
/// clears the pending chord rather than leaving Insert (#158) — the
|
||||
/// default leader, `ctrl-a`.
|
||||
#[tokio::test]
|
||||
async fn leader_twice_sends_the_literal_leader_byte_and_stays_in_insert() {
|
||||
let (app, sent) = drive_insert_sequence(
|
||||
&TuiSection::default(),
|
||||
&[ctrl_key_event('a'), ctrl_key_event('a')],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sent, vec![vec![0x01]]);
|
||||
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 (#158).
|
||||
#[tokio::test]
|
||||
async fn a_rebound_leader_still_reaches_the_literal_leader_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('b')]).await;
|
||||
assert_eq!(sent, vec![vec![0x02]]);
|
||||
assert_eq!(app.state.mode, Mode::Insert);
|
||||
}
|
||||
|
||||
/// After the literal-leader escape fires, the chord is gone — a further
|
||||
/// leader press starts a fresh chord rather than resolving as a
|
||||
/// three-deep sequence (#158).
|
||||
#[tokio::test]
|
||||
async fn leader_twice_then_ctrl_k_is_a_fresh_chord() {
|
||||
let (_app, sent) = drive_insert_sequence(
|
||||
&TuiSection::default(),
|
||||
&[
|
||||
ctrl_key_event('a'),
|
||||
ctrl_key_event('a'),
|
||||
ctrl_key_event('a'),
|
||||
ctrl_key_event('k'),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sent, vec![vec![0x01], vec![0x0b]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3379,4 +3609,79 @@ mod tests {
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- the session action page (#155) ----
|
||||
|
||||
/// Drill into the second row's session, then pick an action by name.
|
||||
fn drill_and_pick(app: &mut App, action: &str) -> (OverlayDecision, Vec<Effect>) {
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
app.handle_overlay_input(&InputKey::Down);
|
||||
app.handle_overlay_input(&InputKey::Tab);
|
||||
type_into_bar(app, action);
|
||||
app.handle_overlay_input(&InputKey::Enter)
|
||||
}
|
||||
|
||||
/// The bar picks the action and hands off: kill goes through the confirm
|
||||
/// this client puts in front of every kill, not straight to the daemon.
|
||||
#[test]
|
||||
fn the_action_page_raises_the_existing_kill_confirm() {
|
||||
let mut app = make_app();
|
||||
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
||||
let (decision, effects) = drill_and_pick(&mut app, "kill");
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
assert!(effects.is_empty(), "the confirm is the whole effect");
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::KillConfirm(alpha)));
|
||||
assert!(app.command_bar.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_action_page_raises_the_existing_rename_prompt_prefilled() {
|
||||
let mut app = make_app();
|
||||
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
||||
let (decision, effects) = drill_and_pick(&mut app, "rename");
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
assert!(effects.is_empty());
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::Rename(alpha)));
|
||||
assert_eq!(app.rename_input.value(), "alpha");
|
||||
assert!(app.command_bar.is_none());
|
||||
}
|
||||
|
||||
/// Spawn similar opens the spawn form seeded from that session's directory
|
||||
/// and command — not from whatever the operator was last looking at.
|
||||
#[test]
|
||||
fn the_action_page_raises_the_spawn_form_seeded_from_the_session() {
|
||||
let mut app = make_app();
|
||||
seed_two_sessions(&mut app);
|
||||
let (decision, effects) = drill_and_pick(&mut app, "spawn");
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
assert_eq!(effects, vec![Effect::LoadDraft]);
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert_eq!(draft.seed_cwd, Some(std::path::PathBuf::from("/")));
|
||||
assert_eq!(draft.seed_command.as_deref(), Some("bash"));
|
||||
assert!(app.command_bar.is_none());
|
||||
}
|
||||
|
||||
/// Backing out is free: the result-list query survives the round trip.
|
||||
#[test]
|
||||
fn escape_on_the_action_page_returns_to_the_result_list() {
|
||||
let mut app = make_app();
|
||||
seed_two_sessions(&mut app);
|
||||
app.dispatch(Action::OpenCommandBar);
|
||||
type_into_bar(&mut app, "alp");
|
||||
app.handle_overlay_input(&InputKey::Tab);
|
||||
assert!(app.command_bar.as_ref().unwrap().on_action_page());
|
||||
|
||||
let (decision, _) = app.handle_overlay_input(&InputKey::Esc);
|
||||
assert_eq!(decision, OverlayDecision::Consumed);
|
||||
let bar = app.command_bar.as_ref().expect("escape backs out, not out");
|
||||
assert!(!bar.on_action_page());
|
||||
assert_eq!(bar.query(), "alp");
|
||||
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
|
||||
|
||||
// A second escape is the way out again.
|
||||
app.handle_overlay_input(&InputKey::Esc);
|
||||
assert!(app.command_bar.is_none());
|
||||
assert!(app.state.overlay().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//! result list, opened from Normal or Insert mode, closed with Escape.
|
||||
//!
|
||||
//! Three kinds live here: sessions and commands, ranked together by default,
|
||||
//! and directories, which need the `~` sigil (#154). The session action page
|
||||
//! (#155) and the leader's literal Ctrl-K (#156) land later.
|
||||
//! and directories, which need the `~` sigil (#154). `tab` on a session drills
|
||||
//! into its action page (#155), the same overlay over three actions. The
|
||||
//! leader's literal Ctrl-K (#156) lands later.
|
||||
//!
|
||||
//! Ranking is [`crate::matcher`]'s job and nothing here reorders it — the
|
||||
//! groups are a rendering of the ranked list, in the order their best-scoring
|
||||
@@ -46,8 +47,42 @@ pub enum BarTarget {
|
||||
/// Fast-forward spawn in this directory, absolute — the abbreviated form
|
||||
/// is what the row shows and what the matcher ranked.
|
||||
Directory(PathBuf),
|
||||
/// A row of the action page: run this action against this session (#155).
|
||||
SessionAction {
|
||||
session: Uuid,
|
||||
action: SessionActionId,
|
||||
},
|
||||
}
|
||||
|
||||
/// The actions a session's action page offers, and no others: the bar picks
|
||||
/// one and hands off to the flow that already implements it
|
||||
/// (`docs/COMMAND-BAR.md`, "Drill-in"). Mirrors the web's `SessionActionId`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionActionId {
|
||||
Kill,
|
||||
Rename,
|
||||
SpawnSimilar,
|
||||
}
|
||||
|
||||
impl SessionActionId {
|
||||
/// The row's text, which is also the only field the matcher sees.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Kill => "Kill session",
|
||||
Self::Rename => "Rename session",
|
||||
Self::SpawnSimilar => "Spawn similar",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's actions in the order an empty query lists them, since the
|
||||
/// matcher keeps input order.
|
||||
pub const SESSION_ACTIONS: [SessionActionId; 3] = [
|
||||
SessionActionId::Kill,
|
||||
SessionActionId::Rename,
|
||||
SessionActionId::SpawnSimilar,
|
||||
];
|
||||
|
||||
/// The first character of the query, when it is one of the two that narrow the
|
||||
/// list to a single kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -255,10 +290,23 @@ pub struct CommandBar {
|
||||
sessions: Vec<BarEntry>,
|
||||
commands: Vec<BarEntry>,
|
||||
directories: Vec<BarEntry>,
|
||||
/// The action page while it is up (#155), and the result-list query it was
|
||||
/// drilled into from — backing out restores that query, so the round trip
|
||||
/// costs the operator nothing.
|
||||
page: Option<ActionPage>,
|
||||
stashed: String,
|
||||
rows: Vec<BarRow>,
|
||||
highlight: usize,
|
||||
}
|
||||
|
||||
/// The session action page while it is up: the name the overlay titles itself
|
||||
/// with, and the three action rows the query line runs over.
|
||||
#[derive(Debug)]
|
||||
struct ActionPage {
|
||||
name: String,
|
||||
actions: Vec<BarEntry>,
|
||||
}
|
||||
|
||||
impl CommandBar {
|
||||
/// Open the bar over the sessions the catalog has right now and the live
|
||||
/// keymap for the current mode. Directories arrive later
|
||||
@@ -293,6 +341,8 @@ impl CommandBar {
|
||||
sessions,
|
||||
commands,
|
||||
directories: Vec::new(),
|
||||
page: None,
|
||||
stashed: String::new(),
|
||||
rows: Vec::new(),
|
||||
highlight: 0,
|
||||
};
|
||||
@@ -381,7 +431,15 @@ impl CommandBar {
|
||||
/// "Keys") does not claim is ordinary editing.
|
||||
pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome {
|
||||
match key {
|
||||
InputKey::Esc => BarOutcome::Close,
|
||||
// On the action page Escape is one level up, not the way out.
|
||||
InputKey::Esc => {
|
||||
if self.page.is_some() {
|
||||
self.leave_page();
|
||||
BarOutcome::Stay
|
||||
} else {
|
||||
BarOutcome::Close
|
||||
}
|
||||
}
|
||||
InputKey::Enter => match self.selected() {
|
||||
Some(row) => BarOutcome::Open(row.target.clone()),
|
||||
// Nothing to open: the bar stays up rather than closing on a
|
||||
@@ -402,9 +460,19 @@ impl CommandBar {
|
||||
self.move_highlight(1);
|
||||
BarOutcome::Stay
|
||||
}
|
||||
// Tab drills into the highlighted session's action page (#155).
|
||||
// Until then it is inert rather than a literal tab in the query.
|
||||
InputKey::Tab | InputKey::BackTab => BarOutcome::Stay,
|
||||
// Tab drills into the highlighted session's action page (#155);
|
||||
// shift-tab is inert rather than a literal tab in the query.
|
||||
InputKey::Tab => {
|
||||
self.drill_in();
|
||||
BarOutcome::Stay
|
||||
}
|
||||
InputKey::BackTab => BarOutcome::Stay,
|
||||
// Ordinary editing, except at an empty query on the action page,
|
||||
// where it is the second way back to the result list.
|
||||
InputKey::Backspace if self.page.is_some() && self.input.value().is_empty() => {
|
||||
self.leave_page();
|
||||
BarOutcome::Stay
|
||||
}
|
||||
other => {
|
||||
let before = self.input.value().to_string();
|
||||
self.input.handle_key(other);
|
||||
@@ -416,15 +484,75 @@ impl CommandBar {
|
||||
}
|
||||
}
|
||||
|
||||
/// `tab`: drill into the highlighted session's action page. No effect on
|
||||
/// any other kind, and none on the page itself, which has no session rows.
|
||||
fn drill_in(&mut self) {
|
||||
if self.page.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(row) = self.selected() else {
|
||||
return;
|
||||
};
|
||||
let BarTarget::Session(session) = row.target else {
|
||||
return;
|
||||
};
|
||||
let name = row.name.clone();
|
||||
let actions = SESSION_ACTIONS
|
||||
.iter()
|
||||
.map(|&action| BarEntry {
|
||||
candidate: MatchCandidate::Command(CommandCandidate {
|
||||
label: action.label().to_string(),
|
||||
}),
|
||||
target: BarTarget::SessionAction { session, action },
|
||||
dead: false,
|
||||
chords: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
self.page = Some(ActionPage { name, actions });
|
||||
self.stashed = self.input.value().to_string();
|
||||
self.input = TextInput::default();
|
||||
self.recompute();
|
||||
}
|
||||
|
||||
/// Escape, or backspace at an empty query: back to the result list with
|
||||
/// the query the drill-in was made from.
|
||||
fn leave_page(&mut self) {
|
||||
self.page = None;
|
||||
self.input = TextInput::with_value(std::mem::take(&mut self.stashed));
|
||||
self.recompute();
|
||||
}
|
||||
|
||||
/// Whether the action page is up. The session it acts on is in every one
|
||||
/// of its rows' targets.
|
||||
pub fn on_action_page(&self) -> bool {
|
||||
self.page.is_some()
|
||||
}
|
||||
|
||||
/// What the matcher sees. On the action page the whole query is the query:
|
||||
/// a sigil narrows the result list to one kind, and the page is one kind
|
||||
/// already.
|
||||
fn parsed_query(&self) -> BarQuery<'_> {
|
||||
match self.page {
|
||||
Some(_) => BarQuery {
|
||||
sigil: None,
|
||||
query: self.input.value(),
|
||||
},
|
||||
None => parse_bar_query(self.input.value()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sigil in force for the query as it stands.
|
||||
pub fn sigil(&self) -> Option<BarSigil> {
|
||||
parse_bar_query(self.input.value()).sigil
|
||||
self.parsed_query().sigil
|
||||
}
|
||||
|
||||
/// The candidates the query line asks for. A sigil narrows to one kind;
|
||||
/// with no sigil the list is mixed — sessions and commands ranked together
|
||||
/// — except on an empty query, where the bar is a session switcher.
|
||||
fn active_entries(&self, parsed: BarQuery<'_>) -> Vec<&BarEntry> {
|
||||
if let Some(page) = &self.page {
|
||||
return page.actions.iter().collect();
|
||||
}
|
||||
match parsed.sigil {
|
||||
Some(BarSigil::Command) => self.commands.iter().collect(),
|
||||
Some(BarSigil::Directory) => self.directories.iter().collect(),
|
||||
@@ -438,7 +566,7 @@ impl CommandBar {
|
||||
/// different result.
|
||||
fn recompute(&mut self) {
|
||||
let rows: Vec<BarRow> = {
|
||||
let parsed = parse_bar_query(self.input.value());
|
||||
let parsed = self.parsed_query();
|
||||
let entries = self.active_entries(parsed);
|
||||
let candidates: Vec<MatchCandidate> =
|
||||
entries.iter().map(|e| e.candidate.clone()).collect();
|
||||
@@ -482,12 +610,16 @@ impl CommandBar {
|
||||
let height = parent.height.saturating_sub(4).clamp(5, 20);
|
||||
let area = centered(parent, 72, height);
|
||||
f.render_widget(Clear, area);
|
||||
// The page names the session being acted on, so the target is never
|
||||
// ambiguous.
|
||||
let title = match (&self.page, self.sigil()) {
|
||||
(Some(page), _) => format!(" act on {} ", page.name),
|
||||
(None, Some(BarSigil::Command)) => " run a command ".to_string(),
|
||||
(None, Some(BarSigil::Directory)) => " spawn in ".to_string(),
|
||||
(None, None) => " go to ".to_string(),
|
||||
};
|
||||
let block = Block::default()
|
||||
.title(match self.sigil() {
|
||||
Some(BarSigil::Command) => " run a command ",
|
||||
Some(BarSigil::Directory) => " spawn in ",
|
||||
None => " go to ",
|
||||
})
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.style(pal.card.base())
|
||||
.border_style(pal.border_style(true));
|
||||
@@ -510,7 +642,11 @@ impl CommandBar {
|
||||
.render(query, f.buffer_mut(), pal.card.text_style());
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
"[enter] open [esc] close",
|
||||
if self.page.is_some() {
|
||||
"[enter] run [esc] back"
|
||||
} else {
|
||||
"[enter] open [esc] close"
|
||||
},
|
||||
pal.card.dim_style(),
|
||||
))),
|
||||
row(inner.height - 1),
|
||||
@@ -540,8 +676,9 @@ impl CommandBar {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut highlight_line = 0usize;
|
||||
// While a sigil is in force there is one kind, so the header is
|
||||
// dropped — it would name what the whole list already is.
|
||||
let headers = self.sigil().is_none();
|
||||
// dropped — it would name what the whole list already is. The action
|
||||
// page is one kind too.
|
||||
let headers = self.sigil().is_none() && self.page.is_none();
|
||||
for group in self.groups() {
|
||||
if headers {
|
||||
lines.push(Line::from(Span::styled(
|
||||
@@ -1219,4 +1356,163 @@ mod tests {
|
||||
}
|
||||
assert_eq!(bold, vec!["w", "s"], "bold cells: {bold:?}");
|
||||
}
|
||||
|
||||
// ---- the session action page (#155) ----
|
||||
|
||||
fn targets(rows: &[BarRow]) -> Vec<BarTarget> {
|
||||
rows.iter().map(|r| r.target.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_on_a_session_opens_its_action_page() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
assert!(bar.on_action_page());
|
||||
assert_eq!(
|
||||
labels(bar.rows()),
|
||||
vec!["Kill session", "Rename session", "Spawn similar"]
|
||||
);
|
||||
assert_eq!(
|
||||
targets(bar.rows()),
|
||||
SESSION_ACTIONS
|
||||
.iter()
|
||||
.map(|&action| BarTarget::SessionAction {
|
||||
session: Uuid::from_u128(1),
|
||||
action,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// Tab has no effect on any other kind, and none on the page itself, which
|
||||
/// has no session rows.
|
||||
#[test]
|
||||
fn tab_on_a_command_or_directory_row_does_nothing() {
|
||||
let keymap = vec![("x".to_string(), Action::KillHighlighted)];
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &keymap, "/home/me");
|
||||
typed(&mut bar, ">");
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
assert!(!bar.on_action_page());
|
||||
assert_eq!(bar.query(), ">");
|
||||
|
||||
let mut bar = CommandBar::new(&[], &[], "/home/me");
|
||||
bar.set_directories(&[PathBuf::from("/home/me/dev/api")]);
|
||||
typed(&mut bar, "~");
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
assert!(!bar.on_action_page());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_on_the_action_page_does_not_drill_further() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
assert!(bar.on_action_page());
|
||||
assert_eq!(labels(bar.rows()).len(), 3);
|
||||
}
|
||||
|
||||
/// The page drills into the highlighted session, not the first one.
|
||||
#[test]
|
||||
fn the_page_acts_on_the_highlighted_session() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
|
||||
bar.move_highlight(1);
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
assert_eq!(
|
||||
bar.rows()[0].target,
|
||||
BarTarget::SessionAction {
|
||||
session: Uuid::from_u128(2),
|
||||
action: SessionActionId::Kill,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_action_page_stays_fuzzy_searchable() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
typed(&mut bar, "ren");
|
||||
assert_eq!(labels(bar.rows()), vec!["Rename session"]);
|
||||
assert_eq!(
|
||||
bar.selected().map(|r| r.target.clone()),
|
||||
Some(BarTarget::SessionAction {
|
||||
session: Uuid::from_u128(1),
|
||||
action: SessionActionId::Rename,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// A sigil narrows the result list to one kind; the page is one kind
|
||||
/// already, so `>` there is a query character like any other.
|
||||
#[test]
|
||||
fn a_sigil_is_an_ordinary_character_on_the_action_page() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
typed(&mut bar, ">");
|
||||
assert_eq!(bar.sigil(), None);
|
||||
assert!(bar.rows().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_returns_to_the_result_list_with_the_query_intact() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me");
|
||||
typed(&mut bar, "alp");
|
||||
assert_eq!(bar.handle_key(&InputKey::Tab), BarOutcome::Stay);
|
||||
assert_eq!(bar.query(), "", "the page starts on an empty query");
|
||||
// Escape is one level up, not the way out.
|
||||
assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Stay);
|
||||
assert!(!bar.on_action_page());
|
||||
assert_eq!(bar.query(), "alp");
|
||||
assert_eq!(labels(bar.rows()), vec!["alpha"]);
|
||||
// Off the page it closes the bar again.
|
||||
assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_at_an_empty_query_returns_to_the_result_list() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||||
typed(&mut bar, "alp");
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
bar.handle_key(&InputKey::Backspace);
|
||||
assert!(!bar.on_action_page());
|
||||
assert_eq!(bar.query(), "alp");
|
||||
}
|
||||
|
||||
/// Backspace with something typed is ordinary editing of the page's own
|
||||
/// query, not the way out.
|
||||
#[test]
|
||||
fn backspace_on_a_non_empty_page_query_edits_it() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||||
typed(&mut bar, "alp");
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
typed(&mut bar, "kx");
|
||||
bar.handle_key(&InputKey::Backspace);
|
||||
assert!(bar.on_action_page());
|
||||
assert_eq!(bar.query(), "k");
|
||||
assert_eq!(labels(bar.rows()), vec!["Kill session"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_the_page_opens_the_action_for_that_session() {
|
||||
let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me");
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
typed(&mut bar, "spawn");
|
||||
assert_eq!(
|
||||
bar.handle_key(&InputKey::Enter),
|
||||
BarOutcome::Open(BarTarget::SessionAction {
|
||||
session: Uuid::from_u128(1),
|
||||
action: SessionActionId::SpawnSimilar,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_action_page_titles_itself_with_the_session_it_acts_on() {
|
||||
let mut bar = CommandBar::new(&[named(1, "amber-falcon")], &[], "/home/me");
|
||||
bar.handle_key(&InputKey::Tab);
|
||||
let rendered = render_to_string(&bar, 90, 20);
|
||||
assert!(rendered.contains("act on amber-falcon"), "{rendered}");
|
||||
assert!(rendered.contains("Kill session"), "{rendered}");
|
||||
// One kind, so the group header is dropped the way a sigil drops it.
|
||||
assert!(!rendered.contains("Commands"), "{rendered}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,25 @@ 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");
|
||||
// Leader, leader → the literal-leader escape hatch (#158), tmux-style:
|
||||
// pressing it twice sends the leader byte itself. Built from the same
|
||||
// leader spec, so rebinding leader rebinds this chord too.
|
||||
keymap
|
||||
.set(
|
||||
Mode::Insert,
|
||||
&format!("{} {}", leader.canonical(), leader.canonical()),
|
||||
"SendLiteralLeader",
|
||||
)
|
||||
.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 +297,60 @@ 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);
|
||||
}
|
||||
|
||||
/// The literal-leader escape hatch (#158) is chorded off the leader
|
||||
/// doubled: `ctrl-a ctrl-a` — tmux's doubled-prefix resolution.
|
||||
#[test]
|
||||
fn default_leader_escapes_a_literal_leader() {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"),
|
||||
Some(Action::SendLiteralLeader),
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebinding the leader must rebind the literal-leader chord with it,
|
||||
/// not just the Insert escape and the Ctrl-K chord — proving it too is
|
||||
/// built from the configured leader rather than a hardcoded `ctrl-a`.
|
||||
#[test]
|
||||
fn rebinding_leader_moves_the_literal_leader_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-b"),
|
||||
Some(Action::SendLiteralLeader),
|
||||
);
|
||||
// The old leader's chord is gone along with the rest of its bindings.
|
||||
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-a"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_normal_override_replaces_default() {
|
||||
let mut section = TuiSection::default();
|
||||
|
||||
@@ -228,6 +228,10 @@ pub struct DraftPane {
|
||||
/// respawns there without touching the picker. Consumed by the first
|
||||
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
|
||||
pub seed_cwd: Option<PathBuf>,
|
||||
/// Command to preselect once the preset list lands — "spawn similar"'s
|
||||
/// half of the clone (#155). Consumed by the first [`Self::set_presets`];
|
||||
/// `None` once applied or when nothing seeded it.
|
||||
pub seed_command: Option<String>,
|
||||
/// The repository picked in step one ([`group_key`] of its main box),
|
||||
/// `None` until a box is picked. Looked up fresh in `groups` rather than
|
||||
/// cached, so a project-list refresh can't leave it stale.
|
||||
@@ -291,6 +295,7 @@ impl Default for DraftPane {
|
||||
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
presets,
|
||||
selected_preset: 0,
|
||||
seed_command: None,
|
||||
custom_command: crate::text_input::TextInput::default(),
|
||||
groups: Vec::new(),
|
||||
filter: crate::text_input::TextInput::default(),
|
||||
@@ -342,6 +347,27 @@ impl DraftPane {
|
||||
.iter()
|
||||
.position(|p| !p.command.is_empty())
|
||||
.unwrap_or(0);
|
||||
self.apply_seed_command();
|
||||
}
|
||||
|
||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||
/// daemon offers one, otherwise the custom escape hatch with the command
|
||||
/// typed in.
|
||||
fn apply_seed_command(&mut self) {
|
||||
let Some(command) = self.seed_command.take() else {
|
||||
return;
|
||||
};
|
||||
match self.presets.iter().position(|p| p.command == command) {
|
||||
Some(index) => self.selected_preset = index,
|
||||
None => {
|
||||
self.selected_preset = self
|
||||
.presets
|
||||
.iter()
|
||||
.position(|p| p.command.is_empty())
|
||||
.unwrap_or(self.selected_preset);
|
||||
self.custom_command = crate::text_input::TextInput::with_value(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the project snapshot: sort fresh-first (#54), group by
|
||||
@@ -1688,6 +1714,48 @@ mod tests {
|
||||
assert_eq!(draft.presets.last().unwrap().id, "custom");
|
||||
}
|
||||
|
||||
/// "Spawn similar" (#155) seeds the cloned session's command: it lands on
|
||||
/// the tile that runs it when the daemon offers one.
|
||||
#[test]
|
||||
fn a_seeded_command_selects_its_own_preset() {
|
||||
let mut draft = DraftPane {
|
||||
seed_command: Some("aider".into()),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "aider".into(),
|
||||
label: "Aider".into(),
|
||||
command: "aider".into(),
|
||||
},
|
||||
]);
|
||||
assert_eq!(draft.command(), "aider");
|
||||
assert!(!draft.is_custom());
|
||||
assert_eq!(draft.seed_command, None, "consumed by the first load");
|
||||
}
|
||||
|
||||
/// A command no preset runs falls back to the custom tile with the command
|
||||
/// typed in, so the clone still spawns what it cloned.
|
||||
#[test]
|
||||
fn a_seeded_command_with_no_preset_fills_the_custom_tile() {
|
||||
let mut draft = DraftPane {
|
||||
seed_command: Some("bash -l".into()),
|
||||
..DraftPane::default()
|
||||
};
|
||||
draft.set_presets(vec![SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
}]);
|
||||
assert!(draft.is_custom());
|
||||
assert_eq!(draft.command(), "bash -l");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_right_cycles_presets_wrapping_into_custom() {
|
||||
let mut draft = DraftPane {
|
||||
|
||||
@@ -387,6 +387,9 @@ pub struct ChordState {
|
||||
held: Vec<String>,
|
||||
last_press: Option<Instant>,
|
||||
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<InputKey>,
|
||||
}
|
||||
|
||||
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<String>) -> 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<InputKey> {
|
||||
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<String> = ["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<String> = ["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));
|
||||
|
||||
+9
-2
@@ -45,8 +45,15 @@ mirroring the TUI's. `ctrl-a` is select-all in a browser; the web
|
||||
`preventDefault`s it, since selecting all of a terminal pane does nothing
|
||||
useful.
|
||||
|
||||
Only Ctrl-K gets an escape hatch. No other control character is intercepted, so
|
||||
none of them needs one.
|
||||
The leader itself needs the same kind of way back: holding it consumes every
|
||||
press, on both clients, even before anything is bound behind it. Pressing it
|
||||
twice — `ctrl-a ctrl-a` by default — sends one literal leader byte to the
|
||||
focused pane and clears the pending chord, the way tmux resolves a doubled
|
||||
prefix key. This chord is also built from the configured leader, so a
|
||||
rebound leader carries its own escape hatch with it.
|
||||
|
||||
Only Ctrl-K and the leader itself get an escape hatch. No other control
|
||||
character is intercepted, so none of them needs one.
|
||||
|
||||
### Focus round-trip
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
||||
| The previewed pane's titlebar | Tinted in the preview hue, at full strength, in place of the focused pane's accent tint. |
|
||||
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||
| A throwaway tab | Dashed border, italic label. |
|
||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. |
|
||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
||||
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
||||
|
||||
Rows say it with fill, not with an edge — see the tier ladder in
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ furthest down wins the paint.
|
||||
| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard |
|
||||
| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) |
|
||||
| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface |
|
||||
| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) |
|
||||
| Previewing (`docs/PREVIEW.md`), unless that row is the one showing in the pane | `--preview-tint` | — (no preview colour role yet) |
|
||||
|
||||
The web ladder is written as one ordered run of equal-specificity rules in
|
||||
`styles.css`, every qualifier inside `:where()`, so the order they appear in
|
||||
|
||||
+36
-17
@@ -39,6 +39,15 @@ export function requestOpenSpawn(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||
}
|
||||
|
||||
/** Ask the shell to open the command bar with an empty query. Dispatched by
|
||||
* the sidebar's search field, which is the touch entry point to the bar
|
||||
* (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone.
|
||||
* Handled in main.ts, which owns the session snapshot the bar needs. */
|
||||
export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar";
|
||||
export function requestOpenCommandBar(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
|
||||
}
|
||||
|
||||
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
||||
* what Tab in the sidebar list does. Handled in main.ts, which owns the
|
||||
* connection pool. */
|
||||
@@ -361,16 +370,12 @@ function beginRename(row: HTMLElement, s: Session): void {
|
||||
input.select();
|
||||
}
|
||||
|
||||
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
||||
// The tree has one empty state: no sessions at all. The old "No matches" one
|
||||
// belonged to the inline substring filter, which the command bar replaced
|
||||
// (#157) — the bar's own no-results state took over with it.
|
||||
function renderEmpty(onSpawn: () => void): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.className = "empty";
|
||||
if (filtering) {
|
||||
el.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">⌕</div>
|
||||
<div class="empty-title">No matches</div>
|
||||
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
|
||||
return el;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">+</div>
|
||||
<div class="empty-title">No sessions yet</div>
|
||||
@@ -441,12 +446,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
// renders with absolute paths until it lands.
|
||||
let homeDir: string | undefined;
|
||||
|
||||
// The field never holds a query: it is the touch entry point to the command
|
||||
// bar (#157), so it is `readonly` — a tap opens the bar instead of raising an
|
||||
// on-screen keyboard over a field nothing is typed into.
|
||||
const searchHeader = document.createElement("header");
|
||||
searchHeader.className = "sidebar-search";
|
||||
searchHeader.innerHTML = `
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
readonly
|
||||
placeholder="Search sessions…"
|
||||
aria-label="Search sessions"
|
||||
autocomplete="off"
|
||||
@@ -555,11 +564,23 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
handle.addEventListener("pointerup", endDrag);
|
||||
handle.addEventListener("pointercancel", endDrag);
|
||||
|
||||
// Touch entry (#157): the field is a button that looks like a field. It
|
||||
// never holds a query — focusing or tapping it hands the keyboard straight
|
||||
// to the bar's own input, which is also why it blurs first: leaving the
|
||||
// focus here would make the bar impossible to close without reopening it.
|
||||
const search = searchHeader.querySelector(".search") as HTMLInputElement;
|
||||
let filter = "";
|
||||
search.addEventListener("input", () => {
|
||||
filter = search.value;
|
||||
refresh();
|
||||
const openBar = () => {
|
||||
search.value = "";
|
||||
search.blur();
|
||||
requestOpenCommandBar();
|
||||
};
|
||||
search.addEventListener("focus", openBar);
|
||||
// A tap can land without a focus event (iOS Safari on a blurred field), so
|
||||
// the pointer path opens the bar too; the blur above keeps the two from
|
||||
// stacking two overlays.
|
||||
search.addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
openBar();
|
||||
});
|
||||
|
||||
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
||||
@@ -716,8 +737,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
if (renamingId !== null) return;
|
||||
const state = store.snapshot();
|
||||
const sessions = [...state.sessions.values()];
|
||||
const tree = buildTree(sessions, homeDir, filter);
|
||||
const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree);
|
||||
const tree = buildTree(sessions, homeDir);
|
||||
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
|
||||
if (signature === renderedSignature) {
|
||||
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
|
||||
@@ -728,9 +749,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
renderedSignature = signature;
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
|
||||
);
|
||||
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
|
||||
return;
|
||||
}
|
||||
if (tree.kind === "folder") {
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
CommandSet,
|
||||
DEFAULT_LEADER,
|
||||
canonicalizeKey,
|
||||
controlByte,
|
||||
defineCommands,
|
||||
isBarTypingTarget,
|
||||
isTerminalTextarea,
|
||||
isTypingTarget,
|
||||
keyCaps,
|
||||
publishedCommands,
|
||||
type Command,
|
||||
} from "./commands.js";
|
||||
|
||||
function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
@@ -307,3 +309,62 @@ describe("ctrl-k over a focused pane (#152)", () => {
|
||||
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("leader pressed twice (#158)", () => {
|
||||
it("sends one literal leader byte and clears the pending chord", () => {
|
||||
const literal = vi.fn();
|
||||
const run = vi.fn();
|
||||
const set = new CommandSet(
|
||||
defineCommands({ "pane.literalLeader": literal, "spawn.open": run }),
|
||||
{ leader: DEFAULT_LEADER },
|
||||
);
|
||||
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
||||
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true);
|
||||
expect(literal).toHaveBeenCalledTimes(1);
|
||||
// The chord is cleared, not still held: the next key is ordinary again.
|
||||
expect(set.handle(key("n"))).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("binds the escape hatch behind the default leader", () => {
|
||||
const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalLeader");
|
||||
expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ${DEFAULT_LEADER}`]);
|
||||
});
|
||||
|
||||
// Not just the default: the chord is resolved from whatever leader this
|
||||
// set was constructed with, so a rebound leader carries the escape hatch
|
||||
// with it — the same proof #156 wrote for the TUI. The web has no config
|
||||
// for this yet (#159), but the mechanism must not be hardcoded to ctrl-a.
|
||||
it("follows a rebound leader, not just the default", () => {
|
||||
const literal = vi.fn();
|
||||
const rebound: Command = {
|
||||
id: "pane.literalLeader",
|
||||
label: "Send a literal leader key to the pane",
|
||||
group: "Pane",
|
||||
scope: "global",
|
||||
keys: ["ctrl-b ctrl-b"],
|
||||
run: literal,
|
||||
};
|
||||
const set = new CommandSet([rebound], { leader: "ctrl-b" });
|
||||
const target = el('<textarea class="xterm-helper-textarea"></textarea>');
|
||||
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
|
||||
expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true);
|
||||
expect(literal).toHaveBeenCalledTimes(1);
|
||||
// The old leader no longer does anything special.
|
||||
expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("controlByte", () => {
|
||||
it("computes the raw control byte from a plain ctrl chord", () => {
|
||||
expect(controlByte("ctrl-a")).toBe("\x01");
|
||||
expect(controlByte("ctrl-k")).toBe("\x0b");
|
||||
});
|
||||
|
||||
it("rejects anything that isn't a single plain ctrl chord", () => {
|
||||
expect(() => controlByte("ctrl-shift-a")).toThrow();
|
||||
expect(() => controlByte("a")).toThrow();
|
||||
expect(() => controlByte("ctrl-a ctrl-a")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
|
||||
keys: [`${DEFAULT_LEADER} ctrl-k`],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "pane.literalLeader",
|
||||
label: "Send a literal leader key to the pane",
|
||||
group: "Pane",
|
||||
keys: [`${DEFAULT_LEADER} ${DEFAULT_LEADER}`],
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
id: "focus.toggle",
|
||||
label: "Move the keyboard between the session list and the pane",
|
||||
@@ -227,6 +234,15 @@ export function canonicalizeKey(e: KeyboardEvent): string | null {
|
||||
return out + e.key.toLowerCase();
|
||||
}
|
||||
|
||||
/** The raw control byte for a canonical `ctrl-<letter>` chord — `ctrl-a` is
|
||||
* `0x01` — mirroring the TUI's `encode_key`. Built from the chord string
|
||||
* itself so a different leader sends its own byte, not a hardcoded one. */
|
||||
export function controlByte(chord: string): string {
|
||||
const m = /^ctrl-([a-z])$/.exec(chord);
|
||||
if (!m) throw new Error(`not a plain ctrl chord: ${chord}`);
|
||||
return String.fromCharCode(m[1].charCodeAt(0) - 96);
|
||||
}
|
||||
|
||||
const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/;
|
||||
|
||||
/** True when a chord carries a modifier or more than one key. Plain single-key
|
||||
|
||||
+20
-12
@@ -7,7 +7,7 @@ import { ConnectionPool } from "./connections.js";
|
||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||
import { installHudKeybind } from "./debug.js";
|
||||
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
||||
import { CommandSet, DEFAULT_LEADER, defineCommands, isBarTypingTarget } from "./commands.js";
|
||||
import { CommandSet, DEFAULT_LEADER, controlByte, defineCommands, isBarTypingTarget } from "./commands.js";
|
||||
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
|
||||
import { DaemonLink } from "./daemonLink.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
@@ -36,6 +36,7 @@ import { createPreviewSchedule } from "./previewSchedule.js";
|
||||
import {
|
||||
FOCUS_PANE_EVENT,
|
||||
FOCUS_SIDEBAR_EVENT,
|
||||
OPEN_COMMAND_BAR_EVENT,
|
||||
OPEN_SPAWN_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
@@ -456,23 +457,30 @@ const focusKeys = new CommandSet(
|
||||
// 0x0B byte (docs/COMMAND-BAR.md). It resolves in this set, ahead of the
|
||||
// bar's, so the chord never opens the bar.
|
||||
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
|
||||
// The leader itself, pressed twice, tmux-style: sends one literal leader
|
||||
// byte and clears the pending chord instead of resolving as a dead chord
|
||||
// (docs/COMMAND-BAR.md, #158). Built from `DEFAULT_LEADER`, not a
|
||||
// hardcoded byte, so a different leader would send its own.
|
||||
"pane.literalLeader": () => pool.sendToFocused(controlByte(DEFAULT_LEADER)),
|
||||
}),
|
||||
{ leader: DEFAULT_LEADER },
|
||||
);
|
||||
|
||||
// Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command
|
||||
// like any other, so the `?` overlay lists it without a second list.
|
||||
const barKeys = new CommandSet(
|
||||
defineCommands({
|
||||
"bar.open": () => {
|
||||
// Opening while a session is previewed discards the preview first, like
|
||||
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
|
||||
schedulePreview.cancel();
|
||||
mv?.previewSession(null);
|
||||
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
|
||||
},
|
||||
}),
|
||||
);
|
||||
const openBar = () => {
|
||||
// Opening while a session is previewed discards the preview first, like
|
||||
// any other real change (docs/PREVIEW.md): the bar is not previewed into.
|
||||
schedulePreview.cancel();
|
||||
mv?.previewSession(null);
|
||||
openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir });
|
||||
};
|
||||
|
||||
const barKeys = new CommandSet(defineCommands({ "bar.open": () => openBar() }));
|
||||
|
||||
// The sidebar's search field is the touch entry point (#157): no `ctrl-k` on a
|
||||
// phone, so the field the operator already reaches for opens the same bar.
|
||||
document.addEventListener(OPEN_COMMAND_BAR_EVENT, () => openBar());
|
||||
|
||||
// Capture phase: an attached xterm swallows every keystroke it is given, so the
|
||||
// bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md).
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// The sidebar's search field is the touch entry point to the command bar
|
||||
// (#157, docs/COMMAND-BAR.md "Touch entry"): tapping or focusing it asks the
|
||||
// shell to open the bar with an empty query, instead of filtering the tree.
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
|
||||
import { sessionBarCandidates } from "./commandBar.js";
|
||||
import { matchAndRank } from "./matcher.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
window.fetch = () => Promise.reject(new Error("offline"));
|
||||
});
|
||||
|
||||
function session(id: string, cwd: string): Session {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
cwd,
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
} as Session;
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
function mount(sessions: Session[] = [session("alpha", "/home/n/alpha")]) {
|
||||
const handle = renderSidebar(storeWith(sessions));
|
||||
document.body.appendChild(handle.el);
|
||||
const search = handle.el.querySelector<HTMLInputElement>(".search")!;
|
||||
const opens: number[] = [];
|
||||
const listener = () => opens.push(1);
|
||||
document.addEventListener(OPEN_COMMAND_BAR_EVENT, listener);
|
||||
return {
|
||||
handle,
|
||||
search,
|
||||
opens,
|
||||
dispose: () => {
|
||||
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, listener);
|
||||
handle.dispose();
|
||||
handle.el.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("sidebar search field opens the command bar (#157)", () => {
|
||||
it("asks the shell to open the bar when the field is focused", () => {
|
||||
const t = mount();
|
||||
t.search.dispatchEvent(new FocusEvent("focus"));
|
||||
expect(t.opens).toHaveLength(1);
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("opens on a tap, where a focus event may never arrive", () => {
|
||||
const t = mount();
|
||||
t.search.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
||||
expect(t.opens).toHaveLength(1);
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("does not trap the keyboard in the field: it gives focus up on open", () => {
|
||||
const t = mount();
|
||||
t.search.focus();
|
||||
expect(document.activeElement).not.toBe(t.search);
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("opens the bar with an empty query — the field never holds one", () => {
|
||||
const t = mount();
|
||||
t.search.focus();
|
||||
expect(t.search.value).toBe("");
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("is readonly: no on-screen keyboard over a field nothing is typed into", () => {
|
||||
const t = mount();
|
||||
expect(t.search.readOnly).toBe(true);
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("no longer filters the tree in place", () => {
|
||||
const t = mount([session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")]);
|
||||
t.search.value = "alpha";
|
||||
t.search.dispatchEvent(new Event("input"));
|
||||
expect(t.handle.el.querySelectorAll(".tree-leaf")).toHaveLength(2);
|
||||
t.dispose();
|
||||
});
|
||||
|
||||
it("has no 'No matches' state left: an empty catalog is empty, not filtered", () => {
|
||||
const t = mount([]);
|
||||
expect(t.handle.el.querySelector(".empty-title")?.textContent).toBe("No sessions yet");
|
||||
t.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// The inline filter matched a session on its name and its directory, case- and
|
||||
// whitespace-insensitively. That behaviour did not go away with the field — it
|
||||
// moved into the bar, which ranks the same two fields (docs/COMMAND-BAR.md).
|
||||
describe("what the retired inline filter did, the bar now does", () => {
|
||||
it("matches a session on both its name and its directory", () => {
|
||||
const sessions = [session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")];
|
||||
const candidates = sessionBarCandidates(sessions, "/home/n");
|
||||
|
||||
const byName = matchAndRank("alpha", candidates).map((r) => r.candidate.session.id);
|
||||
expect(byName).toEqual(["alpha"]);
|
||||
|
||||
const byDir = matchAndRank("beta", candidates).map((r) => r.candidate.session.id);
|
||||
expect(byDir).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
it("ignores case and surrounding whitespace, and shows nothing on no match", () => {
|
||||
const candidates = sessionBarCandidates([session("alpha", "/home/n/Alpha")], "/home/n");
|
||||
|
||||
expect(matchAndRank(" ALPHA ", candidates)).toHaveLength(1);
|
||||
expect(matchAndRank("zzz", candidates)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+21
-4
@@ -1202,8 +1202,10 @@ textarea {
|
||||
}
|
||||
|
||||
/* 5. The row the content pane is previewing right now. Last, so previewing a
|
||||
session that is already open still reads as provisional. */
|
||||
.tree-leaf:where(.is-previewing) {
|
||||
session that is already open still reads as provisional — except the session
|
||||
already mounted in the pane (tier 4). Previewing that one changes nothing on
|
||||
screen, so its accent stays put instead of blinking out under the pointer. */
|
||||
.tree-leaf:where(.is-previewing):where(:not(.is-active)) {
|
||||
background: var(--preview-tint);
|
||||
}
|
||||
|
||||
@@ -1558,7 +1560,10 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
/* Phone: the bottom-sheet treatment the spawn modal already uses. */
|
||||
@media (max-width: 600px) {
|
||||
/* Bottom sheet at phone width, the same treatment (and the same 599px break)
|
||||
the spawn modal uses: the bar is reached by thumb here, through the sidebar's
|
||||
search field (#157), so it rides the bottom of the screen. */
|
||||
@media (max-width: 599px) {
|
||||
.cmdbar-overlay {
|
||||
grid-template-rows: 1fr auto;
|
||||
padding: 0;
|
||||
@@ -1567,9 +1572,21 @@ input.leaf-rename:focus {
|
||||
.cmdbar-panel {
|
||||
grid-row: 2;
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
max-height: 70dvh;
|
||||
border-radius: var(--r-3) var(--r-3) 0 0;
|
||||
border-bottom: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Thumb-sized rows and a field that clears the on-screen keyboard's
|
||||
zoom-on-focus threshold (16px). */
|
||||
.cmdbar-input {
|
||||
font-size: var(--fs-17);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.cmdbar-row {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-26
@@ -26,7 +26,6 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/work"), session("b", "/home/n/work")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(names(root)).toEqual(["~/work"]);
|
||||
@@ -35,7 +34,7 @@ describe("buildTree", () => {
|
||||
});
|
||||
|
||||
it("collapses a linear chain into one folder", () => {
|
||||
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n", "") as TreeFolder;
|
||||
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n") as TreeFolder;
|
||||
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
|
||||
});
|
||||
|
||||
@@ -43,7 +42,6 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
|
||||
@@ -53,7 +51,6 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(names(root)).toEqual(["~/git"]);
|
||||
@@ -65,30 +62,11 @@ describe("buildTree", () => {
|
||||
const root = buildTree(
|
||||
[session("loose", ""), session("nested", "/home/n/work")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(root.children[0].kind).toBe("folder");
|
||||
expect(root.children[1].kind).toBe("leaf");
|
||||
});
|
||||
|
||||
it("filters on both session name and directory", () => {
|
||||
const sessions = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")];
|
||||
|
||||
const byDir = buildTree(sessions, "/home/n", "alpha") as TreeFolder;
|
||||
expect(names(byDir)).toEqual(["~/alpha"]);
|
||||
|
||||
const byCommand = buildTree(sessions, "/home/n", "claude") as TreeFolder;
|
||||
expect(names(byCommand)).toEqual(["~/alpha"]);
|
||||
|
||||
const noMatch = buildTree(sessions, "/home/n", "zzz") as TreeFolder;
|
||||
expect(noMatch.children).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores case and surrounding whitespace in the filter", () => {
|
||||
const root = buildTree([session("a", "/home/n/Alpha")], "/home/n", " ALPHA ") as TreeFolder;
|
||||
expect(root.children).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeLinear", () => {
|
||||
@@ -127,7 +105,6 @@ describe("worktree sessions group under their repository (#99)", () => {
|
||||
),
|
||||
],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||
@@ -147,7 +124,6 @@ describe("worktree sessions group under their repository (#99)", () => {
|
||||
),
|
||||
],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||
@@ -157,7 +133,7 @@ describe("worktree sessions group under their repository (#99)", () => {
|
||||
});
|
||||
|
||||
it("leaves a session without a worktree where it is", () => {
|
||||
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
|
||||
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n") as TreeFolder;
|
||||
expect(topFolders(root)).toEqual(["~/tea/arr"]);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-6
@@ -25,19 +25,14 @@ export function compareNodes(a: TreeNode, b: TreeNode): number {
|
||||
return an.localeCompare(bn);
|
||||
}
|
||||
|
||||
export function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode {
|
||||
export function buildTree(sessions: Session[], home: string | undefined): TreeNode {
|
||||
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
|
||||
const lc = filter.trim().toLowerCase();
|
||||
|
||||
for (const s of sessions) {
|
||||
// Grouped by repository, so a worktree session lands under its repo
|
||||
// rather than in a folder of its own (#99).
|
||||
const cwd = sessionTreePath(s);
|
||||
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
||||
if (lc) {
|
||||
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
||||
if (!haystack.includes(lc)) continue;
|
||||
}
|
||||
const parts = dir ? dir.split("/").filter(Boolean) : [];
|
||||
let cursor = root;
|
||||
for (const part of parts) {
|
||||
|
||||
Reference in New Issue
Block a user