diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 089a808..7ecb642 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -168,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 { @@ -386,6 +394,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, @@ -459,6 +469,16 @@ impl App { 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)]) + } } } Some(OverlayKind::Help) => { @@ -506,7 +526,7 @@ impl App { }; self.state.show_overlay(OverlayKind::Help); } - Action::OpenCommandBar => self.open_command_bar(), + Action::OpenCommandBar => effects.extend(self.open_command_bar()), Action::OpenSpawnModal => { self.open_spawn_modal(); effects.push(Effect::LoadDraft); @@ -628,12 +648,21 @@ impl App { /// 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) { + fn open_command_bar(&mut self) -> Vec { let snapshot = self.catalog.snapshot(); - self.command_bar = Some(CommandBar::new(&snapshot.sessions, &self.home_dir)); + // 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 @@ -990,18 +1019,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. @@ -1036,7 +1074,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(); @@ -1064,16 +1104,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 @@ -1087,13 +1163,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; @@ -1105,8 +1189,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); } @@ -1463,7 +1549,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`. @@ -2222,6 +2313,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. @@ -2850,12 +3037,115 @@ mod tests { .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] diff --git a/crates/maestro-tui/src/command_bar.rs b/crates/maestro-tui/src/command_bar.rs index c2fce8f..7a8d4d7 100644 --- a/crates/maestro-tui/src/command_bar.rs +++ b/crates/maestro-tui/src/command_bar.rs @@ -1,15 +1,17 @@ //! 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. //! -//! Sessions are the only kind here (#153); the sigils (#154), the session -//! action page (#155) and the leader's literal Ctrl-K (#156) land later. The -//! grouping below is already kind-generic so those slot in without a rewrite. +//! Three kinds live here: sessions and commands, ranked together by default, +//! and directories, which need the `~` sigil (#154). The session action page +//! (#155) and the leader's literal Ctrl-K (#156) land later. //! //! 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; @@ -19,19 +21,73 @@ 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, MatchCandidate, MatchFieldName, MatchRange, SessionCandidate, + match_and_rank, CandidateKind, CommandCandidate, DirectoryCandidate, MatchCandidate, + MatchFieldName, MatchRange, SessionCandidate, }; use crate::overlays::centered; use crate::palette; use crate::text_input::TextInput; -/// What Enter on a row does. Only sessions exist today; `>` commands and `~` -/// directories (#154) become further variants. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// 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), +} + +/// 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 @@ -50,6 +106,9 @@ pub struct BarRow { 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 { @@ -78,7 +137,7 @@ fn kind_title(kind: CandidateKind) -> &'static str { } /// What a keystroke asked the bar to do. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum BarOutcome { /// The bar stays up. Stay, @@ -128,40 +187,112 @@ pub fn session_candidate(session: &Session, home_dir: &str) -> SessionCandidate } } +/// 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, - /// Matcher input, in the empty-query order. Never reordered — the matcher's - /// last tie-break is what keeps that order meaningful. - candidates: Vec, - /// What each candidate opens, parallel to `candidates`. - targets: Vec, - /// Whether each candidate's session is dead, parallel to `candidates`. - dead: Vec, + 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, rows: Vec, highlight: usize, } impl CommandBar { - /// Open the bar over the sessions the catalog has right now. - pub fn new(sessions: &[Session], home_dir: &str) -> Self { + /// 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 candidates = ordered + let sessions = ordered .iter() - .map(|s| MatchCandidate::Session(session_candidate(s, home_dir))) + .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 targets = ordered.iter().map(|s| BarTarget::Session(s.id)).collect(); - let dead = ordered - .iter() - .map(|s| s.status == SessionStatus::Dead) + 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(), - candidates, - targets, - dead, + home_dir: home_dir.to_string(), + sessions, + commands, + directories: Vec::new(), rows: Vec::new(), highlight: 0, }; @@ -169,6 +300,34 @@ impl CommandBar { 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() } @@ -224,7 +383,7 @@ impl CommandBar { match key { InputKey::Esc => BarOutcome::Close, InputKey::Enter => match self.selected() { - Some(row) => BarOutcome::Open(row.target), + 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, @@ -257,31 +416,58 @@ impl CommandBar { } } + /// The sigil in force for the query as it stands. + pub fn sigil(&self) -> Option { + parse_bar_query(self.input.value()).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> { + 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 results = match_and_rank(self.input.value(), &self.candidates); - results + let parsed = parse_bar_query(self.input.value()); + 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 = self - .candidates + let index = candidates .iter() .position(|c| std::ptr::eq(c, result.candidate)) - .expect("every result borrows one of this bar's candidates"); - let MatchCandidate::Session(session) = result.candidate else { - unreachable!("sessions are the only kind the bar builds (#153)") + .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: CandidateKind::Session, - target: self.targets[index], - name: session.name.clone(), - meta: meta_text(session, result.field), + kind: entry.candidate_kind(), + target: entry.target.clone(), + name, + meta, field: result.field, ranges: result.ranges.clone(), - dead: self.dead[index], + dead: entry.dead, + chords: entry.chords.clone(), } }) .collect() @@ -297,7 +483,11 @@ impl CommandBar { let area = centered(parent, 72, height); f.render_widget(Clear, area); let block = Block::default() - .title(" go to ") + .title(match self.sigil() { + Some(BarSigil::Command) => " run a command ", + Some(BarSigil::Directory) => " spawn in ", + None => " go to ", + }) .borders(Borders::ALL) .style(pal.card.base()) .border_style(pal.border_style(true)); @@ -349,11 +539,16 @@ impl CommandBar { // 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. + let headers = self.sigil().is_none(); for group in self.groups() { - lines.push(Line::from(Span::styled( - group.title.to_string(), - pal.group_row_style(), - ))); + 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(); @@ -384,6 +579,12 @@ impl CommandBar { &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); @@ -485,6 +686,7 @@ mod tests { rows.iter() .map(|r| match r.target { BarTarget::Session(id) => id, + ref other => panic!("expected a session row, got {other:?}"), }) .collect() } @@ -509,7 +711,7 @@ mod tests { let mut c = named(3, "charlie"); c.last_focused_at = Some(at(20)); - let bar = CommandBar::new(&[a, b, c], "/home/me"); + 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)] @@ -527,7 +729,7 @@ mod tests { let mut newer = named(3, "newer"); newer.created_at = at(20); - let bar = CommandBar::new(&[older, newer, focused], "/home/me"); + 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)] @@ -542,7 +744,7 @@ mod tests { dead.last_focused_at = Some(at(99)); let live = named(2, "live"); - let bar = CommandBar::new(&[dead, live], "/home/me"); + let bar = CommandBar::new(&[dead, live], &[], "/home/me"); assert_eq!( ids(bar.rows()), vec![Uuid::from_u128(2), Uuid::from_u128(1)] @@ -552,7 +754,11 @@ mod tests { #[test] fn typing_ranks_through_the_matcher_and_returns_ranges() { - let mut bar = CommandBar::new(&[named(1, "web-search"), named(2, "websearch")], "/home/me"); + 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!( @@ -565,7 +771,7 @@ mod tests { #[test] fn a_query_matching_nothing_leaves_no_rows() { - let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me"); + let mut bar = CommandBar::new(&[named(1, "alpha")], &[], "/home/me"); typed(&mut bar, "zzz"); assert!(bar.rows().is_empty()); assert!(bar.selected().is_none()); @@ -577,7 +783,7 @@ mod tests { 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"); + 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")); @@ -590,7 +796,7 @@ mod tests { 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"); + 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)); @@ -598,7 +804,7 @@ mod tests { #[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 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"); @@ -612,6 +818,7 @@ mod tests { 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); @@ -640,7 +847,7 @@ mod tests { #[test] fn enter_opens_the_highlighted_session_and_escape_closes() { - let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "bravo")], "/home/me"); + 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), @@ -651,14 +858,14 @@ mod tests { #[test] fn enter_with_no_results_leaves_the_bar_up() { - let mut bar = CommandBar::new(&[named(1, "alpha")], "/home/me"); + 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"); + 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"); @@ -676,7 +883,7 @@ mod tests { 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"); + 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 { @@ -689,6 +896,251 @@ mod tests { ); } + 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(); @@ -710,7 +1162,7 @@ mod tests { #[test] fn renders_the_query_the_headers_and_the_rows() { - let mut bar = CommandBar::new(&[named(1, "alpha"), named(2, "alpine")], "/home/me"); + 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}"); @@ -721,9 +1173,21 @@ mod tests { 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"); + 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}"); @@ -732,7 +1196,7 @@ mod tests { /// 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"); + 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(); 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![