diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 2b83457..cac1729 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -251,6 +251,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index ecfea91..d59f92e 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -285,33 +285,50 @@ impl SessionManager { /// Subscribe to a session's PTY broadcast and return the replay buffer /// (NAP-229). Callers send the buffer before pumping the live receiver /// so freshly-attached clients see the last screenful immediately. + /// + /// Opening an attach is focusing (#145): it stamps + /// [`Session::last_focused_at`], the same field `set_focused` stamps, so + /// a client that only ever attaches sorts identically to one that + /// reports focus explicitly. pub async fn subscribe_pty_with_replay( &self, id: Uuid, ) -> Option<(Vec, broadcast::Receiver>)> { let tmux = self.inner.tmux.clone(); - let sessions = self.inner.sessions.lock().await; - let rec = sessions.get(&id)?; - // For a live session, tmux's own screen beats the byte tail: the tail - // starts mid-stream, so a full-screen program (an agent TUI) replayed - // from it renders as garbage until something forces a repaint. A dead - // session has no pane left to capture, so it keeps the byte tail. - let alive = rec.session.status == SessionStatus::Running; - // The capture shells out to tmux while the replay lock is held, which - // parks the reader for the length of one tmux round-trip. That only - // happens on attach, and the alternative is a seam in the stream. - Some(rec.bridge.subscribe_with_snapshot(|| { - if !alive { - return None; - } - match tmux.capture_pane(id) { - Ok(snapshot) => snapshot, - Err(e) => { - tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes"); - None + let (result, updated) = { + let mut sessions = self.inner.sessions.lock().await; + let rec = sessions.get_mut(&id)?; + rec.session.last_focused_at = Some(Utc::now()); + let updated = rec.session.clone(); + // For a live session, tmux's own screen beats the byte tail: the + // tail starts mid-stream, so a full-screen program (an agent TUI) + // replayed from it renders as garbage until something forces a + // repaint. A dead session has no pane left to capture, so it + // keeps the byte tail. + let alive = rec.session.status == SessionStatus::Running; + // The capture shells out to tmux while the replay lock is held, + // which parks the reader for the length of one tmux round-trip. + // That only happens on attach, and the alternative is a seam in + // the stream. + let result = rec.bridge.subscribe_with_snapshot(|| { + if !alive { + return None; } - } - })) + match tmux.capture_pane(id) { + Ok(snapshot) => snapshot, + Err(e) => { + tracing::warn!(error = ?e, %id, "capture-pane failed; replaying raw bytes"); + None + } + } + }); + (result, updated) + }; + let _ = self + .inner + .events + .send(CatalogEvent::SessionUpdated { session: updated }); + Some(result) } pub async fn has(&self, id: Uuid) -> bool { @@ -448,6 +465,7 @@ impl SessionManager { // The watcher fills this in on its first blocked-work pass; a // fresh tree has nothing in the way anyway. blocked_work: None, + last_focused_at: None, }; if let Err(e) = self.inner.sidecar.write(&session) { @@ -652,6 +670,7 @@ impl SessionManager { current_cwd: None, blocked_work: None, + last_focused_at: None, }, }; let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); @@ -838,11 +857,26 @@ impl SessionManager { /// Record whether a client's pane is currently showing `id`. Read by the /// activity pass's next tick to decide whether an `AwaitingInput` glow /// has been looked at long enough to clear (`apply_focus_suppression`). - /// An unknown session is [`SessionError::NotFound`]. + /// Gaining focus also stamps [`Session::last_focused_at`] (#145) — losing + /// it does not, since the command bar's ordering cares about the last + /// time the operator arrived, not when they left. An unknown session is + /// [`SessionError::NotFound`]. pub async fn set_focused(&self, id: Uuid, focused: bool) -> Result<(), SessionError> { - let mut sessions = self.inner.sessions.lock().await; - let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?; - rec.focused_since = focused.then(std::time::Instant::now); + let updated = { + let mut sessions = self.inner.sessions.lock().await; + let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?; + rec.focused_since = focused.then(std::time::Instant::now); + if focused { + rec.session.last_focused_at = Some(Utc::now()); + } + focused.then(|| rec.session.clone()) + }; + if let Some(session) = updated { + let _ = self + .inner + .events + .send(CatalogEvent::SessionUpdated { session }); + } Ok(()) } } diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index d46622c..9861796 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -383,6 +383,41 @@ async fn patch_unknown_session_returns_404() { assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); } +/// #145: `POST /.../focus` with `focused: true` over HTTP stamps +/// `last_focused_at`, observable on the next `GET /api/sessions`. +#[tokio::test] +async fn post_focus_stamps_last_focused_at() { + let h = spawn("naps-laptop").await; + let session = h + .manager + .create(CreateSessionRequest { + cwd: "/tmp".into(), + command: "sleep 60".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .unwrap(); + assert_eq!(session.last_focused_at, None); + + let resp = reqwest::Client::new() + .post(url(h.addr, &format!("/api/sessions/{}/focus", session.id))) + .json(&serde_json::json!({ "focused": true })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); + + let sessions: Vec = reqwest::get(url(h.addr, "/api/sessions")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(sessions[0].last_focused_at.is_some()); +} + #[tokio::test] async fn delete_owned_session_kills_it() { let h = spawn("naps-laptop").await; diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 49c6fd3..650dc34 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -1091,3 +1091,109 @@ async fn spawned_sessions_export_maestro_session_id_and_url() { fx.manager.kill(s.id, false).await.expect("kill"); } + +/// docs/COMMAND-BAR.md, empty-query ordering: a session that has never been +/// focused carries no `last_focused_at` at all, which is what lets a client +/// sort it after every session that has one (#145). +#[tokio::test] +async fn a_freshly_created_session_has_never_been_focused() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + + assert_eq!(s.last_focused_at, None); + assert_eq!(fx.manager.snapshot().await[0].last_focused_at, None); +} + +/// #145: `POST /.../focus` reporting `focused: true` stamps +/// `last_focused_at` and announces it on the event stream; reporting +/// `focused: false` (losing focus) leaves the stamp alone, since the bar +/// orders by when the operator last arrived, not when they left. +#[tokio::test] +async fn set_focused_true_stamps_last_focused_at() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + + let mut events = fx.manager.events(); + let before = chrono::Utc::now(); + fx.manager.set_focused(s.id, true).await.expect("focus"); + + let updated = fx.manager.snapshot().await[0].clone(); + let stamp = updated.last_focused_at.expect("stamped on focus"); + assert!(stamp >= before); + + let mut saw_update = false; + while let Ok(event) = tokio::time::timeout(Duration::from_millis(200), events.recv()).await { + if let Ok(CatalogEvent::SessionUpdated { session }) = event { + if session.id == s.id && session.last_focused_at == Some(stamp) { + saw_update = true; + break; + } + } + } + assert!(saw_update, "expected a SessionUpdated carrying the stamp"); + + fx.manager.set_focused(s.id, false).await.expect("unfocus"); + let after_unfocus = fx.manager.snapshot().await[0].clone(); + assert_eq!( + after_unfocus.last_focused_at, + Some(stamp), + "losing focus must not clear the last-focused stamp" + ); +} + +/// #145: opening `/api/attach/:id` is focusing too — it must stamp the same +/// field `set_focused` does, so a client that only ever attaches sorts +/// identically to one that reports focus explicitly. +#[tokio::test] +async fn attaching_stamps_last_focused_at() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + assert_eq!(s.last_focused_at, None); + + let before = chrono::Utc::now(); + let (_replay, _rx) = fx + .manager + .subscribe_pty_with_replay(s.id) + .await + .expect("known session"); + + let updated = fx.manager.snapshot().await[0].clone(); + let stamp = updated.last_focused_at.expect("attach stamps focus"); + assert!(stamp >= before); +} diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index 0274844..2934d3a 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -31,6 +31,7 @@ fn sample(id: Uuid) -> Session { activity_since: chrono::Utc::now(), current_cwd: Some(PathBuf::from("/tmp/work/crates")), blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-protocol/src/display.rs b/crates/maestro-protocol/src/display.rs index fe2deda..9aac71b 100644 --- a/crates/maestro-protocol/src/display.rs +++ b/crates/maestro-protocol/src/display.rs @@ -202,6 +202,41 @@ pub fn command_program(command: &str) -> String { } } +/// Program name (already basenamed and lowercased) -> the tool id its mark +/// stands for. Mirrors the web's `BY_PROGRAM` in `web/src/toolIcon.ts`; +/// recognition is deliberately narrow, so an unrecognised program has no tool. +const BY_PROGRAM: [(&str, &str); 17] = [ + ("claude", "claude"), + ("claude-code", "claude"), + ("codex", "codex"), + ("opencode", "opencode"), + ("gemini", "gemini"), + ("aider", "aider"), + ("cursor", "cursor"), + ("cursor-agent", "cursor"), + ("pi", "pi"), + ("bash", "shell"), + ("zsh", "shell"), + ("sh", "shell"), + ("fish", "shell"), + ("dash", "shell"), + ("ksh", "shell"), + ("nu", "shell"), + ("xonsh", "shell"), +]; + +/// The tool id a command line's icon stands for (`claude`, `codex`, +/// `opencode`, ...), or `None` when the program is not one we have a mark for. +/// The command bar matches this field (`docs/COMMAND-BAR.md`), so it must name +/// the same tools the web's `splitCommand().tool` does. +pub fn command_tool(command: &str) -> Option<&'static str> { + let program = command_program(command).to_lowercase(); + BY_PROGRAM + .iter() + .find(|(name, _)| *name == program) + .map(|(_, tool)| *tool) +} + /// `FOO=bar`, the shell's own prefix for one command's environment. fn is_env_assignment(word: &str) -> bool { let mut chars = word.chars(); @@ -248,6 +283,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } @@ -475,4 +511,22 @@ mod tests { assert_eq!(command_program("sudo"), "sudo"); assert_eq!(command_program(""), ""); } + + /// The command bar's `tool` field (docs/COMMAND-BAR.md) names the same + /// tools the web's `splitCommand().tool` does. + #[test] + fn command_tool_names_the_mark_the_card_shows() { + assert_eq!(command_tool("claude --resume"), Some("claude")); + assert_eq!(command_tool("npx opencode"), Some("opencode")); + assert_eq!(command_tool("/bin/zsh -l"), Some("shell")); + assert_eq!(command_tool("CLAUDE"), Some("claude")); + } + + /// Narrow on purpose: an unrecognised program has no mark, so the + /// candidate has no `tool` field to match rather than a wrong one. + #[test] + fn command_tool_is_absent_for_an_unrecognised_program() { + assert_eq!(command_tool("hx src/main.rs"), None); + assert_eq!(command_tool(""), None); + } } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 696a28f..68fbeab 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -108,6 +108,14 @@ pub struct Session { /// from daemons that predate the field stay forward-compatible. #[serde(default)] pub blocked_work: Option, + /// Wall-clock time this session last became the pane a client was + /// showing (#145) — stamped by both `POST /.../focus` and opening + /// `/api/attach/:id`, since either one is the operator moving there. + /// `None` until the session has been focused at least once, which is + /// also how the command bar's empty-query ordering (`docs/COMMAND-BAR.md`) + /// sorts a never-focused session after every session that has one. + #[serde(default)] + pub last_focused_at: Option>, } /// Default for [`Session::last_activity_at`] when an older payload omits it. @@ -847,6 +855,7 @@ mod tests { .with_timezone(&Utc), current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), blocked_work: None, + last_focused_at: None, } } @@ -896,6 +905,28 @@ mod tests { ); } + #[test] + fn last_focused_at_defaults_to_none_on_legacy_payload() { + // Pre-#145 payload omits the field; `None` means never-focused. + let json = serde_json::json!({ + "id": Uuid::from_u128(1), + "cwd": "/tmp", + "command": "bash", + "status": "Running", + "created_at": "2026-05-24T12:00:00Z" + }); + let s: Session = serde_json::from_value(json).unwrap(); + assert_eq!(s.last_focused_at, None); + + let mut focused = sample_session(); + focused.last_focused_at = Some( + DateTime::parse_from_rfc3339("2026-05-24T12:06:00Z") + .unwrap() + .with_timezone(&Utc), + ); + assert_eq!(focused, round_trip(&focused)); + } + #[test] fn activity_since_defaults_to_epoch_on_legacy_payload() { // A daemon predating #102 omits `activity_since`; it must deserialize diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index 0a1c535..5a192fa 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -46,6 +46,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index d821a6d..25e8e70 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -53,6 +53,13 @@ pub enum Action { RenameHighlighted, // Chrome + /// 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, @@ -94,6 +101,8 @@ impl Action { Action::FastForwardSpawn => "Create session with defaults".into(), 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(), @@ -120,6 +129,8 @@ impl Action { "FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "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), @@ -239,10 +250,38 @@ mod tests { ); } + /// The command bar is an ordinary keymap entry, so it must round-trip + /// like every other action (#153). + #[test] + fn open_command_bar_round_trips() { + assert_eq!( + Action::parse("OpenCommandBar").unwrap(), + Action::OpenCommandBar + ); + assert_eq!(Action::OpenCommandBar.to_string(), "OpenCommandBar"); + assert_eq!(Action::OpenCommandBar.help_label(), "Go to a session"); + } + + /// The escape hatch (#156) is an ordinary action like any other. + #[test] + fn send_literal_ctrl_k_round_trips() { + assert_eq!( + Action::parse("SendLiteralCtrlK").unwrap(), + Action::SendLiteralCtrlK + ); + assert_eq!(Action::SendLiteralCtrlK.to_string(), "SendLiteralCtrlK"); + assert_eq!( + Action::SendLiteralCtrlK.help_label(), + "Send literal Ctrl-K to session" + ); + } + #[test] fn display_round_trips() { for a in [ Action::EnterInsert, + Action::OpenCommandBar, + Action::SendLiteralCtrlK, Action::RenameHighlighted, Action::Quit, Action::OpenSpawnModal, diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index d3e6d2e..f283bd3 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -23,6 +23,7 @@ use uuid::Uuid; use crate::action::Action; use crate::catalog::CatalogStore; +use crate::command_bar::{BarOutcome, BarTarget, CommandBar, SessionActionId}; use crate::config::TuiConfig; use crate::daemon_link::DaemonLink; use crate::draft::{DraftInputResult, DraftPane}; @@ -78,6 +79,9 @@ pub struct App { /// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the /// same reason the worktree entries are. rename_input: TextInput, + /// Query buffer and result list behind [`OverlayKind::CommandBar`] (#153), + /// kept here for the same reason the rename buffer is. + command_bar: Option, region: RegionMap, last_sent_size: HashMap, clicks: ClickTracker, @@ -164,6 +168,14 @@ pub enum Effect { /// Fast-forward spawn (#67): resolve every default from the daemon and /// spawn with no form; unresolvable defaults open a pre-filled form. FastForward, + /// The same fast path pinned to one directory — the command bar's `~` + /// (#154). Nothing may override the directory the operator named. + FastForwardIn(PathBuf), + /// Fetch the command bar's `~` candidates: recent dirs and the project + /// list. `bar` guards a load landing in a bar that was reopened since. + LoadBarDirectories { + bar: u64, + }, /// The form's target changed: probe `/api/gitroot` so the worktree /// section knows its base repo (#65). `seq` guards stale answers. ProbeGitRoot { @@ -210,6 +222,7 @@ impl App { link, http, rename_input: TextInput::default(), + command_bar: None, draft_tx, draft_rx, region: RegionMap::default(), @@ -293,6 +306,7 @@ impl App { return Ok(true); } } + self.chord.take_replay(); return Ok(false); } @@ -312,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), } } } @@ -381,6 +409,8 @@ impl App { Effect::Kill(id) => self.submit_kill(id).await, Effect::LoadDraft => self.spawn_load_draft(), Effect::FastForward => self.spawn_fast_forward(), + Effect::FastForwardIn(dir) => self.spawn_fast_forward_in(dir), + Effect::LoadBarDirectories { bar } => self.spawn_load_bar_directories(bar), Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path), Effect::LoadBranches => self.spawn_load_branches(), Effect::Rename { id, name } => self.submit_rename(id, &name).await, @@ -430,6 +460,61 @@ impl App { (OverlayDecision::Consumed, Vec::new()) } }, + // The bar owns every key while it is up: the keymap must not see + // `j`, `x` or `q` as commands mid-query. + Some(OverlayKind::CommandBar) => { + let outcome = match self.command_bar.as_mut() { + Some(bar) => bar.handle_key(key), + None => BarOutcome::Close, + }; + match outcome { + BarOutcome::Stay => (OverlayDecision::Consumed, Vec::new()), + BarOutcome::Close => { + self.close_command_bar(); + (OverlayDecision::Consumed, Vec::new()) + } + BarOutcome::Open(BarTarget::Session(sid)) => { + self.close_command_bar(); + // The same open path the sidebar's own attach uses, so + // a bar attach can never leave the layout in a state a + // highlight-and-enter could not produce. + self.state.sidebar_highlight = Some(SidebarItem::Session(sid)); + ( + OverlayDecision::Consumed, + self.dispatch(Action::AttachHighlighted), + ) + } + // The bar picks the action and hands off; it never + // reimplements one. + BarOutcome::Open(BarTarget::Command(action)) => { + self.close_command_bar(); + (OverlayDecision::Consumed, self.dispatch(action)) + } + BarOutcome::Open(BarTarget::Directory(dir)) => { + 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) => { self.state.dismiss_overlay(); (OverlayDecision::Consumed, Vec::new()) @@ -475,6 +560,11 @@ 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); @@ -503,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); } } } @@ -537,6 +615,10 @@ impl App { /// highlight already sitting on the attached session — clears at once. fn refresh_preview(&mut self) { let wanted = match (self.state.focus, &self.state.sidebar_highlight) { + // The bar is a view over the app, not a place in it: while it is + // up the sidebar highlight behind it previews nothing + // (`docs/COMMAND-BAR.md`, "Focus round-trip"). + _ if self.state.overlay() == Some(&OverlayKind::CommandBar) => None, (Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid), _ => None, }; @@ -588,6 +670,34 @@ impl App { } } + /// Put the command bar up over whatever is behind it (#153). Opening + /// while a session is previewed discards the preview first, like any + /// other real change (`docs/PREVIEW.md`); focus and mode are left alone, + /// so closing gives the keyboard back to whatever held it. + fn open_command_bar(&mut self) -> Vec { + let snapshot = self.catalog.snapshot(); + // The keymap for the mode the bar was opened from, which is the one + // the operator would be typing into behind it. Same source the help + // overlay reads, so the two can never disagree. + let keymap = self.cfg.keymap.entries(self.state.mode); + let bar = CommandBar::new(&snapshot.sessions, &keymap, &self.home_dir); + let id = bar.id(); + self.command_bar = Some(bar); + self.pending_preview = None; + self.clear_preview(); + self.state.show_overlay(OverlayKind::CommandBar); + // `~`'s candidates are a network round trip; the bar opens without + // them and fills in when they land. + vec![Effect::LoadBarDirectories { bar: id }] + } + + /// Take the bar down, dropping its query. Every exit goes through here so + /// a stale buffer can never outlive the overlay. + fn close_command_bar(&mut self) { + self.command_bar = None; + self.state.dismiss_overlay(); + } + fn clear_preview(&mut self) { if self.state.preview.take().is_some() { self.epoch = self.epoch.wrapping_add(1); @@ -774,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 @@ -787,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 { + 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): @@ -935,18 +1093,27 @@ impl App { projects, names, focused_cwd, + pinned, } = update { - self.fast_forward_spawn(info, projects, names, focused_cwd) + self.fast_forward_spawn(info, projects, names, focused_cwd, pinned) .await; return; } + // Addressed to the bar, not to a form: a load that outlived its bar is + // dropped rather than filling in the next one. + if let DraftUpdate::BarDirectories { bar, paths } = update { + if let Some(open) = self.command_bar.as_mut().filter(|b| b.id() == bar) { + open.set_directories(&paths); + } + return; + } let want = match &update { DraftUpdate::Data { draft, .. } | DraftUpdate::GitRoot { draft, .. } | DraftUpdate::Branches { draft, .. } => *draft, - // Handled above; it targets no particular draft. - DraftUpdate::FastForward { .. } => unreachable!(), + // Handled above; they target no particular draft. + DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => unreachable!(), }; // Match by identity: dismissing the form and opening another while a // load is in flight must not let the old answer land in the new form. @@ -981,7 +1148,9 @@ impl App { DraftUpdate::Branches { cwd, branches, .. } => { draft.set_branches(&cwd, branches); } - DraftUpdate::FastForward { .. } => unreachable!(), + DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => { + unreachable!() + } } if follow_up_branches { self.spawn_load_branches(); @@ -1009,16 +1178,52 @@ impl App { projects: projects.ok(), names, focused_cwd, + pinned: None, }); }); } + /// The same fast path, pinned to the directory the command bar's `~` row + /// named (#154). Off the loop for the same reason as [`Self:: + /// spawn_fast_forward`]: a cold `/api/projects` walks all of `$HOME`. + fn spawn_fast_forward_in(&self, dir: PathBuf) { + let (daemon, http, base, tx) = self.loader_handles(); + tokio::spawn(async move { + let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects()); + let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await; + let _ = tx.send(DraftUpdate::FastForward { + info: info.ok(), + projects: projects.ok(), + names, + focused_cwd: None, + pinned: Some(dir), + }); + }); + } + + /// Fetch `~`'s candidates: the spawn form's own two sources, recent dirs + /// (newest first) ahead of the project list. Either failing is a shorter + /// list, not an error state. + fn spawn_load_bar_directories(&self, bar: u64) { + let daemon = self.daemon.clone(); + let tx = self.draft_tx.clone(); + tokio::spawn(async move { + let (recents, projects) = tokio::join!(daemon.recent_dirs(), daemon.list_projects()); + let mut paths: Vec = recents + .map(|r| r.dirs.into_iter().map(|d| d.cwd).collect()) + .unwrap_or_default(); + paths.extend(projects.unwrap_or_default().into_iter().map(|p| p.path)); + let _ = tx.send(DraftUpdate::BarDirectories { bar, paths }); + }); + } + async fn fast_forward_spawn( &mut self, info: Option, projects: Option>, names: Vec, focused_cwd: Option, + pinned: Option, ) { // Keep the home dir: without it the pre-filled draft renders // unabbreviated paths and "Other directory…" seeds `/` instead of @@ -1032,13 +1237,21 @@ impl App { _ => Vec::new(), }; let discovered = projects.unwrap_or_default(); - let resolved = crate::fast_forward::resolve( - &discovered, - &presets, - names.first().cloned(), - Utc::now(), - focused_cwd.as_deref(), - ); + // A pinned directory is the operator's own pick, so it takes no + // fallback: a directory outside the project scan resolves no project, + // which is an unresolvable default and opens the form seeded with it. + let resolved = match pinned.as_deref() { + Some(dir) => { + crate::fast_forward::resolve_at(dir, &discovered, &presets, names.first().cloned()) + } + None => crate::fast_forward::resolve( + &discovered, + &presets, + names.first().cloned(), + Utc::now(), + focused_cwd.as_deref(), + ), + }; if let Some(req) = resolved.build_request() { self.submit_spawn(req).await; return; @@ -1050,8 +1263,10 @@ impl App { }; draft.home_dir = home_dir; // The fast path resolved its own project; its pick wins over the - // focused session's, so the seed never lands on top of it. - draft.seed_cwd = None; + // focused session's, so the seed never lands on top of it. A pinned + // directory is the exception: it is the seed, whether or not the scan + // knew it as a project. + draft.seed_cwd = pinned; if !presets.is_empty() { draft.set_presets(presets); } @@ -1222,6 +1437,7 @@ impl App { draft: self.draft.as_ref(), help: &self.help, rename_input: &self.rename_input, + command_bar: self.command_bar.as_ref(), link: &link_status, }; @@ -1407,7 +1623,12 @@ enum DraftUpdate { projects: Option>, names: Vec, focused_cwd: Option, + /// The command bar pinned the spawn to this directory (#154): nothing + /// — not the focused session, not the recency ranking — may move it. + pinned: Option, }, + /// The command bar's `~` candidates arrived (#154). + BarDirectories { bar: u64, paths: Vec }, } /// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`. @@ -1670,6 +1891,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }) .collect(), }); @@ -1850,6 +2072,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }], }); app.dispatch(Action::SidebarNext); @@ -1876,6 +2099,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } @@ -2163,6 +2387,102 @@ mod tests { assert_eq!(req["worktree"]["create_branch"], json!(true)); } + /// The command bar's `~` pins the spawn: the directory the operator named + /// wins over the recency ranking that plain fast-forward would follow. + #[tokio::test] + async fn a_pinned_directory_spawns_there_not_in_the_freshest_project() { + use axum::routing::{get, post}; + use axum::Json; + use serde_json::json; + use std::sync::{Arc, Mutex}; + + async fn info() -> Json { + Json(json!({ + "hostname": "box", "version": "0.1.0", "home_dir": "/home/u", + "presets": [{"id": "claude", "label": "Claude", "command": "claude"}] + })) + } + async fn projects() -> Json { + Json(json!([ + {"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh", + "last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"}, + {"path": "/repos/pinned", "name": "pinned", "base_repo": "/repos/pinned", + "last_activity": (chrono::Utc::now() - chrono::TimeDelta::days(3)).to_rfc3339(), + "last_command": "zsh"} + ])) + } + async fn names() -> Json { + Json(json!({"names": ["crimson-otter"]})) + } + let posted: Arc>> = Arc::default(); + let seen = posted.clone(); + async fn spawn_session( + axum::extract::State(seen): axum::extract::State>>>, + Json(body): Json, + ) -> Json { + seen.lock().unwrap().push(body); + Json(json!({ + "id": uuid::Uuid::from_u128(78).to_string(), + "cwd": "/repos/pinned", "command": "zsh", + "status": "Running", "created_at": chrono::Utc::now().to_rfc3339() + })) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::PROJECTS, get(projects)) + .route(maestro_protocol::routes::NAMES, get(names)) + .route( + maestro_protocol::routes::SESSIONS, + post(spawn_session).with_state(seen), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + app.perform(vec![Effect::FastForwardIn(PathBuf::from("/repos/pinned"))]) + .await; + let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv()) + .await + .expect("load completed") + .expect("channel open"); + app.apply_draft_update(update).await; + + let posts = posted.lock().unwrap(); + assert_eq!(posts.len(), 1, "one immediate POST, no form shown"); + assert_eq!(posts[0]["cwd"], json!("/repos/pinned")); + assert_eq!(posts[0]["command"], json!("zsh")); + } + + /// A directory the project scan does not know resolves no project, which + /// is an unresolvable default: the form opens seeded with it rather than + /// the spawn silently landing somewhere else. + #[tokio::test] + async fn a_pinned_directory_outside_the_scan_opens_the_prefilled_form() { + let mut app = make_app(); + app.fast_forward_spawn( + None, + Some(vec![maestro_protocol::Project { + path: PathBuf::from("/repos/fresh"), + name: "fresh".into(), + is_worktree: false, + base_repo: None, + last_activity: Some(Utc::now()), + last_command: Some("aider".into()), + }]), + vec!["crimson-otter".into()], + None, + Some(PathBuf::from("/elsewhere")), + ) + .await; + assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn)); + let draft = app.draft.as_ref().expect("form is up"); + // The seed is dropped by the picker when no project matches it, so + // the operator picks by hand — but nothing spawned in `/repos/fresh`. + assert_ne!(draft.picked_cwd, Some(PathBuf::from("/repos/fresh"))); + } + /// `sync_focus` reports every path that moves `Focus::Pane`: attaching, /// switching which session the pane shows, and leaving the pane back to /// the sidebar. A sidebar highlight alone must report nothing. @@ -2611,6 +2931,55 @@ mod tests { CtEvent::Key(crossterm::event::KeyEvent::new(code, mods)) } + fn ctrl_key_event(c: char) -> CtEvent { + key_event( + crossterm::event::KeyCode::Char(c), + crossterm::event::KeyModifiers::CONTROL, + ) + } + + /// Drive a sequence of key events through an app in Insert mode with a + /// session attached, and report the app afterwards plus every chunk of + /// bytes that reached that session, in order. + async fn drive_insert_sequence( + section: &TuiSection, + events: &[CtEvent], + ) -> (App, Vec>) { + use crate::session_task::{SessionHandle, SessionState}; + use std::sync::atomic::{AtomicU64, AtomicU8}; + use std::sync::{Arc, Mutex}; + use tokio::sync::mpsc; + + let sid = Uuid::from_u128(41); + let mut app = App::new( + TuiConfig::load(section).unwrap(), + CatalogStore::default(), + DaemonClient::new("http://127.0.0.1:7070"), + DaemonLink::default(), + ); + let (tx, mut rx) = mpsc::channel(8); + app.emulators.insert( + sid, + SessionHandle { + tx, + screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))), + state: Arc::new(AtomicU8::new(SessionState::Live as u8)), + revision: Arc::new(AtomicU64::new(0)), + }, + ); + app.state.attach_to_focused(sid); + app.state.mode = Mode::Insert; + app.state.focus = Focus::Pane; + for ev in events { + app.handle_event(ev.clone()).await.unwrap(); + } + let mut sent = Vec::new(); + while let Ok(SessionInput::Bytes(bytes)) = rx.try_recv() { + sent.push(bytes); + } + (app, sent) + } + /// Escape belongs to the session: full-screen apps (vim, Claude Code) read /// it constantly, so no Insert-mode default may swallow it. The leader is /// the way back to the sidebar. @@ -2629,19 +2998,74 @@ mod tests { /// `Noop` suppresses a binding, and in Insert mode a key with no binding is /// the session's — so binding a key to `Noop` hands it to the pty. + /// + /// Uses `ctrl-x`, not the leader: the leader is also a chord prefix + /// (#156), so a single fed keystroke on it resolves as `Pending`, not + /// `Resolved` — a different mechanism, covered separately below. #[tokio::test] async fn a_noop_insert_binding_forwards_the_key_to_the_session() { let mut section = TuiSection::default(); - section.keymap_insert.insert("ctrl-a".into(), "Noop".into()); + section.keymap_insert.insert("ctrl-x".into(), "Noop".into()); let sent = bytes_sent_for( §ion, key_event( - crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyCode::Char('x'), crossterm::event::KeyModifiers::CONTROL, ), ) .await; - assert_eq!(sent, Some(vec![0x01])); + assert_eq!(sent, Some(vec![0x18])); + } + + /// Leader then `ctrl-k` sends the literal byte and never opens the bar + /// (#156) — the default leader, `ctrl-a`. + #[tokio::test] + async fn leader_then_ctrl_k_sends_the_literal_byte_and_does_not_open_the_bar() { + let (app, sent) = drive_insert_sequence( + &TuiSection::default(), + &[ctrl_key_event('a'), ctrl_key_event('k')], + ) + .await; + assert_eq!(sent, vec![vec![0x0b]]); + assert_eq!(app.state.overlay(), None); + assert_eq!(app.state.mode, Mode::Insert, "still in Insert, not left"); + } + + /// The escape hatch is built from the configured leader, not a hardcoded + /// `ctrl-a` — rebinding `[tui].leader` rebinds it too (#156). + #[tokio::test] + async fn a_rebound_leader_still_reaches_the_literal_ctrl_k_escape() { + let section = TuiSection { + leader: Some("ctrl-b".into()), + ..Default::default() + }; + let (app, sent) = + drive_insert_sequence(§ion, &[ctrl_key_event('b'), ctrl_key_event('k')]).await; + assert_eq!(sent, vec![vec![0x0b]]); + assert_eq!(app.state.overlay(), None); + } + + /// Leader alone, followed by an ordinary key, still leaves Insert mode — + /// the escape hatch chord doesn't cost the leader its existing meaning + /// (#156). + #[tokio::test] + async fn leader_alone_still_leaves_insert_when_followed_by_another_key() { + let (app, sent) = drive_insert_sequence( + &TuiSection::default(), + &[ + ctrl_key_event('a'), + key_event( + crossterm::event::KeyCode::Char('j'), + crossterm::event::KeyModifiers::NONE, + ), + ], + ) + .await; + assert!( + sent.is_empty(), + "no bytes for the leader chord itself: {sent:?}" + ); + assert_eq!(app.state.mode, Mode::Normal, "leader still leaves Insert"); } #[test] @@ -2713,6 +3137,284 @@ mod tests { .is_none()); } + // -- command bar (#153) ------------------------------------------------- + + fn seed_two_sessions(app: &mut App) -> (Uuid, Uuid) { + use chrono::Utc; + use maestro_protocol::{CatalogEvent, Session, SessionStatus}; + let make = |n: u128, name: &str, focused: Option>| Session { + id: Uuid::from_u128(n), + command: "bash".into(), + cwd: std::path::PathBuf::from("/"), + env: Default::default(), + metadata: HashMap::from([( + "name".to_string(), + serde_json::Value::String(name.to_string()), + )]), + worktree: None, + status: SessionStatus::Running, + created_at: Utc::now(), + foreground: None, + activity: Default::default(), + activity_detail: None, + last_activity_at: Utc::now(), + activity_since: Utc::now(), + current_cwd: None, + blocked_work: None, + last_focused_at: focused, + }; + let older = Utc::now() - chrono::Duration::seconds(60); + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: vec![ + make(1, "alpha", Some(older)), + make(2, "bravo", Some(Utc::now())), + ], + }); + (Uuid::from_u128(1), Uuid::from_u128(2)) + } + + /// `ctrl-k` is an ordinary keymap entry, bound in both modes so the bar + /// opens from the sidebar and from inside a pane (`docs/COMMAND-BAR.md`). + #[test] + fn ctrl_k_is_bound_to_the_command_bar_in_both_modes() { + let app = make_app(); + assert_eq!( + app.cfg.keymap.resolve(Mode::Normal, "ctrl-k"), + Some(Action::OpenCommandBar) + ); + assert_eq!( + app.cfg.keymap.resolve(Mode::Insert, "ctrl-k"), + Some(Action::OpenCommandBar) + ); + } + + /// The help overlay builds its lines from the live keymap, so the bar + /// appears there with no second list to maintain. + #[test] + fn the_help_overlay_lists_the_command_bar() { + let app = make_app(); + let lines = render_help_lines(&app.cfg); + assert!( + lines + .iter() + .any(|l| l.contains("ctrl-k") && l.contains("Go to a session")), + "{lines:#?}" + ); + } + + #[test] + fn opening_the_bar_lists_sessions_most_recently_focused_first() { + let mut app = make_app(); + let (alpha, bravo) = seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar)); + let bar = app.command_bar.as_ref().unwrap(); + assert_eq!(bar.query(), ""); + assert_eq!( + bar.rows() + .iter() + .map(|r| match r.target { + BarTarget::Session(id) => id, + ref other => panic!("expected a session row, got {other:?}"), + }) + .collect::>(), + vec![bravo, alpha] + ); + } + + /// `~`'s candidates are a network round trip, so opening asks for them + /// rather than blocking the keystroke on one. + #[test] + fn opening_the_bar_asks_for_its_directories() { + let mut app = make_app(); + let effects = app.dispatch(Action::OpenCommandBar); + let bar = app.command_bar.as_ref().unwrap().id(); + assert_eq!(effects, vec![Effect::LoadBarDirectories { bar }]); + } + + fn type_into_bar(app: &mut App, text: &str) { + for c in text.chars() { + app.handle_overlay_input(&InputKey::Char { + c, + ctrl: false, + alt: false, + shift: false, + }); + } + } + + /// `>` lists the live keymap for the current mode, matched on the label + /// alone, and Enter hands the action to the ordinary dispatcher. + #[test] + fn enter_on_a_command_row_dispatches_the_action() { + let mut app = make_app(); + app.dispatch(Action::OpenCommandBar); + type_into_bar(&mut app, ">defaults"); + let row = app.command_bar.as_ref().unwrap().selected().unwrap(); + assert_eq!(row.name, "Create session with defaults"); + assert_eq!( + row.target, + BarTarget::Command(Action::FastForwardSpawn), + "chords: {:?}", + row.chords + ); + + let (decision, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!(effects, vec![Effect::FastForward]); + assert!(app.state.overlay().is_none()); + assert!(app.command_bar.is_none()); + } + + /// The row shows the chord bound to it, and both chords when the map binds + /// two — but the chord is never what matched. + #[test] + fn a_command_row_carries_its_chords() { + let mut app = make_app(); + app.dispatch(Action::OpenCommandBar); + type_into_bar(&mut app, ">open selected"); + let row = app.command_bar.as_ref().unwrap().selected().unwrap(); + assert_eq!(row.name, "Open selected item"); + assert!( + row.chords.iter().any(|c| c == "enter"), + "chords: {:?}", + row.chords + ); + assert!( + row.chords.len() > 1, + "the default map binds more than one chord to it: {:?}", + row.chords + ); + } + + #[test] + fn enter_on_a_directory_row_fast_forward_spawns_there() { + let mut app = make_app(); + app.dispatch(Action::OpenCommandBar); + app.command_bar + .as_mut() + .unwrap() + .set_directories(&[PathBuf::from("/repos/api")]); + type_into_bar(&mut app, "~api"); + let (decision, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!( + effects, + vec![Effect::FastForwardIn(PathBuf::from("/repos/api"))] + ); + assert!(app.state.overlay().is_none()); + } + + /// A load that outlived its bar fills in nothing: the reopened bar has its + /// own identity. + #[tokio::test] + async fn a_stale_directory_load_is_dropped() { + let mut app = make_app(); + app.dispatch(Action::OpenCommandBar); + let stale = app.command_bar.as_ref().unwrap().id(); + app.handle_overlay_input(&InputKey::Esc); + app.dispatch(Action::OpenCommandBar); + + app.apply_draft_update(DraftUpdate::BarDirectories { + bar: stale, + paths: vec![PathBuf::from("/repos/api")], + }) + .await; + type_into_bar(&mut app, "~"); + assert!(app.command_bar.as_ref().unwrap().rows().is_empty()); + } + + /// Opening from Insert mode leaves the keyboard where it was, so closing + /// gives it back — the bar is a view over the app, not a place in it. + #[test] + fn opening_from_insert_mode_leaves_focus_and_mode_alone() { + let mut app = make_app(); + seed_two_sessions(&mut app); + app.state.attach_to_focused(Uuid::from_u128(1)); + assert_eq!( + (app.state.focus, app.state.mode), + (Focus::Pane, Mode::Insert) + ); + + app.dispatch(Action::OpenCommandBar); + assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar)); + assert_eq!( + (app.state.focus, app.state.mode), + (Focus::Pane, Mode::Insert) + ); + + let (decision, effects) = app.handle_overlay_input(&InputKey::Esc); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + assert!(app.state.overlay().is_none()); + assert!(app.command_bar.is_none()); + assert_eq!( + (app.state.focus, app.state.mode), + (Focus::Pane, Mode::Insert) + ); + } + + /// Enter goes through the same open path the sidebar's attach uses. + #[test] + fn enter_attaches_the_highlighted_session() { + let mut app = make_app(); + let (_alpha, bravo) = seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + let (decision, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!(effects, vec![Effect::Attach(bravo)]); + assert!(app.state.overlay().is_none()); + assert!(app.command_bar.is_none()); + assert_eq!(app.state.active_session(), Some(bravo)); + } + + /// Down moves the highlight, so Enter opens the second row instead. + #[test] + fn the_highlight_moves_before_enter_attaches() { + let mut app = make_app(); + let (alpha, _bravo) = seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + app.handle_overlay_input(&InputKey::Down); + let (_, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!(effects, vec![Effect::Attach(alpha)]); + assert_eq!(app.state.active_session(), Some(alpha)); + } + + /// Typing goes to the query, not to the keymap: `q` must not quit and `x` + /// must not raise a kill confirm while the bar is up. + #[test] + fn typing_reaches_the_query_rather_than_the_keymap() { + let mut app = make_app(); + seed_two_sessions(&mut app); + app.dispatch(Action::OpenCommandBar); + for c in "bra".chars() { + let (decision, _) = app.handle_overlay_input(&InputKey::Char { + c, + ctrl: false, + alt: false, + shift: false, + }); + assert_eq!(decision, OverlayDecision::Consumed); + } + let bar = app.command_bar.as_ref().unwrap(); + assert_eq!(bar.query(), "bra"); + assert_eq!(bar.rows().len(), 1); + } + + /// The bar is not previewed into (`docs/PREVIEW.md`): opening discards a + /// preview and the highlight behind it raises no new one. + #[test] + fn opening_the_bar_discards_a_preview() { + let mut app = make_app(); + let (alpha, _bravo) = seed_two_sessions(&mut app); + app.state.sidebar_highlight = Some(SidebarItem::Session(alpha)); + app.state.preview = Some(alpha); + + app.dispatch(Action::OpenCommandBar); + assert_eq!(app.state.preview, None); + assert_eq!(app.pending_preview, None); + } + #[test] fn kill_highlighted_opens_confirm() { use chrono::Utc; @@ -2737,6 +3439,7 @@ mod tests { current_cwd: None, blocked_work: None, + last_focused_at: None, }], }); app.dispatch(Action::KillHighlighted); @@ -2771,6 +3474,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }], }); } @@ -2853,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) { + 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()); + } } diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index ec589b2..36f39d5 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -222,6 +222,7 @@ mod tests { activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } diff --git a/crates/maestro-tui/src/command_bar.rs b/crates/maestro-tui/src/command_bar.rs new file mode 100644 index 0000000..3b2081b --- /dev/null +++ b/crates/maestro-tui/src/command_bar.rs @@ -0,0 +1,1518 @@ +//! The `ctrl-k` command bar (`docs/COMMAND-BAR.md`): a query line over a +//! 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). `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 +//! member appears. The web bar (`web/src/commandBar.ts`) implements the same +//! contract; the two must agree. + +use std::path::{Path, PathBuf}; + +use maestro_protocol::display::{abbreviate_dir, command_tool, session_tree_path}; +use maestro_protocol::{Session, SessionStatus}; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; +use uuid::Uuid; + +use crate::action::Action; +use crate::input::InputKey; +use crate::matcher::{ + match_and_rank, CandidateKind, CommandCandidate, DirectoryCandidate, MatchCandidate, + MatchFieldName, MatchRange, SessionCandidate, +}; +use crate::overlays::centered; +use crate::palette; +use crate::text_input::TextInput; + +/// Identity for routing an async directory load back to *this* bar: a load in +/// flight must not land in a bar the operator closed and reopened. +static NEXT_BAR_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// What Enter on a row does. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BarTarget { + Session(Uuid), + /// Dispatch the action the row stands for. The chord is displayed beside + /// the row but is not part of the target: two chords bound to one action + /// are one row. + Command(Action), + /// 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)] +pub enum BarSigil { + /// `>` — commands from the live keymap. + Command, + /// `~` — recent dirs and the project list. + Directory, +} + +/// A raw query line split into its sigil and the query proper. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BarQuery<'q> { + pub sigil: Option, + /// What the matcher sees: everything after a sigil, or the whole query. + pub query: &'q str, +} + +/// Split a raw query line into its sigil and the query proper. +/// +/// A sigil is only a sigil in first position: `>` or `~` anywhere else is a +/// literal query character, which is what a directory query with a second `~` +/// in it depends on. Backspacing past a sigil needs no special case either — +/// the first character goes and the query is a mixed one again. Mirrors the +/// web's `parseBarQuery`. +pub fn parse_bar_query(raw: &str) -> BarQuery<'_> { + let sigil = match raw.chars().next() { + Some('>') => BarSigil::Command, + Some('~') => BarSigil::Directory, + _ => { + return BarQuery { + sigil: None, + query: raw, + } + } + }; + BarQuery { + sigil: Some(sigil), + query: &raw[1..], + } +} + +/// One row of the result list, already resolved to what it renders and what it +/// opens. Headers are labels, not rows, so they are never in this list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BarRow { + pub kind: CandidateKind, + pub target: BarTarget, + /// Primary text — the name as the sidebar shows it. + pub name: String, + /// Secondary text: the field the match was found in when that was not the + /// name, so a session found through its directory says so. + pub meta: Option, + /// The field that won, and the ranges inside it. `None` for an empty + /// query, which matches everything and highlights nothing. + pub field: Option, + pub ranges: Vec, + pub dead: bool, + /// Every chord bound to a command row's action, displayed beside it and + /// never matched. Empty for every other kind. + pub chords: Vec, +} + +impl BarRow { + /// Whether the highlight belongs on [`Self::meta`] rather than the name. + fn meta_is_matched(&self) -> bool { + !matches!(self.field, None | Some(MatchFieldName::Name)) + } +} + +/// One kind's slice of the ranked list, as a header plus the rows under it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BarGroup { + pub kind: CandidateKind, + pub title: &'static str, + /// Index of this group's first row in [`CommandBar::rows`]. + pub start: usize, + pub len: usize, +} + +fn kind_title(kind: CandidateKind) -> &'static str { + match kind { + CandidateKind::Session => "Sessions", + CandidateKind::Command => "Commands", + CandidateKind::Directory => "Directories", + } +} + +/// What a keystroke asked the bar to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BarOutcome { + /// The bar stays up. + Stay, + /// Escape: close and give the keyboard back. + Close, + /// Enter on a row: close and open what it points at. + Open(BarTarget), +} + +/// The empty-query order (`docs/COMMAND-BAR.md`, "Empty query"): most recently +/// focused first; a session that has never been focused sorts after every +/// session that has, most recently created first among those; and a dead +/// session sorts after every live one regardless of recency, because a session +/// you cannot attach to is not where you just were. +/// +/// This is also the input order for a non-empty query, which is what the +/// matcher's last tie-break preserves. Mirrors the web's +/// `orderSessionsForBar`. +pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> { + let mut ordered: Vec<&Session> = sessions.iter().collect(); + ordered.sort_by(|a, b| { + let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead); + dead(a) + .cmp(&dead(b)) + .then_with(|| { + a.last_focused_at + .is_none() + .cmp(&b.last_focused_at.is_none()) + }) + .then_with(|| match (a.last_focused_at, b.last_focused_at) { + (Some(x), Some(y)) => y.cmp(&x), + _ => std::cmp::Ordering::Equal, + }) + .then_with(|| b.created_at.cmp(&a.created_at)) + }); + ordered +} + +/// One session's candidate: the fields `docs/COMMAND-BAR.md`'s candidate table +/// names, with the directory abbreviated the way the sidebar shows it. +pub fn session_candidate(session: &Session, home_dir: &str) -> SessionCandidate { + SessionCandidate { + name: crate::sidebar::name_for(session), + directory: session_tree_path(session).map(|path| abbreviate_dir(&path, home_dir)), + command: (!session.command.is_empty()).then(|| session.command.clone()), + tool: command_tool(&session.command).map(str::to_string), + } +} + +/// One command row: a distinct [`Action`] and every chord bound to it. +/// +/// Dedupe is by **action, never by chord** — the default map binds both +/// `enter` and `tab` to `AttachHighlighted`, and listed naively that is two +/// identical rows. Actions that share a `help_label` collapse too: +/// `AttachHighlighted` and `ToggleAttachHighlighted` are the same match arm in +/// `app.rs` and genuinely do the same thing, so they are one row carrying both +/// chords rather than two rows a reader cannot tell apart. `Noop` is left out: +/// it exists to suppress a default binding, not as something to run. +/// +/// Input order is the keymap's own (`Keymap::entries` sorts by chord), and the +/// first mention of each action keeps its place — the registry order the +/// contract asks an empty `>` query to list. +pub fn command_entries(entries: &[(String, Action)]) -> Vec<(Action, Vec)> { + let mut out: Vec<(Action, Vec)> = Vec::new(); + for (chord, action) in entries { + if *action == Action::Noop { + continue; + } + let label = action.help_label(); + match out + .iter_mut() + .find(|(kept, _)| kept == action || kept.help_label() == label) + { + Some((_, chords)) => { + if !chords.iter().any(|c| c == chord) { + chords.push(chord.clone()); + } + } + None => out.push((action.clone(), vec![chord.clone()])), + } + } + out +} + +/// One candidate plus what it renders and what Enter does with it. The matcher +/// never sees anything but `candidate`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct BarEntry { + candidate: MatchCandidate, + target: BarTarget, + dead: bool, + chords: Vec, +} + +impl BarEntry { + fn candidate_kind(&self) -> CandidateKind { + match self.candidate { + MatchCandidate::Session(_) => CandidateKind::Session, + MatchCandidate::Command(_) => CandidateKind::Command, + MatchCandidate::Directory(_) => CandidateKind::Directory, + } + } +} + +/// The bar's state while it is up: the query buffer, the candidates it was +/// opened over, the rows the current query produces, and the highlight. +#[derive(Debug)] +pub struct CommandBar { + /// Identity for routing an async directory load back to this bar. + id: u64, + input: TextInput, + home_dir: String, + /// Matcher input per kind, in the empty-query order. Never reordered — + /// the matcher's last tie-break is what keeps that order meaningful. + sessions: Vec, + commands: Vec, + directories: Vec, + /// 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, + stashed: String, + rows: Vec, + 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, +} + +impl CommandBar { + /// Open the bar over the sessions the catalog has right now and the live + /// keymap for the current mode. Directories arrive later + /// ([`Self::set_directories`]) — they are a network round trip and the + /// keystroke must not wait on one. + pub fn new(sessions: &[Session], keymap: &[(String, Action)], home_dir: &str) -> Self { + let ordered = order_sessions_for_bar(sessions); + let sessions = ordered + .iter() + .map(|s| BarEntry { + candidate: MatchCandidate::Session(session_candidate(s, home_dir)), + target: BarTarget::Session(s.id), + dead: s.status == SessionStatus::Dead, + chords: Vec::new(), + }) + .collect(); + let commands = command_entries(keymap) + .into_iter() + .map(|(action, chords)| BarEntry { + candidate: MatchCandidate::Command(CommandCandidate { + label: action.help_label(), + }), + target: BarTarget::Command(action), + dead: false, + chords, + }) + .collect(); + let mut bar = Self { + id: NEXT_BAR_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + input: TextInput::default(), + home_dir: home_dir.to_string(), + sessions, + commands, + directories: Vec::new(), + page: None, + stashed: String::new(), + rows: Vec::new(), + highlight: 0, + }; + bar.recompute(); + bar + } + + pub fn id(&self) -> u64 { + self.id + } + + /// Hand the bar `~`'s candidates once the daemon has answered: recent dirs + /// (newest first) ahead of the project list, deduped by the directory a + /// spawn would land in, keeping the first mention of each. + pub fn set_directories(&mut self, paths: &[PathBuf]) { + let mut seen: Vec<&Path> = Vec::new(); + let mut entries = Vec::new(); + for path in paths { + if path.as_os_str().is_empty() || seen.contains(&path.as_path()) { + continue; + } + seen.push(path.as_path()); + entries.push(BarEntry { + candidate: MatchCandidate::Directory(DirectoryCandidate { + path: abbreviate_dir(path, &self.home_dir), + }), + target: BarTarget::Directory(path.clone()), + dead: false, + chords: Vec::new(), + }); + } + self.directories = entries; + self.recompute(); + } + + pub fn query(&self) -> &str { + self.input.value() + } + + pub fn rows(&self) -> &[BarRow] { + &self.rows + } + + pub fn highlight(&self) -> usize { + self.highlight + } + + /// The row Enter would open. + pub fn selected(&self) -> Option<&BarRow> { + self.rows.get(self.highlight) + } + + /// Group the ranked rows by kind for rendering. Grouping never reorders: + /// a group holds its matches in rank order, and groups appear in the order + /// their best-scoring member does — so the first row under the first + /// header is the globally best result. + pub fn groups(&self) -> Vec { + let mut groups: Vec = Vec::new(); + for (index, row) in self.rows.iter().enumerate() { + match groups.iter_mut().find(|g| g.kind == row.kind) { + Some(group) => group.len += 1, + None => groups.push(BarGroup { + kind: row.kind, + title: kind_title(row.kind), + start: index, + len: 1, + }), + } + } + groups + } + + /// Move the highlight. It does not wrap: it stops at the first and last + /// row. + pub fn move_highlight(&mut self, delta: isize) { + if self.rows.is_empty() { + self.highlight = 0; + return; + } + let last = self.rows.len() as isize - 1; + let next = (self.highlight as isize + delta).clamp(0, last); + self.highlight = next as usize; + } + + /// Feed the bar a key. Everything the key table (`docs/COMMAND-BAR.md`, + /// "Keys") does not claim is ordinary editing. + pub fn handle_key(&mut self, key: &InputKey) -> BarOutcome { + match key { + // 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 + // key that did nothing. + None => BarOutcome::Stay, + }, + InputKey::Up + | InputKey::Char { + c: 'p', ctrl: true, .. + } => { + self.move_highlight(-1); + BarOutcome::Stay + } + InputKey::Down + | InputKey::Char { + c: 'n', ctrl: true, .. + } => { + self.move_highlight(1); + 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); + if self.input.value() != before { + self.recompute(); + } + BarOutcome::Stay + } + } + } + + /// `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 { + 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(), + None if parsed.query.is_empty() => self.sessions.iter().collect(), + None => self.sessions.iter().chain(self.commands.iter()).collect(), + } + } + + /// Re-rank for the current query. The highlight starts on the globally + /// best row every time the list changes: an old index would point at a + /// different result. + fn recompute(&mut self) { + let rows: Vec = { + let parsed = self.parsed_query(); + let entries = self.active_entries(parsed); + let candidates: Vec = + entries.iter().map(|e| e.candidate.clone()).collect(); + match_and_rank(parsed.query, &candidates) + .iter() + .map(|result| { + let index = candidates + .iter() + .position(|c| std::ptr::eq(c, result.candidate)) + .expect("every result borrows one of this query's candidates"); + let entry = entries[index]; + let (name, meta) = match &entry.candidate { + MatchCandidate::Session(session) => { + (session.name.clone(), meta_text(session, result.field)) + } + // A command's label and a directory's path are the row + // itself; neither has a second field to fall back to. + MatchCandidate::Command(command) => (command.label.clone(), None), + MatchCandidate::Directory(directory) => (directory.path.clone(), None), + }; + BarRow { + kind: entry.candidate_kind(), + target: entry.target.clone(), + name, + meta, + field: result.field, + ranges: result.ranges.clone(), + dead: entry.dead, + chords: entry.chords.clone(), + } + }) + .collect() + }; + self.rows = rows; + self.highlight = 0; + } + + /// Draw the bar and return where the terminal cursor belongs. + pub fn render(&self, f: &mut Frame, parent: Rect) -> Option<(u16, u16)> { + let pal = palette::active(); + 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(title) + .borders(Borders::ALL) + .style(pal.card.base()) + .border_style(pal.border_style(true)); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height < 3 || inner.width == 0 { + return None; + } + let row = |offset: u16| Rect { + x: inner.x, + y: inner.y + offset, + width: inner.width, + height: 1, + }; + + // Query line, then the results, then the key hint on the last row. + let query = row(0); + let col = self + .input + .render(query, f.buffer_mut(), pal.card.text_style()); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + if self.page.is_some() { + "[enter] run [esc] back" + } else { + "[enter] open [esc] close" + }, + pal.card.dim_style(), + ))), + row(inner.height - 1), + ); + + let list_height = inner.height.saturating_sub(2) as usize; + if list_height > 0 { + for (offset, line) in self.visible_lines(list_height).into_iter().enumerate() { + f.render_widget(Paragraph::new(line), row(1 + offset as u16)); + } + } + Some((col, query.y)) + } + + /// The result lines that fit in `height`, scrolled so the highlighted row + /// is on screen. + fn visible_lines(&self, height: usize) -> Vec> { + let pal = palette::active(); + if self.rows.is_empty() { + return vec![Line::from(Span::styled( + "No matches", + pal.card.muted_style(), + ))]; + } + // Headers and rows, in one list, remembering which line the highlight + // sits on so the scroll can keep it visible. + let mut lines: Vec> = 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. 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( + group.title.to_string(), + pal.group_row_style(), + ))); + } + for index in group.start..group.start + group.len { + if index == self.highlight { + highlight_line = lines.len(); + } + lines.push(self.row_line(index)); + } + } + let scroll = highlight_line.saturating_sub(height.saturating_sub(1)); + lines.into_iter().skip(scroll).take(height).collect() + } + + fn row_line(&self, index: usize) -> Line<'static> { + let pal = palette::active(); + let row = &self.rows[index]; + let selected = index == self.highlight; + let surface = pal.surface(selected); + let base = if row.dead { + surface.dim_style() + } else { + surface.text_style() + }; + let hit = base.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + + let mut spans = vec![Span::styled(if selected { "› " } else { " " }, base)]; + let name_ranges: &[MatchRange] = if row.meta_is_matched() { + &[] + } else { + &row.ranges + }; + spans.extend(highlighted_spans(&row.name, name_ranges, base, hit)); + // The chord is teaching material, not a match target: it comes from + // the live keymap and is never fed to the matcher. + if !row.chords.is_empty() { + spans.push(Span::styled(" ", base)); + spans.push(Span::styled(row.chords.join(", "), surface.muted_style())); + } + if let Some(meta) = &row.meta { + let meta_style = surface.muted_style(); + let meta_hit = meta_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + spans.push(Span::styled(" ", base)); + let meta_ranges: &[MatchRange] = if row.meta_is_matched() { + &row.ranges + } else { + &[] + }; + spans.extend(highlighted_spans(meta, meta_ranges, meta_style, meta_hit)); + } + Line::from(spans) + } +} + +/// The secondary line under a row's name: the field the match was found in +/// when that was not the name, so a session found through its directory says +/// so. Mirrors the web's `metaText`. +fn meta_text(session: &SessionCandidate, field: Option) -> Option { + match field { + Some(MatchFieldName::Directory) => session.directory.clone(), + Some(MatchFieldName::Command) => session.command.clone(), + Some(MatchFieldName::Tool) => session.tool.clone(), + _ => session.directory.clone(), + } +} + +/// Split `text` into styled spans, with the matcher's `[start, end)` character +/// ranges carrying `hit` and everything else `base`. +fn highlighted_spans( + text: &str, + ranges: &[MatchRange], + base: Style, + hit: Style, +) -> Vec> { + let chars: Vec = text.chars().collect(); + let slice = |from: usize, to: usize| -> String { + chars[from.min(chars.len())..to.min(chars.len())] + .iter() + .collect() + }; + let mut spans = Vec::new(); + let mut cursor = 0usize; + for &(start, end) in ranges { + if start > cursor { + spans.push(Span::styled(slice(cursor, start), base)); + } + spans.push(Span::styled(slice(start, end), hit)); + cursor = end; + } + if cursor < chars.len() { + spans.push(Span::styled(slice(cursor, chars.len()), base)); + } + spans +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{DateTime, TimeZone, Utc}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use std::collections::HashMap; + use std::path::PathBuf; + + fn at(secs: i64) -> DateTime { + Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap() + } + + fn session(n: u128, command: &str) -> Session { + Session { + id: Uuid::from_u128(n), + cwd: PathBuf::from("/home/me/dev/api"), + command: command.to_string(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + status: SessionStatus::Running, + created_at: at(0), + foreground: None, + activity: Default::default(), + activity_detail: None, + last_activity_at: at(0), + activity_since: at(0), + current_cwd: None, + blocked_work: None, + last_focused_at: None, + } + } + + fn named(n: u128, name: &str) -> Session { + let mut s = session(n, "bash"); + s.metadata + .insert("name".into(), serde_json::Value::String(name.to_string())); + s + } + + fn ids(rows: &[BarRow]) -> Vec { + rows.iter() + .map(|r| match r.target { + BarTarget::Session(id) => id, + ref other => panic!("expected a session row, got {other:?}"), + }) + .collect() + } + + fn typed(bar: &mut CommandBar, text: &str) { + for c in text.chars() { + bar.handle_key(&InputKey::Char { + c, + ctrl: false, + alt: false, + shift: false, + }); + } + } + + #[test] + fn empty_query_lists_sessions_most_recently_focused_first() { + let mut a = named(1, "alpha"); + a.last_focused_at = Some(at(10)); + let mut b = named(2, "bravo"); + b.last_focused_at = Some(at(30)); + let mut c = named(3, "charlie"); + c.last_focused_at = Some(at(20)); + + let bar = CommandBar::new(&[a, b, c], &[], "/home/me"); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(2), Uuid::from_u128(3), Uuid::from_u128(1)] + ); + // Every row matches with no ranges, so nothing is highlighted inside. + assert!(bar.rows().iter().all(|r| r.ranges.is_empty())); + } + + #[test] + fn never_focused_sessions_sort_after_focused_ones_newest_first() { + let mut focused = named(1, "focused"); + focused.last_focused_at = Some(at(5)); + let mut older = named(2, "older"); + older.created_at = at(10); + let mut newer = named(3, "newer"); + newer.created_at = at(20); + + let bar = CommandBar::new(&[older, newer, focused], &[], "/home/me"); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(1), Uuid::from_u128(3), Uuid::from_u128(2)] + ); + } + + /// A session you cannot attach to is not where you just were. + #[test] + fn dead_sessions_sort_after_live_ones_however_recent() { + let mut dead = named(1, "dead"); + dead.status = SessionStatus::Dead; + dead.last_focused_at = Some(at(99)); + let live = named(2, "live"); + + let bar = CommandBar::new(&[dead, live], &[], "/home/me"); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(2), Uuid::from_u128(1)] + ); + assert!(bar.rows()[1].dead); + } + + #[test] + fn typing_ranks_through_the_matcher_and_returns_ranges() { + let mut bar = CommandBar::new( + &[named(1, "web-search"), named(2, "websearch")], + &[], + "/home/me", + ); + typed(&mut bar, "ws"); + // F9: the `s` after `-` earns the boundary bonus. + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(1), Uuid::from_u128(2)] + ); + assert_eq!(bar.rows()[0].ranges, vec![(0, 1), (4, 5)]); + assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Name)); + } + + #[test] + fn a_query_matching_nothing_leaves_no_rows() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + typed(&mut bar, "zzz"); + assert!(bar.rows().is_empty()); + assert!(bar.selected().is_none()); + } + + /// A session found through its directory highlights inside the directory, + /// which is also what the row's meta text shows. + #[test] + fn a_directory_match_highlights_in_the_meta_text() { + let mut worker = named(1, "worker"); + worker.cwd = PathBuf::from("/home/me/dev/api"); + let mut bar = CommandBar::new(&[worker], &[], "/home/me"); + typed(&mut bar, "api"); + assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Directory)); + assert_eq!(bar.rows()[0].meta.as_deref(), Some("~/dev/api")); + assert_eq!(bar.rows()[0].ranges, vec![(6, 9)]); + } + + /// The candidate's `tool` is the mark the card's icon stands for, so + /// `claude` reaches a session whose name says nothing about it. + #[test] + fn the_tool_field_is_a_candidate_field() { + let mut nameless = named(1, "nameless"); + nameless.command = "claude --resume".into(); + let mut bar = CommandBar::new(&[nameless, named(2, "clay")], &[], "/home/me"); + typed(&mut bar, "claude"); + assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(1)]); + assert_eq!(bar.rows()[0].field, Some(MatchFieldName::Tool)); + } + + #[test] + fn results_group_under_kind_headers_with_the_best_row_first() { + let bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me"); + let groups = bar.groups(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].title, "Sessions"); + assert_eq!(groups[0].kind, CandidateKind::Session); + assert_eq!((groups[0].start, groups[0].len), (0, 2)); + // The highlight starts on the globally best row. + assert_eq!(bar.highlight(), 0); + } + + #[test] + fn arrows_and_ctrl_pn_move_the_highlight_without_wrapping() { + let mut bar = CommandBar::new( + &[named(1, "alpha"), named(2, "bravo"), named(3, "charlie")], + &[], + "/home/me", + ); + assert_eq!(bar.handle_key(&InputKey::Down), BarOutcome::Stay); + assert_eq!(bar.highlight(), 1); + bar.handle_key(&InputKey::Char { + c: 'n', + ctrl: true, + alt: false, + shift: false, + }); + assert_eq!(bar.highlight(), 2); + // Stops at the last row rather than wrapping to the first. + bar.handle_key(&InputKey::Down); + assert_eq!(bar.highlight(), 2); + bar.handle_key(&InputKey::Char { + c: 'p', + ctrl: true, + alt: false, + shift: false, + }); + assert_eq!(bar.highlight(), 1); + bar.handle_key(&InputKey::Up); + bar.handle_key(&InputKey::Up); + assert_eq!(bar.highlight(), 0); + } + + #[test] + fn enter_opens_the_highlighted_session_and_escape_closes() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], &[], "/home/me"); + bar.handle_key(&InputKey::Down); + assert_eq!( + bar.handle_key(&InputKey::Enter), + BarOutcome::Open(BarTarget::Session(Uuid::from_u128(2))) + ); + assert_eq!(bar.handle_key(&InputKey::Esc), BarOutcome::Close); + } + + #[test] + fn enter_with_no_results_leaves_the_bar_up() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + typed(&mut bar, "zzz"); + assert_eq!(bar.handle_key(&InputKey::Enter), BarOutcome::Stay); + } + + #[test] + fn editing_the_query_re_ranks_and_resets_the_highlight() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], &[], "/home/me"); + bar.handle_key(&InputKey::Down); + assert_eq!(bar.highlight(), 1); + typed(&mut bar, "alp"); + assert_eq!(bar.query(), "alp"); + assert_eq!(bar.highlight(), 0); + bar.handle_key(&InputKey::Backspace); + assert_eq!(bar.query(), "al"); + assert_eq!(bar.rows().len(), 2); + } + + /// Backspacing back to an empty query is not a special key: the list + /// becomes the recency order again. + #[test] + fn backspacing_to_an_empty_query_restores_the_recency_order() { + let mut recent = named(1, "alpha"); + recent.last_focused_at = Some(at(50)); + let older = named(2, "alpine"); + let mut bar = CommandBar::new(&[older, recent], &[], "/home/me"); + typed(&mut bar, "alpi"); + assert_eq!(ids(bar.rows()), vec![Uuid::from_u128(2)]); + for _ in 0..4 { + bar.handle_key(&InputKey::Backspace); + } + assert_eq!(bar.query(), ""); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(1), Uuid::from_u128(2)] + ); + } + + fn keymap(pairs: &[(&str, Action)]) -> Vec<(String, Action)> { + pairs + .iter() + .map(|(chord, action)| (chord.to_string(), action.clone())) + .collect() + } + + fn labels(rows: &[BarRow]) -> Vec { + rows.iter().map(|r| r.name.clone()).collect() + } + + // ---- sigil parsing ---- + + #[test] + fn a_leading_sigil_is_a_sigil_and_the_rest_is_the_query() { + assert_eq!( + parse_bar_query(">kill"), + BarQuery { + sigil: Some(BarSigil::Command), + query: "kill" + } + ); + assert_eq!( + parse_bar_query("~dev"), + BarQuery { + sigil: Some(BarSigil::Directory), + query: "dev" + } + ); + assert_eq!( + parse_bar_query("api"), + BarQuery { + sigil: None, + query: "api" + } + ); + assert_eq!( + parse_bar_query(""), + BarQuery { + sigil: None, + query: "" + } + ); + } + + /// A directory query is where a second `~` is most likely to be typed, and + /// it must reach the matcher. + #[test] + fn a_sigil_anywhere_but_first_is_a_literal_query_character() { + assert_eq!( + parse_bar_query("~~/dev"), + BarQuery { + sigil: Some(BarSigil::Directory), + query: "~/dev" + } + ); + assert_eq!( + parse_bar_query("a>b"), + BarQuery { + sigil: None, + query: "a>b" + } + ); + } + + // ---- `>` commands ---- + + /// The default map binds both `enter` and `tab` to `AttachHighlighted`; + /// listed naively that is two identical rows. + #[test] + fn command_rows_dedupe_by_action_and_keep_every_chord() { + let entries = command_entries(&keymap(&[ + ("enter", Action::AttachHighlighted), + ("tab", Action::AttachHighlighted), + ("q", Action::Quit), + ])); + assert_eq!( + entries, + vec![ + ( + Action::AttachHighlighted, + vec!["enter".to_string(), "tab".to_string()] + ), + (Action::Quit, vec!["q".to_string()]), + ] + ); + } + + /// `AttachHighlighted` and `ToggleAttachHighlighted` share a `help_label` + /// because they are the same match arm and genuinely do the same thing. + /// Two rows a reader cannot tell apart is the bug; relabelling them would + /// describe behaviour that does not exist. + #[test] + fn actions_sharing_a_label_collapse_into_one_row() { + let entries = command_entries(&keymap(&[ + ("enter", Action::AttachHighlighted), + ("o", Action::ToggleAttachHighlighted), + ])); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].1, vec!["enter".to_string(), "o".to_string()]); + } + + /// `Noop` exists to suppress a default binding, not as something to run. + #[test] + fn noop_bindings_are_not_commands() { + let entries = command_entries(&keymap(&[("x", Action::Noop), ("q", Action::Quit)])); + assert_eq!(entries, vec![(Action::Quit, vec!["q".to_string()])]); + } + + #[test] + fn the_command_sigil_narrows_to_commands_matched_on_their_label() { + let map = keymap(&[ + ("K", Action::KillHighlighted), + ("q", Action::Quit), + ("r", Action::RenameHighlighted), + ]); + let mut bar = CommandBar::new(&[named(1, "kill-me")], &map, "/home/me"); + typed(&mut bar, ">kill"); + assert_eq!( + labels(bar.rows()), + vec!["Kill selected session".to_string()] + ); + assert!(bar.rows().iter().all(|r| r.kind == CandidateKind::Command)); + assert_eq!(bar.rows()[0].chords, vec!["K".to_string()]); + } + + /// Matching the chord would make `>` results depend on a user's rebinds. + #[test] + fn a_chord_is_displayed_and_never_matched() { + let map = keymap(&[("zzz", Action::Quit)]); + let mut bar = CommandBar::new(&[], &map, "/home/me"); + typed(&mut bar, ">zzz"); + assert!(bar.rows().is_empty(), "{:?}", labels(bar.rows())); + bar.handle_key(&InputKey::Backspace); + bar.handle_key(&InputKey::Backspace); + bar.handle_key(&InputKey::Backspace); + assert_eq!(labels(bar.rows()), vec!["Quit Maestro".to_string()]); + assert_eq!(bar.rows()[0].chords, vec!["zzz".to_string()]); + } + + #[test] + fn enter_on_a_command_row_dispatches_its_action() { + let map = keymap(&[("q", Action::Quit)]); + let mut bar = CommandBar::new(&[], &map, "/home/me"); + typed(&mut bar, ">quit"); + assert_eq!( + bar.handle_key(&InputKey::Enter), + BarOutcome::Open(BarTarget::Command(Action::Quit)) + ); + } + + /// An empty `>` query lists every command in keymap order. + #[test] + fn an_empty_command_query_lists_the_whole_keymap() { + let map = keymap(&[("K", Action::KillHighlighted), ("q", Action::Quit)]); + let mut bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me"); + typed(&mut bar, ">"); + assert_eq!( + labels(bar.rows()), + vec![ + "Kill selected session".to_string(), + "Quit Maestro".to_string() + ] + ); + } + + // ---- `~` directories ---- + + #[test] + fn the_directory_sigil_narrows_to_directories_deduped_and_abbreviated() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + bar.set_directories(&[ + PathBuf::from("/home/me/dev/api"), + PathBuf::from("/home/me/dev/api"), + PathBuf::from("/srv/www"), + ]); + typed(&mut bar, "~"); + assert_eq!( + labels(bar.rows()), + vec!["~/dev/api".to_string(), "/srv/www".to_string()] + ); + assert!(bar + .rows() + .iter() + .all(|r| r.kind == CandidateKind::Directory)); + } + + #[test] + fn enter_on_a_directory_row_spawns_there() { + let mut bar = CommandBar::new(&[], &[], "/home/me"); + bar.set_directories(&[PathBuf::from("/home/me/dev/api")]); + typed(&mut bar, "~api"); + assert_eq!( + bar.handle_key(&InputKey::Enter), + BarOutcome::Open(BarTarget::Directory(PathBuf::from("/home/me/dev/api"))) + ); + } + + // ---- the mixed list ---- + + #[test] + fn no_sigil_ranks_sessions_and_commands_together_under_kind_headers() { + let map = keymap(&[("K", Action::KillHighlighted)]); + let mut bar = CommandBar::new(&[named(1, "killer")], &map, "/home/me"); + typed(&mut bar, "kill"); + let groups = bar.groups(); + assert_eq!( + groups.iter().map(|g| g.title).collect::>(), + vec!["Sessions", "Commands"] + ); + assert_eq!( + labels(bar.rows()), + vec!["killer".to_string(), "Kill selected session".to_string()] + ); + } + + /// An empty bar is a session switcher: commands and directories need a + /// sigil or a query to appear. + #[test] + fn an_empty_query_lists_sessions_only() { + let map = keymap(&[("q", Action::Quit)]); + let bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me"); + assert_eq!(labels(bar.rows()), vec!["alpha".to_string()]); + } + + /// The sigil is one character of the query like any other: deleting it is + /// not a special key. + #[test] + fn backspacing_past_a_sigil_returns_to_the_mixed_list() { + let map = keymap(&[("K", Action::KillHighlighted)]); + let mut bar = CommandBar::new(&[named(1, "kill-me")], &map, "/home/me"); + typed(&mut bar, ">kill"); + assert_eq!(bar.rows().len(), 1); + assert_eq!(bar.sigil(), Some(BarSigil::Command)); + bar.handle_key(&InputKey::Home); + bar.handle_key(&InputKey::Right); + bar.handle_key(&InputKey::Backspace); + assert_eq!(bar.query(), "kill"); + assert_eq!(bar.sigil(), None); + assert_eq!( + labels(bar.rows()), + vec!["kill-me".to_string(), "Kill selected session".to_string()] + ); + } + + fn render_to_string(bar: &CommandBar, w: u16, h: u16) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + bar.render(f, f.area()); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn renders_the_query_the_headers_and_the_rows() { + let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], &[], "/home/me"); + typed(&mut bar, "alp"); + let rendered = render_to_string(&bar, 90, 20); + assert!(rendered.contains("go to"), "{rendered}"); + assert!(rendered.contains("alp"), "{rendered}"); + assert!(rendered.contains("Sessions"), "{rendered}"); + assert!(rendered.contains("alpha"), "{rendered}"); + assert!(rendered.contains("alpine"), "{rendered}"); + assert!(rendered.contains("[esc] close"), "{rendered}"); + } + + /// While a sigil is in force there is one kind, so the kind header goes. + #[test] + fn a_sigil_drops_the_kind_header_and_renders_the_chord() { + let map = keymap(&[("K", Action::KillHighlighted)]); + let mut bar = CommandBar::new(&[named(1, "alpha")], &map, "/home/me"); + typed(&mut bar, ">kill"); + let rendered = render_to_string(&bar, 90, 20); + assert!(rendered.contains("Kill selected session"), "{rendered}"); + assert!(rendered.contains(" K"), "{rendered}"); + assert!(!rendered.contains("Commands"), "{rendered}"); + } + + #[test] + fn renders_a_no_results_state() { + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); + typed(&mut bar, "zzz"); + let rendered = render_to_string(&bar, 90, 20); + assert!(rendered.contains("No matches"), "{rendered}"); + } + + /// The matched characters are drawn bold, so the row shows why it matched. + #[test] + fn matched_ranges_are_styled_apart_from_the_rest_of_the_row() { + let mut bar = CommandBar::new(&[named(1, "web-search")], &[], "/home/me"); + typed(&mut bar, "ws"); + let backend = TestBackend::new(90, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + bar.render(f, f.area()); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut bold: Vec = Vec::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + let cell = &buf[(x, y)]; + if cell.style().add_modifier.contains(Modifier::BOLD) + && cell.style().add_modifier.contains(Modifier::UNDERLINED) + { + bold.push(cell.symbol().to_string()); + } + } + } + assert_eq!(bold, vec!["w", "s"], "bold cells: {bold:?}"); + } + + // ---- the session action page (#155) ---- + + fn targets(rows: &[BarRow]) -> Vec { + 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::>() + ); + } + + /// 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}"); + } +} diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index fe9a4b8..3362c23 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -65,6 +65,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("pageup", "ScrollPanePageUp"), ("pagedown", "ScrollPanePageDown"), // Chrome + // The command bar (#153). An ordinary entry, so it is rebindable and the + // help overlay picks it up from the live keymap. + ("ctrl-k", "OpenCommandBar"), ("ctrl-w b", "ToggleSidebar"), ("?", "ShowHelp"), ("ctrl-w q", "Quit"), @@ -75,13 +78,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("mouse:dblclick(sidebar-row)", "AttachHighlighted"), ]; -/// Default Insert-mode bindings — deliberately empty. Every key typed in +/// Default Insert-mode bindings — almost empty on purpose. Every key typed in /// Insert mode belongs to the session, `esc` included: full-screen apps read /// it constantly, so binding it here would make it unreachable. The only way /// back to the sidebar is the leader (`LeaveInsert`), which /// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds /// the Insert escape with it. -const DEFAULT_INSERT: &[(&str, &str)] = &[]; +/// +/// The one exception is the command bar (#153): `ctrl-k` opens it from inside +/// a pane too, so `docs/COMMAND-BAR.md`'s "from anywhere" holds. Readline's +/// kill-to-end-of-line is given back through the leader (#156). +const DEFAULT_INSERT: &[(&str, &str)] = &[("ctrl-k", "OpenCommandBar")]; impl TuiConfig { /// Build a runtime config from a [`TuiSection`] (typically the `tui` @@ -139,6 +146,15 @@ impl TuiConfig { keymap .set(Mode::Insert, &leader.canonical(), "LeaveInsert") .expect("leader canonical form is valid by construction"); + // Leader, ctrl-k → the literal-Ctrl-K escape hatch (#156), built from + // the same leader spec so rebinding leader rebinds this chord too. + keymap + .set( + Mode::Insert, + &format!("{} ctrl-k", leader.canonical()), + "SendLiteralCtrlK", + ) + .expect("leader canonical form is valid by construction"); for (input, action) in §ion.keymap_normal { keymap.set(Mode::Normal, input, action).map_err(|source| { @@ -271,6 +287,33 @@ mod tests { assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a"), None); } + /// The literal-Ctrl-K escape hatch (#156) is chorded off the default + /// leader, `ctrl-a ctrl-k` — not the command bar. + #[test] + fn default_leader_escapes_a_literal_ctrl_k() { + let cfg = TuiConfig::load(&TuiSection::default()).unwrap(); + assert_eq!( + cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), + Some(Action::SendLiteralCtrlK), + ); + } + + /// Rebinding the leader must rebind the literal-Ctrl-K chord with it, not + /// just the Insert escape — proving the chord is built from the + /// configured leader rather than a hardcoded `ctrl-a`. + #[test] + fn rebinding_leader_moves_the_literal_ctrl_k_chord() { + let mut section = TuiSection::default(); + section.leader = Some("ctrl-b".into()); + let cfg = TuiConfig::load(§ion).unwrap(); + assert_eq!( + cfg.keymap.resolve(Mode::Insert, "ctrl-b ctrl-k"), + Some(Action::SendLiteralCtrlK), + ); + // The old leader's chord is gone along with its Insert escape. + assert_eq!(cfg.keymap.resolve(Mode::Insert, "ctrl-a ctrl-k"), None); + } + #[test] fn user_normal_override_replaces_default() { let mut section = TuiSection::default(); diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index eb2d869..e89e562 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -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, + /// 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, /// 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 { diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs index 287ddca..b4b6004 100644 --- a/crates/maestro-tui/src/fast_forward.rs +++ b/crates/maestro-tui/src/fast_forward.rs @@ -49,6 +49,33 @@ pub fn resolve( .into_iter() .next() }); + defaults(project, presets, handle) +} + +/// Resolve the defaults for a spawn pinned to one directory — the command +/// bar's `~` (#154), where the operator named the directory and nothing may +/// override it. Unlike [`resolve`] there is no recency fallback: a directory +/// the project scan does not know leaves `project` `None`, which is an +/// unresolvable default, so the keystroke opens the pre-filled form instead of +/// spawning somewhere else. +pub fn resolve_at( + dir: &std::path::Path, + projects: &[Project], + presets: &[SpawnPreset], + handle: Option, +) -> FastForwardDefaults { + let project = projects.iter().find(|p| p.path == dir).cloned(); + defaults(project, presets, handle) +} + +/// The project's last command, or the first non-custom preset, wrapped up with +/// the rest of the defaults. Shared by both resolvers so the two entry points +/// can never settle on different commands. +fn defaults( + project: Option, + presets: &[SpawnPreset], + handle: Option, +) -> FastForwardDefaults { let command = project .as_ref() .and_then(|p| p.last_command.clone()) @@ -159,6 +186,46 @@ mod tests { } } + /// The command bar's `~` names the directory; nothing may override it. + #[test] + fn resolve_at_pins_the_directory_over_the_recency_ranking() { + let projects = vec![ + project("/repos/fresh", 0, Some("aider")), + project("/repos/pinned", 30, Some("zsh")), + ]; + let resolved = resolve_at( + std::path::Path::new("/repos/pinned"), + &projects, + &[preset("claude", "claude")], + Some("otter".into()), + ); + assert_eq!( + resolved.project.as_ref().map(|p| p.path.clone()), + Some(PathBuf::from("/repos/pinned")) + ); + assert_eq!(resolved.command.as_deref(), Some("zsh")); + let req = resolved.build_request().expect("every default resolved"); + assert_eq!(req.cwd, PathBuf::from("/repos/pinned")); + } + + /// A directory the scan does not know resolves no project, so there is no + /// request to build — the caller opens the pre-filled form instead of + /// spawning somewhere else. + #[test] + fn resolve_at_takes_no_fallback_for_an_unknown_directory() { + let projects = vec![project("/repos/fresh", 0, Some("aider"))]; + let resolved = resolve_at( + std::path::Path::new("/elsewhere"), + &projects, + &[preset("claude", "claude")], + Some("otter".into()), + ); + assert_eq!(resolved.project, None); + // The preset still resolves; the missing project is what blocks it. + assert_eq!(resolved.command.as_deref(), Some("claude")); + assert_eq!(resolved.build_request(), None); + } + #[test] fn resolves_every_default_from_the_most_recent_project() { let projects = vec![ diff --git a/crates/maestro-tui/src/input.rs b/crates/maestro-tui/src/input.rs index 760ffdc..a167101 100644 --- a/crates/maestro-tui/src/input.rs +++ b/crates/maestro-tui/src/input.rs @@ -387,6 +387,9 @@ pub struct ChordState { held: Vec, last_press: Option, timeout: Duration, + /// A key bumped out of an abandoned chord (see [`Self::feed`]), waiting + /// to be replayed as a fresh keystroke by the caller. + replay: Option, } impl ChordState { @@ -395,6 +398,7 @@ impl ChordState { held: Vec::new(), last_press: None, timeout, + replay: None, } } @@ -404,6 +408,13 @@ impl ChordState { /// binding; wait for the next key. /// * `Resolved(s)` — `s` is the canonical chord (or single key) to look /// up in the keymap. The held buffer is cleared. + /// + /// A held prefix that is itself directly bound (e.g. a leader key bound + /// alone as well as chorded, like `ctrl-a` and `ctrl-a ctrl-k`) resolves + /// to that binding the moment the next key fails to extend it, rather + /// than swallowing the held key and losing its meaning — the new key + /// never belonged to the chord and is handed back via [`Self::take_replay`] + /// so the caller can process it as its own keystroke. pub fn feed(&mut self, key: &InputKey, known_bindings: &HashSet) -> ChordResult { if let Some(t) = self.last_press { if t.elapsed() > self.timeout { @@ -428,11 +439,31 @@ impl ChordState { if is_prefix { self.held.push(key_s); - ChordResult::Pending - } else { - self.held.clear(); - ChordResult::Resolved(candidate) + return ChordResult::Pending; } + + if known_bindings.contains(&candidate) { + self.held.clear(); + return ChordResult::Resolved(candidate); + } + + if !self.held.is_empty() { + let held_str = self.held.join(" "); + self.held.clear(); + if known_bindings.contains(&held_str) { + self.replay = Some(key.clone()); + return ChordResult::Resolved(held_str); + } + } + + ChordResult::Resolved(candidate) + } + + /// Take the key bumped out of an abandoned chord by the last [`Self::feed`] + /// call, if any. The caller feeds it through again as a brand new + /// keystroke. + pub fn take_replay(&mut self) -> Option { + self.replay.take() } } @@ -733,6 +764,41 @@ mod tests { assert_eq!(r, ChordResult::Resolved("g x".into())); } + /// A key bound both alone and as a chord prefix (the leader: `ctrl-a` and + /// `ctrl-a ctrl-k`) must still fire its own binding when the next key + /// doesn't complete the chord — the held key's meaning isn't lost, and + /// the new key comes back via `take_replay` for the caller to process on + /// its own (#156). + #[test] + fn a_bound_prefix_resolves_itself_and_replays_the_breaking_key() { + let mut chord = ChordState::new(Duration::from_secs(10)); + let known: HashSet = ["ctrl-a".into(), "ctrl-a ctrl-k".into()] + .into_iter() + .collect(); + + let r = chord.feed(&InputKey::ctrl_char('a'), &known); + assert_eq!(r, ChordResult::Pending); + + let r = chord.feed(&InputKey::char('j'), &known); + assert_eq!(r, ChordResult::Resolved("ctrl-a".into())); + assert_eq!(chord.take_replay(), Some(InputKey::char('j'))); + } + + /// The full chord still resolves normally when the next key completes + /// it — no replay, no early fire of the shorter binding. + #[test] + fn a_completed_chord_off_a_bound_prefix_has_no_replay() { + let mut chord = ChordState::new(Duration::from_secs(10)); + let known: HashSet = ["ctrl-a".into(), "ctrl-a ctrl-k".into()] + .into_iter() + .collect(); + + chord.feed(&InputKey::ctrl_char('a'), &known); + let r = chord.feed(&InputKey::ctrl_char('k'), &known); + assert_eq!(r, ChordResult::Resolved("ctrl-a ctrl-k".into())); + assert_eq!(chord.take_replay(), None); + } + #[test] fn chord_state_lone_key_resolves_immediately() { let mut chord = ChordState::new(Duration::from_secs(10)); diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index d180427..55c99f4 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -6,6 +6,7 @@ pub mod action; pub mod app; pub mod catalog; pub mod cli; +pub mod command_bar; pub mod config; pub mod daemon_link; pub mod draft; @@ -13,6 +14,7 @@ pub mod emulators; pub mod fast_forward; pub mod input; pub mod keymap; +pub mod matcher; pub mod overlays; pub mod palette; pub mod pane; diff --git a/crates/maestro-tui/src/matcher.rs b/crates/maestro-tui/src/matcher.rs new file mode 100644 index 0000000..e552b17 --- /dev/null +++ b/crates/maestro-tui/src/matcher.rs @@ -0,0 +1,750 @@ +//! Pure match-and-rank function for the `ctrl-k` command bar. +//! +//! Implements exactly the algorithm in `docs/COMMAND-BAR.md` ("What a +//! candidate is", "Match and rank", "Fixtures"). Must produce the same order +//! and scores as `web/src/matcher.ts` for the same input. + +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CandidateKind { + Session, + Command, + Directory, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MatchFieldName { + Name, + Directory, + Command, + Tool, + Label, + Path, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionCandidate { + pub name: String, + pub directory: Option, + pub command: Option, + pub tool: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandCandidate { + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryCandidate { + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MatchCandidate { + Session(SessionCandidate), + Command(CommandCandidate), + Directory(DirectoryCandidate), +} + +pub type MatchRange = (usize, usize); + +#[derive(Debug, PartialEq, Eq)] +pub struct MatchResult<'c> { + pub candidate: &'c MatchCandidate, + pub score: i32, + pub field: Option, + pub ranges: Vec, +} + +struct Field<'c> { + name: MatchFieldName, + text: &'c str, + weight: i32, +} + +impl MatchCandidate { + fn kind(&self) -> CandidateKind { + match self { + MatchCandidate::Session(_) => CandidateKind::Session, + MatchCandidate::Command(_) => CandidateKind::Command, + MatchCandidate::Directory(_) => CandidateKind::Directory, + } + } + + fn fields(&self) -> Vec> { + match self { + MatchCandidate::Session(session) => { + let mut fields = vec![Field { + name: MatchFieldName::Name, + text: &session.name, + weight: 0, + }]; + if let Some(directory) = &session.directory { + fields.push(Field { + name: MatchFieldName::Directory, + text: directory, + weight: -24, + }); + } + if let Some(command) = &session.command { + fields.push(Field { + name: MatchFieldName::Command, + text: command, + weight: -32, + }); + } + if let Some(tool) = &session.tool { + fields.push(Field { + name: MatchFieldName::Tool, + text: tool, + weight: -40, + }); + } + fields + } + MatchCandidate::Command(command) => vec![Field { + name: MatchFieldName::Label, + text: &command.label, + weight: 0, + }], + MatchCandidate::Directory(directory) => vec![Field { + name: MatchFieldName::Path, + text: &directory.path, + weight: 0, + }], + } + } +} + +fn kind_order(kind: CandidateKind) -> u8 { + match kind { + CandidateKind::Session => 0, + CandidateKind::Command => 1, + CandidateKind::Directory => 2, + } +} + +fn lower_char(c: char) -> char { + c.to_lowercase().next().unwrap_or(c) +} + +fn lower_chars(chars: &[char]) -> Vec { + chars.iter().copied().map(lower_char).collect() +} + +fn normalize_query(query: &str) -> Vec { + query + .chars() + .filter(|c| !c.is_whitespace()) + .map(lower_char) + .collect() +} + +const SEPARATORS: [char; 6] = ['/', '\\', '-', '_', '.', ':']; + +fn boundary_bonus(text: &[char], position: usize) -> i32 { + if position == 0 { + return 18; + } + + let previous = text[position - 1]; + if previous == ' ' || SEPARATORS.contains(&previous) { + return 16; + } + + let current = text[position]; + if current.is_uppercase() && previous.is_lowercase() { + return 12; + } + + let is_digit = current.is_ascii_digit(); + let previous_is_digit = previous.is_ascii_digit(); + if is_digit && !previous_is_digit { + return 12; + } + + 0 +} + +fn contribution(text: &[char], position: usize, previous_position: isize) -> i32 { + let gap = position as isize - previous_position - 1; + 16 + boundary_bonus(text, position) + if gap == 0 { 8 } else { -gap.min(12) as i32 } +} + +#[derive(Clone)] +struct Embedding { + score: i32, + positions: Vec, +} + +fn is_better_embedding(candidate: &Embedding, incumbent: Option<&Embedding>) -> bool { + let Some(incumbent) = incumbent else { + return true; + }; + if candidate.score != incumbent.score { + return candidate.score > incumbent.score; + } + for (c, i) in candidate.positions.iter().zip(incumbent.positions.iter()) { + if c != i { + return c < i; + } + } + false +} + +/// The dynamic program over `(query index, text position)` described in +/// "Which embedding": greedy leftmost-first matching is insufficient (e.g. +/// `mtu` against `maestro-tui`), so every reachable position at each query +/// index carries its best score and position vector forward. +fn best_embedding(query: &[char], text: &[char]) -> Option { + let lower_text = lower_chars(text); + let mut previous_states: HashMap = HashMap::new(); + + for (query_index, &qc) in query.iter().enumerate() { + let mut states: HashMap = HashMap::new(); + + for (position, &text_char) in lower_text.iter().enumerate() { + if text_char != qc { + continue; + } + + if query_index == 0 { + states.insert( + position, + Embedding { + score: contribution(text, position, -1), + positions: vec![position], + }, + ); + continue; + } + + for (&previous_position, previous) in previous_states.iter() { + if previous_position >= position { + continue; + } + let mut positions = previous.positions.clone(); + positions.push(position); + let candidate = Embedding { + score: previous.score + + contribution(text, position, previous_position as isize), + positions, + }; + if is_better_embedding(&candidate, states.get(&position)) { + states.insert(position, candidate); + } + } + } + + if states.is_empty() { + return None; + } + previous_states = states; + } + + let mut best: Option = None; + for embedding in previous_states.into_values() { + if is_better_embedding(&embedding, best.as_ref()) { + best = Some(embedding); + } + } + best +} + +fn to_ranges(positions: &[usize]) -> Vec { + let mut ranges: Vec = Vec::new(); + for &position in positions { + if let Some(last) = ranges.last_mut() { + if last.1 == position { + last.1 = position + 1; + continue; + } + } + ranges.push((position, position + 1)); + } + ranges +} + +fn compact(text: &str) -> String { + text.chars() + .filter(|c| !c.is_whitespace()) + .map(lower_char) + .collect() +} + +struct Ranked<'c> { + candidate: &'c MatchCandidate, + score: i32, + field: Option, + ranges: Vec, + matched_text: String, + input_index: usize, +} + +/// `(query, candidates) -> ranked subset with matched ranges`. Knows nothing +/// about sessions, panes or the network — see docs/COMMAND-BAR.md. +pub fn match_and_rank<'c>(query: &str, candidates: &'c [MatchCandidate]) -> Vec> { + let normalized_query = normalize_query(query); + + if normalized_query.is_empty() { + return candidates + .iter() + .map(|candidate| MatchResult { + candidate, + score: 0, + field: None, + ranges: Vec::new(), + }) + .collect(); + } + + let mut results: Vec> = Vec::new(); + + for (input_index, candidate) in candidates.iter().enumerate() { + let mut best: Option<(MatchFieldName, &str, i32, Vec)> = None; + + for field in candidate.fields() { + let text_chars: Vec = field.text.chars().collect(); + let Some(embedding) = best_embedding(&normalized_query, &text_chars) else { + continue; + }; + + let compact_text = compact(field.text); + let normalized_query_str: String = normalized_query.iter().collect(); + let adjustment = if compact_text == normalized_query_str { + 64 + } else if compact_text.starts_with(&normalized_query_str) { + 32 + } else { + 0 + }; + let score = embedding.score + adjustment + field.weight; + + let is_better = match &best { + None => true, + Some((_, _, best_score, _)) => score > *best_score, + }; + if is_better { + best = Some((field.name, field.text, score, embedding.positions)); + } + } + + if let Some((field_name, field_text, score, positions)) = best { + results.push(Ranked { + candidate, + score, + field: Some(field_name), + ranges: to_ranges(&positions), + matched_text: field_text.to_string(), + input_index, + }); + } + } + + results.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| kind_order(a.candidate.kind()).cmp(&kind_order(b.candidate.kind()))) + .then_with(|| { + a.matched_text + .chars() + .count() + .cmp(&b.matched_text.chars().count()) + }) + .then_with(|| a.matched_text.cmp(&b.matched_text)) + .then_with(|| a.input_index.cmp(&b.input_index)) + }); + + results + .into_iter() + .map(|ranked| MatchResult { + candidate: ranked.candidate, + score: ranked.score, + field: ranked.field, + ranges: ranked.ranges, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session( + name: &str, + directory: Option<&str>, + command: Option<&str>, + tool: Option<&str>, + ) -> MatchCandidate { + MatchCandidate::Session(SessionCandidate { + name: name.to_string(), + directory: directory.map(str::to_string), + command: command.map(str::to_string), + tool: tool.map(str::to_string), + }) + } + + fn command(label: &str) -> MatchCandidate { + MatchCandidate::Command(CommandCandidate { + label: label.to_string(), + }) + } + + fn directory(path: &str) -> MatchCandidate { + MatchCandidate::Directory(DirectoryCandidate { + path: path.to_string(), + }) + } + + fn label(candidate: &MatchCandidate) -> String { + match candidate { + MatchCandidate::Session(s) => format!("session:{}", s.name), + MatchCandidate::Command(c) => format!("command:{}", c.label), + MatchCandidate::Directory(d) => format!("directory:{}", d.path), + } + } + + fn positions(result: &MatchResult) -> Vec { + result + .ranges + .iter() + .flat_map(|&(start, end)| start..end) + .collect() + } + + #[allow(clippy::type_complexity)] + fn expect_results( + query: &str, + candidates: &[MatchCandidate], + expected: &[(&str, i32, Option, &[usize])], + ) { + let results = match_and_rank(query, candidates); + + let actual: Vec<(String, i32)> = results + .iter() + .map(|r| (label(r.candidate), r.score)) + .collect(); + let expected_labels: Vec<(String, i32)> = expected + .iter() + .map(|(l, s, _, _)| (l.to_string(), *s)) + .collect(); + assert_eq!(actual, expected_labels, "order/scores for query {query:?}"); + + let actual_fields: Vec> = results.iter().map(|r| r.field).collect(); + let expected_fields: Vec> = + expected.iter().map(|(_, _, f, _)| *f).collect(); + assert_eq!(actual_fields, expected_fields, "fields for query {query:?}"); + + let actual_positions: Vec> = results.iter().map(positions).collect(); + let expected_positions: Vec> = + expected.iter().map(|(_, _, _, p)| p.to_vec()).collect(); + assert_eq!( + actual_positions, expected_positions, + "positions for query {query:?}" + ); + } + + #[test] + fn f1_empty_query_keeps_input_order() { + expect_results( + "", + &[ + session("kill", Some("~/dev/api"), Some("bash"), None), + session( + "killer-app", + Some("~/dev/killer-app"), + Some("claude --resume"), + Some("claude"), + ), + command("Kill selected session"), + ], + &[ + ("session:kill", 0, None, &[]), + ("session:killer-app", 0, None, &[]), + ("command:Kill selected session", 0, None, &[]), + ], + ); + } + + #[test] + fn f2_exact_session_name_beats_prefix_kind_breaks_tie() { + expect_results( + "kill", + &[ + session("kill", Some("~/dev/api"), Some("bash"), None), + session( + "killer-app", + Some("~/dev/killer-app"), + Some("claude --resume"), + Some("claude"), + ), + command("Kill selected session"), + ], + &[ + ( + "session:kill", + 178, + Some(MatchFieldName::Name), + &[0, 1, 2, 3], + ), + ( + "session:killer-app", + 146, + Some(MatchFieldName::Name), + &[0, 1, 2, 3], + ), + ( + "command:Kill selected session", + 146, + Some(MatchFieldName::Label), + &[0, 1, 2, 3], + ), + ], + ); + } + + #[test] + fn f3_path_segment_boundaries_beat_mid_segment() { + expect_results( + "foo", + &[ + directory("~/dev/foobar"), + directory("~/src/myfoo"), + directory("~/foo"), + ], + &[ + ( + "directory:~/foo", + 78, + Some(MatchFieldName::Path), + &[2, 3, 4], + ), + ( + "directory:~/dev/foobar", + 74, + Some(MatchFieldName::Path), + &[6, 7, 8], + ), + ( + "directory:~/src/myfoo", + 56, + Some(MatchFieldName::Path), + &[8, 9, 10], + ), + ], + ); + } + + #[test] + fn f4_no_match_returns_empty() { + expect_results( + "zzz", + &[ + session("kill", Some("~/dev/api"), Some("bash"), None), + command("Kill selected session"), + ], + &[], + ); + } + + #[test] + fn f5_highest_scoring_embedding_beats_greedy() { + expect_results( + "mtu", + &[ + session("maestro-tui", None, None, None), + session("maestro-daemon", None, None, None), + session("mtui-scratch", None, None, None), + ], + &[ + ( + "session:mtui-scratch", + 122, + Some(MatchFieldName::Name), + &[0, 1, 2], + ), + ( + "session:maestro-tui", + 91, + Some(MatchFieldName::Name), + &[0, 8, 9], + ), + ], + ); + } + + #[test] + fn f6_name_beats_weighted_directory() { + expect_results( + "api", + &[ + session("api", Some("~/dev/web"), Some("bash"), None), + session("worker", Some("~/dev/api"), Some("bash"), None), + ], + &[ + ("session:api", 154, Some(MatchFieldName::Name), &[0, 1, 2]), + ( + "session:worker", + 50, + Some(MatchFieldName::Directory), + &[6, 7, 8], + ), + ], + ); + } + + #[test] + fn f7_case_insensitive_length_breaks_tie() { + expect_results( + "api", + &[ + session("API-Gateway", None, None, None), + session("apiary", None, None, None), + ], + &[ + ( + "session:apiary", + 122, + Some(MatchFieldName::Name), + &[0, 1, 2], + ), + ( + "session:API-Gateway", + 122, + Some(MatchFieldName::Name), + &[0, 1, 2], + ), + ], + ); + } + + #[test] + fn f8_exact_tool_beats_command_field() { + expect_results( + "claude", + &[ + session( + "nameless", + Some("~/dev/x"), + Some("claude --resume"), + Some("claude"), + ), + session("clay", Some("~/dev/y"), Some("bash"), None), + ], + &[( + "session:nameless", + 186, + Some(MatchFieldName::Tool), + &[0, 1, 2, 3, 4, 5], + )], + ); + } + + #[test] + fn f9_separator_earns_word_boundary_bonus() { + expect_results( + "ws", + &[ + session("websearch", None, None, None), + session("web-search", None, None, None), + ], + &[ + ( + "session:web-search", + 71, + Some(MatchFieldName::Name), + &[0, 4], + ), + ("session:websearch", 56, Some(MatchFieldName::Name), &[0, 3]), + ], + ); + } + + #[test] + fn f10_command_labels_rank_by_boundary_and_gap() { + expect_results( + "se", + &[ + command("Kill selected session"), + command("Create a session"), + command("Show help"), + ], + &[ + ( + "command:Show help", + 53, + Some(MatchFieldName::Label), + &[0, 6], + ), + ( + "command:Kill selected session", + 51, + Some(MatchFieldName::Label), + &[5, 6], + ), + ( + "command:Create a session", + 47, + Some(MatchFieldName::Label), + &[9, 10], + ), + ], + ); + } + + #[test] + fn f11_directory_matches_separate_on_initial_gap() { + expect_results( + "dev", + &[ + session("api", Some("~/dev/api"), Some("bash"), None), + session("notes", Some("~/documents/dev-notes"), Some("nvim"), None), + session("scratch", Some("~/srv/devops"), Some("bash"), None), + ], + &[ + ( + "session:api", + 54, + Some(MatchFieldName::Directory), + &[2, 3, 4], + ), + ( + "session:scratch", + 50, + Some(MatchFieldName::Directory), + &[6, 7, 8], + ), + ( + "session:notes", + 44, + Some(MatchFieldName::Directory), + &[12, 13, 14], + ), + ], + ); + } + + #[test] + fn f12_exact_beats_prefix() { + expect_results( + "quit", + &[command("Quit Maestro"), command("Quit")], + &[ + ( + "command:Quit", + 178, + Some(MatchFieldName::Label), + &[0, 1, 2, 3], + ), + ( + "command:Quit Maestro", + 146, + Some(MatchFieldName::Label), + &[0, 1, 2, 3], + ), + ], + ); + } +} diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 5ecf71b..7d697f3 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -86,6 +86,9 @@ pub struct RenderInputs<'a> { pub help: &'a HelpOverlay, /// Edit buffer behind the rename prompt (#125). pub rename_input: &'a crate::text_input::TextInput, + /// The command bar behind [`OverlayKind::CommandBar`] (#153), when one is + /// up. + pub command_bar: Option<&'a crate::command_bar::CommandBar>, /// Liveness + release of the daemon, for the status bar's indicator and the /// restart overlay (`docs/RESTART.md`). pub link: &'a crate::daemon_link::LinkStatus, @@ -198,6 +201,14 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { } map.modal = Some(area); } + Some(OverlayKind::CommandBar) => { + if let Some(bar) = inputs.command_bar { + if let Some((x, y)) = bar.render(f, area) { + f.set_cursor_position((x, y)); + } + map.modal = Some(area); + } + } Some(OverlayKind::Help) => { inputs.help.render(f, area); map.modal = Some(area); @@ -428,6 +439,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link, }; let backend = ratatui::backend::TestBackend::new(120, 24); @@ -524,6 +536,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link: &online_link(), }; @@ -578,6 +591,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); @@ -619,6 +633,7 @@ mod tests { state: &state, help: &help, rename_input: &crate::text_input::TextInput::default(), + command_bar: None, link: &online_link(), }; let backend = ratatui::backend::TestBackend::new(80, 24); diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 97d9c5e..e739907 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -499,6 +499,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, } } @@ -1264,6 +1265,7 @@ mod tests { activity_since: Utc::now(), current_cwd: None, blocked_work: None, + last_focused_at: None, }; let sb = Sidebar { sessions: vec![s], diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 6614cec..4487e0e 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -52,6 +52,10 @@ pub enum OverlayKind { /// so this stays comparable. Rename(Uuid), Help, + /// The `ctrl-k` command bar (#153). Its query buffer and result list live + /// on `App` as [`crate::command_bar::CommandBar`], for the same reason the + /// rename buffer does — so this stays comparable. + CommandBar, Toast(String), } diff --git a/web/src/attach.ts b/web/src/attach.ts index e85be1a..3886680 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -5,6 +5,7 @@ import "@xterm/xterm/css/xterm.css"; import { attachWsUrl } from "./routes.js"; import { FOCUS_SIDEBAR_EVENT } from "./catalog.js"; +import { CommandSet, defineCommands } from "./commands.js"; import { escapeHtml } from "./html.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; @@ -13,6 +14,15 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js"; import type { ClientControl, Uuid } from "./types.js"; import { WebglPolicy } from "./webglRenderer.js"; +// The pane's half of the command registry (#148): plain Escape hands the +// keyboard back to the session list, the pair to the list's Tab. +const paneKeys = new CommandSet( + defineCommands({ + "pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)), + }), + { preventDefault: false }, +); + /** One budget for the whole page — see webglRenderer.ts for why contexts are * rationed rather than handed to every connection. */ const webglPolicy = new WebglPolicy(); @@ -40,6 +50,10 @@ export interface AttachConnection { onUnmount(): void; /** Put the keyboard back into this terminal — what leaving the sidebar does. */ focusTerminal(): void; + /** Type raw bytes straight into the PTY, bypassing the terminal's own key + * handling — the leader's literal Ctrl-K escape hatch + * (docs/COMMAND-BAR.md). */ + sendInput(data: string): void; /** Fully disconnect: close the WS, dispose the terminal, remove the element. */ dispose(): void; } @@ -56,8 +70,6 @@ export interface AttachOptions { shouldReconnect?: (sessionId: Uuid, code: number) => boolean; } - - /** What the header shows about the session (name / command). Optional — falls * back to a short session id when the session isn't known yet. */ export interface AttachInfo { @@ -210,12 +222,12 @@ export function createAttachConnection( } // Plain Escape hands the keyboard back to the session list instead of the - // shell/app. `false` tells xterm to skip its own handling of this event. + // shell/app. `false` tells xterm to skip its own handling of this event — + // which is also why the set doesn't `preventDefault`: swallowing here is + // xterm's return value, not the browser's. term.attachCustomKeyEventHandler((e) => { - if (e.type !== "keydown" || e.key !== "Escape") return true; - if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return true; - document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)); - return false; + if (e.type !== "keydown") return true; + return !paneKeys.handle(e); }); // Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a @@ -498,6 +510,9 @@ export function createAttachConnection( focusTerminal() { term.focus(); }, + // The closure above: the same bytes-out path keystrokes and the mobile key + // bar already take. + sendInput, dispose() { if (disposed) return; disposed = true; diff --git a/web/src/barActions.test.ts b/web/src/barActions.test.ts new file mode 100644 index 0000000..0720de6 --- /dev/null +++ b/web/src/barActions.test.ts @@ -0,0 +1,131 @@ +// @vitest-environment happy-dom +// +// The sidebar's half of the command bar's action page (#151): the bar picks +// kill or rename and raises the flow the card already has, so what the page +// runs is what the ✕ button and the `r` key run. +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js"; +import type { CatalogListener, CatalogStore } from "./events.js"; +import type { Session } from "./types.js"; + +beforeAll(() => { + Object.defineProperty(window, "localStorage", { + value: { getItem: () => null, setItem: () => {} }, + }); +}); + +let mounted: { el: HTMLElement; dispose: () => void } | null = null; + +afterEach(() => { + document.querySelector(".kill-dialog")?.remove(); + mounted?.dispose(); + mounted = null; + document.body.replaceChildren(); +}); + +function session(overrides: Partial = {}): Session { + return { + id: "aaaaaaaa-0000-0000-0000-0000000000ff", + cwd: "/home/naps/tea/maestro", + command: "claude", + env: {}, + metadata: { name: "amber-falcon" }, + status: "Running", + created_at: "2026-08-24T10:00:00Z", + ...overrides, + }; +} + +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(s: Session): HTMLElement { + const handle = renderSidebar(storeWith([s])); + mounted = handle; + document.body.appendChild(handle.el); + return handle.el; +} + +function raise(name: string, s: Session): void { + document.dispatchEvent(new CustomEvent(name, { detail: s })); +} + +/** Let the kill chain's awaits (fetch, its json body) settle. */ +async function flush(): Promise { + for (let i = 0; i < 8; i++) await Promise.resolve(); +} + +describe("kill from the action page", () => { + it("goes through the daemon, without a first confirmation", async () => { + const calls: string[] = []; + window.fetch = ((url: string, init?: RequestInit) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + return Promise.resolve(new Response(null, { status: 204 })); + }) as typeof fetch; + const s = session(); + mount(s); + raise(KILL_SESSION_EVENT, s); + await flush(); + expect(calls).toContain(`DELETE /api/sessions/${s.id}`); + expect(document.querySelector(".kill-dialog")).toBeNull(); + }); + + it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => { + window.fetch = ((url: string) => { + if (String(url).includes("force=true")) { + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve( + new Response( + JSON.stringify({ + error: { + code: "kill_guarded", + message: "worktree has unsaved work", + unsaved_work: { untracked_files: 1 }, + }, + }), + { status: 409, headers: { "content-type": "application/json" } }, + ), + ); + }) as typeof fetch; + const s = session({ blocked_work: { untracked_files: 1 } }); + mount(s); + raise(KILL_SESSION_EVENT, s); + await flush(); + const dialog = document.querySelector(".kill-dialog"); + expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon"); + expect(dialog?.textContent).toContain("1 untracked file"); + }); +}); + +describe("rename from the action page", () => { + it("expands the folder hiding the card, since the prompt lives in the card", () => { + window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch; + const s = session(); + const el = mount(s); + const folder = el.querySelector(".tree-folder-header"); + folder?.click(); + expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull(); + raise(RENAME_SESSION_EVENT, s); + expect(el.querySelector(".leaf-rename")?.value).toBe("amber-falcon"); + }); + + it("raises the card's own prompt, pre-filled with the session's name", () => { + window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch; + const s = session(); + const el = mount(s); + raise(RENAME_SESSION_EVENT, s); + const input = el.querySelector(".leaf-rename"); + expect(input?.value).toBe("amber-falcon"); + expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon"); + }); +}); diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 8473a67..55674e9 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -2,10 +2,12 @@ import { escapeHtml } from "./html.js"; import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js"; import { openForceKillDialog } from "./killDialog.js"; import { openContextMenu } from "./contextMenu.js"; -import { isTypingTarget, toggleHelp } from "./help.js"; +import { toggleHelp } from "./help.js"; +import { CommandSet, defineCommands } from "./commands.js"; import { CatalogStore } from "./events.js"; import type { InTheWay, Session } from "./types.js"; import { + abbreviateCwd, abbreviateFolderName, blockerStats, customSessionName, @@ -15,6 +17,7 @@ import { sessionBlockers, isProvisionalName, sessionName, + sessionTreePath, } from "./types.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js"; import { @@ -49,6 +52,17 @@ export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar"; * ("Spawn similar"). Detail is the session being cloned. */ export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; +/** Ask the sidebar to kill a session — the command bar's action page picking + * `kill` (#151). Detail is the session. Handled here, so the kill goes + * through the same confirm-and-blockers flow the card's ✕ does + * (`docs/WORKTREES.md`); the bar never reimplements it. */ +export const KILL_SESSION_EVENT = "catalog-kill-session"; + +/** Ask the sidebar to rename a session — the command bar's action page picking + * `rename` (#151). Detail is the session. The prompt is the card's own inline + * field, pre-filled with the session's name (`docs/SIDEBAR.md`). */ +export const RENAME_SESSION_EVENT = "catalog-rename-session"; + /** A session row was clicked (issue #133). The row's `href` drives normal * hash navigation, but a split/drag can move focus without ever touching * `location.hash` — so a click back onto a session whose hash still @@ -669,24 +683,30 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S applyNav(); }; - treeHost.addEventListener("keydown", (e) => { - if (e.ctrlKey || e.altKey || e.metaKey) return; - // The inline rename field lives inside a row and owns its own keys. - if (isTypingTarget(e.target)) return; - if (e.key === "j" || e.key === "ArrowDown") moveNav(1); - else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1); - else if (e.key === "Enter") activateNav(); - else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)); - else if (e.key === "c") toggleHighlightedFolder(); - else if (e.key === "x") { - const hit = highlightedSession(); - if (hit) void requestKill(hit.session, sessionName(hit.session)); - } else if (e.key === "r") { - const hit = highlightedSession(); - if (hit) beginRename(hit.row, hit.session); - } else return; - e.preventDefault(); - }); + // The session list's half of the command registry (#148). One set per + // sidebar, so a second sidebar (tests, hot reload) drives its own rows; the + // labels and chords still come from the one registry. + const listKeys = new CommandSet( + defineCommands({ + "list.next": () => moveNav(1), + "list.prev": () => moveNav(-1), + "list.activate": () => activateNav(), + "list.toggleFolder": () => toggleHighlightedFolder(), + "list.rename": () => { + const hit = highlightedSession(); + if (hit) beginRename(hit.row, hit.session); + }, + "list.kill": () => { + const hit = highlightedSession(); + if (hit) void requestKill(hit.session, sessionName(hit.session)); + }, + "list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)), + }), + ); + + // The inline rename field lives inside a row and owns its own keys; the set's + // plain-key rule keeps this walk out of it. + treeHost.addEventListener("keydown", (e) => listKeys.handle(e)); /** Signature of what is currently on screen — see `treeSignature`. */ let renderedSignature: string | null = null; @@ -738,6 +758,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S }; document.addEventListener("catalog-rerender", rerenderHandler); + // The command bar's action page (#151) picks an action and hands it here: + // both flows are the ones the card already has, reached by session rather + // than by row. + const killHandler = (ev: Event) => { + const s = (ev as CustomEvent).detail; + void requestKill(s, sessionName(s)); + }; + const renameHandler = (ev: Event) => { + const s = (ev as CustomEvent).detail; + const row = rowForSession(s); + if (row) beginRename(row, s); + }; + document.addEventListener(KILL_SESSION_EVENT, killHandler); + document.addEventListener(RENAME_SESSION_EVENT, renameHandler); + + /** The card a session is rendered as, expanding whatever folder hides it + * first: the rename prompt lives inside the card, so a collapsed folder + * would otherwise swallow the request. */ + function rowForSession(s: Session): HTMLElement | null { + const find = () => treeHost.querySelector(`.tree-leaf[data-session-id="${s.id}"]`); + const found = find(); + if (found) return found; + // Folder paths are the abbreviated directory's segments joined back up + // (see buildTree), so the session's own path is normalised the same way + // before it is compared against them. + const cwd = sessionTreePath(s); + const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/"); + for (const folder of [...collapsed]) { + if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder); + } + rerenderHandler(); + return find(); + } + // "Spawn similar" (#28) is dispatched here and handled in main.ts, where the // multi-view controller can seed the spawn modal with the session's dir/command. @@ -787,6 +841,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S // refusing to refresh. renamingId = null; document.removeEventListener("catalog-rerender", rerenderHandler); + document.removeEventListener(KILL_SESSION_EVENT, killHandler); + document.removeEventListener(RENAME_SESSION_EVENT, renameHandler); unsubscribe(); }, }; diff --git a/web/src/commandBar.test.ts b/web/src/commandBar.test.ts new file mode 100644 index 0000000..abb8bd9 --- /dev/null +++ b/web/src/commandBar.test.ts @@ -0,0 +1,723 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + KILL_SESSION_EVENT, + RENAME_SESSION_EVENT, + SESSION_SELECT_EVENT, + SPAWN_SIMILAR_EVENT, +} from "./catalog.js"; +import { + barGroups, + barRows, + closeCommandBar, + commandBarCandidates, + dedupeCommands, + directoryBarCandidates, + directoryCandidate, + isCommandBarOpen, + openCommandBar, + orderSessionsForBar, + parseBarQuery, + sessionBarCandidates, + sessionActionCandidates, + sessionCandidate, + SPAWN_DIRECTORY_EVENT, +} from "./commandBar.js"; +import { COMMAND_SPECS, type Command } from "./commands.js"; +import type { Session } from "./types.js"; + +function session(id: string, over: Partial = {}): Session { + return { + id, + cwd: "/home/me/dev/api", + command: "bash", + env: {}, + metadata: { name: id }, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + ...over, + }; +} + +afterEach(() => { + closeCommandBar(); + document.body.replaceChildren(); +}); + +function press(key: string, init: KeyboardEventInit = {}): void { + const input = document.querySelector(".cmdbar-input") as HTMLInputElement; + input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init })); +} + +function type(value: string): void { + const input = document.querySelector(".cmdbar-input") as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +function rowNames(): string[] { + return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? ""); +} + +function activeName(): string | undefined { + return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined; +} + +describe("empty-query ordering", () => { + it("sorts most recently focused first", () => { + const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" }); + const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" }); + expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]); + }); + + it("sorts a never-focused session after every focused one, newest created first", () => { + const focused = session("focused", { + last_focused_at: "2020-01-01T00:00:00Z", + created_at: "2020-01-01T00:00:00Z", + }); + const oldFresh = session("old-fresh", { created_at: "2026-01-01T00:00:00Z" }); + const newFresh = session("new-fresh", { created_at: "2026-06-01T00:00:00Z" }); + expect(orderSessionsForBar([oldFresh, newFresh, focused]).map((s) => s.id)).toEqual([ + "focused", + "new-fresh", + "old-fresh", + ]); + }); + + it("sorts dead sessions after live ones regardless of recency", () => { + const deadRecent = session("dead", { + status: "Dead", + last_focused_at: "2026-06-01T00:00:00Z", + }); + const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" }); + const liveNever = session("never"); + expect(orderSessionsForBar([deadRecent, liveNever, liveStale]).map((s) => s.id)).toEqual([ + "live", + "never", + "dead", + ]); + }); + + it("treats a null last_focused_at as never focused", () => { + const nulled = session("nulled", { last_focused_at: null }); + const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" }); + expect(orderSessionsForBar([nulled, stamped]).map((s) => s.id)).toEqual(["stamped", "nulled"]); + }); +}); + +describe("session candidates", () => { + it("carries the candidate table's fields, with the directory abbreviated", () => { + const s = session("api", { + metadata: { name: "api" }, + command: "claude --resume", + current_cwd: "/home/me/dev/api", + }); + expect(sessionCandidate(s, "/home/me")).toEqual({ + kind: "session", + session: s, + fields: { + name: "api", + directory: "~/dev/api", + command: "claude --resume", + tool: "claude", + }, + }); + }); + + it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => { + const s = session("edit", { + metadata: {}, + command: "hx src/", + worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" }, + }); + const candidate = sessionCandidate(s, "/home/me"); + expect(candidate.fields.tool).toBeUndefined(); + expect(candidate.fields.directory).toBe("~/dev/api"); + // No `metadata.name`: the sidebar's fallback is the command line. + expect(candidate.fields.name).toBe("hx src/"); + }); + + it("builds candidates in the empty-query order", () => { + const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" }); + const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" }); + expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]); + }); +}); + +describe("rows and grouping", () => { + it("an empty query keeps every session in input order", () => { + const candidates = sessionBarCandidates([session("a"), session("b")]); + expect(barRows("", candidates).map((r) => r.candidate.session.id)).toEqual(["a", "b"]); + }); + + it("groups by kind without reordering the ranking", () => { + const candidates = sessionBarCandidates([ + session("worker", { metadata: { name: "worker" }, current_cwd: "/home/me/dev/api" }), + session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/web" }), + ]); + const rows = barRows("api", candidates); + const groups = barGroups(rows); + expect(groups).toHaveLength(1); + expect(groups[0].title).toBe("Sessions"); + expect(groups[0].results.map((r) => r.candidate.session.id)).toEqual( + rows.map((r) => r.candidate.session.id), + ); + // F6: the name beats the directory, and the directory match highlights + // inside the directory. + expect(groups[0].results[0].candidate.session.id).toBe("api"); + expect(groups[0].results[1].field).toBe("directory"); + }); +}); + +describe("the overlay", () => { + const sessions = [ + session("api", { + metadata: { name: "api" }, + current_cwd: "/home/me/dev/api", + last_focused_at: "2026-01-01T11:00:00Z", + }), + session("worker", { + metadata: { name: "worker" }, + current_cwd: "/home/me/dev/worker", + last_focused_at: "2026-01-01T10:00:00Z", + }), + session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }), + ]; + + // Sessions only: the mixed list's command half is the sigil describe's + // business, and the registry is whatever another test file happened to bind. + function open(over: Parameters[0] | null = null) { + return openCommandBar( + over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} }, + ); + } + + it("is a registry command, so the help overlay lists it", () => { + const spec = COMMAND_SPECS.find((s) => s.id === "bar.open"); + expect(spec?.keys).toEqual(["ctrl-k"]); + expect(spec?.scope).toBe("global"); + }); + + it("lists sessions by recency under a kind header on an empty query", () => { + open(); + expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([ + "Sessions", + ]); + expect(rowNames()).toEqual(["api", "worker", "notes"]); + expect(activeName()).toBe("api"); + }); + + it("opening twice leaves one overlay", () => { + open(); + open(); + expect(document.querySelectorAll(".cmdbar-overlay")).toHaveLength(1); + expect(isCommandBarOpen()).toBe(true); + }); + + it("ranks a typed query through the matcher and highlights the matched range", () => { + open(); + type("wor"); + expect(rowNames()).toEqual(["worker"]); + const marks = [...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent); + expect(marks).toEqual(["wor"]); + }); + + it("highlights inside the directory when that is the field that matched", () => { + open(); + type("notes"); + const row = document.querySelector(".cmdbar-row") as HTMLElement; + expect(row.dataset.sessionId).toBe("notes"); + type("dev/wor"); + expect(rowNames()).toEqual(["worker"]); + expect( + [...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""), + ).toBe("dev/wor"); + }); + + it("renders a no-results state rather than an empty list", () => { + open(); + type("zzz"); + expect(rowNames()).toEqual([]); + expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches"); + }); + + it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => { + open(); + press("ArrowUp"); + expect(activeName()).toBe("api"); + press("ArrowDown"); + expect(activeName()).toBe("worker"); + press("n", { ctrlKey: true }); + expect(activeName()).toBe("notes"); + press("ArrowDown"); + expect(activeName()).toBe("notes"); + press("p", { ctrlKey: true }); + expect(activeName()).toBe("worker"); + }); + + it("puts the highlight back on the best row whenever the query changes", () => { + open(); + press("ArrowDown"); + expect(activeName()).toBe("worker"); + type("o"); + expect(activeName()).toBe(document.querySelector(".cmdbar-row-name")?.textContent); + }); + + it("opens the highlighted session through the shared open path on Enter", () => { + const seen: string[] = []; + const listener = (e: Event) => seen.push((e as CustomEvent).detail); + document.addEventListener(SESSION_SELECT_EVENT, listener); + openCommandBar({ sessions, homeDir: "/home/me", commands: [] }); + press("ArrowDown"); + press("Enter"); + document.removeEventListener(SESSION_SELECT_EVENT, listener); + expect(seen).toEqual(["worker"]); + expect(isCommandBarOpen()).toBe(false); + }); + + it("Enter on an empty result list does nothing", () => { + const onOpenSession = vi.fn(); + open({ sessions, homeDir: "/home/me", commands: [], onOpenSession }); + type("zzz"); + press("Enter"); + expect(onOpenSession).not.toHaveBeenCalled(); + expect(isCommandBarOpen()).toBe(true); + }); + + it("closes on Escape and on a press outside the panel", () => { + open(); + press("Escape"); + expect(isCommandBarOpen()).toBe(false); + + open(); + (document.querySelector(".cmdbar-overlay") as HTMLElement).dispatchEvent( + new MouseEvent("mousedown", { bubbles: true }), + ); + expect(isCommandBarOpen()).toBe(false); + }); + + it("stays open for a press inside the panel", () => { + open(); + (document.querySelector(".cmdbar-panel") as HTMLElement).dispatchEvent( + new MouseEvent("mousedown", { bubbles: true }), + ); + expect(isCommandBarOpen()).toBe(true); + }); + + it("returns the keyboard to whatever held it when it opened", () => { + const before = document.createElement("button"); + document.body.appendChild(before); + before.focus(); + open(); + expect(document.activeElement).toBe(document.querySelector(".cmdbar-input")); + press("Escape"); + expect(document.activeElement).toBe(before); + }); + + it("clicking a row opens that session", () => { + const onOpenSession = vi.fn(); + open({ sessions, homeDir: "/home/me", commands: [], onOpenSession }); + (document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click(); + expect(onOpenSession).toHaveBeenCalledWith("worker"); + expect(isCommandBarOpen()).toBe(false); + }); +}); + +// ---- the sigils (#150) ---- + +function command(id: string, label: string, keys: string[], run = () => {}): Command { + return { id, label, group: "Test", keys, scope: "global", run }; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function rowKeys(): string[][] { + return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) => + [...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""), + ); +} + +describe("parsing the query line", () => { + it("reads a sigil only in first position", () => { + expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" }); + expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" }); + expect(parseBarQuery("")).toEqual({ sigil: null, query: "" }); + expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" }); + }); + + it("leaves a sigil character elsewhere in the query literal", () => { + expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" }); + // The case that matters: the second `~` of a directory query reaches the + // matcher. + expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" }); + }); + + it("returns to the mixed list when the sigil is backspaced away", () => { + expect(parseBarQuery(">").sigil).toBe(">"); + expect(parseBarQuery("").sigil).toBeNull(); + }); +}); + +describe("command candidates", () => { + it("dedupes by action, not by chord", () => { + const run = () => {}; + const kill = command("a.kill", "Kill the session", ["x"], run); + const alias = command("b.kill", "Kill the session", ["ctrl-x"], run); + const other = command("c.rename", "Rename the session", ["r"]); + expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]); + }); + + it("matches on the label only, and keeps the chord for display", () => { + const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]); + expect(candidates[0].fields).toEqual({ label: "Open the spawn form" }); + expect(candidates[0].command.keys).toEqual(["ctrl-k"]); + }); +}); + +describe("directory candidates", () => { + it("abbreviates the path it shows and keeps the one it spawns in", () => { + const c = directoryCandidate("/home/me/dev/api", "/home/me"); + expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } }); + }); + + it("expands a typed `~` and dedupes by the directory spawned in", () => { + const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"]; + expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([ + "/home/me/dev/api", + "/tmp/scratch", + ]); + }); +}); + +describe("the sigils in the overlay", () => { + const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })]; + const ran: string[] = []; + const commands = [ + command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")), + command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")), + ]; + + afterEach(() => { + ran.length = 0; + }); + + function open(over: Partial[0]> = {}) { + return openCommandBar({ + sessions, + homeDir: "/home/me", + commands, + onOpenSession: () => {}, + loadDirectories: async () => [], + listDirEntries: async () => [], + onSpawnDirectory: () => {}, + ...over, + }); + } + + it("lists every command in registry order under `>`, with its chord and no header", () => { + open(); + type(">"); + expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]); + expect(rowKeys()).toEqual([["n"], ["x"]]); + expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); + }); + + it("matches a command on its label and never on its chord", () => { + open(); + type(">kill"); + expect(rowNames()).toEqual(["Kill the highlighted session"]); + // `n` and `x` are chords, not label text: querying one finds nothing that + // the label does not also justify. + type(">ctrlx"); + expect(rowNames()).toEqual([]); + }); + + it("runs the highlighted command on Enter", () => { + open(); + type(">kill"); + press("Enter"); + expect(ran).toEqual(["list.kill"]); + expect(isCommandBarOpen()).toBe(false); + }); + + it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => { + open(); + type("s"); + expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([ + "Sessions", + "Commands", + ]); + type(">spawn"); + expect(rowNames()).toEqual(["Open the spawn form"]); + // Backspacing the sigil away is ordinary editing. + type("spawn"); + expect(rowNames()).toContain("Open the spawn form"); + type(""); + expect(rowNames()).toEqual(["api"]); + }); + + it("lists recent dirs then projects under `~`", async () => { + open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] }); + type("~"); + await flush(); + expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]); + expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); + }); + + it("fills the list in from /api/dirs as a path is typed", async () => { + const asked: string[] = []; + open({ + loadDirectories: async () => ["/home/me/notes"], + listDirEntries: async (dir) => { + asked.push(dir); + return ["maestro", "api"]; + }, + }); + // The second `~` is a literal query character and reaches the matcher. + type("~~/dev/mae"); + await flush(); + expect(asked).toEqual(["~/dev/"]); + expect(rowNames()).toEqual(["~/dev/maestro"]); + }); + + it("spawns in the highlighted directory on Enter", async () => { + const spawned: string[] = []; + open({ + loadDirectories: async () => ["~/dev/api"], + onSpawnDirectory: (path) => spawned.push(path), + }); + type("~api"); + await flush(); + press("Enter"); + expect(spawned).toEqual(["/home/me/dev/api"]); + expect(isCommandBarOpen()).toBe(false); + }); + + it("raises the spawn event when the caller supplies no handler", async () => { + const seen: string[] = []; + const listener = (e: Event) => seen.push((e as CustomEvent).detail); + document.addEventListener(SPAWN_DIRECTORY_EVENT, listener); + openCommandBar({ + sessions, + homeDir: "/home/me", + commands, + onOpenSession: () => {}, + loadDirectories: async () => ["/home/me/dev/api"], + listDirEntries: async () => [], + }); + type("~"); + await flush(); + press("Enter"); + document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener); + expect(seen).toEqual(["/home/me/dev/api"]); + }); +}); + +describe("the focus round-trip over a pane (#152)", () => { + function terminalTextarea(): HTMLTextAreaElement { + const ta = document.createElement("textarea"); + ta.className = "xterm-helper-textarea"; + document.body.appendChild(ta); + return ta; + } + + it("hands the keyboard back to the terminal that had it", () => { + const ta = terminalTextarea(); + ta.focus(); + openCommandBar({ sessions: [], commands: [] }); + expect(document.activeElement).toBe(document.querySelector(".cmdbar-input")); + press("Escape"); + expect(document.activeElement).toBe(ta); + }); + + it("reports no focus change to the daemon, opening or closing", () => { + const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))); + vi.stubGlobal("fetch", fetchMock); + try { + const ta = terminalTextarea(); + ta.focus(); + openCommandBar({ sessions: [], commands: [] }); + press("Escape"); + const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) => + String(url).includes("/focus"), + ); + expect(focusCalls).toEqual([]); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe("the session action page (#151)", () => { + const sessions = [ + session("api", { + metadata: { name: "api" }, + current_cwd: "/home/me/dev/api", + last_focused_at: "2026-01-01T11:00:00Z", + }), + session("worker", { + metadata: { name: "worker" }, + current_cwd: "/home/me/dev/worker", + last_focused_at: "2026-01-01T10:00:00Z", + }), + ]; + + function open(over: Partial[0]> = {}) { + return openCommandBar({ + sessions, + homeDir: "/home/me", + commands: [], + onOpenSession: () => {}, + ...over, + }); + } + + function headerText(): string | undefined { + return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined; + } + + it("lists kill, rename and spawn similar for the highlighted session", () => { + open(); + press("Tab"); + expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]); + expect(activeName()).toBe("Kill session"); + }); + + it("names the session being acted on", () => { + open(); + press("ArrowDown"); + press("Tab"); + expect(headerText()).toContain("worker"); + }); + + it("drops the kind headers, since the page is one kind", () => { + open(); + press("Tab"); + expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0); + }); + + it("stays fuzzy-searchable over the three actions", () => { + open(); + press("Tab"); + type("ren"); + expect(rowNames()).toEqual(["Rename session"]); + type("spwn"); + expect(rowNames()).toEqual(["Spawn similar"]); + type("zzz"); + expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches"); + }); + + it("does nothing on a command row", () => { + openCommandBar({ + sessions, + homeDir: "/home/me", + commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command], + onOpenSession: () => {}, + }); + type(">"); + const before = rowNames(); + press("Tab"); + expect(rowNames()).toEqual(before); + expect(headerText()).toBeUndefined(); + }); + + it("does nothing on a directory row", async () => { + open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) }); + type("~"); + await Promise.resolve(); + press("Tab"); + expect(rowNames()).toEqual(["~/dev/api"]); + expect(headerText()).toBeUndefined(); + }); + + it("returns to the result list on Escape, with the previous query intact", () => { + open(); + type("wor"); + press("Tab"); + expect(headerText()).toContain("worker"); + press("Escape"); + expect(isCommandBarOpen()).toBe(true); + expect(headerText()).toBeUndefined(); + expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor"); + expect(rowNames()).toEqual(["worker"]); + }); + + it("returns to the result list on backspace at an empty query", () => { + open(); + type("wor"); + press("Tab"); + type(""); + press("Backspace"); + expect(headerText()).toBeUndefined(); + expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor"); + expect(rowNames()).toEqual(["worker"]); + }); + + it("keeps backspace ordinary while the page's query has text", () => { + open(); + press("Tab"); + type("ren"); + press("Backspace"); + expect(headerText()).toContain("api"); + expect(rowNames()).toEqual(["Rename session"]); + }); + + it("closes the bar when the page's Escape has already been used", () => { + open(); + press("Tab"); + press("Escape"); + press("Escape"); + expect(isCommandBarOpen()).toBe(false); + }); + + it("raises kill, rename and spawn similar with the session, closing the bar", () => { + const seen: string[] = []; + for (const [hook, key] of [ + ["onKillSession", "Kill session"], + ["onRenameSession", "Rename session"], + ["onSpawnSimilar", "Spawn similar"], + ] as const) { + open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) }); + type("wor"); + press("Tab"); + type(key); + press("Enter"); + expect(isCommandBarOpen()).toBe(false); + } + expect(seen).toEqual([ + "onKillSession:worker", + "onRenameSession:worker", + "onSpawnSimilar:worker", + ]); + }); + + it("hands each action to the flow it already has, by default", () => { + const seen: string[] = []; + const listeners: Array<[string, EventListener]> = [ + [KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent).detail.id}`)], + [RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent).detail.id}`)], + [SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent).detail.id}`)], + ]; + for (const [name, fn] of listeners) document.addEventListener(name, fn); + try { + for (const action of ["Kill session", "Rename session", "Spawn similar"]) { + open(); + press("Tab"); + type(action); + press("Enter"); + } + } finally { + for (const [name, fn] of listeners) document.removeEventListener(name, fn); + } + expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]); + }); + + it("offers no action beyond the three", () => { + expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([ + "kill", + "rename", + "spawn-similar", + ]); + }); +}); diff --git a/web/src/commandBar.ts b/web/src/commandBar.ts new file mode 100644 index 0000000..cd37d06 --- /dev/null +++ b/web/src/commandBar.ts @@ -0,0 +1,809 @@ +// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result +// list, opened from the sidebar, closed with Escape. +// +// Three kinds live here: sessions and commands, ranked together by default, +// and directories, which need the `~` sigil. `tab` on a session drills into +// its action page (#151), which is the same overlay over three actions. + +import { listDir, projects as fetchProjects, recentDirs } from "./api.js"; +import { + KILL_SESSION_EVENT, + RENAME_SESSION_EVENT, + SESSION_SELECT_EVENT, + SPAWN_SIMILAR_EVENT, +} from "./catalog.js"; +import { keyCaps, publishedCommands, type Command } from "./commands.js"; +import { + matchAndRank, + type CandidateKind, + type CommandCandidate, + type DirectoryCandidate, + type MatchRange, + type MatchResult, + type SessionCandidate, +} from "./matcher.js"; +import { splitPath } from "./paths.js"; +import { splitCommand } from "./toolIcon.js"; +import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js"; + +/** A matcher candidate that remembers what it came from, so a result can be + * acted on without a second lookup. The extra property is invisible to the + * matcher, which only ever reads `kind` and `fields`. */ +export type SessionBarCandidate = SessionCandidate & { session: Session }; +export type CommandBarCandidate = CommandCandidate & { command: Command }; +/** `fields.path` is the abbreviated path the operator reads and the matcher + * ranks; `path` is the one a spawn is made in. */ +export type DirectoryBarCandidate = DirectoryCandidate & { path: string }; + +/** One row of a session's action page. It is a `command` to the matcher — a + * single `label` field, ranked exactly like a registry command — and carries + * the action it stands for instead of a registry entry. */ +export type ActionBarCandidate = CommandCandidate & { action: SessionAction }; + +export type BarCandidate = + SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate; +export type BarResult = MatchResult; + +/** Raised when Enter lands on a directory row. The bar picks the directory; the + * shell owns the spawn, which is the same fast-forward path `N` takes. */ +export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory"; + +/** One kind's slice of the ranked list, in rank order. */ +export interface BarGroup { + kind: CandidateKind; + title: string; + results: MatchResult[]; +} + +const KIND_TITLES: Record = { + session: "Sessions", + command: "Commands", + directory: "Directories", +}; + +// ---- the sigils ---- + +export type BarSigil = ">" | "~"; + +export interface BarQuery { + /** The sigil in force, or null for the mixed list. */ + sigil: BarSigil | null; + /** What the matcher sees: everything after a sigil, or the whole query. */ + query: string; +} + +/** Split a raw query line into its sigil and the query proper. + * + * A sigil is only a sigil in first position: `>` or `~` anywhere else is a + * literal query character, which is what a directory query with a second `~` + * in it depends on. Backspacing past a sigil needs no special case either — + * the first character goes and the query is a mixed one again. */ +export function parseBarQuery(raw: string): BarQuery { + const first = raw[0]; + if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) }; + return { sigil: null, query: raw }; +} + +// ---- the session action page ---- + +/** 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"). */ +export type SessionActionId = "kill" | "rename" | "spawn-similar"; + +export interface SessionAction { + id: SessionActionId; + label: string; +} + +export const SESSION_ACTIONS: readonly SessionAction[] = [ + { id: "kill", label: "Kill session" }, + { id: "rename", label: "Rename session" }, + { id: "spawn-similar", label: "Spawn similar" }, +]; + +/** The action page's candidates, in the order above — which is the order an + * empty query lists them in, since the matcher keeps input order. */ +export function sessionActionCandidates(): ActionBarCandidate[] { + return SESSION_ACTIONS.map((action) => ({ + kind: "command", + action, + fields: { label: action.label }, + })); +} + +/** Narrow a `command` candidate to an action row. Both share the matcher's + * `command` kind, so what separates them is what they carry. */ +export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate { + return candidate.kind === "command" && "action" in candidate; +} + +// ---- candidates ---- + +function focusedAt(s: Session): number | null { + const stamp = s.last_focused_at; + if (typeof stamp !== "string" || stamp === "") return null; + const ms = Date.parse(stamp); + return Number.isNaN(ms) ? null : ms; +} + +function createdAt(s: Session): number { + const ms = Date.parse(s.created_at ?? ""); + return Number.isNaN(ms) ? 0 : ms; +} + +/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently + * focused first; a session that has never been focused sorts after every + * session that has, most recently created first among those; and a dead + * session sorts after every live one regardless of recency, because a session + * you cannot attach to is not where you just were. + * + * This is also the input order for a non-empty query, which is what the + * matcher's last tie-break preserves. */ +export function orderSessionsForBar(sessions: readonly Session[]): Session[] { + return [...sessions].sort((a, b) => { + const deadA = a.status === "Dead" ? 1 : 0; + const deadB = b.status === "Dead" ? 1 : 0; + if (deadA !== deadB) return deadA - deadB; + + const focusA = focusedAt(a); + const focusB = focusedAt(b); + if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1; + if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA; + + return createdAt(b) - createdAt(a); + }); +} + +/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate + * table names, with the directory abbreviated the way the sidebar shows it. */ +export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate { + const treePath = sessionTreePath(s); + const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined; + const tool = splitCommand(s.command).tool ?? undefined; + return { + kind: "session", + session: s, + fields: { + name: sessionName(s), + ...(directory ? { directory } : {}), + ...(s.command ? { command: s.command } : {}), + ...(tool ? { tool } : {}), + }, + }; +} + +/** Every session as a candidate, in the empty-query order. */ +export function sessionBarCandidates( + sessions: readonly Session[], + homeDir?: string, +): SessionBarCandidate[] { + return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir)); +} + +/** Drop the commands that would list twice. Several chords can be bound to one + * action — the registry already carries its aliases on one entry, but nothing + * stops two entries running the same handler, and listed naively that is two + * identical rows. The dedupe is by **action**, never by chord: the chord is + * displayed, so a command with two of them is still one row. */ +export function dedupeCommands(commands: readonly Command[]): Command[] { + const seenRun = new Set(); + const seenLabel = new Set(); + const kept: Command[] = []; + for (const command of commands) { + if (seenRun.has(command.run) || seenLabel.has(command.label)) continue; + seenRun.add(command.run); + seenLabel.add(command.label); + kept.push(command); + } + return kept; +} + +/** Every bound command as a candidate, in registry order. Only the label is a + * field: the chord is displayed beside the row, never matched, because + * matching it would make `>` results depend on a user's rebinds. */ +export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] { + return dedupeCommands(commands).map((command) => ({ + kind: "command", + command, + fields: { label: command.label }, + })); +} + +/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for + * a path the operator typed or `/api/dirs` completed. */ +export function expandHome(path: string, homeDir?: string): string { + if (!homeDir) return path; + if (path === "~") return homeDir; + return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path; +} + +export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate { + const absolute = expandHome(path, homeDir); + return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } }; +} + +/** Candidates for `~`, deduped by the directory they spawn in and keeping the + * first mention of each — recent dirs (newest first) ahead of the project + * list, which is the empty-query order the contract asks for. */ +export function directoryBarCandidates( + paths: readonly string[], + homeDir?: string, +): DirectoryBarCandidate[] { + const seen = new Set(); + const out: DirectoryBarCandidate[] = []; + for (const path of paths) { + if (path === "") continue; + const candidate = directoryCandidate(path, homeDir); + if (seen.has(candidate.path)) continue; + seen.add(candidate.path); + out.push(candidate); + } + return out; +} + +/** The directory list the bar starts from: what the spawn form already knows — + * `GET /api/recent-dirs` and the project list — either alone if the other + * fails. */ +export async function loadBarDirectories(): Promise { + const [recents, discovered] = await Promise.all([ + recentDirs() + .then((r) => r.dirs.map((d) => d.cwd)) + .catch(() => [] as string[]), + fetchProjects() + .then((list) => list.map((p) => p.path)) + .catch(() => [] as string[]), + ]); + return [...recents, ...discovered]; +} + +// ---- grouping ---- + +/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the + * ranking, never a reordering of it: a group holds its matches in rank order, + * and groups appear in the order their best-scoring member does — so the first + * row under the first header is the globally best result. */ +export function barGroups( + results: readonly MatchResult[], +): BarGroup[] { + const groups: BarGroup[] = []; + for (const result of results) { + const kind = result.candidate.kind; + let group = groups.find((g) => g.kind === kind); + if (!group) { + group = { kind, title: KIND_TITLES[kind], results: [] }; + groups.push(group); + } + group.results.push(result); + } + return groups; +} + +/** The rows a query produces, flat and in rank order — what the highlight walks + * and what Enter acts on. Headers are labels, not rows, so they are never in + * this list. */ +export function barRows( + query: string, + candidates: readonly C[], +): MatchResult[] { + return matchAndRank(query, candidates); +} + +// ---- rendering ---- + +/** Text with its matched ranges wrapped in ``. Ranges come from the + * matcher as `[start, end)` character pairs over the field that won. */ +export function renderHighlighted(text: string, ranges: readonly MatchRange[]): DocumentFragment { + const frag = document.createDocumentFragment(); + let cursor = 0; + for (const [start, end] of ranges) { + if (start > cursor) frag.append(text.slice(cursor, start)); + const mark = document.createElement("mark"); + mark.className = "cmdbar-hit"; + mark.textContent = text.slice(start, end); + frag.append(mark); + cursor = end; + } + if (cursor < text.length) frag.append(text.slice(cursor)); + return frag; +} + +/** The name line of a row: the session's name, the command's label, or the + * directory's path. */ +function rowName(candidate: BarCandidate): string { + switch (candidate.kind) { + case "session": + return candidate.fields.name; + case "command": + return candidate.fields.label; + case "directory": + return candidate.fields.path; + } +} + +/** The secondary line under a session row: the field the match was found in + * when that was not the name, so a session found through its directory says + * so. Only sessions have one — a command shows keycaps instead, and a + * directory's path is already the name. */ +function metaText(result: BarResult): string | undefined { + if (result.candidate.kind !== "session") return undefined; + const fields = result.candidate.fields; + switch (result.field) { + case "directory": + return fields.directory; + case "command": + return fields.command; + case "tool": + return fields.tool; + default: + return fields.directory; + } +} + +function renderRow(result: BarResult, index: number): HTMLElement { + const candidate = result.candidate; + const row = document.createElement("div"); + row.className = "cmdbar-row"; + row.setAttribute("role", "option"); + row.id = `cmdbar-row-${index}`; + row.dataset.index = String(index); + row.dataset.kind = candidate.kind; + if (candidate.kind === "session") { + row.dataset.sessionId = candidate.session.id; + if (candidate.session.status === "Dead") row.classList.add("is-dead"); + } else if (isActionCandidate(candidate)) { + row.dataset.actionId = candidate.action.id; + } else if (candidate.kind === "command") { + row.dataset.commandId = candidate.command.id; + } else { + row.dataset.path = candidate.path; + } + + const name = document.createElement("span"); + name.className = "cmdbar-row-name"; + const nameText = rowName(candidate); + const nameMatched = + result.field === "name" || result.field === "label" || result.field === "path"; + name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText); + row.appendChild(name); + + // The chord is teaching material, not a match target: it is rendered from + // the live registry entry and never fed to the matcher. + if (candidate.kind === "command" && !isActionCandidate(candidate)) { + const keys = document.createElement("span"); + keys.className = "cmdbar-row-keys"; + for (const cap of keyCaps(candidate.command.keys[0])) { + const kbd = document.createElement("kbd"); + kbd.textContent = cap; + keys.appendChild(kbd); + } + row.appendChild(keys); + return row; + } + + const meta = metaText(result); + if (meta) { + const el = document.createElement("span"); + el.className = "cmdbar-row-meta"; + const highlighted = result.field !== undefined && result.field !== "name"; + el.append(highlighted ? renderHighlighted(meta, result.ranges) : meta); + row.appendChild(el); + } + return row; +} + +const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = { + mixed: "Go to a session…", + ">": "Run a command…", + "~": "Spawn in a directory…", +}; + +// ---- the overlay ---- + +export interface CommandBarOptions { + /** Sessions to build candidates from, as the store has them right now. */ + sessions: readonly Session[]; + /** The daemon's home dir, for the directory field's `~/…` abbreviation. */ + homeDir?: string; + /** Open the highlighted session. Defaults to the shared open path — the + * sidebar's click event — which is what the contract requires; tests pass + * their own. */ + onOpenSession?: (sessionId: string) => void; + /** The command registry to list under `>`. Defaults to every bound command, + * in registry order. */ + commands?: readonly Command[]; + /** The directory list behind `~`, fetched once on first use. */ + loadDirectories?: () => Promise; + /** One directory's subdirectories, for filling the list in as a path is + * typed. Defaults to `GET /api/dirs`. */ + listDirEntries?: (dir: string) => Promise; + /** Spawn a session in the highlighted directory. Defaults to raising + * `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward + * spawn. */ + onSpawnDirectory?: (path: string) => void; + /** The action page's three actions (#151). Each defaults to raising the + * event the flow that already implements it listens on — the sidebar's + * confirm-and-blockers kill, its inline rename prompt, the spawn form + * seeded from the session. The bar picks the action and nothing more. */ + onKillSession?: (session: Session) => void; + onRenameSession?: (session: Session) => void; + onSpawnSimilar?: (session: Session) => void; +} + +interface OpenBar { + el: HTMLElement; + dispose: () => void; +} + +let current: OpenBar | null = null; + +export function isCommandBarOpen(): boolean { + return current !== null; +} + +export function closeCommandBar(): void { + current?.dispose(); +} + +/** Mount the bar. Opening while one is up replaces it, so a stray second call + * can never stack two overlays. */ +export function openCommandBar(opts: CommandBarOptions): HTMLElement { + closeCommandBar(); + + // Closing returns the keyboard to whatever held it when the bar opened: a + // round trip through the bar never silently moves focus. + const previouslyFocused = document.activeElement as HTMLElement | null; + const openSession = + opts.onOpenSession ?? + ((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id }))); + const spawnDirectory = + opts.onSpawnDirectory ?? + ((path: string) => + document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path }))); + const sessionEvent = + (name: string) => + (session: Session): void => { + document.dispatchEvent(new CustomEvent(name, { detail: session })); + }; + const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT); + const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT); + const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT); + const loadDirectories = opts.loadDirectories ?? loadBarDirectories; + const listEntries = + opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries)); + + const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates( + opts.sessions, + opts.homeDir, + ); + const commandCandidates: CommandBarCandidate[] = commandBarCandidates( + opts.commands ?? publishedCommands(), + ); + + const el = document.createElement("div"); + el.className = "cmdbar-overlay"; + el.setAttribute("role", "dialog"); + el.setAttribute("aria-modal", "true"); + el.setAttribute("aria-label", "Command bar"); + + const panel = document.createElement("div"); + panel.className = "cmdbar-panel"; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "cmdbar-input"; + input.setAttribute("role", "combobox"); + input.setAttribute("aria-expanded", "true"); + input.setAttribute("aria-controls", "cmdbar-results"); + input.setAttribute("aria-label", "Search sessions, commands and directories"); + input.setAttribute("autocomplete", "off"); + input.spellcheck = false; + input.placeholder = PLACEHOLDERS.mixed; + + const results = document.createElement("div"); + results.className = "cmdbar-results"; + results.id = "cmdbar-results"; + results.setAttribute("role", "listbox"); + + panel.append(input, results); + el.appendChild(panel); + document.body.appendChild(el); + + let rows: BarResult[] = []; + let highlighted = 0; + + // The action page (#151). `page` is the session being acted on, `stashed` + // the result-list query it was drilled into from — backing out restores it, + // so the round trip costs the operator nothing. + let page: Session | null = null; + let stashed = ""; + let header: HTMLElement | null = null; + + // `~`'s candidates: the recents-and-projects list, fetched once, plus + // whatever `/api/dirs` has filled in for the directory being typed. + let directories: DirectoryBarCandidate[] = []; + let directoriesRequested = false; + let probed: DirectoryBarCandidate[] = []; + let probedDir: string | null = null; + const listings = new Map(); + + function alive(): boolean { + return current?.el === el; + } + + /** Fetch the recents-and-projects list the first time `~` is used. */ + function ensureDirectories(): void { + if (directoriesRequested) return; + directoriesRequested = true; + loadDirectories() + .then((paths) => { + directories = directoryBarCandidates(paths, opts.homeDir); + if (alive()) render(); + }) + .catch(() => { + /* no directories is a shorter list, not an error state */ + }); + } + + /** Fill the list in from `GET /api/dirs` once the query looks like a path: + * the directory it names is listed and its children join the candidates, so + * `~` reaches a directory that was never spawned in. */ + function probeDirectories(query: string): void { + const { dir } = splitPath(query); + if (dir === "") { + probed = []; + probedDir = null; + return; + } + if (dir === probedDir) return; + probedDir = dir; + const cached = listings.get(dir); + if (cached) { + probed = directoryBarCandidates( + cached.map((entry) => `${dir}${entry}`), + opts.homeDir, + ); + return; + } + probed = []; + listEntries(dir) + .then((entries) => { + listings.set(dir, entries); + if (!alive() || probedDir !== dir) return; + probed = directoryBarCandidates( + entries.map((entry) => `${dir}${entry}`), + opts.homeDir, + ); + render(); + }) + .catch(() => { + /* an unreadable directory contributes nothing */ + }); + } + + /** What the matcher sees for the query line as it stands. With no sigil the + * list is mixed — sessions and commands together — except on an empty query, + * where the bar is a session switcher. */ + function candidatesFor(parsed: BarQuery): BarCandidate[] { + if (page) return sessionActionCandidates(); + switch (parsed.sigil) { + case ">": + return [...commandCandidates]; + case "~": { + ensureDirectories(); + probeDirectories(parsed.query); + const seen = new Set(); + return [...directories, ...probed].filter((c) => + seen.has(c.path) ? false : (seen.add(c.path), true), + ); + } + default: + return parsed.query === "" + ? [...sessionCandidates] + : [...sessionCandidates, ...commandCandidates]; + } + } + + function paintHighlight(): void { + for (const row of results.querySelectorAll(".cmdbar-row")) { + const active = Number(row.dataset.index) === highlighted; + row.classList.toggle("is-active", active); + row.setAttribute("aria-selected", active ? "true" : "false"); + if (active) { + row.scrollIntoView({ block: "nearest" }); + input.setAttribute("aria-activedescendant", row.id); + } + } + if (rows.length === 0) input.removeAttribute("aria-activedescendant"); + } + + /** The line naming the session being acted on, so the target is never + * ambiguous. Present only while the action page is up. */ + function paintHeader(): void { + header?.remove(); + header = null; + if (!page) return; + header = document.createElement("div"); + header.className = "cmdbar-page-header"; + header.setAttribute("role", "presentation"); + header.textContent = `Actions for "${sessionName(page)}"`; + panel.insertBefore(header, results); + } + + function render(): void { + // On the action page the query line is over the three actions, so there is + // no sigil to read: `>` and `~` are ordinary characters there. + const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value); + input.placeholder = page + ? `Act on ${sessionName(page)}…` + : PLACEHOLDERS[parsed.sigil ?? "mixed"]; + paintHeader(); + rows = barRows(parsed.query, candidatesFor(parsed)); + // The highlight starts on the globally best row every time the list + // changes: an old index would point at a different result. + highlighted = 0; + results.replaceChildren(); + + if (rows.length === 0) { + const empty = document.createElement("div"); + empty.className = "cmdbar-empty"; + empty.textContent = "No matches"; + results.appendChild(empty); + paintHighlight(); + return; + } + + let index = 0; + for (const group of barGroups(rows)) { + // While a sigil is in force there is one kind, so the header is + // dropped — and the action page is one kind too. + if (parsed.sigil === null && !page) { + const header = document.createElement("div"); + header.className = "cmdbar-group-title"; + header.setAttribute("role", "presentation"); + header.textContent = group.title; + results.appendChild(header); + } + for (const result of group.results) { + const at = index; + const row = renderRow(result, at); + row.addEventListener("click", () => activate(at)); + results.appendChild(row); + index += 1; + } + } + paintHighlight(); + } + + /** The highlight does not wrap: it stops at the first and last row. */ + function move(delta: number): void { + if (rows.length === 0) return; + highlighted = Math.min(rows.length - 1, Math.max(0, highlighted + delta)); + paintHighlight(); + } + + /** `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. */ + function drillIn(): void { + const candidate = rows[highlighted]?.candidate; + if (page || candidate?.kind !== "session") return; + page = candidate.session; + stashed = input.value; + input.value = ""; + render(); + } + + /** Escape, or backspace at an empty query: back to the result list with the + * query the drill-in was made from. */ + function leavePage(): void { + page = null; + input.value = stashed; + stashed = ""; + render(); + } + + function runAction(action: SessionAction, session: Session): void { + close(); + switch (action.id) { + case "kill": + killSession(session); + return; + case "rename": + renameSession(session); + return; + case "spawn-similar": + spawnSimilar(session); + return; + } + } + + function activate(index: number): void { + const result = rows[index]; + if (!result) return; + const candidate = result.candidate; + if (isActionCandidate(candidate)) { + // The page cannot be up without a session to act on. + if (page) runAction(candidate.action, page); + return; + } + close(); + switch (candidate.kind) { + case "session": + openSession(candidate.session.id); + return; + case "command": + candidate.command.run(); + return; + case "directory": + spawnDirectory(candidate.path); + return; + } + } + + function onKeydown(e: KeyboardEvent): void { + switch (e.key) { + case "Escape": + e.preventDefault(); + // On an action page Escape is one level up, not the way out. + if (page) leavePage(); + else close(); + return; + case "Tab": + e.preventDefault(); + drillIn(); + return; + case "Backspace": + // Ordinary editing, except at an empty query on the action page. + if (page && input.value === "") { + e.preventDefault(); + leavePage(); + } + return; + case "Enter": + e.preventDefault(); + activate(highlighted); + return; + case "ArrowDown": + e.preventDefault(); + move(1); + return; + case "ArrowUp": + e.preventDefault(); + move(-1); + return; + case "n": + if (e.ctrlKey) { + e.preventDefault(); + move(1); + } + return; + case "p": + if (e.ctrlKey) { + e.preventDefault(); + move(-1); + } + return; + } + } + + function close(): void { + if (current?.el !== el) return; + current = null; + el.remove(); + // Restoring focus is what makes the bar a view over the app rather than a + // place in it; a detached node cannot take it. + if (previouslyFocused?.isConnected) previouslyFocused.focus(); + } + + input.addEventListener("keydown", onKeydown); + input.addEventListener("input", render); + // A press on the scrim closes; one inside the panel is left alone. + el.addEventListener("mousedown", (ev) => { + if (!panel.contains(ev.target as Node)) close(); + }); + + current = { el, dispose: close }; + render(); + input.focus(); + return el; +} diff --git a/web/src/commands.test.ts b/web/src/commands.test.ts new file mode 100644 index 0000000..5202ab5 --- /dev/null +++ b/web/src/commands.test.ts @@ -0,0 +1,309 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; + +import { + COMMAND_SPECS, + CommandSet, + DEFAULT_LEADER, + canonicalizeKey, + defineCommands, + isBarTypingTarget, + isTerminalTextarea, + isTypingTarget, + keyCaps, + publishedCommands, +} from "./commands.js"; + +function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent { + return new KeyboardEvent("keydown", { key: k, cancelable: true, ...init }); +} + +function el(html: string): HTMLElement { + const d = document.createElement("div"); + d.innerHTML = html; + return d.firstElementChild as HTMLElement; +} + +/** Dispatch on a target, so `isTypingTarget` sees something other than null. */ +function pressOn(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit): boolean { + const e = key(k, init); + Object.defineProperty(e, "target", { value: target }); + return set.handle(e); +} + +describe("command specs", () => { + it("has a unique id and at least one chord per command", () => { + const ids = COMMAND_SPECS.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + for (const spec of COMMAND_SPECS) { + expect(spec.keys.length).toBeGreaterThan(0); + expect(spec.label.length).toBeGreaterThan(0); + } + }); + + it("binds each chord once within a scope", () => { + const seen = new Set(); + for (const spec of COMMAND_SPECS) { + for (const k of spec.keys) { + const slot = `${spec.scope}:${k}`; + expect(seen.has(slot)).toBe(false); + seen.add(slot); + } + } + }); + + it("rejects a handler for an id that isn't in the registry", () => { + expect(() => defineCommands({ "nope.missing": () => {} })).toThrow(/unknown command id/); + }); + + it("carries every spec field onto the defined command", () => { + const [cmd] = defineCommands({ "spawn.open": () => {} }); + expect(cmd).toMatchObject({ id: "spawn.open", keys: ["n"], scope: "global" }); + }); +}); + +describe("canonicalizeKey", () => { + it("spells keys the way the TUI's InputKey::canonical does", () => { + expect(canonicalizeKey(key("n"))).toBe("n"); + expect(canonicalizeKey(key("N", { shiftKey: true }))).toBe("N"); + expect(canonicalizeKey(key("?", { shiftKey: true }))).toBe("?"); + expect(canonicalizeKey(key("a", { ctrlKey: true }))).toBe("ctrl-a"); + expect(canonicalizeKey(key("Escape"))).toBe("esc"); + expect(canonicalizeKey(key("Enter"))).toBe("enter"); + expect(canonicalizeKey(key("Tab"))).toBe("tab"); + expect(canonicalizeKey(key("Tab", { shiftKey: true }))).toBe("backtab"); + expect(canonicalizeKey(key("ArrowDown"))).toBe("down"); + }); + + it("folds a modified letter to lower case with an explicit shift-", () => { + expect(canonicalizeKey(key("H", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h"); + expect(canonicalizeKey(key("h", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h"); + }); + + it("has no canonical form for a modifier pressed alone", () => { + expect(canonicalizeKey(key("Control", { ctrlKey: true }))).toBeNull(); + expect(canonicalizeKey(key("Shift", { shiftKey: true }))).toBeNull(); + }); +}); + +describe("keyCaps", () => { + it("renders one cap per modifier and key", () => { + expect(keyCaps("ctrl-shift-h")).toEqual(["Ctrl", "Shift", "H"]); + expect(keyCaps("enter")).toEqual(["Enter"]); + expect(keyCaps("j")).toEqual(["j"]); + expect(keyCaps("N")).toEqual(["N"]); + expect(keyCaps("?")).toEqual(["?"]); + expect(keyCaps("ctrl-a ctrl-k")).toEqual(["Ctrl", "A", "then", "Ctrl", "K"]); + }); +}); + +describe("CommandSet", () => { + it("runs the command bound to a chord, and swallows the browser default", () => { + const run = vi.fn(); + const set = new CommandSet(defineCommands({ "spawn.open": run })); + const e = key("n"); + expect(set.handle(e)).toBe(true); + expect(run).toHaveBeenCalledTimes(1); + expect(e.defaultPrevented).toBe(true); + }); + + it("leaves an unbound key alone", () => { + const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() })); + const e = key("q"); + expect(set.handle(e)).toBe(false); + expect(e.defaultPrevented).toBe(false); + }); + + it("treats a modified key as a different binding", () => { + const run = vi.fn(); + const set = new CommandSet(defineCommands({ "spawn.open": run })); + expect(set.handle(key("n", { ctrlKey: true }))).toBe(false); + expect(set.handle(key("n", { metaKey: true }))).toBe(false); + expect(run).not.toHaveBeenCalled(); + }); + + it("runs every alias of a command", () => { + const run = vi.fn(); + const set = new CommandSet(defineCommands({ "list.next": run })); + set.handle(key("j")); + set.handle(key("ArrowDown")); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("keeps plain keys deaf inside a form field, and chorded ones live", () => { + const plain = vi.fn(); + const chorded = vi.fn(); + const set = new CommandSet( + defineCommands({ "spawn.open": plain, "focus.toggle": chorded }), + ); + const input = el(""); + expect(pressOn(set, input, "n")).toBe(false); + expect(plain).not.toHaveBeenCalled(); + expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true); + expect(chorded).toHaveBeenCalledTimes(1); + }); + + it("keeps pane commands live inside the terminal's textarea", () => { + const run = vi.fn(); + const set = new CommandSet(defineCommands({ "pane.focusList": run }), { + preventDefault: false, + }); + const textarea = el(''); + const e = key("Escape"); + Object.defineProperty(e, "target", { value: textarea }); + expect(set.handle(e)).toBe(true); + expect(run).toHaveBeenCalledTimes(1); + // xterm is told to swallow by the return value, not by the browser default. + expect(e.defaultPrevented).toBe(false); + }); +}); + +describe("the leader", () => { + it("defaults to the TUI's ctrl-a", () => { + expect(DEFAULT_LEADER).toBe("ctrl-a"); + }); + + it("holds the leader as a partial chord instead of selecting the page", () => { + const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), { + leader: DEFAULT_LEADER, + }); + const e = key("a", { ctrlKey: true }); + expect(set.handle(e)).toBe(true); + expect(e.defaultPrevented).toBe(true); + }); + + it("swallows the key after the leader rather than running its plain binding", () => { + const run = vi.fn(); + const set = new CommandSet(defineCommands({ "spawn.open": run }), { + leader: DEFAULT_LEADER, + }); + set.handle(key("a", { ctrlKey: true })); + expect(set.handle(key("n"))).toBe(true); + expect(run).not.toHaveBeenCalled(); + // The next key is an ordinary one again. + set.handle(key("n")); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("forgets a held leader once the chord timeout passes", () => { + const run = vi.fn(); + let now = 0; + const set = new CommandSet(defineCommands({ "spawn.open": run }), { + leader: DEFAULT_LEADER, + chordTimeoutMs: 1000, + now: () => now, + }); + set.handle(key("a", { ctrlKey: true })); + now = 1500; + set.handle(key("n")); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("leaves select-all alone in a real form field", () => { + const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), { + leader: DEFAULT_LEADER, + }); + const input = el(""); + const e = key("a", { ctrlKey: true }); + Object.defineProperty(e, "target", { value: input }); + expect(set.handle(e)).toBe(false); + expect(e.defaultPrevented).toBe(false); + }); +}); + +describe("publishing", () => { + it("lists a constructed set's commands, replacing an id rather than doubling it", () => { + new CommandSet(defineCommands({ "list.kill": () => {} })); + new CommandSet(defineCommands({ "list.kill": () => {} })); + const ids = publishedCommands().map((c) => c.id); + expect(ids.filter((id) => id === "list.kill")).toEqual(["list.kill"]); + }); + + it("keeps published commands in registry order", () => { + const order = COMMAND_SPECS.map((s) => s.id); + const ids = publishedCommands().map((c) => c.id); + expect(ids).toEqual(order.filter((id) => ids.includes(id))); + }); +}); + +describe("isTypingTarget", () => { + it("is true for form fields and editable nodes, false otherwise", () => { + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el(""))).toBe(true); + expect(isTypingTarget(el('
'))).toBe(true); + expect(isTypingTarget(el(""))).toBe(false); + expect(isTypingTarget(null)).toBe(false); + }); +}); + +describe("isTerminalTextarea", () => { + it("recognises xterm's offscreen textarea, and nothing else", () => { + expect(isTerminalTextarea(el(''))).toBe(true); + expect(isTerminalTextarea(el(""))).toBe(false); + expect(isTerminalTextarea(el(""))).toBe(false); + expect(isTerminalTextarea(null)).toBe(false); + }); +}); + +describe("isBarTypingTarget", () => { + it("holds the bar's keys out of real form fields", () => { + expect(isBarTypingTarget(el(""))).toBe(true); + expect(isBarTypingTarget(el(""))).toBe(true); + }); + + it("does not count the terminal's textarea, so ctrl-k reaches the bar there", () => { + expect(isBarTypingTarget(el(''))).toBe(false); + expect(isBarTypingTarget(el(""))).toBe(false); + expect(isBarTypingTarget(null)).toBe(false); + }); +}); + +describe("ctrl-k over a focused pane (#152)", () => { + const terminal = () => el(''); + + /** The global listener's routing: the bar's set only sees a key that is not + * aimed at a real form field. */ + function route(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit) { + if (isBarTypingTarget(target)) return false; + return pressOn(set, target, k, init); + } + + it("opens the bar when the terminal has the keyboard", () => { + const open = vi.fn(); + const set = new CommandSet(defineCommands({ "bar.open": open })); + expect(route(set, terminal(), "k", { ctrlKey: true })).toBe(true); + expect(open).toHaveBeenCalledTimes(1); + }); + + it("leaves the spawn form's inputs their own Ctrl-K", () => { + const open = vi.fn(); + const set = new CommandSet(defineCommands({ "bar.open": open })); + expect(route(set, el(""), "k", { ctrlKey: true })).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); + + it("sends the literal 0x0B after the leader, without opening the bar", () => { + const open = vi.fn(); + const literal = vi.fn(); + const bar = new CommandSet(defineCommands({ "bar.open": open })); + const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literal }), { + leader: DEFAULT_LEADER, + }); + const target = terminal(); + expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true); + const e = key("k", { ctrlKey: true }); + Object.defineProperty(e, "target", { value: target }); + expect(set.handle(e)).toBe(true); + expect(literal).toHaveBeenCalledTimes(1); + // The leader's set consumed the key, so the bar's set never sees it. + expect(open).not.toHaveBeenCalled(); + expect(bar.handle(e)).toBe(true); // sanity: it would have opened otherwise + expect(open).toHaveBeenCalledTimes(1); + }); + + it("binds the escape hatch behind the default leader", () => { + const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalCtrlK"); + expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]); + }); +}); diff --git a/web/src/commands.ts b/web/src/commands.ts new file mode 100644 index 0000000..1d8ff61 --- /dev/null +++ b/web/src/commands.ts @@ -0,0 +1,408 @@ +// The web client's command registry (#148). One entry per command — a stable +// id, a plain-language label, the key chord it answers to, and a `run()` — so +// there is exactly one list of what the client can do. The `?` overlay renders +// from it (`help.ts`), and every keybinding is dispatched through it; a command +// added here is bound and documented in the same edit. +// +// Keys are canonical strings, the same spelling the TUI's `InputKey::canonical` +// produces (`ctrl-shift-h`, `enter`, `esc`, `n`, `N`) and chords are those +// joined by a space (`ctrl-a ctrl-k`). Matching the TUI's spelling is what lets +// docs/COMMAND-BAR.md describe one key model for both clients. + +/** Where a command's keys are listened for. `global` is the window, `list` the + * sidebar's session tree, `pane` the attached terminal, `overlay` the help + * dialog while it is up. */ +export type CommandScope = "global" | "list" | "pane" | "overlay"; + +export interface CommandSpec { + /** Stable identifier. Never rendered; safe to bind config and tests to. */ + id: string; + /** Plain-language label — what the `?` overlay and (later) the command bar + * show. */ + label: string; + /** Help-overlay section. Groups render in the order they first appear here. */ + group: string; + /** Canonical chords that run it. The first is the one shown to the operator; + * the rest are aliases (`j` and `down`). */ + keys: string[]; + scope: CommandScope; +} + +export interface Command extends CommandSpec { + run: () => void; +} + +/** Default leader, mirroring the TUI's `[tui].leader`. `ctrl-a` is select-all + * in a browser, so a set that owns the leader `preventDefault`s it. */ +export const DEFAULT_LEADER = "ctrl-a"; + +/** Every command the web client has. The single list this issue exists to + * create: labels, chords and grouping live here and nowhere else. */ +export const COMMAND_SPECS: readonly CommandSpec[] = [ + { + id: "spawn.open", + label: "Open the spawn form", + group: "Sessions", + keys: ["n"], + scope: "global", + }, + { + id: "spawn.fastForward", + label: "Spawn a session with every default resolved", + group: "Sessions", + keys: ["N"], + scope: "global", + }, + { + id: "bar.open", + label: "Open the command bar", + group: "Sessions", + keys: ["ctrl-k"], + scope: "global", + }, + { + id: "pane.literalCtrlK", + label: "Send a literal Ctrl-K to the pane", + group: "Pane", + keys: [`${DEFAULT_LEADER} ctrl-k`], + scope: "global", + }, + { + id: "focus.toggle", + label: "Move the keyboard between the session list and the pane", + group: "Session list", + keys: ["ctrl-shift-h"], + scope: "global", + }, + { + id: "list.next", + label: "Highlight the next session", + group: "Session list", + keys: ["j", "down"], + scope: "list", + }, + { + id: "list.prev", + label: "Highlight the previous session", + group: "Session list", + keys: ["k", "up"], + scope: "list", + }, + { + id: "list.activate", + label: "Open the highlighted session, or expand a folder", + group: "Session list", + keys: ["enter"], + scope: "list", + }, + { + id: "list.toggleFolder", + label: "Collapse or expand the highlighted folder", + group: "Session list", + keys: ["c"], + scope: "list", + }, + { + id: "list.rename", + label: "Rename the highlighted session", + group: "Session list", + keys: ["r"], + scope: "list", + }, + { + id: "list.kill", + label: "Kill the highlighted session", + group: "Session list", + keys: ["x"], + scope: "list", + }, + { + id: "list.focusPane", + label: "Send the keyboard to the pane", + group: "Session list", + keys: ["tab"], + scope: "list", + }, + { + id: "pane.focusList", + label: "Hand the keyboard back to the session list, from the pane", + group: "Session list", + keys: ["esc"], + scope: "pane", + }, + { + id: "help.toggle", + label: "Show or hide this help", + group: "Help", + keys: ["?"], + scope: "global", + }, + { + id: "help.close", + label: "Close the help overlay or an open menu", + group: "Help", + keys: ["esc"], + scope: "overlay", + }, + { + id: "debug.toggleHud", + label: "Toggle the debug HUD", + group: "Debug", + keys: ["ctrl-shift-d"], + scope: "global", + }, +]; + +const SPEC_BY_ID = new Map(COMMAND_SPECS.map((s) => [s.id, s])); + +/** Attach handlers to specs, by id. Throws on an unknown id, so a typo is a + * test failure rather than a key that silently does nothing. */ +export function defineCommands(handlers: Record void>): Command[] { + return Object.entries(handlers).map(([id, run]) => { + const spec = SPEC_BY_ID.get(id); + if (!spec) throw new Error(`unknown command id: ${id}`); + return { ...spec, run }; + }); +} + +// ---- what the help overlay renders ---- + +const published = new Map(); + +/** Record commands as bound. Re-registering an id replaces it, so a second + * sidebar (tests, hot reload) doesn't duplicate rows in the overlay. */ +export function publishCommands(cmds: Command[]): void { + for (const c of cmds) published.set(c.id, c); +} + +/** Every bound command, in `COMMAND_SPECS` order. */ +export function publishedCommands(): Command[] { + return COMMAND_SPECS.map((s) => published.get(s.id)).filter((c): c is Command => c !== undefined); +} + +// ---- key canonicalisation ---- + +const NAMED: Record = { + Escape: "esc", + Enter: "enter", + Tab: "tab", + Backspace: "backspace", + ArrowUp: "up", + ArrowDown: "down", + ArrowLeft: "left", + ArrowRight: "right", + Home: "home", + End: "end", + PageUp: "pageup", + PageDown: "pagedown", + Delete: "delete", + " ": "space", +}; + +/** The canonical string for one keydown. Mirrors the TUI's + * `InputKey::canonical`: modifier prefixes in ctrl/alt/shift order, then the + * key. A shifted character already spells itself (`N`, `?`), so `shift-` is + * only written when another modifier is present and the char was folded to + * lower case. */ +export function canonicalizeKey(e: KeyboardEvent): string | null { + if (!e.key) return null; + // Modifier keys pressed alone are never a binding. + if (e.key === "Control" || e.key === "Alt" || e.key === "Shift" || e.key === "Meta") return null; + const named = NAMED[e.key]; + if (named === "tab" && e.shiftKey) return "backtab"; + let out = ""; + if (e.ctrlKey) out += "ctrl-"; + if (e.altKey) out += "alt-"; + if (e.metaKey) out += "meta-"; + if (named) { + if (e.shiftKey) out += "shift-"; + return out + named; + } + if (e.key.length === 1) { + if (out === "") return e.key; + if (e.shiftKey) out += "shift-"; + return out + e.key.toLowerCase(); + } + if (e.shiftKey) out += "shift-"; + return out + e.key.toLowerCase(); +} + +const MOD_PREFIX = /^(ctrl|alt|meta|shift)-/; + +/** True when a chord carries a modifier or more than one key. Plain single-key + * chords are the ones that stay deaf inside form fields. */ +function isChorded(chord: string): boolean { + return chord.includes(" ") || MOD_PREFIX.test(chord); +} + +/** True when the event target swallows plain keystrokes — a form field, or the + * hidden textarea xterm types into. Every plain single-key binding is deaf in + * the same places. */ +export function isTypingTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + if (!el || typeof el.tagName !== "string") return false; + return ( + el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + el.tagName === "SELECT" || + el.isContentEditable + ); +} + +/** xterm's offscreen textarea. It is a typing target like any other, but it is + * also where the leader has to work — a pane is exactly where the operator + * needs the escape hatch (docs/COMMAND-BAR.md). */ +export function isTerminalTextarea(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + return !!el && typeof el.className === "string" && el.classList?.contains("xterm-helper-textarea"); +} + +/** True where the command bar's own keys must stay out of the way: a real form + * field, and not the pane. `ctrl-k` is intercepted ahead of xterm + * (docs/COMMAND-BAR.md, "Opening"), so a focused terminal opens the bar, while + * the spawn form's inputs keep their own Ctrl-K. */ +export function isBarTypingTarget(target: EventTarget | null): boolean { + return isTypingTarget(target) && !isTerminalTextarea(target); +} + +// ---- display ---- + +const CAPS: Record = { + ctrl: "Ctrl", + alt: "Alt", + meta: "Meta", + shift: "Shift", + esc: "Esc", + enter: "Enter", + tab: "Tab", + backtab: "Shift Tab", + backspace: "Backspace", + up: "Up", + down: "Down", + left: "Left", + right: "Right", + home: "Home", + end: "End", + pageup: "PgUp", + pagedown: "PgDn", + delete: "Del", + space: "Space", +}; + +/** Keycaps for a chord, left to right: `ctrl-shift-h` → Ctrl, Shift, H. A + * multi-key chord separates its keys with a `then` cap. */ +export function keyCaps(chord: string): string[] { + const caps: string[] = []; + for (const [i, key] of chord.split(" ").entries()) { + if (i > 0) caps.push("then"); + const parts = key.split("-"); + // A bare `-` key splits into two empties; treat it as the key itself. + if (parts.every((p) => p === "")) { + caps.push("-"); + continue; + } + const last = parts.pop() as string; + const mods = parts.filter((p) => p !== ""); + for (const m of mods) caps.push(CAPS[m] ?? m); + caps.push(CAPS[last] ?? (mods.length > 0 && last.length === 1 ? last.toUpperCase() : last)); + } + return caps; +} + +// ---- dispatch ---- + +/** How long a partial chord waits for its next key — the TUI's + * `chord_timeout_ms` default. */ +export const DEFAULT_CHORD_TIMEOUT_MS = 1000; + +export interface CommandSetOptions { + /** Canonical leader key. A set given one holds every leader press as a + * pending chord, whether or not anything is bound behind it yet. */ + leader?: string | null; + chordTimeoutMs?: number; + /** Call `preventDefault()` on a handled key. Off for the terminal's own key + * handler, which swallows by returning `false` to xterm instead. */ + preventDefault?: boolean; + /** Clock, for tests. */ + now?: () => number; +} + +/** A group of commands listening on one surface. Resolves a keydown to a + * command the way the TUI's `ChordState` does: hold while the sequence is a + * strict prefix of some binding, otherwise resolve and dispatch. */ +export class CommandSet { + private readonly byChord = new Map(); + private readonly leader: string | null; + private readonly timeout: number; + private readonly preventDefault: boolean; + private readonly now: () => number; + private held: string[] = []; + private lastPress = 0; + + constructor( + readonly commands: Command[], + opts: CommandSetOptions = {}, + ) { + for (const c of commands) for (const k of c.keys) this.byChord.set(k, c); + this.leader = opts.leader ?? null; + this.timeout = opts.chordTimeoutMs ?? DEFAULT_CHORD_TIMEOUT_MS; + this.preventDefault = opts.preventDefault ?? true; + this.now = opts.now ?? (() => Date.now()); + publishCommands(commands); + } + + /** Whether any binding starts with `candidate` and continues. */ + private isPrefix(candidate: string): boolean { + const marker = `${candidate} `; + for (const chord of this.byChord.keys()) if (chord.startsWith(marker)) return true; + return false; + } + + /** Feed one keydown. Returns true when the key was consumed — dispatched, or + * held as a partial chord. */ + handle(e: KeyboardEvent): boolean { + const key = canonicalizeKey(e); + if (key === null) return false; + if (this.held.length > 0 && this.now() - this.lastPress > this.timeout) this.held = []; + const candidate = this.held.length > 0 ? `${this.held.join(" ")} ${key}` : key; + this.lastPress = this.now(); + + // The leader is a prefix by construction, so it stops being select-all + // before anything is bound behind it (#149 binds the first chord). + if (this.leader !== null && this.held.length === 0 && key === this.leader) { + if (isTypingTarget(e.target) && !isTerminalTextarea(e.target)) return false; + this.held = [key]; + if (this.preventDefault) e.preventDefault(); + return true; + } + + if (this.isPrefix(candidate)) { + this.held.push(key); + if (this.preventDefault) e.preventDefault(); + return true; + } + + const wasChord = this.held.length > 0; + this.held = []; + const cmd = this.byChord.get(candidate); + if (!cmd) { + // A chord that resolved to nothing is still consumed: the leader took + // the key, so it must not also fall through to a plain binding. + return wasChord; + } + // Plain single-key bindings are deaf where typing happens; chorded ones are + // not, and neither are the pane's and the overlay's, which are only fed + // from the surface that owns them. + if ( + !isChorded(candidate) && + cmd.scope !== "pane" && + cmd.scope !== "overlay" && + isTypingTarget(e.target) + ) { + return false; + } + if (this.preventDefault) e.preventDefault(); + cmd.run(); + return true; + } +} diff --git a/web/src/connections.test.ts b/web/src/connections.test.ts index 27622de..9464504 100644 --- a/web/src/connections.test.ts +++ b/web/src/connections.test.ts @@ -3,6 +3,23 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ConnectionPool } from "./connections.js"; +// The pane's own bytes-out path, stubbed: the escape hatch only needs to know +// the pool reaches the focused connection with them. +const sent: { id: string; data: string }[] = []; +vi.mock("./attach.js", () => ({ + createAttachConnection: (id: string) => ({ + sessionId: id, + el: document.createElement("div"), + onMount() {}, + onUnmount() {}, + focusTerminal() {}, + sendInput(data: string) { + sent.push({ id, data }); + }, + dispose() {}, + }), +})); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -85,3 +102,26 @@ describe("ConnectionPool.setFocus", () => { await Promise.resolve(); }); }); + +describe("ConnectionPool.sendToFocused", () => { + it("types the bytes into the focused session's PTY", () => { + mockFetch(); + sent.length = 0; + const pool = new ConnectionPool(); + const pane = document.createElement("div"); + pool.mount(A, pane); + pool.setFocus(A); + + expect(pool.sendToFocused("\x0b")).toBe(true); + expect(sent).toEqual([{ id: A, data: "\x0b" }]); + }); + + it("is a no-op with nothing focused", () => { + mockFetch(); + sent.length = 0; + const pool = new ConnectionPool(); + + expect(pool.sendToFocused("\x0b")).toBe(false); + expect(sent).toEqual([]); + }); +}); diff --git a/web/src/connections.ts b/web/src/connections.ts index 53b7082..1f17afe 100644 --- a/web/src/connections.ts +++ b/web/src/connections.ts @@ -159,6 +159,18 @@ export class ConnectionPool { this.cache.get(this.focusedId)?.focusTerminal(); } + /** Type raw bytes into the focused session's PTY. Returns false when there is + * nothing focused, or its connection is gone — the leader's literal Ctrl-K + * is a no-op over the welcome pane rather than an error + * (docs/COMMAND-BAR.md). */ + sendToFocused(data: string): boolean { + if (this.focusedId === null) return false; + const conn = this.cache.get(this.focusedId); + if (!conn) return false; + conn.sendInput(data); + return true; + } + /** Fully disconnect + forget a session. */ close(id: Uuid): void { const conn = this.cache.get(id); diff --git a/web/src/debug.ts b/web/src/debug.ts index 5dfcc0b..93623d1 100644 --- a/web/src/debug.ts +++ b/web/src/debug.ts @@ -2,6 +2,8 @@ // survives reloads, flipped by a keybind (Ctrl+Shift+D), and force-enabled by // `?hud` in dev. A `maestro-hud-toggle` event lets the HUD re-render on change. +import { CommandSet, defineCommands } from "./commands.js"; + const STORAGE_KEY = "maestro:hud"; export const HUD_TOGGLE_EVENT = "maestro-hud-toggle"; @@ -31,12 +33,9 @@ export function toggleHud(): void { setHudEnabled(!isHudEnabled()); } +const hudKeys = new CommandSet(defineCommands({ "debug.toggleHud": () => toggleHud() })); + /** Global keybind: Ctrl+Shift+D toggles the debug HUD. */ export function installHudKeybind(): void { - window.addEventListener("keydown", (e) => { - if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) { - e.preventDefault(); - toggleHud(); - } - }); + window.addEventListener("keydown", (e) => hudKeys.handle(e)); } diff --git a/web/src/help.test.ts b/web/src/help.test.ts index 660e06d..f5b9e70 100644 --- a/web/src/help.test.ts +++ b/web/src/help.test.ts @@ -1,14 +1,8 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - SHORTCUTS, - closeHelp, - installHelpKeybind, - isHelpOpen, - isTypingTarget, - openHelp, -} from "./help.js"; +import { keyCaps, publishedCommands } from "./commands.js"; +import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js"; beforeEach(() => { installHelpKeybind(); @@ -30,14 +24,24 @@ function rows(): string[] { } describe("help overlay", () => { - it("renders every registered shortcut, grouped", () => { + it("renders exactly the commands the registry has bound, grouped", () => { openHelp(); - const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent); - expect(groups).toEqual(SHORTCUTS.map((g) => g.title)); - expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" ")))); - expect(document.querySelectorAll(".help-desc")).toHaveLength( - SHORTCUTS.reduce((n, g) => n + g.items.length, 0), + const groups = groupedCommands(); + expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual( + groups.map((g) => g.title), ); + expect(rows()).toEqual( + groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))), + ); + expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual( + groups.flatMap((g) => g.items.map((c) => c.label)), + ); + }); + + it("names each row by its command id, so a row can't drift from its command", () => { + openHelp(); + expect([...document.querySelectorAll(".help-row")].map((r) => r.dataset.commandId)) + .toEqual(publishedCommands().map((c) => c.id)); }); it("opens on ? and closes on a second ?", () => { @@ -94,19 +98,3 @@ describe("help overlay", () => { expect(document.querySelectorAll(".help-overlay")).toHaveLength(1); }); }); - -describe("isTypingTarget", () => { - it("is true for form fields and editable nodes, false otherwise", () => { - const el = (html: string) => { - const d = document.createElement("div"); - d.innerHTML = html; - return d.firstElementChild as HTMLElement; - }; - expect(isTypingTarget(el(""))).toBe(true); - expect(isTypingTarget(el(""))).toBe(true); - expect(isTypingTarget(el(""))).toBe(true); - expect(isTypingTarget(el('
'))).toBe(true); - expect(isTypingTarget(el(""))).toBe(false); - expect(isTypingTarget(null)).toBe(false); - }); -}); diff --git a/web/src/help.ts b/web/src/help.ts index da97c92..22fd80f 100644 --- a/web/src/help.ts +++ b/web/src/help.ts @@ -1,69 +1,15 @@ /** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help - * overlay. One registry drives both the rendering and what we claim is bound, - * so a binding added to `main.ts` without an entry here reads as missing. */ + * overlay. It renders the command registry (`commands.ts`) — the same list + * that dispatches the keys — so a binding can never be documented as one + * thing and do another. */ -export interface Shortcut { - /** Keycaps, rendered left to right as separate . */ - keys: string[]; - description: string; -} - -export interface ShortcutGroup { - title: string; - items: Shortcut[]; -} - -export const SHORTCUTS: ShortcutGroup[] = [ - { - title: "Sessions", - items: [ - { keys: ["n"], description: "Open the spawn form" }, - { keys: ["N"], description: "Spawn a session with every default resolved" }, - ], - }, - { - title: "Session list", - items: [ - { - keys: ["Ctrl", "Shift", "H"], - description: "Move the keyboard between the session list and the pane", - }, - { keys: ["j"], description: "Highlight the next session" }, - { keys: ["k"], description: "Highlight the previous session" }, - { keys: ["Enter"], description: "Open the highlighted session, or expand a folder" }, - { keys: ["c"], description: "Collapse or expand the highlighted folder" }, - { keys: ["r"], description: "Rename the highlighted session" }, - { keys: ["x"], description: "Kill the highlighted session" }, - { keys: ["Tab"], description: "Send the keyboard to the pane" }, - { keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" }, - ], - }, - { - title: "Help", - items: [ - { keys: ["?"], description: "Show or hide this help" }, - { keys: ["Esc"], description: "Close the help overlay or an open menu" }, - ], - }, - { - title: "Debug", - items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }], - }, -]; - -/** True when the event target swallows plain keystrokes — a form field, or the - * hidden textarea xterm types into. Shared with `main.ts` so every global - * single-key binding is deaf in the same places. */ -export function isTypingTarget(target: EventTarget | null): boolean { - const el = target as HTMLElement | null; - if (!el || typeof el.tagName !== "string") return false; - return ( - el.tagName === "INPUT" || - el.tagName === "TEXTAREA" || - el.tagName === "SELECT" || - el.isContentEditable - ); -} +import { + CommandSet, + defineCommands, + keyCaps, + publishedCommands, + type Command, +} from "./commands.js"; let open: { el: HTMLElement; dispose: () => void } | null = null; @@ -108,7 +54,7 @@ export function openHelp(): HTMLElement { head.append(title, close); panel.appendChild(head); - for (const group of SHORTCUTS) { + for (const group of groupedCommands()) { const section = document.createElement("section"); section.className = "help-group"; const heading = document.createElement("h3"); @@ -116,19 +62,20 @@ export function openHelp(): HTMLElement { heading.textContent = group.title; const list = document.createElement("dl"); list.className = "help-list"; - for (const item of group.items) { + for (const cmd of group.items) { const row = document.createElement("div"); row.className = "help-row"; + row.dataset.commandId = cmd.id; const keys = document.createElement("dt"); keys.className = "help-keys"; - for (const key of item.keys) { + for (const key of keyCaps(cmd.keys[0])) { const cap = document.createElement("kbd"); cap.textContent = key; keys.appendChild(cap); } const desc = document.createElement("dd"); desc.className = "help-desc"; - desc.textContent = item.description; + desc.textContent = cmd.label; row.append(keys, desc); list.appendChild(row); } @@ -153,6 +100,26 @@ export function openHelp(): HTMLElement { return el; } +/** The registry's commands, in registry order, bucketed into the help + * overlay's sections. Groups appear in the order their first command does. */ +export function groupedCommands(): { title: string; items: Command[] }[] { + const groups: { title: string; items: Command[] }[] = []; + for (const cmd of publishedCommands()) { + let group = groups.find((g) => g.title === cmd.group); + if (!group) { + group = { title: cmd.group, items: [] }; + groups.push(group); + } + group.items.push(cmd); + } + return groups; +} + +// The overlay's own two commands. `help.close` is `overlay`-scoped: it is only +// fed keys while the dialog is up. +const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => toggleHelp() })); +const overlayKeys = new CommandSet(defineCommands({ "help.close": () => closeHelp() })); + let installed = false; /** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a @@ -161,16 +128,7 @@ export function installHelpKeybind(): void { if (installed) return; installed = true; window.addEventListener("keydown", (e) => { - if (e.key === "Escape" && open) { - e.preventDefault(); - closeHelp(); - return; - } - // Shift is how `?` is typed on most layouts; the others would be a - // different binding entirely. - if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return; - if (isTypingTarget(e.target)) return; - e.preventDefault(); - toggleHelp(); + if (open && overlayKeys.handle(e)) return; + helpKeys.handle(e); }); } diff --git a/web/src/main.ts b/web/src/main.ts index 648710b..6811186 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -6,7 +6,9 @@ import { renderSidebar } from "./catalog.js"; import { ConnectionPool } from "./connections.js"; import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js"; import { installHudKeybind } from "./debug.js"; -import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js"; +import { installHelpKeybind, isHelpOpen } from "./help.js"; +import { CommandSet, DEFAULT_LEADER, defineCommands, isBarTypingTarget } from "./commands.js"; +import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js"; import { DaemonLink } from "./daemonLink.js"; import { CatalogStore } from "./events.js"; import { installRestartOverlay } from "./restartOverlay.js"; @@ -15,6 +17,7 @@ import { createSession, fetchNames, getDaemonInfo, + gitRoot, projects as fetchProjects, } from "./api.js"; import { @@ -79,6 +82,15 @@ async function bootApp(): Promise { const store = new CatalogStore(); +// The daemon's home dir, for the command bar's `~/…` directory abbreviation. +// Absolute paths are a fine fallback until it lands. +let homeDir: string | undefined; +void getDaemonInfo() + .then((info) => (homeDir = info.home_dir)) + .catch(() => { + /* absolute paths are a fine fallback */ + }); + // The daemon's identity and liveness: names the release in the sidebar's status // dot, and blocks the UI behind the restart overlay while the daemon is away // (docs/RESTART.md). The events socket is the trigger — it dies first. @@ -326,6 +338,16 @@ function mountSpawned(session: Session, placement?: { newTab?: boolean }): void // a new branch named after that name. Anything unresolvable (no projects // known yet, no command, no generated name) opens the form pre-filled with // what did resolve instead. +async function spawnPresets(): Promise { + try { + const info = await getDaemonInfo(); + if (info.presets?.length) return info.presets; + } catch { + /* keep fallbacks */ + } + return FALLBACK_PRESETS; +} + async function requestFastForward(): Promise { const [discovered, name] = await Promise.all([ fetchProjects().catch(() => [] as Project[]), @@ -333,13 +355,7 @@ async function requestFastForward(): Promise { .then((batch) => batch.names[0]) .catch(() => undefined), ]); - let presets: SpawnPreset[] = FALLBACK_PRESETS; - try { - const info = await getDaemonInfo(); - if (info.presets?.length) presets = info.presets; - } catch { - /* keep fallbacks */ - } + const presets = await spawnPresets(); const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd()); const req = buildFastForwardRequest(resolved); if (req) { @@ -361,38 +377,122 @@ async function requestFastForward(): Promise { }); } -// Keyboard pair (#67): `n` opens the spawn modal, `N` fast-forwards a spawn. -// Ignored while typing anywhere (form fields, the terminal's hidden -// textarea, search) so an attached session's keys are never hijacked. -document.addEventListener("keydown", (e) => { - if (e.ctrlKey || e.altKey || e.metaKey) return; - if (isTypingTarget(e.target)) return; - // The help overlay owns the keyboard while it is up. - if (isHelpOpen()) return; - if (e.key === "n") { - e.preventDefault(); - requestOpenSpawn(); - } else if (e.key === "N") { - e.preventDefault(); - void requestFastForward(); +// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same +// fast-forward spawn, aimed at the chosen directory instead of the most recent +// project. A directory the project scan never saw stands in as its own project; +// one outside any repo spawns without a worktree, since there is no repo to cut +// a branch from. Anything still unresolved opens the pre-filled form. +async function requestSpawnInDirectory(path: string): Promise { + const [discovered, name] = await Promise.all([ + fetchProjects().catch(() => [] as Project[]), + fetchNames(1) + .then((batch) => batch.names[0]) + .catch(() => undefined), + ]); + const presets = await spawnPresets(); + const known = discovered.find((p) => p.path === path); + const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path }; + const resolved = resolveFastForward([project], presets, name, path); + const req = buildFastForwardRequest(resolved); + if (req) { + const inRepo = + known !== undefined || + (await gitRoot(path) + .then((r) => !!r.root) + .catch(() => false)); + if (!inRepo) delete req.worktree; + try { + mountSpawned(await createSession(req)); + return; + } catch { + // A failed POST degrades to the pre-filled form, never a dead row. + } } + openSpawnModal({ + seed: fastForwardSeed(resolved), + onSuccess: (session) => mountSpawned(session), + }); +} + +document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => { + void requestSpawnInDirectory((ev as CustomEvent).detail); }); +// The global half of the command registry (#148). Two sets, because they +// differ in one thing: the help overlay owns the keyboard while it is up, so +// the plain keys go deaf behind it while the focus chord does not — an +// attached terminal must never be able to trap the focus keys. +// +// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain +// keys, so `isTypingTarget` inside the set keeps them out of form fields and the terminal's +// hidden textarea. +const spawnKeys = new CommandSet( + defineCommands({ + "spawn.open": () => requestOpenSpawn(), + "spawn.fastForward": () => void requestFastForward(), + }), +); + // Ctrl+Shift+H moves the keyboard between the session list and the pane, the // web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached // terminal owns every plain keystroke, so nothing unmodified could ever get // the focus back out of it. -window.addEventListener("keydown", (e) => { - if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return; - if (e.key !== "H" && e.key !== "h") return; - e.preventDefault(); - if (sidebar.hasListFocus()) { - pool.focusTerminal(); - } else { - drawer.open(); // no-op on desktop; on mobile the list is off-canvas - sidebar.focusList(); - } -}); +// +// This set also owns the leader (`ctrl-a`, the TUI's default): it holds the +// key as a partial chord and takes select-all off it, so the escape hatch +// docs/COMMAND-BAR.md describes has somewhere to land (#149). +const focusKeys = new CommandSet( + defineCommands({ + "focus.toggle": () => { + if (sidebar.hasListFocus()) { + pool.focusTerminal(); + } else { + drawer.open(); // no-op on desktop; on mobile the list is off-canvas + sidebar.focusList(); + } + }, + // The escape hatch: binding ctrl-k takes readline's kill-to-end-of-line + // away from every pane, and leader ctrl-k gives it back as the literal + // 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"), + }), + { 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 }); + }, + }), +); + +// 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). +window.addEventListener( + "keydown", + (e) => { + // The bar owns the keyboard while it is up; its own input handles the keys. + if (isCommandBarOpen()) return; + if (focusKeys.handle(e)) return; + // The help overlay owns the keyboard while it is up. + if (isHelpOpen()) return; + // The pane's hidden textarea is not a typing target for the bar: ctrl-k is + // seen ahead of xterm and opens the bar (#152). A real form field — the + // spawn form's inputs — keeps its own Ctrl+K. + if (!isBarTypingTarget(e.target) && barKeys.handle(e)) return; + spawnKeys.handle(e); + }, + true, +); + // Tab in the session list hands the keyboard to the pane; Escape in the pane // hands it back — the pair the TUI gets from `tab` and `esc`. diff --git a/web/src/matcher.test.ts b/web/src/matcher.test.ts new file mode 100644 index 0000000..094c089 --- /dev/null +++ b/web/src/matcher.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; + +import { + matchAndRank, + type CommandCandidate, + type DirectoryCandidate, + type MatchCandidate, + type MatchResult, + type SessionCandidate, +} from "./matcher.js"; + +function session( + name: string, + directory?: string, + command?: string, + tool?: string, +): SessionCandidate { + return { kind: "session", fields: { name, directory, command, tool } }; +} + +function command(label: string): CommandCandidate { + return { kind: "command", fields: { label } }; +} + +function directory(path: string): DirectoryCandidate { + return { kind: "directory", fields: { path } }; +} + +function label(result: MatchResult): string { + switch (result.candidate.kind) { + case "session": + return `session:${result.candidate.fields.name}`; + case "command": + return `command:${result.candidate.fields.label}`; + case "directory": + return `directory:${result.candidate.fields.path}`; + } +} + +function positions(result: MatchResult): number[] { + return result.ranges.flatMap(([start, end]) => + Array.from({ length: end - start }, (_, offset) => start + offset), + ); +} + +function expectResults( + query: string, + candidates: MatchCandidate[], + expected: Array<[label: string, score: number, field?: string, positions?: number[]]>, +): void { + const results = matchAndRank(query, candidates); + + expect(results.map((result) => [label(result), result.score])).toEqual( + expected.map(([candidateLabel, score]) => [candidateLabel, score]), + ); + expect(results.map((result) => result.field)).toEqual( + expected.map(([, , field]) => field), + ); + expect(results.map(positions)).toEqual(expected.map(([, , , matched]) => matched ?? [])); +} + +describe("matchAndRank fixtures", () => { + it("F1: an empty query keeps input order", () => { + expectResults( + "", + [ + session("kill", "~/dev/api", "bash"), + session("killer-app", "~/dev/killer-app", "claude --resume", "claude"), + command("Kill selected session"), + ], + [ + ["session:kill", 0], + ["session:killer-app", 0], + ["command:Kill selected session", 0], + ], + ); + }); + + it("F2: an exact session name beats a prefix and kind breaks a tie", () => { + expectResults( + "kill", + [ + session("kill", "~/dev/api", "bash"), + session("killer-app", "~/dev/killer-app", "claude --resume", "claude"), + command("Kill selected session"), + ], + [ + ["session:kill", 178, "name", [0, 1, 2, 3]], + ["session:killer-app", 146, "name", [0, 1, 2, 3]], + ["command:Kill selected session", 146, "label", [0, 1, 2, 3]], + ], + ); + }); + + it("F3: path segment boundaries beat mid-segment matches", () => { + expectResults( + "foo", + [directory("~/dev/foobar"), directory("~/src/myfoo"), directory("~/foo")], + [ + ["directory:~/foo", 78, "path", [2, 3, 4]], + ["directory:~/dev/foobar", 74, "path", [6, 7, 8]], + ["directory:~/src/myfoo", 56, "path", [8, 9, 10]], + ], + ); + }); + + it("F4: a query matching nothing returns no candidates", () => { + expectResults("zzz", [session("kill", "~/dev/api", "bash"), command("Kill selected session")], []); + }); + + it("F5: the highest-scoring embedding can differ from the greedy embedding", () => { + expectResults( + "mtu", + [session("maestro-tui"), session("maestro-daemon"), session("mtui-scratch")], + [ + ["session:mtui-scratch", 122, "name", [0, 1, 2]], + ["session:maestro-tui", 91, "name", [0, 8, 9]], + ], + ); + }); + + it("F6: a name beats a weighted directory match", () => { + expectResults( + "api", + [session("api", "~/dev/web", "bash"), session("worker", "~/dev/api", "bash")], + [ + ["session:api", 154, "name", [0, 1, 2]], + ["session:worker", 50, "directory", [6, 7, 8]], + ], + ); + }); + + it("F7: matching is case-insensitive and field length breaks a tie", () => { + expectResults( + "api", + [session("API-Gateway"), session("apiary")], + [ + ["session:apiary", 122, "name", [0, 1, 2]], + ["session:API-Gateway", 122, "name", [0, 1, 2]], + ], + ); + }); + + it("F8: the exact tool field beats the command field", () => { + expectResults( + "claude", + [session("nameless", "~/dev/x", "claude --resume", "claude"), session("clay", "~/dev/y", "bash")], + [["session:nameless", 186, "tool", [0, 1, 2, 3, 4, 5]]], + ); + }); + + it("F9: a separator earns a word-boundary bonus", () => { + expectResults( + "ws", + [session("websearch"), session("web-search")], + [ + ["session:web-search", 71, "name", [0, 4]], + ["session:websearch", 56, "name", [0, 3]], + ], + ); + }); + + it("F10: command labels rank by boundary and initial gap", () => { + expectResults( + "se", + [command("Kill selected session"), command("Create a session"), command("Show help")], + [ + ["command:Show help", 53, "label", [0, 6]], + ["command:Kill selected session", 51, "label", [5, 6]], + ["command:Create a session", 47, "label", [9, 10]], + ], + ); + }); + + it("F11: directory matches separate on their initial gap", () => { + expectResults( + "dev", + [ + session("api", "~/dev/api", "bash"), + session("notes", "~/documents/dev-notes", "nvim"), + session("scratch", "~/srv/devops", "bash"), + ], + [ + ["session:api", 54, "directory", [2, 3, 4]], + ["session:scratch", 50, "directory", [6, 7, 8]], + ["session:notes", 44, "directory", [12, 13, 14]], + ], + ); + }); + + it("F12: an exact field beats a prefix", () => { + expectResults( + "quit", + [command("Quit Maestro"), command("Quit")], + [ + ["command:Quit", 178, "label", [0, 1, 2, 3]], + ["command:Quit Maestro", 146, "label", [0, 1, 2, 3]], + ], + ); + }); +}); diff --git a/web/src/matcher.ts b/web/src/matcher.ts new file mode 100644 index 0000000..00adb5c --- /dev/null +++ b/web/src/matcher.ts @@ -0,0 +1,224 @@ +export type CandidateKind = "session" | "command" | "directory"; + +export interface SessionCandidate { + kind: "session"; + fields: { + name: string; + directory?: string; + command?: string; + tool?: string; + }; +} + +export interface CommandCandidate { + kind: "command"; + fields: { label: string }; +} + +export interface DirectoryCandidate { + kind: "directory"; + fields: { path: string }; +} + +export type MatchCandidate = SessionCandidate | CommandCandidate | DirectoryCandidate; +export type MatchFieldName = "name" | "directory" | "command" | "tool" | "label" | "path"; +export type MatchRange = readonly [start: number, end: number]; + +export interface MatchResult { + candidate: C; + score: number; + field: MatchFieldName | undefined; + ranges: MatchRange[]; +} + +interface Field { + name: MatchFieldName; + text: string; + weight: number; +} + +interface Embedding { + score: number; + positions: number[]; +} + +interface RankedResult extends MatchResult { + matchedText: string; + inputIndex: number; +} + +const KIND_ORDER: Record = { + session: 0, + command: 1, + directory: 2, +}; + +function fields(candidate: MatchCandidate): Field[] { + switch (candidate.kind) { + case "session": { + const values: Array<[MatchFieldName, string | undefined, number]> = [ + ["name", candidate.fields.name, 0], + ["directory", candidate.fields.directory, -24], + ["command", candidate.fields.command, -32], + ["tool", candidate.fields.tool, -40], + ]; + return values.flatMap(([name, text, weight]) => + text === undefined ? [] : [{ name, text, weight }], + ); + } + case "command": + return [{ name: "label", text: candidate.fields.label, weight: 0 }]; + case "directory": + return [{ name: "path", text: candidate.fields.path, weight: 0 }]; + } +} + +function normalizeQuery(query: string): string { + return query.toLowerCase().replace(/\s/g, ""); +} + +function boundaryBonus(text: string, position: number): number { + if (position === 0) return 18; + + const previous = text[position - 1]; + if ("/\\-_ .:".includes(previous)) return 16; + + const current = text[position]; + const isUppercase = current !== current.toLowerCase() && current === current.toUpperCase(); + const previousIsLowercase = + previous !== previous.toUpperCase() && previous === previous.toLowerCase(); + if (isUppercase && previousIsLowercase) return 12; + + const isDigit = current >= "0" && current <= "9"; + const previousIsDigit = previous >= "0" && previous <= "9"; + if (isDigit && !previousIsDigit) return 12; + + return 0; +} + +function contribution(text: string, position: number, previousPosition: number): number { + const gap = position - previousPosition - 1; + return 16 + boundaryBonus(text, position) + (gap === 0 ? 8 : -Math.min(gap, 12)); +} + +function isBetterEmbedding(candidate: Embedding, incumbent: Embedding | undefined): boolean { + if (!incumbent || candidate.score !== incumbent.score) { + return !incumbent || candidate.score > incumbent.score; + } + for (let index = 0; index < candidate.positions.length; index++) { + if (candidate.positions[index] !== incumbent.positions[index]) { + return candidate.positions[index] < incumbent.positions[index]; + } + } + return false; +} + +function bestEmbedding(query: string, text: string): Embedding | undefined { + const lowerText = text.toLowerCase(); + let previousStates = new Map(); + + for (let queryIndex = 0; queryIndex < query.length; queryIndex++) { + const states = new Map(); + for (let position = 0; position < lowerText.length; position++) { + if (lowerText[position] !== query[queryIndex]) continue; + + if (queryIndex === 0) { + states.set(position, { + score: contribution(text, position, -1), + positions: [position], + }); + continue; + } + + for (const [previousPosition, previous] of previousStates) { + if (previousPosition >= position) continue; + const candidate = { + score: previous.score + contribution(text, position, previousPosition), + positions: [...previous.positions, position], + }; + if (isBetterEmbedding(candidate, states.get(position))) states.set(position, candidate); + } + } + if (states.size === 0) return undefined; + previousStates = states; + } + + let best: Embedding | undefined; + for (const embedding of previousStates.values()) { + if (isBetterEmbedding(embedding, best)) best = embedding; + } + return best; +} + +function toRanges(positions: number[]): MatchRange[] { + const ranges: MatchRange[] = []; + for (const position of positions) { + const previous = ranges[ranges.length - 1]; + if (previous && previous[1] === position) { + ranges[ranges.length - 1] = [previous[0], position + 1]; + } else { + ranges.push([position, position + 1]); + } + } + return ranges; +} + +function compareByCodePoint(left: string, right: string): number { + const leftPoints = Array.from(left, (character) => character.codePointAt(0)!); + const rightPoints = Array.from(right, (character) => character.codePointAt(0)!); + const length = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < length; index++) { + if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index]; + } + return leftPoints.length - rightPoints.length; +} + +export function matchAndRank( + query: string, + candidates: readonly C[], +): MatchResult[] { + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery === "") { + return candidates.map((candidate) => ({ candidate, score: 0, field: undefined, ranges: [] })); + } + + const results: RankedResult[] = []; + candidates.forEach((candidate, inputIndex) => { + let best: + | { field: Field; score: number; positions: number[] } + | undefined; + + for (const field of fields(candidate)) { + const embedding = bestEmbedding(normalizedQuery, field.text); + if (!embedding) continue; + + const compactText = field.text.toLowerCase().replace(/\s/g, ""); + const adjustment = + compactText === normalizedQuery ? 64 : compactText.startsWith(normalizedQuery) ? 32 : 0; + const score = embedding.score + adjustment + field.weight; + if (!best || score > best.score) best = { field, score, positions: embedding.positions }; + } + + if (best) { + results.push({ + candidate, + score: best.score, + field: best.field.name, + ranges: toRanges(best.positions), + matchedText: best.field.text, + inputIndex, + }); + } + }); + + results.sort( + (left, right) => + right.score - left.score || + KIND_ORDER[left.candidate.kind] - KIND_ORDER[right.candidate.kind] || + left.matchedText.length - right.matchedText.length || + compareByCodePoint(left.matchedText, right.matchedText) || + left.inputIndex - right.inputIndex, + ); + + return results.map(({ candidate, score, field, ranges }) => ({ candidate, score, field, ranges })); +} diff --git a/web/src/styles.css b/web/src/styles.css index 94ad67e..15d583f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1425,6 +1425,156 @@ input.leaf-rename:focus { color: var(--muted); } +/* ---------- command bar (Ctrl+K) — docs/COMMAND-BAR.md ---------- */ +/* Same family of surface as the spawn modal and the help overlay: the overlay + tokens below are theirs, not a third set. */ +.cmdbar-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: grid; + grid-template-rows: 12vh auto 1fr; + justify-items: center; + padding: var(--sp-4); + background: oklch(0 0 0 / 0.5); + backdrop-filter: blur(4px); +} + +.cmdbar-panel { + grid-row: 2; + display: flex; + flex-direction: column; + width: min(560px, 100%); + max-height: min(60vh, 520px); + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--r-3); + box-shadow: var(--shadow-2); +} + +.cmdbar-input { + appearance: none; + background: none; + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + color: var(--ink); + font-family: var(--font-mono); + font-size: var(--fs-15); + padding: var(--sp-3) var(--sp-4); +} + +.cmdbar-input:focus-visible { + outline: none; +} + +.cmdbar-results { + overflow-y: auto; + padding: var(--sp-2) 0; +} + +/* The action page's header (#151): names the session being acted on, so the + target of kill/rename/spawn-similar is never ambiguous. */ +.cmdbar-page-header { + padding: var(--sp-2) var(--sp-4); + border-bottom: 1px solid var(--border); + font-size: var(--fs-12); + color: var(--muted); +} + +.cmdbar-group-title { + padding: var(--sp-2) var(--sp-4) var(--sp-1); + font-size: var(--fs-11); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); +} + +.cmdbar-row { + display: flex; + align-items: baseline; + gap: var(--sp-3); + padding: var(--sp-1) var(--sp-4); + cursor: pointer; +} + +.cmdbar-row.is-active { + background: var(--surface-3); +} + +.cmdbar-row-name { + font-size: var(--fs-13); + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* A dead session still lists — it just is not where you just were. */ +.cmdbar-row.is-dead .cmdbar-row-name { + color: var(--muted); +} + +.cmdbar-row-meta { + flex: 1 1 auto; + min-width: 0; + font-size: var(--fs-11); + color: var(--faint); + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* A command row's chord, shown so the bar teaches the keymap. */ +.cmdbar-row-keys { + flex: 1 1 auto; + display: flex; + justify-content: flex-end; + gap: var(--sp-1); + min-width: 0; +} + +.cmdbar-row-keys kbd { + font-family: var(--font-mono); + font-size: var(--fs-11); + color: var(--muted); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-1); + padding: 0 var(--sp-1); +} + +.cmdbar-hit { + background: none; + color: var(--accent); + font-weight: 600; +} + +.cmdbar-empty { + padding: var(--sp-3) var(--sp-4); + font-size: var(--fs-13); + color: var(--muted); +} + +/* Phone: the bottom-sheet treatment the spawn modal already uses. */ +@media (max-width: 600px) { + .cmdbar-overlay { + grid-template-rows: 1fr auto; + padding: 0; + } + + .cmdbar-panel { + grid-row: 2; + width: 100%; + max-height: 70vh; + border-radius: var(--r-3) var(--r-3) 0 0; + border-bottom: 0; + } +} + /* Line 2 — dot + duration in the state's colour, then muted fragments. The state word itself is never spelled out here; the colour carries it. */ .card-line2 { diff --git a/web/src/types.ts b/web/src/types.ts index 53e6a2c..b18c86f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -50,6 +50,12 @@ export interface Session { * create one. `base_repo` is the repository's main checkout, which is what * the session tree groups by (#99). */ worktree?: SessionWorktree | null; + /** Wall-clock of when this session last became the pane a client was showing + * (#145) — stamped by both `POST /api/sessions/:id/focus` and opening + * `/api/attach/:id`. Absent until the session has been focused once, which is + * how the command bar's empty query sorts a never-focused session after every + * session that has one (`docs/COMMAND-BAR.md`). */ + last_focused_at?: IsoTimestamp | null; } /** A session's daemon-created worktree (`SessionWorktree` on the wire). */