Compare commits

...
Author SHA1 Message Date
Miguel Palhas c1c283c0f8 feat(maestro): give readline's kill-to-end-of-line back via the leader
ctrl-k now opens the command bar in both modes (#153), taking readline's
kill-to-end-of-line away from every pane. Leader then ctrl-k — ctrl-a
ctrl-k by default — sends the literal 0x0B byte to the focused pane
instead, built from the configured leader so rebinding it rebinds the
chord too.

Chording a second binding off the leader exposed a gap in ChordState:
a key bound both alone and as a chord prefix (ctrl-a alone, and ctrl-a
ctrl-k) would get stuck waiting on the next keystroke, discarding its
own meaning the moment that keystroke didn't continue the chord. Feed
now resolves the held prefix to its own binding when that happens and
hands the breaking key back via take_replay for the caller to process
fresh — otherwise, pressing leader then any other key would never
leave Insert mode again.
2026-09-03 17:55:50 +01:00
Miguel PalhasandClaude Opus 5 1b260b5c20 feat(maestro): drill into a session's actions with tab
`tab` on a highlighted session opens its action page inside the command
bar: kill, rename, spawn similar, still fuzzy-searchable, with the
overlay titled after the session. Escape or backspace at an empty query
returns to the result list with the previous query intact.

Each action raises the flow the TUI already has — `KillConfirm`, the
rename prompt pre-filled, and the spawn form, now seeded from that
session's directory and command rather than the focused one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:35:24 +01:00
6 changed files with 802 additions and 62 deletions
+21
View File
@@ -56,6 +56,10 @@ pub enum Action {
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
/// over the sessions, ranked by the shared matcher.
OpenCommandBar,
/// Escape hatch (#156): leader then `ctrl-k` sends the literal `0x0B`
/// byte to the focused pane instead of opening the command bar, giving
/// readline's kill-to-end-of-line back.
SendLiteralCtrlK,
ToggleSidebar,
ShowHelp,
Quit,
@@ -98,6 +102,7 @@ impl Action {
Action::KillHighlighted => "Kill selected session".into(),
Action::RenameHighlighted => "Rename selected session".into(),
Action::OpenCommandBar => "Go to a session".into(),
Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
Action::ToggleSidebar => "Show or hide sidebar".into(),
Action::ShowHelp => "Show help".into(),
Action::Quit => "Quit Maestro".into(),
@@ -125,6 +130,7 @@ impl Action {
"KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
"ShowHelp" => return Ok(Action::ShowHelp),
"Quit" => return Ok(Action::Quit),
@@ -256,11 +262,26 @@ mod tests {
assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session");
}
/// The escape hatch (#156) is an ordinary action like any other.
#[test]
fn send_literal_ctrl_k_round_trips() {
assert_eq!(
Action::parse("SendLiteralCtrlK").unwrap(),
Action::SendLiteralCtrlK
);
assert_eq!(Action::SendLiteralCtrlK.to_string(), "SendLiteralCtrlK");
assert_eq!(
Action::SendLiteralCtrlK.help_label(),
"Send literal Ctrl-K to session"
);
}
#[test]
fn display_round_trips() {
for a in [
Action::EnterInsert,
Action::OpenCommandBar,
Action::SendLiteralCtrlK,
Action::RenameHighlighted,
Action::Quit,
Action::OpenSpawnModal,
+295 -42
View File
@@ -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(
&current_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(&current_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,10 @@ impl App {
self.state.show_overlay(OverlayKind::Help);
}
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
// Escape hatch (#156): the literal byte, not the bar.
Action::SendLiteralCtrlK => {
effects.extend(self.forward_to_session(&InputKey::ctrl_char('k')));
}
Action::OpenSpawnModal => {
self.open_spawn_modal();
effects.push(Effect::LoadDraft);
@@ -555,24 +593,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 +884,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 +921,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 +2931,55 @@ mod tests {
CtEvent::Key(crossterm::event::KeyEvent::new(code, mods))
}
fn ctrl_key_event(c: char) -> CtEvent {
key_event(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::CONTROL,
)
}
/// Drive a sequence of key events through an app in Insert mode with a
/// session attached, and report the app afterwards plus every chunk of
/// bytes that reached that session, in order.
async fn drive_insert_sequence(
section: &TuiSection,
events: &[CtEvent],
) -> (App, Vec<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 +2998,74 @@ mod tests {
/// `Noop` suppresses a binding, and in Insert mode a key with no binding is
/// the session's — so binding a key to `Noop` hands it to the pty.
///
/// Uses `ctrl-x`, not the leader: the leader is also a chord prefix
/// (#156), so a single fed keystroke on it resolves as `Pending`, not
/// `Resolved` — a different mechanism, covered separately below.
#[tokio::test]
async fn a_noop_insert_binding_forwards_the_key_to_the_session() {
let mut section = TuiSection::default();
section.keymap_insert.insert("ctrl-a".into(), "Noop".into());
section.keymap_insert.insert("ctrl-x".into(), "Noop".into());
let sent = bytes_sent_for(
&section,
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(&section, &[ctrl_key_event('b'), ctrl_key_event('k')]).await;
assert_eq!(sent, vec![vec![0x0b]]);
assert_eq!(app.state.overlay(), None);
}
/// Leader alone, followed by an ordinary key, still leaves Insert mode —
/// the escape hatch chord doesn't cost the leader its existing meaning
/// (#156).
#[tokio::test]
async fn leader_alone_still_leaves_insert_when_followed_by_another_key() {
let (app, sent) = drive_insert_sequence(
&TuiSection::default(),
&[
ctrl_key_event('a'),
key_event(
crossterm::event::KeyCode::Char('j'),
crossterm::event::KeyModifiers::NONE,
),
],
)
.await;
assert!(
sent.is_empty(),
"no bytes for the leader chord itself: {sent:?}"
);
assert_eq!(app.state.mode, Mode::Normal, "leader still leaves Insert");
}
#[test]
@@ -3379,4 +3557,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());
}
}
+312 -16
View File
@@ -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}");
}
}
+36
View File
@@ -146,6 +146,15 @@ impl TuiConfig {
keymap
.set(Mode::Insert, &leader.canonical(), "LeaveInsert")
.expect("leader canonical form is valid by construction");
// Leader, ctrl-k → the literal-Ctrl-K escape hatch (#156), built from
// the same leader spec so rebinding leader rebinds this chord too.
keymap
.set(
Mode::Insert,
&format!("{} ctrl-k", leader.canonical()),
"SendLiteralCtrlK",
)
.expect("leader canonical form is valid by construction");
for (input, action) in &section.keymap_normal {
keymap.set(Mode::Normal, input, action).map_err(|source| {
@@ -278,6 +287,33 @@ mod tests {
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None);
}
/// The literal-Ctrl-K escape hatch (#156) is chorded off the default
/// leader, `ctrl-a ctrl-k` — not the command bar.
#[test]
fn default_leader_escapes_a_literal_ctrl_k() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"),
Some(Action::SendLiteralCtrlK),
);
}
/// Rebinding the leader must rebind the literal-Ctrl-K chord with it, not
/// just the Insert escape — proving the chord is built from the
/// configured leader rather than a hardcoded `ctrl-a`.
#[test]
fn rebinding_leader_moves_the_literal_ctrl_k_chord() {
let mut section = TuiSection::default();
section.leader = Some("ctrl-b".into());
let cfg = TuiConfig::load(&section).unwrap();
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-k"),
Some(Action::SendLiteralCtrlK),
);
// The old leader's chord is gone along with its Insert escape.
assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None);
}
#[test]
fn user_normal_override_replaces_default() {
let mut section = TuiSection::default();
+68
View File
@@ -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 {
+70 -4
View File
@@ -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));