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 596e7e2f49 feat(maestro): debounce the gitroot probe in the TUI draft
Typing in the raw path field fired one /api/gitroot probe per
keystroke (#74). The draft now schedules the probe 250ms out — matching
GITROOT_PROBE_DEBOUNCE_MS in web/src/spawnForm.ts — and every keystroke
resets it; the 30Hz heartbeat polls for due probes so the burst's one
request fires after typing stops. The seq guard in resolve_gitroot is
untouched, so a late answer still cannot overwrite a newer input.
2026-08-24 15:04:47 +01:00

2129 lines
78 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 std::time::{Duration, Instant};
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;
/// 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,
/// 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,
/// 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,
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,
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,
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.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 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::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;
// A directory that *is* a linked worktree defaults to spawning in
// place (#91): creating a second worktree of its branch fails.
// Turning it back on by hand still tries (git's own message),
// which is deliberate (#91 non-goal).
self.worktree_enabled = !project.is_worktree;
// The scan already knows this directory's repo (#61): no probe
// round trip needed, exactly like the web form.
let repo = project
.base_repo
.clone()
.unwrap_or_else(|| project.path.clone());
if let Some(cmd) = project.last_command.as_deref() {
if !cmd.is_empty() {
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
self.selected_preset = idx;
}
}
}
self.note_picked_repo(repo);
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`.
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");
}
#[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(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");
}
#[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(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);
}
#[test]
fn picking_a_worktree_project_defaults_the_toggle_off() {
let mut draft = DraftPane::default();
let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None);
wt.is_worktree = true;
draft.set_projects(
vec![
repo_project("main", "/repos/main", "/repos/main", 2, None),
wt,
],
Utc::now(),
);
draft.focused_field = DraftField::DirPicker;
typed(&mut draft, "wt-91");
draft.on_input(&InputKey::Enter);
assert!(
!draft.worktree_enabled,
"the directory is already a worktree; spawning in place"
);
assert!(
draft.build_request().unwrap().worktree.is_none(),
"no worktree record — the session just runs there (#91)"
);
// Deliberately re-enabling still works, and the scan-known base repo
// survives so the toggle-on path needs no new probe.
draft.focused_field = DraftField::WorktreeToggle;
draft.on_input(&InputKey::Enter);
assert!(draft.worktree_enabled);
assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main")));
assert!(draft.take_gitroot_probe(probe_due()).is_none());
}
#[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(), "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 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_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)"
);
}
}