Compare commits

..
Author SHA1 Message Date
Miguel Palhas 0d06827b69 fix(maestro): keep Enter toggling the worktree checkbox, not submitting
Matches the TUI's WorktreeToggle, whose Enter also toggles rather than
submitting — otherwise the two clients would disagree on this one
field despite #97 requiring the same rule everywhere else.
2026-08-24 15:51:19 +01:00
Miguel Palhas 8a6cb81d3a feat(maestro): Enter submits the TUI draft pane from any field (#97)
DirPicker mirrors the web filter/picker split: typing narrows and the
next Enter submits with the highlighted project directly (nothing was
chosen yet); an explicit Up/Down move onto a different row is
mid-selection, so Enter there only chooses it and a further Enter (now
the active pick) submits. WorktreeChoice, BranchExisting, Preset and
BranchMode already apply their highlight eagerly on Up/Down/Left/Right,
so Enter always submits there. Advanced and WorktreeToggle keep
activating on Enter, matching the web form's own button controls.
2026-08-24 15:50:28 +01:00
Miguel Palhas 5b0a0c2001 feat(maestro): Enter submits the spawn form from any field (#97)
A project-box or worktree-choice picker button that isn't yet the
active pick takes the Enter to choose it instead; the next Enter, once
it's active, submits. The project filter resolves to the top filtered
project rather than sending whatever cwd was last set. The raw-cwd
autocomplete's own Enter (accept the highlight) keeps working via
e.defaultPrevented.
2026-08-24 15:38:44 +01:00
Miguel Palhas df9013ffdf merge: two-step worktree picker in the TUI draft
Keeps both #93 and #96 in the draft layout: the worktree toggle and body
stay behind the Advanced disclosure, while the worktree choice step
belongs to the directory picker (#95 rule 3) and stays visible once a
project is picked.

Closes #96
2026-08-24 15:30:02 +01:00
Miguel Palhas 39341b278c merge: collapse the worktree section by default in both clients
Closes #93
2026-08-24 15:27:38 +01:00
Miguel Palhas b646cc5adf Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/96-tui-picker 2026-08-24 15:25:29 +01:00
Miguel Palhas cfc125144f feat(maestro): two-step worktree picker in the TUI draft
Mirrors the web project-picker redesign (#95) in the TUI (#96): step one
lists one box per repository (name, full path); picking one reveals step
two — "+ new" then that repository's worktrees newest-first, defaulting
to "+ new" except on a box that is itself a linked worktree. "+ new"
creates (cwd is the repo, create-worktree on); an existing worktree runs
in it (cwd is that worktree, create-worktree off). Neither step probes
/api/gitroot — only the free-text "Other directory…" hatch does.

Replaces the flat indented project+worktree list from #91/#64 with
projects::group_projects-backed DraftGroup boxes; the filter still
matches a repository via its own name/path or any of its worktrees.
2026-08-24 15:25:27 +01:00
4 changed files with 937 additions and 125 deletions
+2 -2
View File
@@ -1551,8 +1551,8 @@ mod tests {
panic!("expected a draft pane");
};
assert_eq!(draft.command(), "aider");
assert_eq!(draft.rows.len(), 1);
assert_eq!(draft.rows[0].project.name, "maestro");
assert_eq!(draft.groups.len(), 1);
assert_eq!(draft.groups[0].main.name, "maestro");
assert_eq!(draft.home_dir, "/home/u");
assert_eq!(draft.name.value(), "crimson-otter");
}
+634 -121
View File
@@ -7,13 +7,31 @@
//!
//! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`):
//! command presets from `/api/info` (with an implicit `custom` free-text
//! entry), a project picker fed by [`crate::projects`] over `/api/projects`
//! plus an `Other directory…` escape hatch, a generated session name from
//! entry), a two-step project/worktree picker (#96, mirroring the web
//! redesign in #95) fed by [`crate::projects`] over `/api/projects` plus an
//! `Other directory…` escape hatch, a generated session name from
//! `/api/names` with a reroll key, and validation that reports which field is
//! missing instead of posting an empty request. A worktree section (#65)
//! mirrors `web/src/spawnForm.ts`: on by default over a git repository,
//! branch tracking the generated name until touched, with a new-or-existing
//! branch switch fed by `/api/branches`.
//!
//! The picker itself is two steps (#96), verbatim from the rules in
//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment:
//! 1. Step one is one box per repository (name, then full path). A worktree
//! is a box only when its main checkout is missing from the scan.
//! 2. The filter keeps a repository when the query matches it or any of its
//! worktrees, so a worktree name still finds its box.
//! 3. Picking a box reveals step two: "+ new", then that repository's
//! worktrees newest-first. "Other directory…" clears both steps.
//! 4. "+ new" is the default selection — except on a box that is itself a
//! linked worktree, which defaults to that worktree.
//! 5. "+ new" means create: cwd is the repository, create-worktree on.
//! An existing worktree means run in it: cwd is that worktree,
//! create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so
//! these are separate acts.
//! 6. Neither step probes `/api/gitroot`; the scan already reports
//! repository membership. Only the free-text hatch pays for a probe.
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
@@ -52,10 +70,15 @@ pub enum DraftField {
Preset,
/// Free-text command, reachable only while a custom preset is selected.
CustomCommand,
/// Project picker: typing filters, Up / Down highlight, Enter picks.
/// Step one: repository picker. Typing filters, Up / Down highlight,
/// Enter picks and reveals step two (#96).
DirPicker,
/// Free-text path, revealed by picking `Other directory…`.
CwdRaw,
/// Step two, shown once a repository is picked: "+ new" then that
/// repository's worktrees newest-first (#96 rule 3). Up / Down move and
/// apply immediately, mirroring the web form's per-click choice.
WorktreeChoice,
Name,
/// Disclosure collapsing the worktree/branch controls below (#93).
/// Closed by default; Enter / Space toggles.
@@ -81,14 +104,50 @@ pub enum WorktreeBranchMode {
Existing,
}
/// One flattened project-picker entry: a discovered repo/worktree plus what
/// rendering needs to know about it. `indent` marks worktrees nested under
/// their main checkout.
/// "new" (create a worktree) or an existing worktree to run in — the TUI
/// mirror of the web form's `WorktreeChoice` (#96 rule 5).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DraftRow {
pub enum WorktreeChoice {
New,
Existing(PathBuf),
}
/// One repository box for step one of the picker (#96 rule 1): the main
/// checkout (or, when it's missing from the scan, its freshest worktree
/// standing in for it) plus every other worktree of the same repository.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DraftGroup {
pub main: Project,
pub main_stale: bool,
pub worktrees: Vec<DraftWorktreeRow>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DraftWorktreeRow {
pub project: Project,
pub stale: bool,
pub indent: bool,
}
/// The repository key a [`DraftGroup`] was formed under — same key
/// `projects::group_projects` groups by, kept separately because the main
/// box's own path only equals it when the main checkout itself was found.
fn group_key(main: &Project) -> PathBuf {
main.base_repo.clone().unwrap_or_else(|| main.path.clone())
}
/// Every worktree of a group, including the group's own box when the scan
/// found no main checkout to nest it under — the TUI mirror of the web
/// form's `worktreesOf`. Already newest-first: when the main box stands in
/// for a missing main checkout it is the freshest member by construction
/// ([`projects::group_projects`]), and `worktrees` itself is sorted
/// newest-first.
fn worktree_rows_of(group: &DraftGroup) -> Vec<Project> {
let mut rows = Vec::new();
if group.main.is_worktree {
rows.push(group.main.clone());
}
rows.extend(group.worktrees.iter().map(|w| w.project.clone()));
rows
}
/// Why a submit was refused. The message names the missing field so the form
@@ -133,15 +192,29 @@ pub struct DraftPane {
pub presets: Vec<SpawnPreset>,
pub selected_preset: usize,
pub custom_command: crate::text_input::TextInput,
/// Discovered projects, sorted fresh-first with worktrees nested.
pub rows: Vec<DraftRow>,
/// Discovered projects, sorted fresh-first and grouped by repository
/// (#96 step one).
pub groups: Vec<DraftGroup>,
pub filter: crate::text_input::TextInput,
/// Highlighted entry within the visible list; the implicit
/// `Other directory…` entry sits at index `visible_rows().len()`.
/// Highlighted entry within the visible step-one list; the implicit
/// `Other directory…` entry sits at index `visible_groups().len()`.
pub highlighted: usize,
/// Set by an explicit Up/Down move, cleared by typing (#97): typing
/// mirrors the web form's plain filter input, where Enter always
/// resolves and submits; an explicit arrow move mirrors Tab landing on
/// a not-yet-active picker button there, where a first Enter only
/// chooses. See `dir_picker_choice_active` at the Enter match arm.
dir_picker_navigated: bool,
pub cwd_raw_visible: bool,
pub cwd_raw: crate::text_input::TextInput,
pub picked_cwd: Option<PathBuf>,
/// 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.
pub selected_group_key: Option<PathBuf>,
/// Step two's current pick (#96 rule 4/5). Meaningless while
/// `selected_group_key` is `None`.
pub worktree_choice: WorktreeChoice,
pub name: crate::text_input::TextInput,
pub name_queue: VecDeque<String>,
/// Set once the operator types or rerolls a name; guards refills against
@@ -200,12 +273,15 @@ impl Default for DraftPane {
presets,
selected_preset: 0,
custom_command: crate::text_input::TextInput::default(),
rows: Vec::new(),
groups: Vec::new(),
filter: crate::text_input::TextInput::default(),
highlighted: 0,
dir_picker_navigated: false,
cwd_raw_visible: false,
cwd_raw: crate::text_input::TextInput::default(),
picked_cwd: None,
selected_group_key: None,
worktree_choice: WorktreeChoice::New,
name: crate::text_input::TextInput::default(),
name_queue: VecDeque::new(),
name_touched: false,
@@ -248,24 +324,24 @@ impl DraftPane {
.unwrap_or(0);
}
/// Replace the project snapshot: sort fresh-first (#54), nest worktrees
/// under their main checkout, and precompute staleness for rendering.
/// Replace the project snapshot: sort fresh-first (#54), group by
/// repository (#96), and precompute staleness for rendering.
pub fn set_projects(&mut self, discovered: Vec<Project>, now: DateTime<Utc>) {
let sorted = projects::sort_projects(discovered, now);
let groups = projects::group_projects(&sorted);
self.rows = groups
self.groups = groups
.iter()
.flat_map(|g| {
std::iter::once(DraftRow {
stale: projects::is_stale(g.main, now),
indent: false,
project: g.main.clone(),
})
.chain(g.worktrees.iter().map(|wt| DraftRow {
stale: projects::is_stale(wt, now),
indent: true,
project: (*wt).clone(),
}))
.map(|g| DraftGroup {
main: g.main.clone(),
main_stale: projects::is_stale(g.main, now),
worktrees: g
.worktrees
.iter()
.map(|wt| DraftWorktreeRow {
project: (*wt).clone(),
stale: projects::is_stale(wt, now),
})
.collect(),
})
.collect();
self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1));
@@ -623,6 +699,8 @@ impl DraftPane {
order.push(DraftField::DirPicker);
if self.cwd_raw_visible {
order.push(DraftField::CwdRaw);
} else if self.selected_group_key.is_some() {
order.push(DraftField::WorktreeChoice);
}
order.push(DraftField::Name);
order.push(DraftField::Advanced);
@@ -667,18 +745,25 @@ impl DraftPane {
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
}
/// Rows surviving the type-to-filter, in display order.
pub fn visible_rows(&self) -> Vec<&DraftRow> {
/// Step-one boxes surviving the type-to-filter, in display order. A
/// group survives when the query matches its box or any of its
/// worktrees (#96 rule 2), so a worktree name still finds its box.
pub fn visible_groups(&self) -> Vec<&DraftGroup> {
let query = self.filter.value();
self.rows
self.groups
.iter()
.filter(|r| projects::matches_filter(&r.project, query))
.filter(|g| {
projects::matches_filter(&g.main, query)
|| g.worktrees
.iter()
.any(|w| projects::matches_filter(&w.project, query))
})
.collect()
}
/// Visible entries including the trailing implicit `Other directory…`.
pub fn entry_count(&self) -> usize {
self.visible_rows().len() + 1
self.visible_groups().len() + 1
}
/// Highlight clamped to the current entry count.
@@ -691,38 +776,62 @@ impl DraftPane {
if count == 0 {
return;
}
self.dir_picker_navigated = true;
self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize;
}
/// Act on the highlighted picker entry: a project becomes the working
/// directory (and pre-selects the command it was last used with), while
/// the trailing entry reveals the free-text path input.
/// The step-one group currently picked, looked up fresh against the
/// latest snapshot so a project-list refresh can't leave a stale clone
/// behind.
pub fn selected_group(&self) -> Option<DraftGroup> {
let key = self.selected_group_key.as_ref()?;
self.groups
.iter()
.find(|g| &group_key(&g.main) == key)
.cloned()
}
/// Step two's rows: "+ new" first, then the repository's worktrees
/// newest-first (#96 rule 3). Empty while no group is picked.
pub fn worktree_choice_rows(&self) -> Vec<(WorktreeChoice, String, String)> {
let Some(group) = self.selected_group() else {
return Vec::new();
};
let mut rows = vec![(
WorktreeChoice::New,
"+ new".to_string(),
format!("new worktree of {}", group.main.name),
)];
rows.extend(worktree_rows_of(&group).into_iter().map(|p| {
(
WorktreeChoice::Existing(p.path.clone()),
p.name.clone(),
p.path.to_string_lossy().into_owned(),
)
}));
rows
}
/// Whether the highlighted step-one row is already the picked group
/// (#97): once true, a further Enter submits instead of re-picking it.
/// Always false on the trailing "Other directory…" row — picking it is
/// never a no-op, so it always takes the Enter to reveal the raw field.
fn dir_picker_choice_active(&self) -> bool {
let visible = self.visible_groups();
let sel = self.selection();
sel < visible.len()
&& self.selected_group_key.as_ref() == Some(&group_key(&visible[sel].main))
}
/// Act on the highlighted step-one box: reveals step two, defaulting per
/// rule 4. The trailing entry reveals the free-text path input and
/// clears both steps (rule 3).
pub fn select_entry(&mut self) {
let visible = self.visible_rows();
let visible = self.visible_groups();
let sel = self.selection();
if sel < visible.len() {
let project = visible[sel].project.clone();
self.picked_cwd = Some(project.path.clone());
self.cwd_raw_visible = false;
// A directory that *is* a linked worktree defaults to spawning in
// place (#91): creating a second worktree of its branch fails.
// Turning it back on by hand still tries (git's own message),
// which is deliberate (#91 non-goal).
self.worktree_enabled = !project.is_worktree;
// The scan already knows this directory's repo (#61): no probe
// round trip needed, exactly like the web form.
let repo = project
.base_repo
.clone()
.unwrap_or_else(|| project.path.clone());
if let Some(cmd) = project.last_command.as_deref() {
if !cmd.is_empty() {
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
self.selected_preset = idx;
}
}
}
self.note_picked_repo(repo);
let group = visible[sel].clone();
self.select_group(&group);
return;
}
let seed = self.cwd().unwrap_or_else(|| {
@@ -732,6 +841,7 @@ impl DraftPane {
PathBuf::from(&self.home_dir)
}
});
self.clear_project_selection();
self.picked_cwd = None;
self.cwd_raw_visible = true;
self.cwd_raw = crate::text_input::TextInput::with_value(seed.to_string_lossy());
@@ -739,6 +849,95 @@ impl DraftPane {
self.note_unknown_repo();
}
/// Pick a step-one box: remember it and default step two (#96 rule 4) —
/// "+ new", except a box that is itself a linked worktree (the scan
/// found no main checkout for it), which defaults to that worktree.
fn select_group(&mut self, group: &DraftGroup) {
self.selected_group_key = Some(group_key(&group.main));
let default = if group.main.is_worktree {
WorktreeChoice::Existing(group.main.path.clone())
} else {
WorktreeChoice::New
};
self.apply_worktree_choice(default);
}
/// Clear both picker steps (#96 rule 3), e.g. for `Other directory…`.
fn clear_project_selection(&mut self) {
self.selected_group_key = None;
self.worktree_choice = WorktreeChoice::New;
}
/// Apply a step-two pick (#96 rule 5): "+ new" targets the repository
/// itself with worktree creation on; an existing worktree targets it
/// directly with worktree creation off — the daemon never adopts
/// (docs/WORKTREES.md). Neither path probes `/api/gitroot` (rule 6): the
/// scan already reported this target's repository.
pub fn apply_worktree_choice(&mut self, choice: WorktreeChoice) {
let Some(group) = self.selected_group() else {
return;
};
let target = match &choice {
WorktreeChoice::New => group.main.clone(),
WorktreeChoice::Existing(path) => {
match worktree_rows_of(&group)
.into_iter()
.find(|p| &p.path == path)
{
Some(p) => p,
None => return,
}
}
};
self.worktree_choice = choice.clone();
self.picked_cwd = Some(target.path.clone());
self.cwd_raw_visible = false;
if let Some(cmd) = target.last_command.as_deref() {
if !cmd.is_empty() {
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
self.selected_preset = idx;
}
}
}
match choice {
WorktreeChoice::New => {
self.worktree_enabled = true;
let repo = target
.base_repo
.clone()
.unwrap_or_else(|| target.path.clone());
self.note_picked_repo(repo);
}
WorktreeChoice::Existing(_) => {
self.worktree_enabled = false;
self.base_repo = None;
self.repo_missing = false;
self.probe_seq += 1;
self.needs_probe = false;
self.probe_due_at = None;
self.probed_cwd = self.cwd();
self.branches_for = None;
}
}
}
/// Move step two's highlight and apply the newly-highlighted choice at
/// once, matching the web form's per-click pick — there is no separate
/// confirm step.
pub fn move_worktree_choice(&mut self, delta: i32) {
let rows = self.worktree_choice_rows();
let count = rows.len() as i32;
if count == 0 {
return;
}
let cur = rows
.iter()
.position(|(c, _, _)| *c == self.worktree_choice)
.unwrap_or(0) as i32;
let next = ((cur + delta).rem_euclid(count)) as usize;
self.apply_worktree_choice(rows[next].0.clone());
}
/// Handle one keystroke while this draft owns input (Insert mode, pane
/// focused). Returns [`DraftInputResult::Submit`] when the user accepts;
/// the caller validates, fires the POST and replaces the draft with the
@@ -753,27 +952,33 @@ impl DraftPane {
self.prev_field();
DraftInputResult::Consumed
}
InputKey::Enter => match self.focused_field {
DraftField::Submit => DraftInputResult::Submit,
DraftField::DirPicker => {
self.select_entry();
// Enter submits from any field (#97), mirroring web/src/spawnForm.ts.
// Two fields keep their own Enter instead: `DirPicker`, whose
// highlighted row isn't the picked one until a first Enter chooses
// it (the next Enter, now that it's active, falls through to
// submit); and the disclosure/toggle pair (`Advanced`,
// `WorktreeToggle`), which activate on Enter like a button rather
// than submitting, same as the web form's own `<button>` controls.
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
let is_other_row = self.selection() >= self.visible_groups().len();
let choose_only =
is_other_row || (self.dir_picker_navigated && !self.dir_picker_choice_active());
self.select_entry();
if choose_only {
DraftInputResult::Consumed
} else {
DraftInputResult::Submit
}
DraftField::Advanced => {
self.toggle_advanced();
DraftInputResult::Consumed
}
DraftField::WorktreeToggle => {
self.toggle_worktree();
DraftInputResult::Consumed
}
// The existing-branch selection is live; Enter just confirms.
DraftField::BranchExisting => DraftInputResult::Consumed,
_ => {
self.next_field();
DraftInputResult::Consumed
}
},
}
InputKey::Enter if self.focused_field == DraftField::Advanced => {
self.toggle_advanced();
DraftInputResult::Consumed
}
InputKey::Enter if self.focused_field == DraftField::WorktreeToggle => {
self.toggle_worktree();
DraftInputResult::Consumed
}
InputKey::Enter => DraftInputResult::Submit,
InputKey::Char {
c: ' ',
ctrl: false,
@@ -798,6 +1003,14 @@ impl DraftPane {
self.move_selection(1);
DraftInputResult::Consumed
}
InputKey::Up if self.focused_field == DraftField::WorktreeChoice => {
self.move_worktree_choice(-1);
DraftInputResult::Consumed
}
InputKey::Down if self.focused_field == DraftField::WorktreeChoice => {
self.move_worktree_choice(1);
DraftInputResult::Consumed
}
InputKey::Left if self.focused_field == DraftField::Preset => {
self.cycle_preset(-1);
DraftInputResult::Consumed
@@ -837,6 +1050,7 @@ impl DraftPane {
DraftField::DirPicker => {
self.filter.handle_key(other);
self.highlighted = 0;
self.dir_picker_navigated = false;
}
DraftField::CwdRaw => {
self.cwd_raw.handle_key(other);
@@ -872,9 +1086,20 @@ impl DraftPane {
let show_raw = self.cwd_raw_visible;
// The worktree toggle and its body stay behind the "Advanced options"
// disclosure (#93): collapsed hides both, regardless of what they'd
// otherwise show.
// otherwise show. The worktree *choice* step is not part of that —
// it belongs to the directory picker (#95 rule 3), so it stays
// visible once a project is picked.
let show_wt_toggle = !self.advanced_collapsed;
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
let show_wt_choice = self.selected_group_key.is_some() && !show_raw;
let wt_choice_rows_data = if show_wt_choice {
self.worktree_choice_rows()
} else {
Vec::new()
};
// Up to four choices (new + worktrees) stay visible around the
// selection.
let wt_choice_capacity = wt_choice_rows_data.len().clamp(1, 4) as u16;
// Up to three branch rows stay visible around the selection; held
// branches need their reason on screen, so they aren't collapsed.
let branch_rows = self.branches.len().clamp(1, 3) as u16;
@@ -882,6 +1107,7 @@ impl DraftPane {
+ u16::from(show_custom)
+ u16::from(show_raw)
+ u16::from(show_wt_toggle)
+ u16::from(show_wt_choice) * wt_choice_capacity
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
+ u16::from(self.error.is_some());
let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize;
@@ -894,6 +1120,8 @@ impl DraftPane {
constraints.push(Constraint::Length(list_capacity as u16));
if show_raw {
constraints.push(Constraint::Length(1));
} else if show_wt_choice {
constraints.push(Constraint::Length(wt_choice_capacity));
}
constraints.push(Constraint::Length(1)); // name
constraints.push(Constraint::Length(1)); // advanced header
@@ -973,7 +1201,10 @@ impl DraftPane {
cursor = Some((col, filter_row.y));
}
let visible = self.visible_rows();
// Step one (#96 rule 1): one box per repository, name then the FULL
// path — not the abbreviated form other lists use — so two projects
// sharing a basename stay distinguishable.
let visible = self.visible_groups();
let sel = self.selection();
let count = visible.len() + 1;
let capacity = list_capacity.min(count);
@@ -986,7 +1217,7 @@ impl DraftPane {
.iter()
.take(start + capacity)
.skip(start)
.map(|row| row.project.name.chars().count() + usize::from(row.indent) * 2)
.map(|group| group.main.name.chars().count())
.max()
.unwrap_or(0)
.min(18);
@@ -999,31 +1230,19 @@ impl DraftPane {
}
let active = pane_focused && self.focused_field == DraftField::DirPicker && i == sel;
let line = if i < visible.len() {
let row = visible[i];
let group = visible[i];
let mut base = Style::default();
if row.stale {
if group.main_stale {
base = base.add_modifier(Modifier::DIM);
}
if active {
base = base.add_modifier(Modifier::REVERSED);
}
let name = if row.indent {
format!(" {}", row.project.name)
} else {
row.project.name.clone()
};
Line::from(vec![
Span::styled(if active { ">" } else { " " }, base),
Span::raw(" "),
Span::styled(format!("{name:<name_width$}"), base),
Span::styled(
format!(" {}", abbreviate_path(&row.project.path, &self.home_dir)),
base,
),
Span::styled(
format!(" {}", row.project.last_command.clone().unwrap_or_default()),
base,
),
Span::styled(format!("{:<name_width$}", group.main.name), base),
Span::styled(format!(" {}", group.main.path.display()), base),
])
} else {
let mut style = Style::default();
@@ -1051,6 +1270,53 @@ impl DraftPane {
if self.focused_field == DraftField::CwdRaw {
cursor = Some((col, row.y));
}
} else if show_wt_choice {
// Step two (#96 rule 3): "+ new" first, then the repository's
// worktrees newest-first.
let choice_rows = wt_choice_rows_data;
let csel = choice_rows
.iter()
.position(|(c, _, _)| *c == self.worktree_choice)
.unwrap_or(0);
let ccap = (wt_choice_capacity as usize).min(choice_rows.len().max(1));
let cstart = if csel >= ccap { csel + 1 - ccap } else { 0 };
let label_width = choice_rows
.iter()
.skip(cstart)
.take(ccap)
.map(|(_, label, _)| label.chars().count())
.max()
.unwrap_or(0)
.min(18);
let choice_row = next_row();
for i in cstart..cstart + ccap {
let y = choice_row.y + (i - cstart) as u16;
if y >= choice_row.y + choice_row.height || i >= choice_rows.len() {
break;
}
let (_, label, sub) = &choice_rows[i];
let active =
pane_focused && self.focused_field == DraftField::WorktreeChoice && i == csel;
let mut base = Style::default();
if active {
base = base.add_modifier(Modifier::REVERSED);
}
let line = Line::from(vec![
Span::styled(if active { ">" } else { " " }, base),
Span::raw(" "),
Span::styled(format!("{label:<label_width$}"), base),
Span::styled(format!(" {sub}"), base),
]);
f.render_widget(
Paragraph::new(line),
Rect {
x: choice_row.x + GUTTER as u16,
y,
width: choice_row.width.saturating_sub(GUTTER as u16),
height: 1,
},
);
}
}
// -- name + reroll ---------------------------------------------------------
@@ -1473,12 +1739,8 @@ mod tests {
let stale_pos = rendered.find("stale-repo").unwrap();
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
assert!(
rendered.contains("~/n/repo"),
"collapsed path stem:\n{rendered}"
);
assert!(
rendered.contains("claude"),
"last command shown:\n{rendered}"
rendered.contains("/home/u/new/repo"),
"the full path, not an abbreviation (#96 rule 1):\n{rendered}"
);
// Staleness dims the row's cells.
@@ -1507,11 +1769,15 @@ mod tests {
assert_eq!(draft.focused_field, DraftField::DirPicker);
typed(&mut draft, "maes");
assert_eq!(
draft.visible_rows().len(),
draft.visible_groups().len(),
1,
"type-to-filter narrows the list"
);
draft.on_input(&InputKey::Enter);
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Submit,
"#97: the project filter submits with the highlighted project in one Enter"
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/maestro"))
@@ -1523,6 +1789,113 @@ mod tests {
);
}
#[test]
fn enter_submits_immediately_from_ordinary_fields_97() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/repos/maestro")),
base_repo: Some(PathBuf::from("/repos/maestro")),
probed_cwd: Some(PathBuf::from("/repos/maestro")),
..DraftPane::default()
};
for field in [
DraftField::Preset,
DraftField::Name,
DraftField::BranchNew,
DraftField::BranchMode,
] {
draft.focused_field = field;
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Submit,
"{field:?} should submit on Enter"
);
}
draft.selected_preset = draft.presets.len() - 1; // custom
draft.focused_field = DraftField::CustomCommand;
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
#[test]
fn enter_submits_immediately_from_eager_apply_pickers_97() {
// WorktreeChoice and BranchExisting apply their highlight the moment
// Up/Down moves it (#96 rule 3/#54) — there is no separate "chosen
// vs highlighted" state left to confirm, so Enter always submits.
let mut draft = DraftPane::default();
draft.set_projects(
vec![repo_project(
"maestro",
"/repos/maestro",
"/repos/maestro",
1,
None,
)],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks "maestro", reveals step two
draft.focused_field = DraftField::WorktreeChoice;
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
draft.focused_field = DraftField::BranchExisting;
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
#[test]
fn dir_picker_enter_chooses_then_submits_once_the_highlight_is_the_active_pick_97() {
let mut draft = DraftPane::default();
draft.set_projects(
vec![
project("maestro", "/repos/maestro", 1, Some("claude")),
project("other", "/repos/other", 2, Some("zsh")),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks "maestro" (nothing chosen yet, submits)
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/maestro"))
);
// Arrow onto the other row: an explicit navigation away from the
// active pick is "mid-selection" — Enter here only chooses it.
draft.move_selection(1);
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Consumed,
"navigating to a different row must not submit the old pick"
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/other")),
"the new row is chosen"
);
// The highlight is now the active pick: the next Enter submits.
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
#[test]
fn dir_picker_enter_on_other_directory_never_submits_directly_97() {
let mut draft = DraftPane::default();
draft.set_projects(
vec![project("maestro", "/repos/maestro", 1, None)],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.move_selection(1); // onto "Other directory…"
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Consumed,
"reveals the raw field instead of submitting, like the web form's own button"
);
assert_eq!(draft.focused_field, DraftField::CwdRaw);
}
#[test]
fn up_down_moves_the_highlight_within_the_list() {
let mut draft = DraftPane::default();
@@ -1791,38 +2164,139 @@ mod tests {
assert!(wt.create_branch);
}
#[test]
fn picking_a_worktree_project_defaults_the_toggle_off() {
let mut draft = DraftPane::default();
let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None);
/// A worktree of `group.main` (matching a solo worktree, is_worktree true).
fn linked_worktree(name: &str, path: &str, base: &str, days_ago: i64) -> Project {
let mut wt = repo_project(name, path, base, days_ago, None);
wt.is_worktree = true;
wt
}
#[test]
fn a_query_matching_only_a_worktree_still_finds_its_repository_box() {
// #96 rule 2: the filter keeps a repository when the query matches
// it or any of its worktrees.
let mut draft = DraftPane::default();
draft.set_projects(
vec![
repo_project("main", "/repos/main", "/repos/main", 2, None),
wt,
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
typed(&mut draft, "wt-91");
draft.on_input(&InputKey::Enter);
assert!(
!draft.worktree_enabled,
"the directory is already a worktree; spawning in place"
assert_eq!(
draft.visible_groups().len(),
1,
"the worktree's name matches its repository's box"
);
assert_eq!(draft.visible_groups()[0].main.name, "main");
}
#[test]
fn picking_a_repository_box_reveals_step_two_defaulting_to_new() {
// #96 rule 3 (reveal) + rule 4 (default): a repository box that is
// itself a real main checkout defaults step two to "+ new".
let mut draft = DraftPane::default();
draft.set_projects(
vec![
repo_project("main", "/repos/main", "/repos/main", 2, None),
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks the only box: "main"
assert_eq!(draft.worktree_choice, WorktreeChoice::New);
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/main")));
assert!(draft.worktree_enabled, "\"+ new\" means create-worktree on");
assert!(
draft.field_order().contains(&DraftField::WorktreeChoice),
"step two is now reachable"
);
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
"rule 6: the scan already knows this repository"
);
// Step two: move onto the worktree and pick it (rule 5 — existing
// worktree means run in it, create-worktree off).
draft.focused_field = DraftField::WorktreeChoice;
draft.on_input(&InputKey::Down);
assert_eq!(
draft.worktree_choice,
WorktreeChoice::Existing(PathBuf::from("/repos/wt91"))
);
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/wt91")));
assert!(!draft.worktree_enabled);
assert!(
draft.build_request().unwrap().worktree.is_none(),
"no worktree record — the session just runs there (#91)"
"no worktree record — the session just runs there"
);
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
"rule 6: still no probe for a scan-known worktree"
);
}
// Deliberately re-enabling still works, and the scan-known base repo
// survives so the toggle-on path needs no new probe.
draft.focused_field = DraftField::WorktreeToggle;
#[test]
fn a_box_that_is_itself_a_linked_worktree_defaults_to_running_there() {
// #96 rule 4: when the scan found no main checkout, the box IS a
// worktree, and step two defaults to that worktree instead of
// "+ new".
let mut draft = DraftPane::default();
draft.set_projects(
vec![linked_worktree(
"solo-wt",
"/repos/solo-wt",
"/repos/solo",
1,
)],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter);
assert!(draft.worktree_enabled);
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main")));
assert!(draft.take_gitroot_probe(probe_due()).is_none());
assert_eq!(
draft.worktree_choice,
WorktreeChoice::Existing(PathBuf::from("/repos/solo-wt"))
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/solo-wt"))
);
assert!(!draft.worktree_enabled, "defaults to running in place");
}
#[test]
fn other_directory_clears_both_picker_steps() {
// #96 rule 3.
let mut draft = DraftPane::default();
draft.set_projects(
vec![
repo_project("main", "/repos/main", "/repos/main", 2, None),
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks "main", reveals step two
assert!(draft.selected_group_key.is_some());
draft.focused_field = DraftField::DirPicker;
draft.move_selection(1); // onto Other directory…
draft.on_input(&InputKey::Enter);
assert!(draft.cwd_raw_visible);
assert!(
draft.selected_group_key.is_none(),
"step one's pick is cleared"
);
assert!(
draft.worktree_choice_rows().is_empty(),
"step two is cleared too"
);
}
#[test]
@@ -2136,6 +2610,45 @@ mod tests {
);
}
#[test]
fn render_shows_step_two_wording_full_paths_and_newest_first_order() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
..DraftPane::default()
};
draft.set_projects(
vec![
repo_project("main", "/home/u/repos/main", "/home/u/repos/main", 5, None),
linked_worktree("older-wt", "/home/u/repos/older", "/home/u/repos/main", 3),
linked_worktree("newer-wt", "/home/u/repos/newer", "/home/u/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter); // picks the "main" box
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("/home/u/repos/main"),
"step one shows the full repository path:\n{rendered}"
);
assert!(
rendered.contains("+ new"),
"step two's create choice:\n{rendered}"
);
assert!(
rendered.contains("/home/u/repos/newer"),
"step two shows worktrees' full paths:\n{rendered}"
);
let new_pos = rendered.find("+ new").unwrap();
let newer_pos = rendered.find("newer-wt").unwrap();
let older_pos = rendered.find("older-wt").unwrap();
assert!(
new_pos < newer_pos && newer_pos < older_pos,
"\"+ new\" first, then worktrees newest-first:\n{rendered}"
);
}
#[test]
fn render_shows_the_worktree_section() {
let mut draft = DraftPane {
+43 -2
View File
@@ -549,11 +549,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (match) selectPreset(match.id);
}
/** Groups with at least one member (itself or a worktree) matching `query`. */
function visibleGroups(query: string): ProjectGroup[] {
return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
}
function renderProjects(): void {
projectsEl.replaceChildren();
groups = groupProjects(sortProjects(discovered, new Date()));
const query = filterInput.value;
const visible = groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
const visible = visibleGroups(filterInput.value);
for (const group of visible) projectsEl.appendChild(projectBox(group));
if (discovered.length > 0 && visible.length === 0) {
const empty = document.createElement("p");
@@ -862,6 +866,43 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
const autocomplete = attachDirAutocomplete(cwdRaw);
// ---- Enter submits from anywhere (#97) --------------------------------
// A project-box/wt-choice that isn't yet the active pick takes the Enter to
// choose it instead; the next Enter, now that it's active, submits.
// dirAutocomplete's own Enter (accept the highlight) already preventDefaults
// when it handles the key, which the `e.defaultPrevented` guard defers to.
// The worktree checkbox keeps Enter as a toggle (TUI parity: `WorktreeToggle`
// does the same) rather than folding it into the blanket-submit rule below.
form.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (target === wtEnabled) {
e.preventDefault();
wtEnabled.checked = !wtEnabled.checked;
wtEnabled.dispatchEvent(new Event("change"));
return;
}
if (target instanceof HTMLButtonElement) {
if (!target.classList.contains("project-box") && !target.classList.contains("wt-choice")) return;
e.preventDefault();
if (!target.classList.contains("is-active")) {
target.click();
return;
}
form.requestSubmit();
return;
}
e.preventDefault();
if (target === filterInput) {
const top = visibleGroups(filterInput.value)[0];
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
}
form.requestSubmit();
});
// ---- submit ----------------------------------------------------------
form.addEventListener("submit", async (e) => {
e.preventDefault();
+258
View File
@@ -0,0 +1,258 @@
// @vitest-environment happy-dom
// Enter submits the draft form from any field, at any point (#97). The two
// exceptions: a picker button that isn't yet the active choice takes the
// Enter to *choose* it instead (the next Enter submits), and the project
// filter must resolve to the highlighted project rather than sending
// whatever cwd was already set.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
import type { Project } from "./types.js";
const MAESTRO: Project = {
path: "/home/user/tea/maestro",
name: "maestro",
is_worktree: false,
last_activity: "2026-08-20T10:00:00Z",
};
const WT_A: Project = {
path: "/home/user/tea/maestro/worktrees/bulgarians",
name: "bulgarians",
is_worktree: true,
base_repo: MAESTRO.path,
last_activity: "2026-08-22T10:00:00Z",
};
const OTHER: Project = {
path: "/home/user/tea/rev",
name: "rev",
is_worktree: false,
last_activity: "2026-08-19T10:00:00Z",
};
function stubFetch(projects: Project[]): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["amber-fox"] }
: url.includes("/api/branches")
? []
: url.includes("/api/dirs")
? { path: "/opt", entries: ["nomatch-zzz"], truncated: false }
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
describe("spawnForm Enter-to-submit (#97)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
async function mount(projects: Project[]): Promise<HTMLFormElement> {
stubFetch(projects);
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
return handle.el;
}
function submitSpy(): ReturnType<typeof vi.fn> {
return vi.fn();
}
function pressEnter(el: HTMLElement): { defaultPrevented: boolean } {
const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
el.dispatchEvent(evt);
return { defaultPrevented: evt.defaultPrevented };
}
const box = (form: HTMLFormElement, path: string) =>
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
expect(toggle.checked).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(toggle);
expect(toggle.checked).toBe(false);
expect(spy).not.toHaveBeenCalled();
pressEnter(toggle);
expect(toggle.checked).toBe(true);
expect(spy).not.toHaveBeenCalled();
});
describe("submits immediately from ordinary fields", () => {
it("name field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=name]")!);
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree branch field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!);
expect(spy).toHaveBeenCalledTimes(1);
});
it("custom command field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
raw.value = "bash -l";
raw.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(raw);
expect(spy).toHaveBeenCalledTimes(1);
});
it("raw cwd field once the autocomplete dropdown is closed", async () => {
const form = await mount([MAESTRO]);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/xyz-nothing-here";
raw.dispatchEvent(new Event("input"));
await vi.advanceTimersByTimeAsync(300);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(raw);
expect(spy).toHaveBeenCalledTimes(1);
});
it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => {
const form = await mount([MAESTRO]);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.focus();
raw.value = "/opt/";
raw.dispatchEvent(new Event("input", { bubbles: true }));
await vi.advanceTimersByTimeAsync(150);
const spy = submitSpy();
form.addEventListener("submit", spy);
const { defaultPrevented } = pressEnter(raw);
expect(defaultPrevented).toBe(true);
expect(spy).not.toHaveBeenCalled();
expect(raw.value).toBe("/opt/nomatch-zzz/");
});
});
describe("two-step picker: first Enter chooses, second submits", () => {
it("project box", async () => {
const form = await mount([MAESTRO]);
const b = box(form, MAESTRO.path);
b.focus();
const spy = submitSpy();
form.addEventListener("submit", spy);
const first = pressEnter(b);
expect(first.defaultPrevented).toBe(true);
expect(b.classList.contains("is-active")).toBe(true);
expect(spy).not.toHaveBeenCalled();
pressEnter(b);
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree choice", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
const choice = form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!;
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(choice);
expect(choice.classList.contains("is-active")).toBe(true);
expect(spy).not.toHaveBeenCalled();
pressEnter(choice);
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe("project filter", () => {
it("submits with the highlighted (first matching) project, not the filter text", async () => {
const form = await mount([MAESTRO, OTHER]);
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "rev";
filter.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
});
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
await drain();
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "maestro";
filter.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
"is-active",
),
).toBe(true);
});
});
});