From d922cdaa32feaa02050e6cd1b5b00dbf7b67165e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:59:26 +0100 Subject: [PATCH 1/4] feat(maestro): fast-forward spawn default resolution (#67) --- crates/maestro-tui/src/fast_forward.rs | 283 +++++++++++++++++++++++++ crates/maestro-tui/src/lib.rs | 1 + 2 files changed, 284 insertions(+) create mode 100644 crates/maestro-tui/src/fast_forward.rs diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs new file mode 100644 index 0000000..4b8abb3 --- /dev/null +++ b/crates/maestro-tui/src/fast_forward.rs @@ -0,0 +1,283 @@ +//! Fast-forward spawn (#67): one keystroke, every default resolved, no form. +//! +//! The defaults mirror what a draft pane would settle on after the operator +//! picked the most recent project and kept their hands off everything else: +//! its last command (or the first non-custom preset), a freshly generated +//! name, and a worktree with a branch named after that name. Any default +//! that cannot be resolved — no projects yet, no command, no generated +//! name — downgrades the keystroke to a draft pane pre-filled with whatever +//! did resolve ([`FastForwardDefaults::apply_to_draft`]). + +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset, WorktreeSpec}; + +use crate::draft::DraftPane; + +/// What one fast-forward keystroke resolved. Every field optional: `None` +/// anywhere means "open a draft instead of spawning". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FastForwardDefaults { + pub project: Option, + pub command: Option, + pub name: Option, +} + +/// Resolve the fast-forward defaults from the daemon's answers. `name` is +/// `None` when `/api/names` yielded nothing; `presets` may be empty (the +/// caller falls back to the built-ins it already has). +pub fn resolve( + projects: &[Project], + presets: &[SpawnPreset], + name: Option, + now: DateTime, +) -> FastForwardDefaults { + let project = crate::projects::sort_projects(projects.to_vec(), now) + .into_iter() + .next(); + let command = project + .as_ref() + .and_then(|p| p.last_command.clone()) + .filter(|c| !c.trim().is_empty()) + .or_else(|| { + presets + .iter() + .find(|p| !p.command.is_empty()) + .map(|p| p.command.clone()) + }); + FastForwardDefaults { + project, + command, + name, + } +} + +impl FastForwardDefaults { + /// The spawn request when *every* default resolved: worktree on, new + /// branch named after the generated session name, base repo from the + /// scan (`None` means the project is itself a main checkout). + pub fn build_request(&self) -> Option { + let project = self.project.as_ref()?; + let command = self.command.as_deref()?.trim(); + let name = self.name.as_deref()?; + if command.is_empty() || name.is_empty() { + return None; + } + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + Some(CreateSessionRequest { + cwd: project.path.clone(), + command: command.to_string(), + env: Default::default(), + metadata, + worktree: Some(WorktreeSpec { + base_repo: project + .base_repo + .clone() + .unwrap_or_else(|| project.path.clone()), + branch: name.to_string(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + } + + /// Seed a fresh draft pane with whatever did resolve — the fallback when + /// [`Self::build_request`] is `None`. The operator fills in the rest. + pub fn apply_to_draft(&self, draft: &mut DraftPane) { + if let Some(project) = &self.project { + draft.picked_cwd = Some(PathBuf::from(&project.path)); + // The scan already knows this directory's repo (#61): no probe + // round trip needed, exactly like picking the row by hand. + draft.base_repo = Some( + project + .base_repo + .clone() + .unwrap_or_else(|| project.path.clone()), + ); + draft.probed_cwd = Some(PathBuf::from(&project.path)); + } + if let Some(command) = &self.command { + if let Some(idx) = draft.presets.iter().position(|p| &p.command == command) { + draft.selected_preset = idx; + } else if !command.trim().is_empty() { + // A last_command no preset advertises lands in custom free text. + draft.selected_preset = draft.presets.len().saturating_sub(1); + draft.custom_command = crate::text_input::TextInput::with_value(command); + } + } + if let Some(name) = &self.name { + draft.set_name(name.clone()); + // Seeded, not generated: a later refill must not clobber it. + draft.name_touched = true; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeDelta; + + fn project(path: &str, days_ago: i64, cmd: Option<&str>) -> Project { + Project { + path: PathBuf::from(path), + name: path.rsplit('/').next().unwrap_or(path).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(path: &str, base: &str, days_ago: i64, cmd: Option<&str>) -> Project { + Project { + base_repo: Some(PathBuf::from(base)), + ..project(path, days_ago, cmd) + } + } + + fn preset(id: &str, command: &str) -> SpawnPreset { + SpawnPreset { + id: id.into(), + label: id.into(), + command: command.into(), + } + } + + #[test] + fn resolves_every_default_from_the_most_recent_project() { + let projects = vec![ + project("/repos/old", 30, Some("zsh")), + repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), + ]; + let presets = vec![preset("aider", "aider"), preset("custom", "")]; + let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now()); + assert_eq!( + r.project.as_ref().unwrap().path, + PathBuf::from("/repos/fresh") + ); + assert_eq!(r.command.as_deref(), Some("claude")); + assert_eq!(r.name.as_deref(), Some("amber-falcon")); + + let req = r.build_request().expect("all defaults resolved"); + assert_eq!(req.cwd, PathBuf::from("/repos/fresh")); + assert_eq!(req.command, "claude"); + assert_eq!( + req.metadata.get("name").and_then(|v| v.as_str()), + Some("amber-falcon") + ); + let wt = req.worktree.expect("worktree on"); + assert_eq!(wt.base_repo, PathBuf::from("/repos/fresh")); + assert_eq!(wt.branch, "amber-falcon"); + assert!(wt.create_branch, "fresh branch named after the session"); + } + + #[test] + fn worktree_of_the_most_recent_project_targets_its_base_repo() { + let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)]; + let r = resolve( + &projects, + &[preset("zsh", "zsh")], + Some("s".into()), + Utc::now(), + ); + let wt = r.build_request().unwrap().worktree.unwrap(); + assert_eq!(wt.base_repo, PathBuf::from("/repos/main")); + } + + #[test] + fn missing_last_command_falls_back_to_the_first_non_custom_preset() { + let projects = vec![project("/repos/p", 0, None)]; + let presets = vec![preset("custom", ""), preset("claude", "claude")]; + let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + assert_eq!(r.command.as_deref(), Some("claude")); + } + + #[test] + fn an_empty_last_command_is_no_command_at_all() { + let projects = vec![project("/repos/p", 0, Some(""))]; + let presets = vec![preset("custom", "")]; + let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + assert_eq!(r.command, None); + assert!(r.build_request().is_none()); + } + + #[test] + fn nothing_resolves_when_no_projects_are_known_yet() { + let r = resolve( + &[], + &[preset("claude", "claude")], + Some("n".into()), + Utc::now(), + ); + assert_eq!(r.project, None); + // No project → no directory → no request, even with command + name. + assert!(r.build_request().is_none()); + } + + #[test] + fn a_missing_generated_name_downgrades_to_a_draft() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + assert!(r.build_request().is_none(), "no name → no immediate spawn"); + } + + #[test] + fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() { + let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))]; + let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now()); + + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + assert_eq!(draft.cwd(), Some(PathBuf::from("/repos/p"))); + assert_eq!(draft.base_repo, Some(PathBuf::from("/repos/p"))); + assert_eq!(draft.command(), "aider"); + assert!( + draft.name.value().is_empty(), + "nothing to seed the name with" + ); + assert!(draft.worktree_enabled); + } + + #[test] + fn a_resolved_name_survives_late_refills_and_tracks_the_branch() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let named = resolve( + &projects, + &[preset("claude", "claude")], + Some("amber-falcon".into()), + Utc::now(), + ); + let mut draft = DraftPane::default(); + named.apply_to_draft(&mut draft); + // A later batch must not overwrite the name the spawn was seeded with. + draft.seed_names(vec!["late-name".into()]); + assert_eq!(draft.name.value(), "amber-falcon"); + assert_eq!(draft.branch.value(), "amber-falcon"); + } + + #[test] + fn an_unresolved_name_still_gets_generated_by_a_later_batch() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + draft.seed_names(vec!["late-name".into()]); + assert_eq!(draft.name.value(), "late-name"); + } + + #[test] + fn a_last_command_no_preset_advertises_lands_in_custom_text() { + let projects = vec![project("/repos/p", 0, Some("nvim -d"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + assert_eq!(draft.command(), "nvim -d"); + assert!(draft.is_custom()); + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 1e8c283..09e58af 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -9,6 +9,7 @@ pub mod cli; pub mod config; pub mod draft; pub mod emulators; +pub mod fast_forward; pub mod input; pub mod keymap; pub mod overlays; From 5aedaf838ab0c60a400915a5174708066d539244 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 23:03:46 +0100 Subject: [PATCH 2/4] feat(maestro): bind S to fast-forward spawn in the TUI (#67) --- crates/maestro-tui/src/action.rs | 14 +++ crates/maestro-tui/src/app.rs | 172 +++++++++++++++++++++++++++++++ crates/maestro-tui/src/config.rs | 7 ++ 3 files changed, 193 insertions(+) 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] From 1647e64aedc0699add32d9af453d28aef2ecd128 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 23:37:57 +0100 Subject: [PATCH 3/4] feat(maestro): fast-forward spawn resolution for the web client (#67) --- web/src/fastForward.test.ts | 93 +++++++++++++++++++++++++++++++++++++ web/src/fastForward.ts | 62 +++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 web/src/fastForward.test.ts create mode 100644 web/src/fastForward.ts diff --git a/web/src/fastForward.test.ts b/web/src/fastForward.test.ts new file mode 100644 index 0000000..4acd661 --- /dev/null +++ b/web/src/fastForward.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + buildFastForwardRequest, + fastForwardSeed, + resolveFastForward, +} from "./fastForward.js"; +import type { Project, SpawnPreset } from "./types.js"; + +const presets: SpawnPreset[] = [ + { id: "custom", label: "Custom…", command: "" }, + { id: "claude", label: "Claude", command: "claude" }, +]; + +function project(overrides: Partial): Project { + return { + path: "/repos/p", + name: "p", + last_activity: new Date().toISOString(), + ...overrides, + }; +} + +describe("resolveFastForward (#67)", () => { + it("resolves every default from the most recent project", () => { + const projects = [ + project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }), + project({ + path: "/repos/fresh", + name: "fresh", + base_repo: "/repos/fresh", + last_command: "aider", + }), + ]; + const res = resolveFastForward(projects, presets, "amber-falcon"); + expect(res.project?.path).toBe("/repos/fresh"); + expect(res.command).toBe("aider"); + expect(res.name).toBe("amber-falcon"); + + const req = buildFastForwardRequest(res)!; + expect(req.cwd).toBe("/repos/fresh"); + expect(req.command).toBe("aider"); + expect(req.metadata).toEqual({ name: "amber-falcon" }); + expect(req.worktree).toEqual({ + base_repo: "/repos/fresh", + branch: "amber-falcon", + cleanup_on_kill: true, + create_branch: true, + }); + }); + + it("falls back to the first non-custom preset when there is no last command", () => { + const res = resolveFastForward([project({ last_command: undefined })], presets, "n"); + expect(res.command).toBe("claude"); + }); + + it("treats an empty last command as no command", () => { + const res = resolveFastForward([project({ last_command: "" })], [presets[0]], "n"); + expect(res.command).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("resolves nothing when no projects are known yet", () => { + const res = resolveFastForward([], presets, "n"); + expect(res.project).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("downgrades to a draft when the generated name is missing", () => { + const res = resolveFastForward([project({ last_command: "claude" })], presets); + expect(res.name).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("seeds the fallback draft with whatever did resolve", () => { + const res = resolveFastForward( + [project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })], + presets, + ); + expect(fastForwardSeed(res)).toEqual({ + cwd: "/repos/p", + command: "aider", + name: "", + worktreeEnabled: true, + worktreeBranch: undefined, + }); + }); + + it("seeds worktree off when not even a project resolved", () => { + const seed = fastForwardSeed(resolveFastForward([], presets)); + expect(seed.worktreeEnabled).toBe(false); + expect(seed.cwd).toBe(""); + }); +}); diff --git a/web/src/fastForward.ts b/web/src/fastForward.ts new file mode 100644 index 0000000..fe05dd5 --- /dev/null +++ b/web/src/fastForward.ts @@ -0,0 +1,62 @@ +// Fast-forward spawn (#67): one keystroke, every default resolved, no form. +// Pure resolution only — the caller (main.ts) owns the fetches and the POST, +// and falls back to a seeded draft pane when anything is missing. + +import type { CreateSessionRequest, Project, SpawnPreset } from "./types.js"; +import { sortProjects } from "./projectList.js"; + +/** What one fast-forward keystroke resolved. Anything absent means "open a + * draft pre-filled with the rest instead of spawning". */ +export interface FastForwardResolution { + project?: Project; + command?: string; + name?: string; +} + +/** The defaults: most recent project (fresh-first, same ordering the picker + * shows), its last command falling back to the first non-custom preset, and + * a freshly generated name when the daemon supplied one. */ +export function resolveFastForward( + projects: Project[], + presets: SpawnPreset[], + name?: string, +): FastForwardResolution { + const project = sortProjects(projects, new Date())[0]; + const lastCommand = project?.last_command?.trim(); + const command = + lastCommand || presets.find((p) => p.command !== "")?.command || undefined; + return { project, command, name: name || undefined }; +} + +/** The immediate spawn request — worktree on, new branch named after the + * generated session name — or `null` when any default is missing. */ +export function buildFastForwardRequest( + res: FastForwardResolution, +): CreateSessionRequest | null { + const { project, command, name } = res; + if (!project || !command?.trim() || !name) return null; + return { + cwd: project.path, + command, + metadata: { name }, + worktree: { + base_repo: project.base_repo ?? project.path, + branch: name, + cleanup_on_kill: true, + create_branch: true, + }, + }; +} + +/** Seed for the fallback draft pane: whatever did resolve goes into the + * form; the rest stays with its own defaults. */ +export function fastForwardSeed(res: FastForwardResolution) { + return { + cwd: res.project?.path ?? "", + command: res.command ?? "", + name: res.name ?? "", + worktreeEnabled: Boolean(res.project), + worktreeBranch: + res.project && res.name ? res.name : undefined, + }; +} From 586c2430568c2f920df3b68ab3ec0a7c7825e728 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 23:39:30 +0100 Subject: [PATCH 4/4] feat(maestro): bind N to fast-forward spawn in the web client (#67) --- web/src/catalog.ts | 2 ++ web/src/main.ts | 74 +++++++++++++++++++++++++++++++++++++++++++- web/src/spawnForm.ts | 5 +-- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/web/src/catalog.ts b/web/src/catalog.ts index b96f4f5..db6186d 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -196,6 +196,8 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): spawnBtn.type = "button"; spawnBtn.className = "btn btn-primary spawn-btn"; spawnBtn.textContent = "+ Spawn"; + // The keyboard pair sits right here in the tooltip (#67). + spawnBtn.title = "Draft pane: n — fast-forward spawn: N"; spawnBtn.addEventListener("click", () => requestOpenSpawn()); actions.appendChild(spawnBtn); return header; diff --git a/web/src/main.ts b/web/src/main.ts index 27de74c..d393b84 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,13 +3,25 @@ import { ConnectionPool } from "./connections.js"; import { installHudKeybind } from "./debug.js"; import { CatalogStore } from "./events.js"; import { shouldRetryClose } from "./reconnect.js"; +import { + createSession, + fetchNames, + getDaemonInfo, + projects as fetchProjects, +} from "./api.js"; +import { + buildFastForwardRequest, + fastForwardSeed, + resolveFastForward, +} from "./fastForward.js"; +import { FALLBACK_PRESETS } from "./spawnForm.js"; import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; import { loadLayout, saveLayout } from "./persistence.js"; import { OPEN_SPAWN_EVENT } from "./catalog.js"; import { initTheme } from "./theme.js"; -import { sessionName, type Session } from "./types.js"; +import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { installViewportSizing } from "./viewport.js"; import "./styles.css"; @@ -212,6 +224,7 @@ function renderWelcome(): HTMLElement { spawn.type = "button"; spawn.className = "btn btn-primary"; spawn.textContent = "+ Spawn session"; + spawn.title = "Draft pane: n — fast-forward spawn: N"; spawn.addEventListener("click", () => requestOpenSpawn()); (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn); return el; @@ -265,6 +278,65 @@ document.addEventListener("catalog-spawn-similar", (ev) => { mv.openDraft({ cwd: s.cwd, command: s.command }); }); +// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns +// immediately with every default resolved — most recent project, its last +// command (or the first non-custom preset), a fresh name, worktree on with +// a new branch named after that name. Anything unresolvable (no projects +// known yet, no command, no generated name) opens a draft pre-filled with +// what did resolve instead. +async function requestFastForward(): Promise { + showMultiView(); + const [discovered, name] = await Promise.all([ + fetchProjects().catch(() => [] as Project[]), + fetchNames(1) + .then((batch) => batch.names[0]) + .catch(() => undefined), + ]); + let presets: SpawnPreset[] = FALLBACK_PRESETS; + try { + const info = await getDaemonInfo(); + if (info.presets?.length) presets = info.presets; + } catch { + /* keep fallbacks */ + } + const resolved = resolveFastForward(discovered, presets, name); + const req = buildFastForwardRequest(resolved); + if (req) { + try { + await createSession(req); + return; + } catch { + // A failed POST degrades to the same pre-filled draft as an + // unresolvable default — never a silent dead keystroke. + } + } + mv.openDraft(fastForwardSeed(resolved)); +} + +// Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn. +// Ignored while typing anywhere (form fields, the terminal's hidden +// textarea, search) so an attached session's keys are never hijacked. +document.addEventListener("keydown", (e) => { + if (e.ctrlKey || e.altKey || e.metaKey) return; + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable) + ) { + return; + } + if (e.key === "n") { + e.preventDefault(); + requestOpenSpawn(); + } else if (e.key === "N") { + e.preventDefault(); + void requestFastForward(); + } +}); + type Route = | { kind: "catalog" } | { kind: "attach"; sessionId: string } diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 238d31a..b64fd0d 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -83,8 +83,9 @@ export function buildSpawnRequest( } /** Built-ins used when the daemon's `/api/info` presets can't be fetched - * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */ -const FALLBACK_PRESETS: SpawnPreset[] = [ + * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. + * Exported for the fast-forward spawn's preset fallback (#67). */ +export const FALLBACK_PRESETS: SpawnPreset[] = [ { id: "claude", label: "Claude", command: "claude" }, { id: "opencode", label: "opencode", command: "opencode" }, { id: "zsh", label: "Shell", command: "zsh" },