Compare commits

..
10 changed files with 710 additions and 3 deletions
+14
View File
@@ -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 [
+172
View File
@@ -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<serde_json::Value> {
Json(json!({
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
}))
}
async fn projects() -> Json<serde_json::Value> {
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<serde_json::Value> {
Json(json!({"names": ["crimson-otter"]}))
}
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
let seen = posted.clone();
async fn spawn_session(
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
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<serde_json::Value> {
Json(json!({
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
}))
}
async fn projects() -> Json<serde_json::Value> {
Json(json!([]))
}
async fn names() -> Json<serde_json::Value> {
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.
+7
View File
@@ -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]
+283
View File
@@ -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<Project>,
pub command: Option<String>,
pub name: Option<String>,
}
/// 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<String>,
now: DateTime<Utc>,
) -> 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<CreateSessionRequest> {
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());
}
}
+1
View File
@@ -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;
+2
View File
@@ -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;
+93
View File
@@ -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>): 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("");
});
});
+62
View File
@@ -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,
};
}
+73 -1
View File
@@ -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<void> {
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 }
+3 -2
View File
@@ -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" },