diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 8a0059c..98d5354 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -39,6 +39,9 @@ pub enum Action { AttachHighlighted, ToggleAttachHighlighted, NewDraftPane, + /// Fast-forward spawn (#67): spawn immediately with every default + /// resolved; unresolvable defaults open a pre-filled draft instead. + FastForwardSpawn, KillHighlighted, // Tabs @@ -79,6 +82,7 @@ impl Action { "AttachHighlighted" => return Ok(Action::AttachHighlighted), "ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted), "NewDraftPane" => return Ok(Action::NewDraftPane), + "FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "KillHighlighted" => return Ok(Action::KillHighlighted), "NewTab" => return Ok(Action::NewTab), "CloseTab" => return Ok(Action::CloseTab), @@ -197,6 +201,16 @@ mod tests { assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into())); } + /// The fast-forward pair sits next to the draft-pane key (#67). + #[test] + fn fast_forward_spawn_round_trips() { + assert_eq!( + Action::parse("FastForwardSpawn").unwrap(), + Action::FastForwardSpawn + ); + assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn"); + } + #[test] fn display_round_trips() { for a in [ diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 076954a..e4b3ac8 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -87,6 +87,9 @@ pub enum Effect { }, /// Fetch presets / projects / names for a freshly opened draft (#64). LoadDraft(usize), + /// Fast-forward spawn (#67): resolve every default from the daemon and + /// spawn with no form; unresolvable defaults open a pre-filled draft. + FastForward, /// A reroll drained the draft's local name batch (#64). RefillNames(usize), /// The draft's target changed: probe `/api/gitroot` so the worktree @@ -202,6 +205,7 @@ impl App { Effect::Spawn(req) => self.submit_spawn(req).await, Effect::Kill(id) => self.submit_kill(id).await, Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await, + Effect::FastForward => self.fast_forward_spawn().await, Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await, Effect::ProbeGitRoot { tab, seq, path } => { self.probe_git_root(tab, seq, path).await @@ -287,6 +291,7 @@ impl App { self.state.open_draft_pane(); effects.push(Effect::LoadDraft(self.state.active_tab_idx)); } + Action::FastForwardSpawn => effects.push(Effect::FastForward), Action::AttachHighlighted | Action::ToggleAttachHighlighted => { if let Some(sid) = self.pick_sidebar_target() { effects.push(Effect::Attach(sid)); @@ -430,6 +435,46 @@ impl App { } } + /// Fast-forward spawn (#67): one keystroke, every default resolved, no + /// form. The most recent project, its last command (or the first + /// non-custom preset), a freshly generated name, worktree on with a new + /// branch named after that name. Anything unresolvable — no projects + /// known yet, no command, no name — downgrades to a draft pane seeded + /// with whatever did resolve. + async fn fast_forward_spawn(&mut self) { + let (info, projects) = tokio::join!(self.daemon.daemon_info(), self.daemon.list_projects()); + let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await; + let presets = match info { + Ok(info) if !info.presets.is_empty() => info.presets, + _ => Vec::new(), + }; + let discovered = projects.unwrap_or_default(); + let resolved = + crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now()); + if let Some(req) = resolved.build_request() { + // Never eat a pane already showing something: the spawn takes a + // fresh tab, same as opening a draft over a session would. + if self.state.active_tab().pane.is_some() { + self.state.new_tab(); + } + self.submit_spawn(req).await; + return; + } + // Unresolved defaults: a draft pre-filled with what did resolve. + self.state.open_draft_pane(); + let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else { + return; + }; + if !presets.is_empty() { + draft.set_presets(presets); + } + draft.set_projects(discovered, Utc::now()); + resolved.apply_to_draft(draft); + // A missing generated name still arrives here; seeding is a no-op + // once apply_to_draft has set a touched name. + draft.seed_names(names); + } + /// Fetch a fresh name batch after a reroll drained the local queue. async fn refill_names(&mut self, tab_idx: usize) { let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await; @@ -846,6 +891,133 @@ mod tests { ); } + /// The fast-forward keystroke itself is side-effect-free until its + /// daemon fetches come back. + #[test] + fn fast_forward_spawn_dispatches_the_fast_forward_effect() { + let mut app = make_app(); + assert_eq!( + app.dispatch(Action::FastForwardSpawn), + vec![Effect::FastForward] + ); + } + + /// Every default resolves: the POST goes straight out — most recent + /// project, its last command, generated name, worktree branch named + /// after it (#67). + #[tokio::test] + async fn fast_forward_spawns_immediately_when_every_default_resolves() { + use axum::routing::{get, post}; + use axum::Json; + use serde_json::json; + use std::sync::{Arc, Mutex}; + + async fn info() -> Json { + Json(json!({ + "hostname": "box", "version": "0.1.0", "home_dir": "/home/u", + "presets": [{"id": "claude", "label": "Claude", "command": "claude"}] + })) + } + async fn projects() -> Json { + Json(json!([ + {"path": "/repos/old", "name": "old", + "last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "zsh"}, + {"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh", + "last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"} + ])) + } + async fn names() -> Json { + Json(json!({"names": ["crimson-otter"]})) + } + let posted: Arc>> = Arc::default(); + let seen = posted.clone(); + async fn spawn_session( + axum::extract::State(seen): axum::extract::State>>>, + Json(body): Json, + ) -> Json { + seen.lock().unwrap().push(body); + Json(json!({ + "id": uuid::Uuid::from_u128(77).to_string(), + "cwd": "/repos/fresh", "command": "aider", + "status": "Running", "created_at": chrono::Utc::now().to_rfc3339() + })) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::PROJECTS, get(projects)) + .route(maestro_protocol::routes::NAMES, get(names)) + .route( + maestro_protocol::routes::SESSIONS, + post(spawn_session).with_state(seen), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + let effects = app.dispatch(Action::FastForwardSpawn); + app.perform(effects).await; + + let posts = posted.lock().unwrap(); + assert_eq!(posts.len(), 1, "one immediate POST, no form shown"); + let req = &posts[0]; + assert_eq!(req["cwd"], json!("/repos/fresh")); + assert_eq!(req["command"], json!("aider")); + assert_eq!(req["metadata"]["name"], json!("crimson-otter")); + assert_eq!(req["worktree"]["base_repo"], json!("/repos/fresh")); + assert_eq!(req["worktree"]["branch"], json!("crimson-otter")); + assert_eq!(req["worktree"]["create_branch"], json!(true)); + } + + /// No projects known yet: nothing can resolve, so the keystroke opens a + /// draft pane (focused, Insert mode) instead of guessing (#67). + #[tokio::test] + async fn unresolvable_defaults_open_a_prefilled_draft_instead() { + use axum::routing::get; + use axum::Json; + use serde_json::json; + + async fn info() -> Json { + Json(json!({ + "hostname": "box", "version": "0.1.0", "home_dir": "/home/u", + "presets": [{"id": "claude", "label": "Claude", "command": "claude"}] + })) + } + async fn projects() -> Json { + Json(json!([])) + } + async fn names() -> Json { + Json(json!({"names": ["amber-falcon"]})) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::PROJECTS, get(projects)) + .route(maestro_protocol::routes::NAMES, get(names)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + let effects = app.dispatch(Action::FastForwardSpawn); + app.perform(effects).await; + + assert!( + matches!(app.state.active_tab().pane, Some(PaneRef::Draft(_))), + "the fallback is a draft pane" + ); + assert_eq!(app.state.focus, Focus::Pane); + assert_eq!(app.state.mode, Mode::Insert); + let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { + panic!("expected a draft pane"); + }; + // Whatever did resolve (nothing project-side) is seeded; the + // generated name still lands so the form is ready to go. + assert_eq!(draft.name.value(), "amber-falcon"); + assert_eq!(draft.command(), "claude"); + } + /// A valid form flows through: pick a project (set directly — the picker /// itself is covered in `draft.rs`), select the custom tile, type a /// command, then submit from the Spawn row. diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 9a727c2..26b0251 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -46,6 +46,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ // Catalog actions ("enter", "AttachHighlighted"), ("s", "NewDraftPane"), + // Fast-forward spawn (#67): the draft-pane key's neighbour does the same + // thing with every default resolved and no form. + ("S", "FastForwardSpawn"), ("x", "KillHighlighted"), // Tabs ("ctrl-t", "NewTab"), @@ -159,6 +162,10 @@ mod tests { cfg.keymap.resolve(Mode::Normal, "s"), Some(Action::NewDraftPane), ); + assert_eq!( + cfg.keymap.resolve(Mode::Normal, "S"), + Some(Action::FastForwardSpawn), + ); } #[test]