feat(maestro): add the TUI command bar's > and ~ sigils

`>` narrows to commands from the live keymap for the current mode, matched
on the action's help_label only — the chord is displayed, never matched, so
results cannot depend on a user's rebinds. Rows dedupe by action, not by
chord: the default map binds both `enter` and `tab` to AttachHighlighted,
and AttachHighlighted and ToggleAttachHighlighted share a label because they
are the same match arm.

`~` narrows to recent dirs plus the project list. Enter takes the
fast-forward path pinned to that directory, with no recency fallback, so a
directory outside the project scan opens the pre-filled form rather than
spawning elsewhere.

Both fall out of parsing the query line, as in web/src/commandBar.ts: a
sigil is only a sigil in first position, and backspacing past one is just
the query becoming mixed again.
This commit is contained in:
Miguel Palhas
2026-09-03 17:17:27 +01:00
parent b2cbb23fe2
commit 9d6b69cc49
3 changed files with 896 additions and 75 deletions
+306 -16
View File
@@ -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<Effect> {
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<PathBuf> = 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<maestro_protocol::DaemonInfo>,
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
focused_cwd: Option<PathBuf>,
pinned: Option<PathBuf>,
) {
// 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<Vec<maestro_protocol::Project>>,
names: Vec<String>,
focused_cwd: Option<PathBuf>,
/// The command bar pinned the spawn to this directory (#154): nothing
/// — not the focused session, not the recency ranking — may move it.
pinned: Option<PathBuf>,
},
/// The command bar's `~` candidates arrived (#154).
BarDirectories { bar: u64, paths: Vec<PathBuf> },
}
/// `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<serde_json::Value> {
Json(json!({
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
}))
}
async fn projects() -> Json<serde_json::Value> {
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<serde_json::Value> {
Json(json!({"names": ["crimson-otter"]}))
}
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
let seen = posted.clone();
async fn spawn_session(
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
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<_>>(),
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]
+523 -59
View File
@@ -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<BarSigil>,
/// 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<MatchFieldName>,
pub ranges: Vec<MatchRange>,
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<String>,
}
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<String>)> {
let mut out: Vec<(Action, Vec<String>)> = 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<String>,
}
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<MatchCandidate>,
/// What each candidate opens, parallel to `candidates`.
targets: Vec<BarTarget>,
/// Whether each candidate's session is dead, parallel to `candidates`.
dead: Vec<bool>,
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<BarEntry>,
commands: Vec<BarEntry>,
directories: Vec<BarEntry>,
rows: Vec<BarRow>,
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<BarSigil> {
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<BarRow> = {
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<MatchCandidate> =
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<Line<'static>> = Vec::new();
let mut highlight_line = 0usize;
// While a sigil is in force there is one kind, so the header is
// dropped — it would name what the whole list already is.
let headers = self.sigil().is_none();
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<String> {
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<_>>(),
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();
+67
View File
@@ -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<String>,
) -> 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<Project>,
presets: &[SpawnPreset],
handle: Option<String>,
) -> 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![