This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-tui/src/draft.rs
T
Miguel PalhasandClaude Opus 5 1b260b5c20 feat(maestro): drill into a session's actions with tab
`tab` on a highlighted session opens its action page inside the command
bar: kill, rename, spawn similar, still fuzzy-searchable, with the
overlay titled after the session. Escape or backspace at an empty query
returns to the result list with the previous query intact.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:35:24 +01:00

2969 lines
112 KiB
Rust

//! The spawn form (issues #63/#64), drawn inside the spawn modal.
//!
//! Submitting hands the caller a built request; the modal closes and the new
//! session takes the pane, matching the web client's spawn modal. Field
//! editing goes through [`crate::text_input::TextInput`]; Tab / BackTab walk
//! the fields.
//!
//! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`):
//! command presets from `/api/info` (with an implicit `custom` free-text
//! 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` for the branch default, 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 its parent directory).
//! 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.
//! 7. Neither step touches the command: the directory and what runs in it
//! are independent choices.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::input::InputKey;
use crate::projects;
/// Shown in place of an empty name field: what happens if the operator types
/// nothing (#131).
pub const NAME_HINT: &str = "named after your first prompt";
/// How many handles one `/api/names` fetch asks for. One is enough: the name
/// field starts empty (#131) and the handle only seeds the worktree branch.
pub const NAME_BATCH_SIZE: usize = 1;
/// Quiet period after raw-path typing before a `/api/gitroot` probe fires
/// (#74). Every keystroke resets it, so a burst issues one request once the
/// operator pauses. Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
/// web/src/spawnForm.ts so both clients debounce alike.
pub const GITROOT_PROBE_DEBOUNCE_MS: u64 = 250;
/// Width of the fixed label gutter every form line starts with (`cmd: `,
/// `dir: `, …).
const GUTTER: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DraftField {
/// The horizontal preset-tile row (Left / Right move the selection).
Preset,
/// Free-text command, reachable only while a custom preset is selected.
CustomCommand,
/// 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.
Advanced,
/// Worktree on/off for this session (#65). Enter / Space toggles.
WorktreeToggle,
/// New-vs-existing branch source; Left / Right switches.
BranchMode,
/// Free-text new-branch name, shown in `New` mode.
BranchNew,
/// Existing-branch picker, shown in `Existing` mode.
BranchExisting,
Submit,
}
/// Where a worktree's branch comes from (#65), matching the web form's
/// `WorktreeBranchMode`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorktreeBranchMode {
/// Create the branch (named after the generated session name by default).
New,
/// Check out a branch picked from `GET /api/branches` as-is.
Existing,
}
/// "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 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,
}
/// A box's second column: the directory holding the project, `~`-abbreviated.
/// Only the stem — the last segment is the name column beside it.
fn parent_dir_label(path: &Path, home: &str) -> String {
match path.parent() {
Some(parent) => abbreviate_dir(parent, home),
None => String::new(),
}
}
/// 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
/// can show it instead of posting an empty request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DraftValidationError {
MissingDirectory,
MissingCommand,
MissingWorktreeRepo,
MissingNewBranch,
MissingExistingBranch,
}
impl std::fmt::Display for DraftValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingDirectory => write!(f, "Pick a working directory."),
Self::MissingCommand => write!(f, "Pick a command (or fill the custom command)."),
Self::MissingWorktreeRepo => {
write!(f, "Worktree needs a directory inside a git repository.")
}
Self::MissingNewBranch => write!(f, "Worktree requires a branch name."),
Self::MissingExistingBranch => write!(f, "Pick an existing branch."),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DraftInputResult {
Consumed,
Submit,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DraftPane {
/// Identity for routing async load results back to *this* draft.
///
/// Tab indices shift when a tab closes, so an in-flight load addressed by
/// index could be dropped or land on the wrong pane (#67 review).
pub id: u64,
/// Includes the implicit custom tile (empty `command`) last.
pub presets: Vec<SpawnPreset>,
pub selected_preset: usize,
pub custom_command: crate::text_input::TextInput,
/// 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 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>,
/// Directory to preselect once the project scan lands — the project of
/// the session the operator was last looking at, so `n` then Enter
/// respawns there without touching the picker. Consumed by the first
/// [`Self::set_projects`]; `None` once applied or when nothing seeded it.
pub seed_cwd: Option<PathBuf>,
/// Command to preselect once the preset list lands — "spawn similar"'s
/// half of the clone (#155). Consumed by the first [`Self::set_presets`];
/// `None` once applied or when nothing seeded it.
pub seed_command: Option<String>,
/// The repository picked in step one ([`group_key`] of its main box),
/// `None` until a box is picked. Looked up fresh in `groups` rather than
/// cached, so a project-list refresh can't leave it stale.
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,
/// Set once the operator types a name; a fetched handle then seeds only
/// the branch, never the field. Guards a late fetch against
/// clobbering it, exactly like the web form's `nameTouched`.
pub name_touched: bool,
/// Worktree on for this spawn (#65). Defaults on; a failed gitroot probe
/// unticks it, like the web form.
pub worktree_enabled: bool,
/// Whether the worktree/branch controls are collapsed behind the
/// "Advanced options" disclosure (#93). Closed by default — most spawns
/// take the default target.
pub advanced_collapsed: bool,
/// Enclosing repository of the chosen target — from the project scan for
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
pub base_repo: Option<PathBuf>,
/// The cwd the current `base_repo` answer belongs to.
pub(crate) probed_cwd: Option<PathBuf>,
/// Bumped on every target change; answers carrying an older seq are stale.
probe_seq: u32,
needs_probe: bool,
/// When the pending probe may fire (debounce, #74). Typing into the raw
/// path resets it; a discrete action like the toggle makes it due at once.
probe_due_at: Option<Instant>,
/// Set when a probe found no repository (drives the rendered hint).
repo_missing: bool,
/// New-branch name. Tracks [`Self::name`] until edited, like the web form.
pub branch: crate::text_input::TextInput,
branch_edited: bool,
pub branch_mode: WorktreeBranchMode,
/// Branches of the target repo (`GET /api/branches`), with occupancy.
pub branches: Vec<BranchInfo>,
/// Highlighted entry in `Existing` mode; always an available branch
/// unless moved by hand.
pub selected_branch: usize,
branches_for: Option<PathBuf>,
needs_branches: bool,
/// Last refusal (validation or a failed POST), rendered inside the form
/// instead of dismissing it.
pub error: Option<String>,
pub focused_field: DraftField,
pub home_dir: String,
}
/// Source of [`DraftPane::id`].
static NEXT_DRAFT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
impl Default for DraftPane {
fn default() -> Self {
// Built-ins until `/api/info` answers — the form must be usable even
// while its data loads, and stay usable when the daemon reports none.
let mut presets = builtin_presets();
presets.push(custom_preset());
Self {
id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
presets,
selected_preset: 0,
seed_command: None,
custom_command: crate::text_input::TextInput::default(),
groups: Vec::new(),
filter: crate::text_input::TextInput::default(),
highlighted: 0,
dir_picker_navigated: false,
cwd_raw_visible: false,
cwd_raw: crate::text_input::TextInput::default(),
picked_cwd: None,
seed_cwd: None,
selected_group_key: None,
worktree_choice: WorktreeChoice::New,
name: crate::text_input::TextInput::default(),
name_touched: false,
worktree_enabled: true,
advanced_collapsed: true,
base_repo: None,
probed_cwd: None,
probe_seq: 0,
needs_probe: false,
probe_due_at: None,
repo_missing: false,
branch: crate::text_input::TextInput::default(),
branch_edited: false,
branch_mode: WorktreeBranchMode::New,
branches: Vec::new(),
selected_branch: 0,
branches_for: None,
needs_branches: false,
error: None,
// The picker owns the keyboard on open: typing filters projects
// straight away, and Tab from here reaches the command tiles.
focused_field: DraftField::DirPicker,
home_dir: String::new(),
}
}
}
impl DraftPane {
/// Replace the preset list from `GET /api/info`. An empty-command tile is
/// treated as the custom escape hatch and appended if missing, so a daemon
/// list without one still leaves the operator a way out.
pub fn set_presets(&mut self, mut presets: Vec<SpawnPreset>) {
if !presets.iter().any(|p| p.command.is_empty()) {
presets.push(custom_preset());
}
self.presets = presets;
self.selected_preset = self
.presets
.iter()
.position(|p| !p.command.is_empty())
.unwrap_or(0);
self.apply_seed_command();
}
/// Land a seeded command on the tile that runs it: its own preset when the
/// daemon offers one, otherwise the custom escape hatch with the command
/// typed in.
fn apply_seed_command(&mut self) {
let Some(command) = self.seed_command.take() else {
return;
};
match self.presets.iter().position(|p| p.command == command) {
Some(index) => self.selected_preset = index,
None => {
self.selected_preset = self
.presets
.iter()
.position(|p| p.command.is_empty())
.unwrap_or(self.selected_preset);
self.custom_command = crate::text_input::TextInput::with_value(command);
}
}
}
/// Replace the project snapshot: sort fresh-first (#54), group by
/// 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.groups = groups
.iter()
.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));
self.apply_seed_cwd();
}
/// Preselect the seeded directory now that the scan has landed: highlight
/// its repository box and pick it, so Enter from the untouched picker
/// spawns there. A seed no box matches is dropped — the operator picks by
/// hand, exactly as before.
fn apply_seed_cwd(&mut self) {
let Some(seed) = self.seed_cwd.take() else {
return;
};
let hit = self.visible_groups().iter().enumerate().find_map(|(i, g)| {
let matches = g.main.path == seed || g.worktrees.iter().any(|w| w.project.path == seed);
matches.then(|| (i, (*g).clone()))
});
let Some((index, group)) = hit else {
return;
};
self.highlighted = index;
self.select_group(&group);
}
/// Seed the worktree branch from a `GET /api/names` handle (#131).
///
/// The name field is deliberately left alone: a session the operator does
/// not name is named by the daemon — a provisional handle at spawn,
/// replaced by one derived from the first prompt. The branch still needs
/// a default, and the name field no longer supplies one.
pub fn seed_names(&mut self, names: Vec<String>) {
let Some(handle) = names.into_iter().next() else {
return;
};
if self.branch_edited
|| self.branch_mode != WorktreeBranchMode::New
|| !self.branch.value().trim().is_empty()
{
return;
}
self.branch = crate::text_input::TextInput::with_value(handle);
}
pub fn set_name(&mut self, name: impl Into<String>) {
self.name = crate::text_input::TextInput::with_value(name.into());
self.sync_branch_to_name();
}
/// The new-branch field mirrors the session name until the operator types
/// their own branch — and only in `New` mode, like the web form.
///
/// A cleared name leaves the branch on its last value (#131): the branch
/// still has to be something, while the session name no longer does.
fn sync_branch_to_name(&mut self) {
if !self.branch_edited
&& self.branch_mode == WorktreeBranchMode::New
&& !self.name.value().is_empty()
{
self.branch = crate::text_input::TextInput::with_value(self.name.value().to_string());
}
}
// -- worktree (#65) -----------------------------------------------------
/// Whether the branch controls are shown: the toggle alone appears until
/// the target is known to be inside a repository.
fn worktree_body_visible(&self) -> bool {
self.worktree_enabled && self.base_repo.is_some()
}
/// A target the draft already knows the repo for (picked project row).
fn note_picked_repo(&mut self, base_repo: PathBuf) {
self.base_repo = Some(base_repo);
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;
self.schedule_branch_load();
}
/// A target with an unknown repo (raw path edit or the Other… hatch).
/// Cancels any in-flight probe and schedules a fresh one after the
/// debounce window (#74).
fn note_unknown_repo(&mut self) {
self.base_repo = None;
self.repo_missing = false;
self.probe_seq += 1;
self.needs_probe = self.worktree_enabled && self.cwd().is_some();
self.probe_due_at = self
.needs_probe
.then(|| Instant::now() + Duration::from_millis(GITROOT_PROBE_DEBOUNCE_MS));
self.branches_for = None;
self.schedule_branch_load();
}
fn schedule_branch_load(&mut self) {
if self.worktree_body_visible() && self.branch_mode == WorktreeBranchMode::Existing {
self.needs_branches = true;
}
}
/// Consume the gitroot-probe request once its debounce window has passed
/// (`now` at or after [`Self::probe_due_at`]): the caller fetches
/// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`]
/// with the same seq. Returns `None` while typing is still in flight.
pub fn take_gitroot_probe(&mut self, now: Instant) -> Option<(u32, PathBuf)> {
if !self.needs_probe {
return None;
}
if now < self.probe_due_at? {
return None;
}
self.needs_probe = false;
self.probe_due_at = None;
let cwd = self.cwd()?;
self.probe_seq += 1;
Some((self.probe_seq, cwd))
}
/// Apply one `/api/gitroot` answer. Stale answers — anything not matching
/// the latest seq — are dropped, so rapid retyping can't resurrect an old
/// repo. A miss unticks the toggle (web parity) and records the hint.
pub fn resolve_gitroot(&mut self, seq: u32, root: Option<PathBuf>) {
if seq != self.probe_seq {
return;
}
self.needs_probe = false;
self.probe_due_at = None;
self.probed_cwd = self.cwd();
match root {
Some(repo) => {
self.base_repo = Some(repo);
self.repo_missing = false;
}
None => {
self.base_repo = None;
self.repo_missing = true;
self.worktree_enabled = false;
}
}
self.schedule_branch_load();
}
/// Consume the branch-list request: the caller fetches
/// `GET /api/branches?path=…` for the returned cwd.
pub fn take_branch_load(&mut self) -> Option<PathBuf> {
if !self.needs_branches {
return None;
}
self.needs_branches = false;
self.cwd()
}
/// Store a fetched branch list; answers for anything but the current cwd
/// are stale and dropped.
pub fn set_branches(&mut self, cwd: &Path, branches: Vec<BranchInfo>) {
if self.cwd().as_deref() != Some(cwd) {
return;
}
self.branches_for = Some(cwd.to_path_buf());
self.branches = branches;
self.selected_branch = (0..self.branches.len())
.find(|&i| Self::branch_is_available(&self.branches[i]))
.unwrap_or(0);
}
pub fn set_branch_mode(&mut self, mode: WorktreeBranchMode) {
if self.branch_mode == mode {
return;
}
self.branch_mode = mode;
self.sync_branch_to_name();
self.schedule_branch_load();
}
pub fn cycle_branch_mode(&mut self, delta: i32) {
let next = match (self.branch_mode, delta >= 0) {
(WorktreeBranchMode::New, true) | (WorktreeBranchMode::Existing, false) => {
WorktreeBranchMode::Existing
}
_ => WorktreeBranchMode::New,
};
self.set_branch_mode(next);
}
/// Enter / Space on the "Advanced options" header (#93).
pub fn toggle_advanced(&mut self) {
self.advanced_collapsed = !self.advanced_collapsed;
}
/// What the collapsed header says about this spawn's default, so it stays
/// visible without expanding (#93).
fn advanced_summary(&self) -> &'static str {
if self.worktree_enabled {
"creates a worktree"
} else {
"no worktree"
}
}
/// Enter / Space on the toggle. Re-enabling reuses a still-valid repo
/// answer instead of re-probing.
pub fn toggle_worktree(&mut self) {
self.worktree_enabled = !self.worktree_enabled;
if !self.worktree_enabled {
return;
}
let cwd = self.cwd();
if cwd.as_deref() != self.probed_cwd.as_deref() {
self.base_repo = None;
self.repo_missing = false;
}
if self.base_repo.is_none() {
self.probe_seq += 1;
// A discrete toggle, not a keystroke burst: due immediately.
self.needs_probe = cwd.is_some();
self.probe_due_at = self.needs_probe.then(Instant::now);
}
self.schedule_branch_load();
}
/// A branch is pickable only when nothing has it checked out. That means
/// no linked worktree *and* not the main checkout's HEAD — git refuses a
/// second checkout either way, and HEAD is commonly `main`, so treating it
/// as available made it the default pick and every spawn failed with
/// "already used by worktree" (#54 review).
fn branch_is_available(b: &BranchInfo) -> bool {
b.worktree.is_none() && !b.is_head
}
fn available_branch(&self, i: usize) -> bool {
self.branches.get(i).is_some_and(Self::branch_is_available)
}
/// Move the existing-branch highlight, skipping branches another worktree
/// holds (git refuses to check them out twice).
pub fn move_branch_selection(&mut self, delta: i32) {
let available: Vec<usize> = (0..self.branches.len())
.filter(|&i| self.available_branch(i))
.collect();
if available.is_empty() {
return;
}
let cur = available
.iter()
.position(|&i| i == self.selected_branch)
.unwrap_or(0);
let len = available.len() as i32;
self.selected_branch = available[((cur as i32) + delta).rem_euclid(len) as usize];
}
/// The highlighted branch, only when nothing has it checked out.
pub fn picked_branch(&self) -> Option<&BranchInfo> {
self.branches
.get(self.selected_branch)
.filter(|b| Self::branch_is_available(b))
}
pub fn is_custom(&self) -> bool {
self.presets
.get(self.selected_preset)
.is_none_or(|p| p.command.is_empty())
}
/// The resolved command: the selected preset's, or the custom free text.
pub fn command(&self) -> &str {
match self.presets.get(self.selected_preset) {
Some(p) if !p.command.is_empty() => &p.command,
_ => self.custom_command.value().trim(),
}
}
/// The chosen working directory: a picked project row, else the raw path
/// input when it holds something.
pub fn cwd(&self) -> Option<PathBuf> {
if let Some(cwd) = &self.picked_cwd {
return Some(cwd.clone());
}
let raw = self.cwd_raw.value().trim();
(!raw.is_empty()).then(|| PathBuf::from(raw))
}
/// Build the spawn request, naming the missing field instead of letting
/// an empty request reach the daemon. A non-empty name rides along as
/// `metadata.name`; an empty one sends no name at all.
pub fn build_request(&self) -> Result<CreateSessionRequest, DraftValidationError> {
let Some(cwd) = self.cwd() else {
return Err(DraftValidationError::MissingDirectory);
};
let command = self.command();
if command.is_empty() {
return Err(DraftValidationError::MissingCommand);
}
let mut metadata = std::collections::HashMap::new();
let name = self.name.value().trim();
if !name.is_empty() {
metadata.insert(
"name".to_string(),
serde_json::Value::String(name.to_string()),
);
}
let worktree = self.build_worktree()?;
Ok(CreateSessionRequest {
cwd,
command: command.to_string(),
env: Default::default(),
metadata,
worktree,
})
}
/// The worktree half of the request (#65): `None` while toggled off;
/// otherwise the inferred base repo plus a branch that is either created
/// (`New`) or checked out as-is (`Existing`). Cleanup is unconditional
/// daemon-side (#56), so no option for it here.
fn build_worktree(&self) -> Result<Option<WorktreeSpec>, DraftValidationError> {
if !self.worktree_enabled {
return Ok(None);
}
let Some(base_repo) = self.base_repo.clone() else {
return Err(DraftValidationError::MissingWorktreeRepo);
};
let spec = match self.branch_mode {
WorktreeBranchMode::New => {
let branch = self.branch.value().trim();
if branch.is_empty() {
return Err(DraftValidationError::MissingNewBranch);
}
WorktreeSpec {
base_repo,
branch: branch.to_string(),
create_branch: true,
}
}
WorktreeBranchMode::Existing => {
let name = self
.picked_branch()
.ok_or(DraftValidationError::MissingExistingBranch)?
.name
.clone();
WorktreeSpec {
base_repo,
branch: name,
create_branch: false,
}
}
};
Ok(Some(spec))
}
fn field_order(&self) -> Vec<DraftField> {
let mut order = vec![DraftField::DirPicker];
if self.cwd_raw_visible {
order.push(DraftField::CwdRaw);
}
order.push(DraftField::Preset);
if self.is_custom() {
order.push(DraftField::CustomCommand);
}
order.push(DraftField::Name);
order.push(DraftField::Advanced);
if !self.advanced_collapsed {
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
order.push(DraftField::WorktreeChoice);
}
order.push(DraftField::WorktreeToggle);
if self.worktree_body_visible() {
order.push(DraftField::BranchMode);
order.push(match self.branch_mode {
WorktreeBranchMode::New => DraftField::BranchNew,
WorktreeBranchMode::Existing => DraftField::BranchExisting,
});
}
}
order.extend([DraftField::Submit]);
order
}
fn step_field(&mut self, delta: i32) {
let order = self.field_order();
let len = order.len() as i32;
let cur = order
.iter()
.position(|f| *f == self.focused_field)
.unwrap_or(0) as i32;
self.focused_field = order[(cur + delta).rem_euclid(len) as usize];
}
pub fn next_field(&mut self) {
self.step_field(1);
}
pub fn prev_field(&mut self) {
self.step_field(-1);
}
pub fn cycle_preset(&mut self, delta: i32) {
let len = self.presets.len();
if len == 0 {
return;
}
self.selected_preset =
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
}
/// 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.groups
.iter()
.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_groups().len() + 1
}
/// Highlight clamped to the current entry count.
pub fn selection(&self) -> usize {
self.highlighted.min(self.entry_count().saturating_sub(1))
}
pub fn move_selection(&mut self, delta: i32) {
let count = self.entry_count() as i32;
if count == 0 {
return;
}
self.dir_picker_navigated = true;
self.highlighted = ((self.selection() as i32) + delta).rem_euclid(count) as usize;
}
/// 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_groups();
let sel = self.selection();
if sel < visible.len() {
let group = visible[sel].clone();
self.select_group(&group);
return;
}
let seed = self.cwd().unwrap_or_else(|| {
if self.home_dir.is_empty() {
PathBuf::from("/")
} else {
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());
self.focused_field = DraftField::CwdRaw;
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;
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
/// session — or shows the reason it couldn't.
pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult {
match key {
InputKey::Tab => {
self.next_field();
DraftInputResult::Consumed
}
InputKey::BackTab => {
self.prev_field();
DraftInputResult::Consumed
}
// 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
}
}
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,
..
} if self.focused_field == DraftField::Advanced => {
self.toggle_advanced();
DraftInputResult::Consumed
}
InputKey::Char {
c: ' ',
ctrl: false,
..
} if self.focused_field == DraftField::WorktreeToggle => {
self.toggle_worktree();
DraftInputResult::Consumed
}
InputKey::Up if self.focused_field == DraftField::DirPicker => {
self.move_selection(-1);
DraftInputResult::Consumed
}
InputKey::Down if self.focused_field == DraftField::DirPicker => {
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
}
InputKey::Right if self.focused_field == DraftField::Preset => {
self.cycle_preset(1);
DraftInputResult::Consumed
}
InputKey::Left if self.focused_field == DraftField::BranchMode => {
self.cycle_branch_mode(-1);
DraftInputResult::Consumed
}
InputKey::Right if self.focused_field == DraftField::BranchMode => {
self.cycle_branch_mode(1);
DraftInputResult::Consumed
}
InputKey::Up if self.focused_field == DraftField::BranchExisting => {
self.move_branch_selection(-1);
DraftInputResult::Consumed
}
InputKey::Down if self.focused_field == DraftField::BranchExisting => {
self.move_branch_selection(1);
DraftInputResult::Consumed
}
other => {
match self.focused_field {
DraftField::Preset | DraftField::Submit => {}
DraftField::CustomCommand => self.custom_command.handle_key(other),
// Typing filters the list; the highlight resets to the top
// so a narrowed search always starts at its best match.
DraftField::DirPicker => {
self.filter.handle_key(other);
self.highlighted = 0;
self.dir_picker_navigated = false;
}
DraftField::CwdRaw => {
self.cwd_raw.handle_key(other);
// The target moved: the old repo answer no longer
// applies and a fresh probe is needed.
self.note_unknown_repo();
}
DraftField::Name => {
self.name.handle_key(other);
self.name_touched = true;
self.sync_branch_to_name();
}
DraftField::BranchNew => {
self.branch.handle_key(other);
self.branch_edited = true;
}
_ => {}
}
DraftInputResult::Consumed
}
}
}
/// Draw into the pane area. `pane_focused` gates the field highlight and
/// cursor placement the same way a session pane hides its cursor when
/// unfocused.
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
let inner = area;
let show_custom = self.is_custom();
let show_raw = self.cwd_raw_visible;
// The worktree choice step, the worktree toggle and its body all stay
// behind the "Advanced options" disclosure (#93): collapsed hides
// them, regardless of what they'd otherwise show.
let show_wt_toggle = !self.advanced_collapsed;
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
let show_wt_choice =
!self.advanced_collapsed && 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;
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
+ 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;
// The picker leads the form: it is where focus lands on open, and Tab
// walks down from it into the command tiles.
let mut constraints = vec![Constraint::Length(1)]; // dir filter
constraints.push(Constraint::Length(list_capacity as u16));
if show_raw {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // command tiles
if show_custom {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // name
constraints.push(Constraint::Length(1)); // advanced header
if show_wt_choice {
constraints.push(Constraint::Length(wt_choice_capacity));
}
if show_wt_toggle {
constraints.push(Constraint::Length(1)); // worktree toggle
}
if show_wt_body {
constraints.push(Constraint::Length(1)); // branch mode
match self.branch_mode {
WorktreeBranchMode::New => constraints.push(Constraint::Length(1)),
WorktreeBranchMode::Existing => constraints.push(Constraint::Length(branch_rows)),
}
}
constraints.push(Constraint::Length(1));
if self.error.is_some() {
constraints.push(Constraint::Length(1));
}
let rows = Layout::vertical(constraints).split(inner);
let mut r = 0usize;
let mut next_row = || {
r += 1;
rows[r - 1]
};
let field_style = |field: DraftField| {
if pane_focused && self.focused_field == field {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
}
};
// Tracks where the terminal cursor belongs for the focused text field.
let mut cursor: Option<(u16, u16)> = None;
// -- directory picker ----------------------------------------------------
let filter_row = next_row();
draw_gutter(f, filter_row, "dir:");
let col = draw_text_field(
f,
&self.filter,
filter_row,
field_style(DraftField::DirPicker),
);
if self.focused_field == DraftField::DirPicker {
cursor = Some((col, filter_row.y));
}
// Step one (#96 rule 1): one box per repository, name then the parent
// directory it sits in. The last segment is the name column already;
// the stem is what keeps two projects sharing a basename apart.
let visible = self.visible_groups();
let sel = self.selection();
let count = visible.len() + 1;
let capacity = list_capacity.min(count);
let start = if sel >= capacity {
sel + 1 - capacity
} else {
0
};
let name_width = visible
.iter()
.take(start + capacity)
.skip(start)
.map(|group| group.main.name.chars().count())
.max()
.unwrap_or(0)
.min(18);
let list_row = next_row();
for i in start..start + capacity {
let y = list_row.y + (i - start) as u16;
if y >= list_row.y + list_row.height {
break;
}
let active = pane_focused && self.focused_field == DraftField::DirPicker && i == sel;
let line = if i < visible.len() {
let group = visible[i];
let mut base = Style::default();
if group.main_stale {
base = base.add_modifier(Modifier::DIM);
}
if active {
base = base.add_modifier(Modifier::REVERSED);
}
Line::from(vec![
Span::styled(if active { ">" } else { " " }, base),
Span::raw(" "),
Span::styled(format!("{:<name_width$}", group.main.name), base),
Span::styled(
format!(" {}", parent_dir_label(&group.main.path, &self.home_dir)),
base,
),
])
} else {
let mut style = Style::default();
if active {
style = style.add_modifier(Modifier::REVERSED);
}
Line::from(Span::styled("Other directory…", style))
};
f.render_widget(
Paragraph::new(line),
Rect {
x: list_row.x,
y,
width: list_row.width,
height: 1,
},
);
}
// -- free-text path (revealed by Other directory…) -----------------------
if show_raw {
let row = next_row();
draw_gutter(f, row, "path");
let col = draw_text_field(f, &self.cwd_raw, row, field_style(DraftField::CwdRaw));
if self.focused_field == DraftField::CwdRaw {
cursor = Some((col, row.y));
}
}
// -- command presets ---------------------------------------------------
let tiles_row = next_row();
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "cmd:"))];
for (i, preset) in self.presets.iter().enumerate() {
if i > 0 {
tiles.push(Span::raw(" "));
}
let mut style = Style::default();
if i == self.selected_preset {
style = style.add_modifier(Modifier::REVERSED);
}
tiles.push(Span::styled(format!("[{}]", preset.label), style));
}
f.render_widget(Paragraph::new(Line::from(tiles)), tiles_row);
// -- custom command (only while a custom tile is selected) -------------
if show_custom {
let row = next_row();
draw_gutter(f, row, "");
let col = draw_text_field(
f,
&self.custom_command,
row,
field_style(DraftField::CustomCommand),
);
if self.focused_field == DraftField::CustomCommand {
cursor = Some((col, row.y));
}
}
// -- name -----------------------------------------------------------------
let name_row = next_row();
draw_gutter(f, name_row, "name:");
let col = draw_text_field(f, &self.name, name_row, field_style(DraftField::Name));
if self.focused_field == DraftField::Name {
cursor = Some((col, name_row.y));
}
// An empty field is not a missing value (#131): the daemon names the
// session, so the row says so rather than looking unfinished.
if self.name.value().is_empty() {
let hint_offset = (GUTTER as u16 + 1).min(name_row.width.saturating_sub(10));
f.render_widget(
Paragraph::new(Line::from(Span::styled(
NAME_HINT,
Style::default().add_modifier(Modifier::DIM),
))),
Rect {
x: name_row.x + hint_offset,
y: name_row.y,
width: name_row.width.saturating_sub(hint_offset),
height: 1,
},
);
}
// -- advanced disclosure (#93) ------------------------------------------
let advanced_row = next_row();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "adv:")),
Span::styled(
format!(
"[{}] Advanced options",
if self.advanced_collapsed { "+" } else { "-" }
),
field_style(DraftField::Advanced),
),
Span::styled(
format!(" — {}", self.advanced_summary()),
Style::default().add_modifier(Modifier::DIM),
),
])),
advanced_row,
);
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,
},
);
}
}
// -- worktree (#65) -----------------------------------------------------
if show_wt_toggle {
let wt_row = next_row();
let hint = if self.cwd().is_none() {
Some("choose a directory first")
} else if self.repo_missing {
Some("not inside a git repository")
} else if !self.worktree_enabled {
Some("off — spawns in the checkout itself")
} else {
None
};
let mut tiles = vec![Span::raw(format!("{:<GUTTER$}", "wt:"))];
tiles.push(Span::styled(
if self.worktree_enabled {
"[x] worktree"
} else {
"[ ] worktree"
},
field_style(DraftField::WorktreeToggle),
));
if let Some(hint) = hint {
tiles.push(Span::styled(
format!(" {hint}"),
Style::default().add_modifier(Modifier::DIM),
));
}
f.render_widget(Paragraph::new(Line::from(tiles)), wt_row);
}
if show_wt_body {
// branch source: new (created) vs existing (checked out as-is)
let mode_row = next_row();
let mode_tile = |label: &str, active: bool| {
Span::styled(
format!("[{label}]"),
if active {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
},
)
};
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "mode:")),
mode_tile("new", self.branch_mode == WorktreeBranchMode::New),
Span::raw(" "),
mode_tile("existing", self.branch_mode == WorktreeBranchMode::Existing),
])),
mode_row,
);
match self.branch_mode {
WorktreeBranchMode::New => {
let row = next_row();
draw_gutter(f, row, "new:");
let col =
draw_text_field(f, &self.branch, row, field_style(DraftField::BranchNew));
if self.focused_field == DraftField::BranchNew {
cursor = Some((col, row.y));
}
}
WorktreeBranchMode::Existing => {
let len = self.branches.len();
let capacity = branch_rows as usize;
let sel = self.selected_branch.min(len.saturating_sub(1));
let start = if sel >= capacity {
sel + 1 - capacity
} else {
0
};
let name_width = self
.branches
.iter()
.map(|b| b.name.chars().count())
.max()
.unwrap_or(0)
.min(20);
let list_row = next_row();
draw_gutter(f, list_row, "pick:");
for i in start..(start + capacity).min(len.max(1)) {
let y = list_row.y + (i - start) as u16;
if y >= list_row.y + list_row.height {
break;
}
let line = match self.branches.get(i) {
Some(b) => {
let held = b.worktree.is_some();
let mut base = Style::default();
if held {
base = base.add_modifier(Modifier::DIM);
}
if pane_focused
&& self.focused_field == DraftField::BranchExisting
&& i == sel
{
base = base.add_modifier(Modifier::REVERSED);
}
let reason = b
.worktree
.as_ref()
.map(|w| {
format!(" — held by {}", abbreviate_path(w, &self.home_dir))
})
.unwrap_or_default();
Line::from(vec![
Span::styled(if i == sel { ">" } else { " " }, base),
Span::raw(" "),
Span::styled(format!("{:<name_width$}", b.name), base),
Span::styled(reason, base),
])
}
None => Line::from(Span::styled(
"no local branches found",
Style::default().add_modifier(Modifier::DIM),
)),
};
f.render_widget(
Paragraph::new(line),
Rect {
x: list_row.x + GUTTER as u16,
y,
width: list_row.width.saturating_sub(GUTTER as u16),
height: 1,
},
);
}
}
}
}
// -- submit ------------------------------------------------------------------
let spawn_row = next_row();
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"[ Spawn ]",
field_style(DraftField::Submit),
))),
spawn_row,
);
// -- error ---------------------------------------------------------------------
if let Some(error) = &self.error {
let row = next_row();
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("error: {error}"),
crate::palette::active().error_style(),
))),
row,
);
}
if let Some((x, y)) = cursor.filter(|_| pane_focused) {
f.set_cursor_position((x, y));
}
}
}
fn draw_gutter(f: &mut Frame, row: Rect, label: &str) {
f.render_widget(
Paragraph::new(Line::from(Span::raw(format!("{label:<GUTTER$}")))),
row,
);
}
/// Draw a text input after the label gutter; returns the cursor column.
fn draw_text_field(
f: &mut Frame,
input: &crate::text_input::TextInput,
row: Rect,
style: Style,
) -> u16 {
let area = Rect {
x: row.x + GUTTER as u16,
y: row.y,
width: row.width.saturating_sub(GUTTER as u16),
height: 1,
};
input.render(area, f.buffer_mut(), style)
}
/// `~` abbreviation plus leading-segment collapsing, matching the web UI's
/// `abbreviateCwd` + `abbreviateFolderName`: `/home/u/tea/Maestro` renders as
/// `/h/u/t/Maestro` given home `/home/u`.
pub(crate) fn abbreviate_path(path: &Path, home: &str) -> String {
let raw = path.to_string_lossy();
let abbreviated = if !home.is_empty() {
if raw == home {
"~".to_string()
} else if let Some(rest) = raw.strip_prefix(home) {
if rest.starts_with('/') {
format!("~{rest}")
} else {
raw.into_owned()
}
} else {
raw.into_owned()
}
} else {
raw.into_owned()
};
let parts: Vec<&str> = abbreviated.split('/').collect();
if parts.len() <= 2 {
return abbreviated;
}
let last = parts[parts.len() - 1];
let stem = parts[..parts.len() - 1]
.iter()
.map(|p| {
if *p == "~" {
"~".to_string()
} else {
p.chars().next().map(String::from).unwrap_or_default()
}
})
.collect::<Vec<_>>()
.join("/");
format!("{stem}/{last}")
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeDelta;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string(w: u16, h: u16, draw: impl FnOnce(&mut Frame)) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(draw).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
fn project(name: &str, path: &str, days_ago: i64, cmd: Option<&str>) -> Project {
Project {
path: PathBuf::from(path),
name: name.into(),
is_worktree: false,
base_repo: None,
last_activity: Some(Utc::now() - TimeDelta::days(days_ago)),
last_command: cmd.map(String::from),
}
}
fn repo_project(
name: &str,
path: &str,
base: &str,
days_ago: i64,
cmd: Option<&str>,
) -> Project {
Project {
base_repo: Some(PathBuf::from(base)),
..project(name, path, days_ago, cmd)
}
}
fn typed(draft: &mut DraftPane, s: &str) {
for c in s.chars() {
draft.on_input(&InputKey::char(c));
}
}
/// A moment comfortably past the probe debounce window (#74).
fn probe_due() -> Instant {
Instant::now() + Duration::from_millis(GITROOT_PROBE_DEBOUNCE_MS) * 10
}
#[test]
fn defaults_fall_back_to_builtin_presets_with_custom_last() {
let draft = DraftPane::default();
assert_eq!(
draft.presets.last().unwrap().id,
maestro_config::CUSTOM_PRESET_ID
);
assert_eq!(draft.selected_preset, 0, "first non-custom tile starts");
assert_eq!(draft.command(), "claude");
assert!(!draft.is_custom());
}
#[test]
fn daemon_presets_replace_builtins_and_keep_the_custom_hatch() {
let mut draft = DraftPane::default();
draft.set_presets(vec![SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
}]);
assert_eq!(
draft.command(),
"aider",
"selection resets to first real preset"
);
assert_eq!(draft.presets.last().unwrap().id, "custom");
}
/// "Spawn similar" (#155) seeds the cloned session's command: it lands on
/// the tile that runs it when the daemon offers one.
#[test]
fn a_seeded_command_selects_its_own_preset() {
let mut draft = DraftPane {
seed_command: Some("aider".into()),
..DraftPane::default()
};
draft.set_presets(vec![
SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
},
SpawnPreset {
id: "aider".into(),
label: "Aider".into(),
command: "aider".into(),
},
]);
assert_eq!(draft.command(), "aider");
assert!(!draft.is_custom());
assert_eq!(draft.seed_command, None, "consumed by the first load");
}
/// A command no preset runs falls back to the custom tile with the command
/// typed in, so the clone still spawns what it cloned.
#[test]
fn a_seeded_command_with_no_preset_fills_the_custom_tile() {
let mut draft = DraftPane {
seed_command: Some("bash -l".into()),
..DraftPane::default()
};
draft.set_presets(vec![SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
}]);
assert!(draft.is_custom());
assert_eq!(draft.command(), "bash -l");
}
#[test]
fn left_right_cycles_presets_wrapping_into_custom() {
let mut draft = DraftPane {
focused_field: DraftField::Preset,
..DraftPane::default()
};
draft.on_input(&InputKey::Right);
assert_eq!(draft.command(), "opencode");
draft.on_input(&InputKey::Left);
assert_eq!(draft.command(), "claude");
// Wrapping backwards off the first tile lands on the custom one.
draft.on_input(&InputKey::Left);
assert!(draft.is_custom(), "wraps backwards onto the custom tile");
draft.on_input(&InputKey::Right);
assert_eq!(draft.command(), "claude");
assert_eq!(
draft.focused_field,
DraftField::Preset,
"selection changed, focus stays"
);
}
#[test]
fn selecting_custom_reveals_a_free_text_command_field() {
let mut draft = DraftPane::default();
draft.selected_preset = draft.presets.len() - 1;
assert!(draft.is_custom());
assert_eq!(
draft.field_order()[..3],
[
DraftField::DirPicker,
DraftField::Preset,
DraftField::CustomCommand
],
"the picker leads; custom command sits right after the tiles"
);
draft.next_field(); // DirPicker → Preset
draft.next_field();
assert_eq!(draft.focused_field, DraftField::CustomCommand);
typed(&mut draft, "bash -l");
assert_eq!(draft.command(), "bash -l");
// Tab walks past the hidden fields in order.
draft.next_field();
assert_eq!(draft.focused_field, DraftField::Name);
}
#[test]
fn custom_command_flows_into_the_request() {
let mut draft = DraftPane::default();
draft.picked_cwd = Some(PathBuf::from("/srv/work"));
draft.base_repo = Some(PathBuf::from("/srv/work"));
draft.probed_cwd = Some(PathBuf::from("/srv/work"));
draft.worktree_enabled = false; // under test: command + cwd only
draft.selected_preset = draft.presets.len() - 1;
draft.custom_command = crate::text_input::TextInput::with_value("bash -l");
let req = draft.build_request().unwrap();
assert_eq!(req.command, "bash -l");
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
}
#[test]
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
..DraftPane::default()
};
draft.set_projects(
vec![
project("stale-repo", "/home/u/old/repo", 30, Some("zsh")),
project("fresh-repo", "/home/u/new/repo", 1, Some("claude")),
],
Utc::now(),
);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(rendered.contains("Other directory…"), "{rendered}");
let fresh_pos = rendered.find("fresh-repo").unwrap();
let stale_pos = rendered.find("stale-repo").unwrap();
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
assert!(
rendered.contains("~/new") && !rendered.contains("~/new/repo"),
"the parent directory only — the name column is the last segment \
(#96 rule 1):\n{rendered}"
);
// Staleness dims the row's cells.
let backend = TestBackend::new(100, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
let buf = terminal.backend().buffer();
let stale_cell = buf
.content()
.iter()
.find(|c| c.symbol() == "s" && c.modifier.contains(Modifier::DIM));
assert!(stale_cell.is_some(), "stale row renders dimmed");
}
#[test]
fn typing_filters_and_enter_picks_a_project_as_the_working_directory() {
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(),
);
assert_eq!(
draft.focused_field,
DraftField::DirPicker,
"the form opens with the project filter focused"
);
typed(&mut draft, "maes");
assert_eq!(
draft.visible_groups().len(),
1,
"type-to-filter narrows the list"
);
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"))
);
assert_eq!(
draft.command(),
"claude",
"the preset is the operator's own choice — picking a project leaves it alone"
);
}
#[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 a_seeded_directory_is_preselected_so_one_enter_spawns_there() {
let mut draft = DraftPane {
seed_cwd: Some(PathBuf::from("/repos/other")),
..DraftPane::default()
};
draft.set_projects(
vec![
project("maestro", "/repos/maestro", 1, Some("claude")),
project("other", "/repos/other", 2, Some("zsh")),
],
Utc::now(),
);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/other")),
"the seeded project is picked before a key is pressed"
);
assert_eq!(draft.selection(), 1, "and its row is the highlighted one");
assert_eq!(
draft.on_input(&InputKey::Enter),
DraftInputResult::Submit,
"one Enter spawns in the seeded project"
);
assert_eq!(draft.picked_cwd.as_deref(), Some(Path::new("/repos/other")));
}
#[test]
fn a_seed_no_project_matches_leaves_the_picker_untouched() {
let mut draft = DraftPane {
seed_cwd: Some(PathBuf::from("/gone")),
..DraftPane::default()
};
draft.set_projects(
vec![project("maestro", "/repos/maestro", 1, None)],
Utc::now(),
);
assert_eq!(draft.picked_cwd, None);
assert_eq!(draft.selection(), 0);
}
#[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();
draft.set_projects(
vec![project("a", "/a", 1, None), project("b", "/b", 2, None)],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
assert_eq!(draft.selection(), 0);
draft.on_input(&InputKey::Down);
draft.on_input(&InputKey::Down);
assert_eq!(
draft.selection(),
2,
"the Other directory… entry is reachable"
);
draft.on_input(&InputKey::Down);
assert_eq!(draft.selection(), 0, "wraps");
draft.on_input(&InputKey::Up);
assert_eq!(draft.selection(), 2, "wraps backwards");
}
#[test]
fn other_directory_reveals_a_free_text_path_seeded_from_home() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
..DraftPane::default()
};
draft.set_projects(vec![project("a", "/a", 1, None)], Utc::now());
draft.focused_field = DraftField::DirPicker;
draft.move_selection(1); // onto Other directory…
draft.on_input(&InputKey::Enter);
assert!(draft.cwd_raw_visible);
assert_eq!(draft.focused_field, DraftField::CwdRaw);
assert_eq!(draft.cwd_raw.value(), "/home/u");
typed(&mut draft, "/opt/tooling");
assert_eq!(draft.cwd(), Some(PathBuf::from("/home/u/opt/tooling")));
// The raw target probed clean, so the worktree spec carries its repo.
let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
draft.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling")));
draft.seed_names(vec!["amber-falcon".into()]);
let req = draft.build_request().unwrap();
assert_eq!(req.cwd, PathBuf::from("/home/u/opt/tooling"));
assert_eq!(
req.worktree.unwrap().base_repo,
PathBuf::from("/home/u/opt/tooling")
);
}
#[test]
fn picking_other_directory_after_a_pick_seeds_the_raw_field_with_the_pick() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
picked_cwd: Some(PathBuf::from("/repos/picked")),
..DraftPane::default()
};
draft.select_entry(); // highlight defaults past the (empty) list → Other…
assert!(draft.cwd_raw_visible);
assert_eq!(draft.cwd_raw.value(), "/repos/picked");
}
/// #131: the field stays empty and the request carries no name — the
/// daemon names the session. The fetched handle only seeds the branch.
#[test]
fn a_fetched_handle_seeds_the_branch_and_leaves_the_name_empty() {
let mut draft = DraftPane::default();
assert!(draft.name.value().is_empty());
draft.seed_names(vec!["crimson-otter".into()]);
assert!(
draft.name.value().is_empty(),
"the name field is the operator's"
);
assert_eq!(draft.branch.value(), "crimson-otter");
draft.picked_cwd = Some(PathBuf::from("/w"));
draft.base_repo = Some(PathBuf::from("/w"));
draft.probed_cwd = Some(PathBuf::from("/w"));
let req = draft.build_request().unwrap();
assert!(
!req.metadata.contains_key("name"),
"an unnamed spawn sends no name"
);
}
/// A name the operator types still rides along, and still seeds the
/// branch (#131).
#[test]
fn a_typed_name_rides_along_and_seeds_the_branch() {
let mut draft = DraftPane::default();
typed_name(&mut draft, "my-session");
assert_eq!(draft.branch.value(), "my-session");
draft.picked_cwd = Some(PathBuf::from("/w"));
draft.base_repo = Some(PathBuf::from("/w"));
draft.probed_cwd = Some(PathBuf::from("/w"));
let req = draft.build_request().unwrap();
assert_eq!(
req.metadata.get("name").and_then(|v| v.as_str()),
Some("my-session")
);
}
/// A handle arriving after the operator typed a name never overwrites it,
/// nor the branch it seeded (#131).
#[test]
fn a_late_handle_never_clobbers_what_was_typed() {
let mut draft = DraftPane::default();
typed_name(&mut draft, "mine");
draft.seed_names(vec!["late-handle".into()]);
assert_eq!(draft.name.value(), "mine");
assert_eq!(draft.branch.value(), "mine");
}
fn typed_name(draft: &mut DraftPane, s: &str) {
draft.focused_field = DraftField::Name;
for c in s.chars() {
draft.on_input(&InputKey::char(c));
}
}
#[test]
fn an_empty_name_sends_no_metadata_at_all() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/w")),
..DraftPane::default()
};
draft.base_repo = Some(PathBuf::from("/w"));
draft.probed_cwd = Some(PathBuf::from("/w"));
// No names seeded: the empty new-branch field would refuse, so spawn
// in the checkout to isolate the metadata assertion.
draft.worktree_enabled = false;
let req = draft.build_request().unwrap();
assert!(req.metadata.is_empty());
}
#[test]
fn validation_names_the_missing_field_instead_of_posting() {
let mut draft = DraftPane::default();
assert_eq!(
draft.build_request(),
Err(DraftValidationError::MissingDirectory)
);
assert_eq!(
DraftValidationError::MissingDirectory.to_string(),
"Pick a working directory."
);
draft.cwd_raw = crate::text_input::TextInput::with_value("/srv/work");
draft.picked_cwd = Some(PathBuf::from("/srv/work"));
draft.selected_preset = draft.presets.len() - 1; // custom, empty text
assert_eq!(
draft.build_request(),
Err(DraftValidationError::MissingCommand)
);
assert_eq!(
DraftValidationError::MissingCommand.to_string(),
"Pick a command (or fill the custom command)."
);
}
#[test]
fn render_shows_every_field_group_and_the_naming_hint() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
..DraftPane::default()
};
draft.set_projects(
vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))],
Utc::now(),
);
draft.seed_names(vec!["crimson-otter".into()]);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
for s in [
"[Claude]",
"[opencode]",
"[Shell]",
"[Custom…]",
"dir:",
"name:",
NAME_HINT,
"maestro",
"[ Spawn ]",
] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
}
#[test]
fn render_shows_the_error_line_when_set() {
let draft = DraftPane {
error: Some(DraftValidationError::MissingDirectory.to_string()),
..DraftPane::default()
};
let rendered = render_to_string(80, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("error: Pick a working directory."),
"{rendered}"
);
}
/// A short pane still shows the essential fields; the list window scrolls
/// with the selection instead of overflowing the border.
#[test]
fn render_survives_a_small_pane() {
let mut draft = DraftPane::default();
draft.set_projects(
(0..10)
.map(|i| project(&format!("p{i}"), &format!("/p{i}"), i, None))
.collect(),
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
for _ in 0..8 {
draft.on_input(&InputKey::Down);
}
let rendered = render_to_string(80, 12, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("[ Spawn ]"),
"submit still visible:\n{rendered}"
);
assert!(rendered.contains('p'), "some projects visible:\n{rendered}");
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (gutter is 5 chars and the pane has no border).
#[test]
fn render_places_the_cursor_in_the_focused_text_field() {
let mut draft = DraftPane {
focused_field: DraftField::DirPicker,
..DraftPane::default()
};
typed(&mut draft, "ab");
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 0));
}
#[test]
fn worktree_is_on_by_default_and_builds_a_create_branch_spec() {
let mut draft = DraftPane::default();
assert!(draft.worktree_enabled, "on before any directory is chosen");
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 the repo row
assert_eq!(
draft.base_repo.as_deref(),
Some(Path::new("/repos/maestro")),
"a picked project resolves its base repo without a probe"
);
assert!(draft.take_gitroot_probe(probe_due()).is_none());
draft.seed_names(vec!["crimson-otter".into()]);
let wt = draft.build_request().unwrap().worktree.unwrap();
assert_eq!(wt.base_repo, PathBuf::from("/repos/maestro"));
assert_eq!(wt.branch, "crimson-otter", "branch defaults to the name");
assert!(wt.create_branch);
}
/// 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),
linked_worktree("wt-91", "/repos/wt91", "/repos/main", 1),
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
typed(&mut draft, "wt-91");
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 lives behind the collapsed \"Advanced options\" disclosure"
);
draft.toggle_advanced();
assert!(
draft.field_order().contains(&DraftField::WorktreeChoice),
"step two is reachable once advanced is open"
);
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"
);
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
"rule 6: still no probe for a scan-known worktree"
);
}
#[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_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]
fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() {
let mut draft = DraftPane {
focused_field: DraftField::DirPicker,
..DraftPane::default()
};
draft.on_input(&InputKey::Enter); // no projects → Other directory…
typed(&mut draft, "/opt/tool");
let (seq, path) = draft
.take_gitroot_probe(probe_due())
.expect("probe requested");
assert_eq!(path, PathBuf::from("/opt/tool"));
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
"one probe at a time"
);
// Further typing supersedes the in-flight probe…
typed(&mut draft, "-x");
let (seq2, _) = draft.take_gitroot_probe(probe_due()).unwrap();
assert_ne!(seq2, seq);
// …so a late answer to the old one must be ignored.
draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else")));
assert!(draft.base_repo.is_none());
draft.resolve_gitroot(seq2, Some(PathBuf::from("/opt/tool")));
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/opt/tool")));
draft.seed_names(vec!["amber-falcon".into()]);
let req = draft.build_request().unwrap();
assert_eq!(req.worktree.unwrap().base_repo, PathBuf::from("/opt/tool"));
}
#[test]
fn a_failed_gitroot_probe_unticks_the_toggle_like_the_web_form() {
let mut draft = DraftPane {
focused_field: DraftField::DirPicker,
..DraftPane::default()
};
draft.on_input(&InputKey::Enter);
typed(&mut draft, "/not/a/repo");
let (seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
draft.resolve_gitroot(seq, None);
assert!(!draft.worktree_enabled);
assert!(
!draft.build_request().unwrap().worktree.is_some(),
"spawns in the checkout itself"
);
// A stale success must not re-enable it.
draft.resolve_gitroot(seq, Some(PathBuf::from("/not/a/repo")));
assert!(!draft.worktree_enabled);
}
#[test]
fn the_new_branch_tracks_the_session_name_until_touched() {
let mut draft = DraftPane::default();
draft.seed_names(vec!["crimson-otter".into()]);
assert_eq!(draft.branch.value(), "crimson-otter");
draft.focused_field = DraftField::Name;
typed(&mut draft, "renamed");
assert_eq!(
draft.branch.value(),
"renamed",
"the branch tracks the name"
);
draft.focused_field = DraftField::BranchNew;
typed(&mut draft, "-mine");
assert_eq!(draft.branch.value(), "renamed-mine");
draft.seed_names(vec!["late-handle".into()]);
assert_eq!(
draft.branch.value(),
"renamed-mine",
"an edited branch stops tracking the name"
);
}
#[test]
fn existing_mode_checks_out_a_picked_branch_without_creating_it() {
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);
draft.focused_field = DraftField::BranchMode;
draft.on_input(&InputKey::Right);
assert_eq!(draft.branch_mode, WorktreeBranchMode::Existing);
assert!(
draft.take_branch_load().is_some(),
"switching modes requests the branch list"
);
draft.set_branches(
Path::new("/repos/maestro"),
vec![
BranchInfo {
name: "main".into(),
is_head: true,
worktree: None,
},
BranchInfo {
name: "held".into(),
is_head: false,
worktree: Some(PathBuf::from("/repos/maestro-held")),
},
BranchInfo {
name: "feature".into(),
is_head: false,
worktree: None,
},
],
);
assert_eq!(
draft.branches[draft.selected_branch].name, "feature",
"selection skips `main`: the main checkout has it, so git would \
refuse a second checkout"
);
draft.focused_field = DraftField::BranchExisting;
draft.on_input(&InputKey::Down);
assert_eq!(
draft.branches[draft.selected_branch].name, "feature",
"the only pickable branch stays selected"
);
let wt = draft.build_request().unwrap().worktree.unwrap();
assert_eq!(wt.branch, "feature");
assert!(!wt.create_branch, "existing picks are checked out as-is");
}
#[test]
fn held_branches_render_unavailable_with_their_holder_as_the_reason() {
let mut draft = DraftPane {
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_projects(
vec![repo_project(
"maestro",
"/repos/maestro",
"/repos/maestro",
1,
None,
)],
Utc::now(),
);
draft.picked_cwd = Some(PathBuf::from("/repos/maestro"));
draft.base_repo = Some(PathBuf::from("/repos/maestro"));
draft.probed_cwd = Some(PathBuf::from("/repos/maestro"));
draft.focused_field = DraftField::BranchMode;
draft.on_input(&InputKey::Right);
let _ = draft.take_branch_load();
draft.set_branches(
Path::new("/repos/maestro"),
vec![
BranchInfo {
name: "held".into(),
is_head: false,
worktree: Some(PathBuf::from("/home/u/wt")),
},
BranchInfo {
name: "free".into(),
is_head: false,
worktree: None,
},
],
);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("held by"),
"the holder path is the reason:\n{rendered}"
);
assert!(rendered.contains("free"), "{rendered}");
// A held branch is never submittable, even highlighted directly.
draft.selected_branch = 0;
assert_eq!(
draft.build_request(),
Err(DraftValidationError::MissingExistingBranch)
);
}
#[test]
fn a_keystroke_burst_schedules_one_debounced_gitroot_probe() {
let mut draft = DraftPane {
focused_field: DraftField::DirPicker,
..DraftPane::default()
};
draft.on_input(&InputKey::Enter); // no projects → Other directory…
// Burst: every keystroke reschedules; nothing may fire inside the
// debounce window (#74).
for c in "/opt/tooling".chars() {
draft.on_input(&InputKey::char(c));
assert!(
draft.take_gitroot_probe(Instant::now()).is_none(),
"no probe while typing is still in flight"
);
}
// Once the quiet period passes, exactly one probe fires.
let (seq, path) = draft
.take_gitroot_probe(probe_due())
.expect("probe due after the quiet period");
assert_eq!(path, PathBuf::from("/opt/tooling"));
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
"one probe total"
);
// The answer still lands through the unchanged seq guard.
draft.resolve_gitroot(seq, Some(PathBuf::from("/opt/tooling")));
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/opt/tooling")));
}
#[test]
fn a_stale_probe_answer_cannot_overwrite_a_newer_target_after_the_debounce() {
let mut draft = DraftPane {
focused_field: DraftField::DirPicker,
..DraftPane::default()
};
draft.on_input(&InputKey::Enter); // Other directory…
typed(&mut draft, "/repo/old");
let (old_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
// Retyping supersedes the in-flight probe and gets a newer seq…
typed(&mut draft, "/repo/new");
let (new_seq, _) = draft.take_gitroot_probe(probe_due()).unwrap();
assert_ne!(new_seq, old_seq);
// …so the late answer to the old target must be ignored.
draft.resolve_gitroot(new_seq, Some(PathBuf::from("/repo/new")));
draft.resolve_gitroot(old_seq, Some(PathBuf::from("/repo/old")));
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repo/new")));
}
#[test]
fn toggling_worktree_off_spawns_in_the_checkout_itself() {
let mut draft = DraftPane::default();
draft.set_projects(
vec![repo_project(
"maestro",
"/repos/maestro",
"/repos/maestro",
1,
None,
)],
Utc::now(),
);
draft.picked_cwd = Some(PathBuf::from("/repos/maestro"));
draft.base_repo = Some(PathBuf::from("/repos/maestro"));
draft.probed_cwd = Some(PathBuf::from("/repos/maestro"));
draft.focused_field = DraftField::WorktreeToggle;
draft.on_input(&InputKey::Enter);
assert!(!draft.worktree_enabled);
assert!(draft.build_request().unwrap().worktree.is_none());
draft.on_input(&InputKey::Enter);
assert!(draft.worktree_enabled);
assert!(
draft.take_gitroot_probe(probe_due()).is_none(),
"re-enabling reuses the known repo for an unchanged target"
);
}
#[test]
fn worktree_validation_names_the_problem() {
let mut draft = DraftPane {
cwd_raw: crate::text_input::TextInput::with_value("/opt/tool"),
picked_cwd: Some(PathBuf::from("/opt/tool")),
..DraftPane::default()
};
assert_eq!(
draft.build_request(),
Err(DraftValidationError::MissingWorktreeRepo)
);
assert_eq!(
DraftValidationError::MissingWorktreeRepo.to_string(),
"Worktree needs a directory inside a git repository."
);
draft.base_repo = Some(PathBuf::from("/opt/tool"));
assert_eq!(
draft.build_request(),
Err(DraftValidationError::MissingNewBranch),
"no names seeded yet, so the new-branch field is empty"
);
assert_eq!(
DraftValidationError::MissingNewBranch.to_string(),
"Worktree requires a branch name."
);
draft.branch_mode = WorktreeBranchMode::Existing;
assert_eq!(
draft.build_request(),
Err(DraftValidationError::MissingExistingBranch)
);
assert_eq!(
DraftValidationError::MissingExistingBranch.to_string(),
"Pick an existing branch."
);
}
#[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 collapsed = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
!collapsed.contains("+ new"),
"step two hides with \"Advanced options\" closed:\n{collapsed}"
);
draft.toggle_advanced();
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("~/repos"),
"step one shows the directory holding the repository:\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 {
home_dir: "/home/u".into(),
advanced_collapsed: false,
..DraftPane::default()
};
draft.set_projects(
vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))],
Utc::now(),
);
draft.seed_names(vec!["crimson-otter".into()]);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
for s in ["wt:", "[x] worktree"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
assert!(
rendered.contains("choose a directory first"),
"hint while no target:\n{rendered}"
);
assert!(
!rendered.contains("[new] [existing]"),
"body hidden until the repo is known:\n{rendered}"
);
draft.picked_cwd = Some(PathBuf::from("/home/u/tea/maestro"));
draft.base_repo = Some(PathBuf::from("/home/u/tea/maestro"));
draft.probed_cwd = Some(PathBuf::from("/home/u/tea/maestro"));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
for s in ["[new] [existing]", "new:", "crimson-otter"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
// The new-branch input and existing-branch picker must not share a
// label — matching the web form's "New branch name" / "Pick a
// branch" (#94).
draft.set_branch_mode(WorktreeBranchMode::Existing);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("pick:"),
"missing pick: label:\n{rendered}"
);
assert!(
!rendered.contains("br:"),
"stale ambiguous label:\n{rendered}"
);
draft.set_branch_mode(WorktreeBranchMode::New);
draft.focused_field = DraftField::WorktreeToggle;
draft.on_input(&InputKey::Enter);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(rendered.contains("[ ] worktree"), "{rendered}");
assert!(
!rendered.contains("[new] [existing]"),
"body hidden while off:\n{rendered}"
);
}
#[test]
fn advanced_section_is_collapsed_by_default_and_states_the_worktree_default() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
..DraftPane::default()
};
draft.set_projects(
vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))],
Utc::now(),
);
assert!(draft.advanced_collapsed, "closed by default");
assert_eq!(
draft
.field_order()
.iter()
.filter(|f| matches!(f, DraftField::WorktreeToggle | DraftField::BranchMode))
.count(),
0,
"collapsed fields drop out of tab order"
);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("Advanced options") && rendered.contains("creates a worktree"),
"header states the on-by-default worktree:\n{rendered}"
);
assert!(
!rendered.contains("[x] worktree"),
"checkbox stays hidden while collapsed:\n{rendered}"
);
}
#[test]
fn advanced_header_states_no_worktree_for_a_worktree_row_91() {
let mut draft = DraftPane::default();
let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None);
wt.is_worktree = true;
draft.set_projects(vec![wt], Utc::now());
draft.focused_field = DraftField::DirPicker;
draft.on_input(&InputKey::Enter);
assert!(!draft.worktree_enabled);
assert!(
draft.advanced_collapsed,
"picking a row must not force it open"
);
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(
rendered.contains("no worktree"),
"header reflects #91's off default:\n{rendered}"
);
}
#[test]
fn enter_on_the_advanced_header_expands_it_without_moving_fields_above() {
let mut draft = DraftPane {
home_dir: "/home/u".into(),
focused_field: DraftField::Advanced,
..DraftPane::default()
};
draft.set_projects(
vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))],
Utc::now(),
);
let before = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
let before_head: Vec<&str> = before.lines().take(6).collect();
draft.on_input(&InputKey::Enter);
assert!(!draft.advanced_collapsed);
let after = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
let after_head: Vec<&str> = after.lines().take(6).collect();
assert_eq!(
before_head, after_head,
"expanding must not move the fields above the disclosure"
);
assert!(after.contains("[x] worktree"), "body now visible:\n{after}");
}
#[test]
fn abbreviate_path_matches_the_web_helpers() {
assert_eq!(
abbreviate_path(Path::new("/home/u/tea/Maestro"), "/home/u"),
"~/t/Maestro"
);
assert_eq!(abbreviate_path(Path::new("/home/u"), "/home/u"), "~");
assert_eq!(
abbreviate_path(Path::new("/opt/x"), "/home/u"),
"/o/x",
"three-or-more segments collapse even outside home (web parity)"
);
assert_eq!(
abbreviate_path(Path::new("/home/u"), ""),
"/h/u",
"segments collapse even when home is unknown (web parity)"
);
}
}