feat(maestro): bind S to fast-forward spawn in the TUI (#67)
This commit is contained in:
@@ -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 [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user