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 Palhas bbbc24a4aa fix(maestro): fifth-review findings, incl. the guarded-worktree dead end
The cleanup guard plus deterministic paths had created a trap: one
untracked file an agent left behind — a log, a .env, a stray artifact —
made the guard refuse removal, the directory stayed, and every later
spawn of that branch hit PathExists. The branch was burned permanently
and the product offered no way to clear it. A leftover tree registered
for the same branch is now adopted, so the operator lands back in their
own unsaved work. A tree a *live* session holds is still rejected, since
two sessions must not share a checkout.

Also:

- Fast-forward still awaited /api/info, /api/projects and /api/names
  inline, the exact freeze the draft loaders were restructured to avoid.
  It posts to the same channel now.
- Draft updates were routed by tab index, which shifts when a tab closes:
  an in-flight load could be dropped or land on the wrong pane. Drafts
  carry an id and are found by it.
- extend_names left the name it had just displayed at the head of the
  queue, so the next reroll produced it again and the key looked dead.
- The fast-forward fallback discarded info.home_dir, so its draft showed
  unabbreviated paths and seeded "Other directory…" with / — disagreeing
  with the normal draft entry point.
- Web fast-forward mounted via openSession, which replaces the focused
  pane: pressing it while attached evicted that session from the layout.
  It reuses an empty pane or splits, as the TUI does.
- branchEdited was inferred from the seeded branch value, but in "new"
  mode that value is the auto-synced session name — so any form rebuild
  froze the branch against later rerolls.
- resolveBaseRepo ignored the worktree checkbox, rebuilding the fieldset
  expanded under an unticked box and probing gitroot for a worktree the
  operator had turned off.
- short_hash used DefaultHasher, whose algorithm is not stable across
  Rust releases, in a directory name — a toolchain bump would orphan
  every worktree under the override root. Now FNV-1a.
2026-08-24 00:28:50 +01:00

2004 lines
73 KiB
Rust

//! Draft pane — the spawn form living in an ordinary pane (issues #63/#64).
//!
//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands
//! the caller a built request and the pane is replaced with the new session,
//! matching the web client's inline draft panes (#57). 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 project picker 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`.
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::input::InputKey;
use crate::projects;
/// How many generated names one `/api/names` refill fetches. Rerolls consume
/// the local batch so they stay instant; the daemon is hit only when it runs
/// dry.
pub const NAME_BATCH_SIZE: usize = 20;
/// 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,
/// Project picker: typing filters, Up / Down highlight, Enter picks.
DirPicker,
/// Free-text path, revealed by picking `Other directory…`.
CwdRaw,
Name,
/// 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,
}
/// 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DraftRow {
pub project: Project,
pub stale: bool,
pub indent: bool,
}
/// 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 with worktrees nested.
pub rows: Vec<DraftRow>,
pub filter: crate::text_input::TextInput,
/// Highlighted entry within the visible list; the implicit
/// `Other directory…` entry sits at index `visible_rows().len()`.
pub highlighted: usize,
pub cwd_raw_visible: bool,
pub cwd_raw: crate::text_input::TextInput,
pub picked_cwd: Option<PathBuf>,
pub name: crate::text_input::TextInput,
pub name_queue: VecDeque<String>,
/// Set once the operator types or rerolls a name; guards refills against
/// clobbering it, exactly like the web form's `nameTouched`.
pub name_touched: bool,
needs_name_refill: bool,
/// Worktree on for this spawn (#65). Defaults on; a failed gitroot probe
/// unticks it, like the web form.
pub worktree_enabled: 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,
/// 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,
custom_command: crate::text_input::TextInput::default(),
rows: Vec::new(),
filter: crate::text_input::TextInput::default(),
highlighted: 0,
cwd_raw_visible: false,
cwd_raw: crate::text_input::TextInput::default(),
picked_cwd: None,
name: crate::text_input::TextInput::default(),
name_queue: VecDeque::new(),
name_touched: false,
needs_name_refill: false,
worktree_enabled: true,
base_repo: None,
probed_cwd: None,
probe_seq: 0,
needs_probe: false,
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,
focused_field: DraftField::Preset,
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);
}
/// Replace the project snapshot: sort fresh-first (#54), nest worktrees
/// under their main checkout, 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
.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(),
}))
})
.collect();
self.highlighted = self.highlighted.min(self.entry_count().saturating_sub(1));
}
/// Fill the local name batch from `GET /api/names`, seeding the field
/// itself unless the operator already typed one.
pub fn seed_names(&mut self, names: Vec<String>) {
self.name_queue.extend(names);
if !self.name_touched && self.name.value().trim().is_empty() {
if let Some(first) = self.name_queue.pop_front() {
self.set_name(first);
}
}
}
/// Append a refill batch; the first name replaces the field (this only
/// happens after an explicit reroll on an empty batch).
///
/// The shown name is *consumed*, not left at the head — queueing it too
/// made the next reroll produce the same name and the key look dead
/// (#67 review).
pub fn extend_names(&mut self, mut names: Vec<String>) {
if names.is_empty() {
return;
}
let first = names.remove(0);
self.set_name(first);
self.name_queue.extend(names);
}
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.
fn sync_branch_to_name(&mut self) {
if !self.branch_edited && self.branch_mode == WorktreeBranchMode::New {
self.branch = crate::text_input::TextInput::with_value(self.name.value().to_string());
}
}
/// Reroll the generated name. Pulls from the local batch; when it has run
/// dry, flags the caller to fetch a new one via [`Self::take_name_refill`].
pub fn reroll(&mut self) {
self.name_touched = true;
match self.name_queue.pop_front() {
Some(next) => self.set_name(next),
None => self.needs_name_refill = true,
}
}
/// Consume the reroll-needs-a-new-batch flag raised by [`Self::reroll`].
pub fn take_name_refill(&mut self) -> bool {
std::mem::take(&mut self.needs_name_refill)
}
// -- 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.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 requests a fresh one.
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.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: the caller fetches
/// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`]
/// with the same seq.
pub fn take_gitroot_probe(&mut self) -> Option<(u32, PathBuf)> {
if !self.needs_probe {
return None;
}
self.needs_probe = false;
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.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 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;
self.needs_probe = cwd.is_some();
}
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(),
cleanup_on_kill: true,
create_branch: true,
}
}
WorktreeBranchMode::Existing => {
let name = self
.picked_branch()
.ok_or(DraftValidationError::MissingExistingBranch)?
.name
.clone();
WorktreeSpec {
base_repo,
branch: name,
cleanup_on_kill: true,
create_branch: false,
}
}
};
Ok(Some(spec))
}
fn field_order(&self) -> Vec<DraftField> {
let mut order = vec![DraftField::Preset];
if self.is_custom() {
order.push(DraftField::CustomCommand);
}
order.push(DraftField::DirPicker);
if self.cwd_raw_visible {
order.push(DraftField::CwdRaw);
}
order.push(DraftField::Name);
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;
}
/// Rows surviving the type-to-filter, in display order.
pub fn visible_rows(&self) -> Vec<&DraftRow> {
let query = self.filter.value();
self.rows
.iter()
.filter(|r| projects::matches_filter(&r.project, query))
.collect()
}
/// Visible entries including the trailing implicit `Other directory…`.
pub fn entry_count(&self) -> usize {
self.visible_rows().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.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.
pub fn select_entry(&mut self) {
let visible = self.visible_rows();
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;
// 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);
return;
}
let seed = self.cwd().unwrap_or_else(|| {
if self.home_dir.is_empty() {
PathBuf::from("/")
} else {
PathBuf::from(&self.home_dir)
}
});
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();
}
/// 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
}
InputKey::Enter => match self.focused_field {
DraftField::Submit => DraftInputResult::Submit,
DraftField::DirPicker => {
self.select_entry();
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::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::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
}
InputKey::Char {
c: 'r', ctrl: true, ..
} if self.focused_field == DraftField::Name => {
self.reroll();
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;
}
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 block = Block::default().title(" draft ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let show_custom = self.is_custom();
let show_raw = self.cwd_raw_visible;
let show_wt_body = self.worktree_body_visible();
// 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 /* worktree */ + 1 /* spawn */
+ u16::from(show_custom)
+ u16::from(show_raw)
+ 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;
let mut constraints = vec![Constraint::Length(1)];
if show_custom {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1));
constraints.push(Constraint::Length(list_capacity as u16));
if show_raw {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // name
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;
// -- 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));
}
}
// -- 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));
}
let visible = self.visible_rows();
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(|row| row.project.name.chars().count() + usize::from(row.indent) * 2)
.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 row = visible[i];
let mut base = Style::default();
if row.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,
),
])
} 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));
}
}
// -- name + reroll ---------------------------------------------------------
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));
}
// Dim reroll hint, tucked after the typed name without covering it.
let hint_offset = (GUTTER as u16 + self.name.value().chars().count() as u16 + 1)
.min(name_row.width.saturating_sub(10));
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"^R reroll",
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,
},
);
// -- worktree (#65) -----------------------------------------------------
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, "br:");
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();
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}"),
Style::default().fg(Color::Red),
))),
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`.
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));
}
}
#[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");
}
#[test]
fn left_right_cycles_presets_wrapping_into_custom() {
let mut draft = 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()[..2],
[DraftField::Preset, DraftField::CustomCommand],
"custom command sits between presets and the dir picker"
);
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::DirPicker);
}
#[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("~/n/repo"),
"collapsed path stem:\n{rendered}"
);
assert!(
rendered.contains("claude"),
"last command shown:\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(),
);
draft.next_field(); // Preset → DirPicker
assert_eq!(draft.focused_field, DraftField::DirPicker);
typed(&mut draft, "maes");
assert_eq!(
draft.visible_rows().len(),
1,
"type-to-filter narrows the list"
);
draft.on_input(&InputKey::Enter);
assert_eq!(
draft.picked_cwd.as_deref(),
Some(Path::new("/repos/maestro"))
);
assert_eq!(
draft.command(),
"claude",
"pick pre-selects the last command"
);
}
#[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().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");
}
#[test]
fn generated_names_seed_the_field_and_metadata() {
let mut draft = DraftPane::default();
assert!(
draft.name.value().is_empty(),
"no name until /api/names lands"
);
draft.seed_names(vec!["crimson-otter".into(), "amber-falcon".into()]);
assert_eq!(draft.name.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_eq!(
req.metadata.get("name").and_then(|v| v.as_str()),
Some("crimson-otter"),
"name rides along as metadata.name"
);
draft.reroll();
assert_eq!(
draft.name.value(),
"amber-falcon",
"reroll consumes the batch"
);
draft.reroll();
assert!(
draft.take_name_refill(),
"dry batch flags the caller to refetch"
);
assert!(!draft.take_name_refill(), "flag consumes");
}
#[test]
fn refills_do_not_clobber_a_name_the_operator_typed() {
let mut draft = DraftPane::default();
draft.seed_names(vec!["seeded".into()]);
typed_name(&mut draft, "-mine");
assert_eq!(draft.name.value(), "seeded-mine");
draft.seed_names(vec!["late-batch".into()]);
assert_eq!(
draft.name.value(),
"seeded-mine",
"late initial batches never overwrite an edited name"
);
}
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_reroll_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:",
"^R reroll",
"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, block border takes column 0).
#[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(1 + GUTTER as u16 + 2, 2)
);
}
#[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().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);
assert!(wt.cleanup_on_kill);
}
#[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().expect("probe requested");
assert_eq!(path, PathBuf::from("/opt/tool"));
assert!(draft.take_gitroot_probe().is_none(), "one probe at a time");
// Further typing supersedes the in-flight probe…
typed(&mut draft, "-x");
let (seq2, _) = draft.take_gitroot_probe().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().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(), "amber-falcon".into()]);
assert_eq!(draft.branch.value(), "crimson-otter");
draft.reroll();
assert_eq!(draft.branch.value(), "amber-falcon", "rerolls carry over");
draft.focused_field = DraftField::BranchNew;
typed(&mut draft, "-mine");
assert_eq!(draft.branch.value(), "amber-falcon-mine");
draft.seed_names(vec!["late-batch".into()]);
assert_eq!(
draft.branch.value(),
"amber-falcon-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::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 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().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_the_worktree_section() {
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 ["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]", "br:", "crimson-otter"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
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 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)"
);
}
}