Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb365f278 | ||
|
|
586c243056 | ||
|
|
1647e64aed | ||
|
|
5aedaf838a | ||
|
|
d922cdaa32 | ||
|
|
b4e57d1a4f |
@@ -39,6 +39,9 @@ pub enum Action {
|
|||||||
AttachHighlighted,
|
AttachHighlighted,
|
||||||
ToggleAttachHighlighted,
|
ToggleAttachHighlighted,
|
||||||
NewDraftPane,
|
NewDraftPane,
|
||||||
|
/// Fast-forward spawn (#67): spawn immediately with every default
|
||||||
|
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
||||||
|
FastForwardSpawn,
|
||||||
KillHighlighted,
|
KillHighlighted,
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
@@ -79,6 +82,7 @@ impl Action {
|
|||||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||||
|
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
"NewTab" => return Ok(Action::NewTab),
|
"NewTab" => return Ok(Action::NewTab),
|
||||||
"CloseTab" => return Ok(Action::CloseTab),
|
"CloseTab" => return Ok(Action::CloseTab),
|
||||||
@@ -197,6 +201,16 @@ mod tests {
|
|||||||
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
|
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]
|
#[test]
|
||||||
fn display_round_trips() {
|
fn display_round_trips() {
|
||||||
for a in [
|
for a in [
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ pub enum Effect {
|
|||||||
},
|
},
|
||||||
/// Fetch presets / projects / names for a freshly opened draft (#64).
|
/// Fetch presets / projects / names for a freshly opened draft (#64).
|
||||||
LoadDraft(usize),
|
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).
|
/// A reroll drained the draft's local name batch (#64).
|
||||||
RefillNames(usize),
|
RefillNames(usize),
|
||||||
/// The draft's target changed: probe `/api/gitroot` so the worktree
|
/// 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::Spawn(req) => self.submit_spawn(req).await,
|
||||||
Effect::Kill(id) => self.submit_kill(id).await,
|
Effect::Kill(id) => self.submit_kill(id).await,
|
||||||
Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).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::RefillNames(tab_idx) => self.refill_names(tab_idx).await,
|
||||||
Effect::ProbeGitRoot { tab, seq, path } => {
|
Effect::ProbeGitRoot { tab, seq, path } => {
|
||||||
self.probe_git_root(tab, seq, path).await
|
self.probe_git_root(tab, seq, path).await
|
||||||
@@ -287,6 +291,7 @@ impl App {
|
|||||||
self.state.open_draft_pane();
|
self.state.open_draft_pane();
|
||||||
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
|
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
|
||||||
}
|
}
|
||||||
|
Action::FastForwardSpawn => effects.push(Effect::FastForward),
|
||||||
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||||
if let Some(sid) = self.pick_sidebar_target() {
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
effects.push(Effect::Attach(sid));
|
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.
|
/// Fetch a fresh name batch after a reroll drained the local queue.
|
||||||
async fn refill_names(&mut self, tab_idx: usize) {
|
async fn refill_names(&mut self, tab_idx: usize) {
|
||||||
let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await;
|
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
|
/// A valid form flows through: pick a project (set directly — the picker
|
||||||
/// itself is covered in `draft.rs`), select the custom tile, type a
|
/// itself is covered in `draft.rs`), select the custom tile, type a
|
||||||
/// command, then submit from the Spawn row.
|
/// command, then submit from the Spawn row.
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
// Catalog actions
|
// Catalog actions
|
||||||
("enter", "AttachHighlighted"),
|
("enter", "AttachHighlighted"),
|
||||||
("s", "NewDraftPane"),
|
("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"),
|
("x", "KillHighlighted"),
|
||||||
// Tabs
|
// Tabs
|
||||||
("ctrl-t", "NewTab"),
|
("ctrl-t", "NewTab"),
|
||||||
@@ -159,6 +162,10 @@ mod tests {
|
|||||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||||
Some(Action::NewDraftPane),
|
Some(Action::NewDraftPane),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Normal, "S"),
|
||||||
|
Some(Action::FastForwardSpawn),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ pub mod cli;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod emulators;
|
pub mod emulators;
|
||||||
|
pub mod fast_forward;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod keymap;
|
pub mod keymap;
|
||||||
pub mod overlays;
|
pub mod overlays;
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
|||||||
spawnBtn.type = "button";
|
spawnBtn.type = "button";
|
||||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||||
spawnBtn.textContent = "+ Spawn";
|
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());
|
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||||
actions.appendChild(spawnBtn);
|
actions.appendChild(spawnBtn);
|
||||||
return header;
|
return header;
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -3,13 +3,25 @@ import { ConnectionPool } from "./connections.js";
|
|||||||
import { installHudKeybind } from "./debug.js";
|
import { installHudKeybind } from "./debug.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import { shouldRetryClose } from "./reconnect.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 { mountHud, type Hud } from "./hud.js";
|
||||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||||
import { createMultiView, type MultiView } from "./multiview.js";
|
import { createMultiView, type MultiView } from "./multiview.js";
|
||||||
import { loadLayout, saveLayout } from "./persistence.js";
|
import { loadLayout, saveLayout } from "./persistence.js";
|
||||||
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
||||||
import { initTheme } from "./theme.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 { installViewportSizing } from "./viewport.js";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
@@ -212,6 +224,7 @@ function renderWelcome(): HTMLElement {
|
|||||||
spawn.type = "button";
|
spawn.type = "button";
|
||||||
spawn.className = "btn btn-primary";
|
spawn.className = "btn btn-primary";
|
||||||
spawn.textContent = "+ Spawn session";
|
spawn.textContent = "+ Spawn session";
|
||||||
|
spawn.title = "Draft pane: n — fast-forward spawn: N";
|
||||||
spawn.addEventListener("click", () => requestOpenSpawn());
|
spawn.addEventListener("click", () => requestOpenSpawn());
|
||||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||||
return el;
|
return el;
|
||||||
@@ -265,6 +278,65 @@ document.addEventListener("catalog-spawn-similar", (ev) => {
|
|||||||
mv.openDraft({ cwd: s.cwd, command: s.command });
|
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 =
|
type Route =
|
||||||
| { kind: "catalog" }
|
| { kind: "catalog" }
|
||||||
| { kind: "attach"; sessionId: string }
|
| { kind: "attach"; sessionId: string }
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ export function buildSpawnRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
|
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
|
||||||
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */
|
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`.
|
||||||
const FALLBACK_PRESETS: SpawnPreset[] = [
|
* Exported for the fast-forward spawn's preset fallback (#67). */
|
||||||
|
export const FALLBACK_PRESETS: SpawnPreset[] = [
|
||||||
{ id: "claude", label: "Claude", command: "claude" },
|
{ id: "claude", label: "Claude", command: "claude" },
|
||||||
{ id: "opencode", label: "opencode", command: "opencode" },
|
{ id: "opencode", label: "opencode", command: "opencode" },
|
||||||
{ id: "zsh", label: "Shell", command: "zsh" },
|
{ id: "zsh", label: "Shell", command: "zsh" },
|
||||||
|
|||||||
Reference in New Issue
Block a user