Compare commits

..
3 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 f89be4a716 feat(maestro): spawn form back in a modal in the TUI
ci / web (push) Successful in 37s
ci / rust (push) Successful in 4m52s
ci / flake (push) Successful in 7m12s
`s` (OpenSpawnModal, was NewDraftPane) puts the form up as a centered
overlay instead of taking over the pane. The attached session stays
attached behind it and comes back untouched on Escape; the modal owns
every key while it is up, so `x` and `j` typed into a field no longer
reach the keymap.

Tab::pane is a session id again and PaneRef is gone. The form lives on
App next to the rename buffer, so its loads no longer address a tab
index that could not identify them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:52:36 +01:00
Miguel PalhasandClaude Opus 5 6be0dba74e feat(maestro): spawn form back in a modal on web
Draft panes are gone: an empty pane is a placeholder again, and every
spawn affordance opens a dialog around the shared spawn form. Escape,
Cancel, the ✕ and a backdrop click dismiss it; a successful submit puts
the session in a new tab (Spawn similar and the fast-forward fallback
keep placing it beside the pane they acted on).

Removes the per-leaf draft state, its persistence strip, and the form
reuse/teardown machinery the panes needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:44:55 +01:00
Miguel PalhasandClaude Opus 5 28778b2224 feat(maestro): add maestro send for typing into a session
ci / web (push) Successful in 1m56s
ci / rust (push) Successful in 5m13s
ci / flake (push) Successful in 8m8s
Adds POST /api/sessions/:id/input and a CLI command on top of it, so a
script (or another agent) can hand an instruction to a running session
without attaching to it.

The text goes in as one write and the Enter follows 150ms later: agents
that run a full-screen prompt coalesce a burst of input into a paste, and
a newline inside that burst becomes a line break instead of sending the
message. `--no-enter` skips the Enter and leaves the text in the prompt.

The session argument takes an ID, a unique ID prefix, or a session name;
names win over prefixes. A session whose pane has exited is refused (409)
rather than silently accepted — unlike a stray keystroke on an attach
socket, a send that goes nowhere is worth hearing about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:10:18 +01:00
43 changed files with 1354 additions and 751 deletions
+2 -1
View File
@@ -11,6 +11,7 @@ persistence, WebSocket for transport.
maestro daemon tmux wrapper + session API on 127.0.0.1:7070
├── GET/POST /api/sessions list / spawn
├── DELETE /api/sessions/:id kill
├── POST /api/sessions/:id/input type text into a session
├── GET /api/info hostname, version, home dir, spawn presets
├── GET /api/dirs directory listing (spawn autocomplete)
├── GET /api/recent-dirs spawn history
@@ -18,7 +19,7 @@ maestro daemon tmux wrapper + session API on 127.0.0.1:7070
├── WS /api/events Snapshot + session deltas
└── WS /api/attach/:id raw PTY bytes
maestro list/spawn/attach/kill CLI client
maestro list/spawn/attach/kill/send CLI client
maestro tui ratatui client
maestro-web serves the SPA, reverse-proxies /api/* to the daemon
```
+6 -5
View File
@@ -100,10 +100,11 @@ Three surfaces, all DOM built in TS, styled by class:
no-sessions, no-search-match.
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
a framed dark xterm with a green cursor.
- **Draft pane** — the spawn form mounted inline in an empty split pane,
focusable like any session pane; mono inputs with green focus ring; grouped
worktree fieldset. Spawn opens one (reusing an empty focused pane, else
splitting), and "Spawn similar" seeds one from a session's dir + command.
- **Spawn modal** — the spawn form in a centered dialog (a bottom sheet below
600px) over a blurred backdrop; mono inputs with green focus ring; grouped
worktree fieldset. Escape, Cancel, ✕ and a backdrop click all dismiss it.
Submitting gives the new session its own tab; "Spawn similar" seeds the form
from a session's dir + command and puts the result beside it.
Interactive elements ship default / hover / focus-visible / active / disabled
states; the accent green is used only for action, selection, focus, and live
@@ -112,5 +113,5 @@ status — never decoration.
## Dev review aids (DEV-only, stripped from production)
`web/src/mock.ts` + query params seed a representative catalog without a running
daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane),
daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal),
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
+51
View File
@@ -41,6 +41,9 @@ pub enum Command {
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// Type text into a running session, as if it had been typed on an
/// attached terminal.
Send(SendArgs),
/// Install or update the agent activity hook (#106).
Hooks(HooksArgs),
}
@@ -103,6 +106,20 @@ pub struct AttachArgs {
pub detach_key: String,
}
/// `maestro send <session> <text...>`
#[derive(Debug, clap::Args)]
pub struct SendArgs {
/// Session to type into: its ID, a unique ID prefix, or its name.
pub session: String,
/// Text to send. Omit it to read the text from stdin.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub text: Vec<String>,
/// Leave the text sitting in the session's prompt instead of pressing
/// Enter after it.
#[arg(long)]
pub no_enter: bool,
}
#[derive(Debug, clap::Args)]
pub struct KillArgs {
/// Session ID to kill.
@@ -245,6 +262,40 @@ mod tests {
}
}
#[test]
fn parses_send_joining_the_message_words() {
let cli =
Cli::try_parse_from(["maestro", "send", "planner", "run", "the", "tests"]).unwrap();
let Command::Send(a) = cli.command else {
panic!("expected send")
};
assert_eq!(a.session, "planner");
assert_eq!(a.text, vec!["run", "the", "tests"]);
assert!(!a.no_enter);
}
/// The message is trailing_var_arg, so a word starting with `-` inside it
/// is text, not a flag clap should reject.
#[test]
fn send_message_may_start_with_a_dash() {
let cli =
Cli::try_parse_from(["maestro", "send", "--no-enter", "planner", "--force"]).unwrap();
let Command::Send(a) = cli.command else {
panic!("expected send")
};
assert!(a.no_enter);
assert_eq!(a.text, vec!["--force"]);
}
#[test]
fn send_text_may_be_omitted_for_stdin() {
let cli = Cli::try_parse_from(["maestro", "send", "planner"]).unwrap();
let Command::Send(a) = cli.command else {
panic!("expected send")
};
assert!(a.text.is_empty());
}
#[test]
fn rejects_env_without_equals() {
let err = Cli::try_parse_from([
+9 -1
View File
@@ -10,7 +10,7 @@ use maestro_protocol::DaemonClient;
use crate::attach::{parse_detach_key, run_attach_url};
use crate::cli::Command;
use crate::sessions::{run_kill, run_list, run_spawn};
use crate::sessions::{run_kill, run_list, run_send, run_spawn};
use maestro_config::{default_config_path, MaestroConfigFile};
#[derive(Debug, thiserror::Error)]
@@ -24,6 +24,10 @@ pub enum CliError {
Attach(#[from] crate::attach::AttachError),
#[error("{0}")]
Hooks(#[from] maestro_hooks::HooksError),
#[error("{0}")]
Resolve(#[from] crate::sessions::ResolveError),
#[error("nothing to send: pass the text as arguments or pipe it on stdin")]
NothingToSend,
#[error("could not determine the home directory ($HOME unset?)")]
NoHome,
#[error("{0}")]
@@ -82,6 +86,10 @@ pub async fn dispatch(
run_kill(&daemon, &args).await?;
println!("killed {}", args.session_id);
}
Command::Send(args) => {
let id = run_send(&daemon, &args, &mut std::io::stdin()).await?;
println!("sent to {id}");
}
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
}
Ok(())
+147 -3
View File
@@ -5,12 +5,13 @@ use std::io::Write;
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
SessionStatus, WorktreeSpec,
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
Session, SessionStatus, WorktreeSpec,
};
use serde_json::Value;
use crate::cli::{KillArgs, ListArgs, SpawnArgs};
use crate::cli::{KillArgs, ListArgs, SendArgs, SpawnArgs};
use crate::dispatch::CliError;
/// `maestro list`
pub async fn run_list(
@@ -66,6 +67,92 @@ pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), Clie
client.kill_session(args.session_id, args.force).await
}
/// Nothing on the daemon answers to what the operator named.
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("no session matches {0:?} — run `maestro list` to see what is running")]
NoMatch(String),
#[error("{0:?} matches {1} sessions; use a session id instead")]
Ambiguous(String, usize),
}
/// Turn what the operator typed into a session id: a full ID, a unique ID
/// prefix, or a session's name (#125), matched case-insensitively. Names come
/// first — an operator who named a session after a hex string meant the name.
pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid, ResolveError> {
let needle = needle.trim();
let by_name: Vec<&Session> = sessions
.iter()
.filter(|s| {
maestro_protocol::display::custom_name(s)
.is_some_and(|n| n.eq_ignore_ascii_case(needle))
})
.collect();
match by_name.len() {
1 => return Ok(by_name[0].id),
0 => {}
n => return Err(ResolveError::Ambiguous(needle.to_string(), n)),
}
let by_id: Vec<&Session> = sessions
.iter()
.filter(|s| {
s.id.to_string()
.to_ascii_lowercase()
.starts_with(&needle.to_ascii_lowercase())
})
.collect();
match by_id.len() {
1 => Ok(by_id[0].id),
0 => Err(ResolveError::NoMatch(needle.to_string())),
n => Err(ResolveError::Ambiguous(needle.to_string(), n)),
}
}
/// `maestro send`. Answers with the session that was typed into, so the
/// caller can name it once the daemon has accepted the text.
///
/// A bare ID skips the catalog fetch; anything else needs the list to match
/// against. Text comes from the arguments, or from stdin when there is none —
/// the shape a heredoc or a pipe wants.
#[allow(clippy::result_large_err)]
pub async fn run_send(
client: &DaemonClient,
args: &SendArgs,
stdin: &mut impl std::io::Read,
) -> Result<uuid::Uuid, CliError> {
let text = if args.text.is_empty() {
let mut buf = String::new();
stdin.read_to_string(&mut buf)?;
// One trailing newline is how a pipe ends, not part of the message —
// keeping it would submit the text twice.
buf.strip_suffix('\n')
.map(|t| t.strip_suffix('\r').unwrap_or(t))
.unwrap_or(&buf)
.to_string()
} else {
args.text.join(" ")
};
if text.is_empty() {
return Err(CliError::NothingToSend);
}
let id = match uuid::Uuid::parse_str(args.session.trim()) {
Ok(id) => id,
Err(_) => {
let sessions = client.list_sessions().await?;
resolve_session(&sessions, &args.session)?
}
};
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
Ok(id)
}
#[allow(clippy::write_literal)]
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
if sessions.is_empty() {
@@ -218,6 +305,63 @@ mod tests {
assert!(out.contains("bash [claude]"), "{out}");
}
fn named(id: u128, name: &str) -> Session {
let mut s = session("/tmp", "claude", id);
s.metadata
.insert("name".into(), serde_json::Value::String(name.into()));
s
}
#[test]
fn resolves_by_name_id_prefix_and_reports_what_it_cannot() {
use super::{resolve_session, ResolveError};
// Ids that differ in their first characters, so a short prefix is
// enough to pick one — the ergonomics the prefix match exists for.
let (a, b) = (1u128 << 124, 2u128 << 124);
let sessions = vec![named(a, "planner"), named(b, "builder")];
let one = Uuid::from_u128(a);
assert_eq!(resolve_session(&sessions, "planner").unwrap(), one);
// Names match case-insensitively and ignore surrounding space.
assert_eq!(resolve_session(&sessions, " Planner ").unwrap(), one);
// An id prefix works when it picks exactly one session.
let prefix = &one.to_string()[..8];
assert_eq!(resolve_session(&sessions, prefix).unwrap(), one);
assert!(matches!(
resolve_session(&sessions, "nope"),
Err(ResolveError::NoMatch(_))
));
}
/// Two sessions sharing a name is legal — the daemon never enforced
/// uniqueness — so `send` must refuse rather than pick one.
#[test]
fn an_ambiguous_name_is_refused() {
use super::{resolve_session, ResolveError};
let sessions = vec![named(1 << 124, "worker"), named(2 << 124, "worker")];
assert!(matches!(
resolve_session(&sessions, "worker"),
Err(ResolveError::Ambiguous(_, 2))
));
}
/// A session named after its own id prefix means the name: an operator
/// who typed a name they chose should not be routed by hex coincidence.
#[test]
fn names_win_over_id_prefixes() {
use super::resolve_session;
let two = Uuid::from_u128(2 << 124);
let prefix = two.to_string()[..8].to_string();
let sessions = vec![named(1 << 124, &prefix), session("/tmp", "bash", 2 << 124)];
assert_eq!(
resolve_session(&sessions, &prefix).unwrap(),
Uuid::from_u128(1 << 124)
);
}
#[test]
fn empty_sessions() {
let mut buf = Vec::new();
@@ -108,3 +108,110 @@ async fn spawn_round_trips_through_the_daemon() {
.expect("kill");
assert_eq!(mock.session_count(), 0);
}
#[tokio::test]
async fn send_types_text_and_submits_by_default() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(7);
mock.add_session(session(id, "/home/mock/work", "claude"));
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["run".into(), "the".into(), "tests".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
let inputs = mock.inputs();
assert_eq!(inputs.len(), 1);
assert_eq!(inputs[0].0, id);
assert_eq!(inputs[0].1.text, "run the tests");
assert!(inputs[0].1.submit, "Enter is the default");
}
/// `--no-enter` leaves the text in the prompt; the flag has to survive the
/// whole way to the daemon or the message is sent anyway.
#[tokio::test]
async fn send_without_enter_leaves_the_text_unsubmitted() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(8);
mock.add_session(session(id, "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["ls".into()],
no_enter: true,
};
maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert!(!mock.inputs()[0].1.submit);
}
/// No text arguments → the message comes from stdin, minus the trailing
/// newline a pipe or heredoc adds.
#[tokio::test]
async fn send_reads_stdin_and_drops_its_trailing_newline() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(9);
mock.add_session(session(id, "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: Vec::new(),
no_enter: false,
};
let mut stdin = std::io::Cursor::new(b"multi\nline\n".to_vec());
maestro_cli::sessions::run_send(&mock.client(), &args, &mut stdin)
.await
.expect("send");
assert_eq!(mock.inputs()[0].1.text, "multi\nline");
}
/// A session named through `PATCH /api/sessions/:id` is addressable by that
/// name, so scripts don't have to carry a UUID around.
#[tokio::test]
async fn send_resolves_a_session_by_name() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(10);
let mut named = session(id, "/tmp", "claude");
named
.metadata
.insert("name".into(), serde_json::Value::String("planner".into()));
mock.add_session(named);
mock.add_session(session(Uuid::from_u128(11), "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: "Planner".into(),
text: vec!["go".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
}
#[tokio::test]
async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await;
mock.add_session(session(Uuid::from_u128(12), "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: "nope".into(),
text: vec!["hi".into()],
no_enter: false,
};
let err = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect_err("should not resolve");
assert!(err.to_string().contains("no session matches"), "{err}");
assert!(mock.inputs().is_empty());
}
+28 -2
View File
@@ -20,7 +20,7 @@ use axum::{Json, Router};
use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
@@ -44,6 +44,7 @@ pub fn router(state: LocalApiState) -> Router {
Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route(
routes::patterns::SESSION,
delete(kill_session).patch(rename_session),
@@ -100,6 +101,9 @@ fn session_error_status(code: &str) -> StatusCode {
"invalid_env" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT,
// The session outlived its pane: nothing to type into, and nothing
// the daemon can do about it.
"not_running" => StatusCode::CONFLICT,
_ => StatusCode::BAD_GATEWAY,
}
}
@@ -137,7 +141,7 @@ struct NamesQuery {
}
/// Fresh session handles for the spawn form's instant reroll (#53). The web
/// client fetches one batch per draft pane and rerolls locally from it.
/// client fetches one batch per spawn form and rerolls locally from it.
async fn names(Query(q): Query<NamesQuery>) -> Json<NameBatch> {
let count = q.count.unwrap_or(1).clamp(1, maestro_names::MAX_BATCH);
Json(NameBatch {
@@ -247,6 +251,28 @@ async fn report_activity(
}
}
/// `POST /api/sessions/:id/input`: type text into a session's pane, then
/// press Enter unless the request turns `submit` off. Answers `204 No
/// Content`. A session that has exited is refused (409) rather than accepted
/// into nowhere.
async fn send_input(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<SendInputRequest>,
) -> Response {
match s.manager.send_input(id, &body.text, body.submit).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let info = e.to_error_info();
error_response(
session_error_status(&info.code),
info.code.as_str(),
info.message.as_str(),
)
}
}
}
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(ApiError::new(code, message))).into_response()
}
+1 -1
View File
@@ -32,7 +32,7 @@ pub const DEFAULT_HOME_IGNORE: &[&str] =
/// triggers a rescan.
///
/// A scan walks the roots and runs one `git worktree list` per hit, holding
/// the scanner lock throughout, and clients re-request whenever a draft pane
/// the scanner lock throughout, and clients re-request whenever a spawn form
/// re-mounts — which `multiview.render()` does on unrelated events. Keep this
/// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is
/// what refreshes, and a request only rescans when that timer has failed to.
+42
View File
@@ -40,6 +40,15 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
/// attempt, which is all #83 needs.
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
/// Gap between the text of a `send` and the Enter that submits it.
///
/// Agents that run a full-screen prompt (Claude Code, opencode) coalesce a
/// burst of input into one paste; a newline inside that burst becomes a line
/// break in the message instead of sending it. Landing the Enter as its own
/// write, after the reader has drained the text, is what makes the message
/// actually go.
const SUBMIT_DELAY: Duration = Duration::from_millis(150);
/// Per-session daemon-local bookkeeping that complements the public `Session`.
#[derive(Debug)]
pub struct SessionRecord {
@@ -78,6 +87,11 @@ pub enum SessionError {
NotFound(Uuid),
#[error("invalid environment variable name: {0:?}")]
InvalidEnv(String),
/// The session exists but its pane is gone, so there is nothing to type
/// into. Writing to a dead session is a client bug worth reporting, unlike
/// a stray keystroke on an attach socket, which is dropped.
#[error("session is no longer running: {0}")]
NotRunning(Uuid),
/// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work.
@@ -94,6 +108,7 @@ impl SessionError {
SessionError::Io(_) => "io_failed",
SessionError::NotFound(_) => "not_found",
SessionError::InvalidEnv(_) => "invalid_env",
SessionError::NotRunning(_) => "not_running",
SessionError::KillGuarded(_) => "kill_guarded",
};
let unsaved_work = match self {
@@ -671,6 +686,33 @@ impl SessionManager {
.map_err(SessionError::Tmux)
}
/// Type `text` into a session's pane, with an Enter after it when
/// `submit` is set.
///
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
/// asked for text to land somewhere, and a silent success would let a
/// script believe an agent had been told something it never heard.
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
match self.status(id).await {
None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
Some(SessionStatus::Running) => {}
}
let tmux = self.inner.tmux.clone();
let bytes = text.as_bytes().to_vec();
tmux_blocking(move || tmux.send_keys(id, &bytes))
.await
.map_err(SessionError::Tmux)?;
if submit {
tokio::time::sleep(SUBMIT_DELAY).await;
let tmux = self.inner.tmux.clone();
tmux_blocking(move || tmux.send_keys(id, b"\r"))
.await
.map_err(SessionError::Tmux)?;
}
Ok(())
}
/// Resize a session's window. No-op for a dead session, as with
/// [`Self::send_keys`].
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
+137
View File
@@ -1250,6 +1250,143 @@ async fn cli_list_reads_from_daemon() {
);
}
/// `maestro send` end to end: the shipped binary, a real daemon, a real tmux
/// pane. The shell only runs the command if the Enter arrived as its own
/// write after the text, so the file appearing is the whole path working.
#[tokio::test(flavor = "multi_thread")]
async fn cli_send_types_a_command_into_a_live_shell() {
let (daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let sess = client
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
.await
.expect("spawn bash");
let home = TempDir::new().unwrap();
let cfg_path = home.path().join("config.toml");
let out = run_cli(
&["send", &sess.id.to_string(), "touch", "marker.txt"],
&[
("HOME", home.path().to_str().unwrap()),
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
("MAESTRO_DAEMON_URL", &daemon.http_base()),
],
);
assert!(
out.status.success(),
"send failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let marker = cwd.path().join("marker.txt");
wait_for_async(
"the shell runs the sent command",
Duration::from_secs(10),
|| {
let marker = marker.clone();
async move { marker.exists() }
},
)
.await;
client.kill_session(sess.id).await.expect("kill");
}
/// `--no-enter` fills the prompt without submitting: the command must sit
/// there unrun. Same setup as the test above, so the only difference is the
/// flag.
#[tokio::test(flavor = "multi_thread")]
async fn send_without_enter_does_not_run_the_command() {
let (daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let sess = client
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
.await
.expect("spawn bash");
let home = TempDir::new().unwrap();
let out = run_cli(
&[
"send",
"--no-enter",
&sess.id.to_string(),
"touch",
"unsubmitted.txt",
],
&[
("HOME", home.path().to_str().unwrap()),
(
"MAESTRO_CONFIG",
home.path().join("c.toml").to_str().unwrap(),
),
("MAESTRO_DAEMON_URL", &daemon.http_base()),
],
);
assert!(
out.status.success(),
"send failed: {}",
String::from_utf8_lossy(&out.stderr)
);
tokio::time::sleep(Duration::from_secs(2)).await;
assert!(
!cwd.path().join("unsubmitted.txt").exists(),
"the command ran despite --no-enter"
);
client.kill_session(sess.id).await.expect("kill");
}
/// A session whose command exited still exists in the catalog, but its pane
/// is gone. Typing into it is refused instead of being swallowed — a script
/// that sends an instruction to a dead agent has to hear about it.
#[tokio::test(flavor = "multi_thread")]
async fn send_to_a_dead_session_is_refused() {
use maestro_protocol::{SendInputRequest, SessionStatus};
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let sess = client
.spawn_session(&create_req(cwd.path(), "true"))
.await
.expect("spawn true");
wait_for_async(
"the session to be seen dead",
Duration::from_secs(15),
|| {
let client = client.clone();
let id = sess.id;
async move {
client
.list_sessions()
.await
.map(|ss| {
ss.iter()
.any(|s| s.id == id && s.status == SessionStatus::Dead)
})
.unwrap_or(false)
}
},
)
.await;
let err = client
.daemon()
.send_input(
sess.id,
&SendInputRequest {
text: "hello".into(),
submit: true,
},
)
.await
.expect_err("a dead session cannot be typed into");
assert!(
err.to_string().contains("no longer running"),
"unexpected error: {err}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cli_list_fails_when_daemon_is_down() {
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
+12 -1
View File
@@ -16,7 +16,7 @@ use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, Session,
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -155,6 +155,17 @@ impl DaemonClient {
Ok(())
}
/// `POST /api/sessions/:id/input`: type text into a session's pane, with
/// an Enter after it unless `submit` is off. Answers `204 No Content`; a
/// session that has exited is refused rather than silently accepted.
pub async fn send_input(&self, id: Uuid, req: &SendInputRequest) -> Result<(), ClientError> {
let resp = self
.send(Method::POST, &routes::session_input(id), &[], Some(req))
.await?;
check(resp).await?;
Ok(())
}
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await
}
+9
View File
@@ -7,6 +7,15 @@ use std::path::{Path, PathBuf};
use crate::Session;
/// The name a session was given (#125), if any. `None` for one that was never
/// renamed — clients fall back to the command.
pub fn custom_name(s: &Session) -> Option<&str> {
match s.metadata.get("name") {
Some(serde_json::Value::String(name)) => Some(name.as_str()),
_ => None,
}
}
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
/// untouched. Mirrors the web's `abbreviateCwd`.
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
+18
View File
@@ -512,6 +512,24 @@ pub struct NameBatch {
pub names: Vec<String>,
}
/// HTTP `POST /api/sessions/:id/input` body: text typed into a session's
/// pane, as if the operator had typed it on an attached terminal.
///
/// `submit` presses Enter after the text — what makes `maestro send` a way to
/// give an agent an instruction rather than only to fill its prompt. It goes
/// out as its own write a beat later, because a TUI agent that reads the text
/// as one paste would otherwise swallow the newline into the message.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SendInputRequest {
pub text: String,
#[serde(default = "submit_default")]
pub submit: bool,
}
fn submit_default() -> bool {
true
}
/// Longest session name the daemon stores (#125), in characters. A name is a
/// sidebar card's line 1; past this it only truncates on every client anyway.
pub const MAX_SESSION_NAME_LEN: usize = 64;
+14
View File
@@ -49,6 +49,11 @@ pub fn session_activity(id: Uuid) -> String {
format!("{SESSIONS}/{id}/activity")
}
/// `POST /api/sessions/:id/input`: type text into a session's pane.
pub fn session_input(id: Uuid) -> String {
format!("{SESSIONS}/{id}/input")
}
/// `WS /api/attach/:id` — raw PTY bytes.
pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}")
@@ -59,6 +64,7 @@ pub fn attach(id: Uuid) -> String {
pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id";
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
pub const SESSION_INPUT: &str = "/api/sessions/:id/input";
pub const ATTACH: &str = "/api/attach/:id";
}
@@ -170,6 +176,10 @@ mod tests {
session_activity(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
);
assert_eq!(
session_input(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input"
);
assert_eq!(
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -192,6 +202,10 @@ mod tests {
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::SESSION_INPUT.trim_end_matches(":id/input"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
+32 -2
View File
@@ -16,13 +16,13 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get};
use axum::routing::{any, delete, get, post};
use axum::{Json, Router};
use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, Session, SessionStatus, PROTOCOL_VERSION,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot};
@@ -55,6 +55,8 @@ struct Inner {
info: Option<DaemonInfo>,
/// When set, every route answers with this instead of its normal response.
forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>,
}
#[derive(Clone)]
@@ -86,6 +88,7 @@ impl MockDaemon {
let app = Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
@@ -167,6 +170,11 @@ impl MockDaemon {
self.state.inner.lock().expect("poisoned").forced_error = None;
}
/// Every `send` this daemon received, oldest first.
pub fn inputs(&self) -> Vec<(Uuid, SendInputRequest)> {
self.state.inner.lock().expect("poisoned").inputs.clone()
}
pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len()
}
@@ -233,6 +241,28 @@ async fn kill_session(State(s): State<AppState>, Path(id): Path<Uuid>) -> Respon
StatusCode::NO_CONTENT.into_response()
}
/// Records the text instead of typing it — the subject under test is the
/// client's request, not tmux.
async fn send_input(
State(s): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<SendInputRequest>,
) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let mut guard = s.inner.lock().expect("poisoned");
if !guard.sessions.iter().any(|x| x.id == id) {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
guard.inputs.push((id, req));
StatusCode::NO_CONTENT.into_response()
}
async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) {
return r;
+13 -12
View File
@@ -43,9 +43,9 @@ pub enum Action {
// Catalog actions on the highlighted sidebar row
AttachHighlighted,
ToggleAttachHighlighted,
NewDraftPane,
OpenSpawnModal,
/// Fast-forward spawn (#67): spawn immediately with every default
/// resolved; unresolvable defaults open a pre-filled draft instead.
/// resolved; unresolvable defaults open a pre-filled form instead.
FastForwardSpawn,
KillHighlighted,
/// Rename the highlighted session (#125): opens a prompt pre-filled with
@@ -90,7 +90,7 @@ impl Action {
Action::ToggleSidebarGroup => "Expand or collapse directory".into(),
Action::AttachHighlighted => "Open selected item".into(),
Action::ToggleAttachHighlighted => "Open selected item".into(),
Action::NewDraftPane => "Create a session".into(),
Action::OpenSpawnModal => "Create a session".into(),
Action::FastForwardSpawn => "Create session with defaults".into(),
Action::KillHighlighted => "Kill selected session".into(),
Action::RenameHighlighted => "Rename selected session".into(),
@@ -116,7 +116,7 @@ impl Action {
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"NewDraftPane" => return Ok(Action::NewDraftPane),
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
@@ -179,7 +179,9 @@ mod tests {
fn help_labels_are_plain_language() {
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
assert_eq!(Action::AttachHighlighted.help_label(), "Open selected item");
assert!(!Action::NewDraftPane.help_label().contains("NewDraftPane"));
assert!(!Action::OpenSpawnModal
.help_label()
.contains("OpenSpawnModal"));
}
#[test]
@@ -214,7 +216,7 @@ mod tests {
assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into()));
}
/// The fast-forward pair sits next to the draft-pane key (#67).
/// The fast-forward pair sits next to the spawn key (#67).
#[test]
fn fast_forward_spawn_round_trips() {
assert_eq!(
@@ -243,7 +245,7 @@ mod tests {
Action::EnterInsert,
Action::RenameHighlighted,
Action::Quit,
Action::NewDraftPane,
Action::OpenSpawnModal,
Action::ScrollPanePageDown,
Action::FocusPane(Direction::Right),
] {
@@ -252,13 +254,12 @@ mod tests {
}
}
/// The spawn modal is gone (issue #63); its old action string must not
/// resolve to anything.
/// Draft panes are gone; their old action string must not resolve.
#[test]
fn the_removed_spawn_modal_action_no_longer_parses() {
fn the_removed_draft_pane_action_no_longer_parses() {
assert_eq!(
Action::parse("OpenSpawnModal").unwrap_err(),
ActionParseError::Unknown("OpenSpawnModal".into())
Action::parse("NewDraftPane").unwrap_err(),
ActionParseError::Unknown("NewDraftPane".into())
);
}
}
+164 -204
View File
@@ -6,9 +6,9 @@
//! changes, and a 30Hz heartbeat.
//!
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
//! (`AttachHighlighted` spawning a `SessionTask`, a draft-pane submit firing
//! (`AttachHighlighted` spawning a `SessionTask`, a spawn-form submit firing
//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in
//! `dispatch` / the Insert-mode pane routing.
//! `dispatch` / the overlay routing.
use std::collections::{HashMap, HashSet};
use std::io::Stdout;
@@ -26,7 +26,7 @@ use uuid::Uuid;
use crate::action::Action;
use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
use crate::draft::DraftInputResult;
use crate::draft::{DraftInputResult, DraftPane};
use crate::emulators::SessionEmulators;
use crate::input::{
canonicalise, ChordResult, ChordState, ClickTracker, InputKey, MouseKind, Region,
@@ -37,7 +37,7 @@ use crate::pane::{Pane, PaneStatus};
use crate::render::{draw, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
use crate::text_input::TextInput;
use crate::TuiError;
@@ -58,6 +58,10 @@ pub struct App {
help: HelpOverlay,
chord: ChordState,
daemon: DaemonClient,
/// The spawn form behind [`OverlayKind::Spawn`], kept here rather than in
/// `AppState` so the overlay enum stays comparable — the same reason the
/// rename buffer lives here.
draft: Option<DraftPane>,
/// Results of draft data loads, delivered back to the event loop.
///
/// These are network calls — `/api/projects` on a cold cache walks all of
@@ -99,22 +103,21 @@ pub enum Effect {
id: Uuid,
bytes: Vec<u8>,
},
/// Fetch presets / projects / names for a freshly opened draft (#64).
LoadDraft(usize),
/// Fetch presets / projects / names for a freshly opened form (#64).
LoadDraft,
/// Fast-forward spawn (#67): resolve every default from the daemon and
/// spawn with no form; unresolvable defaults open a pre-filled draft.
/// spawn with no form; unresolvable defaults open a pre-filled form.
FastForward,
/// A reroll drained the draft's local name batch (#64).
RefillNames(usize),
/// The draft's target changed: probe `/api/gitroot` so the worktree
/// A reroll drained the form's local name batch (#64).
RefillNames,
/// The form's target changed: probe `/api/gitroot` so the worktree
/// section knows its base repo (#65). `seq` guards stale answers.
ProbeGitRoot {
tab: usize,
seq: u32,
path: PathBuf,
},
/// Fetch the draft target's branches from `/api/branches` (#65).
LoadBranches(usize),
/// Fetch the form target's branches from `/api/branches` (#65).
LoadBranches,
/// Rename a session (#125). An empty name clears the custom one, so the
/// card falls back to the command.
Rename {
@@ -141,6 +144,7 @@ impl App {
catalog,
emulators: SessionEmulators::default(),
state: AppState::new(),
draft: None,
help,
chord: ChordState::new(chord_timeout),
daemon,
@@ -222,14 +226,7 @@ impl App {
self.perform(effects).await;
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
{
// Insert mode over a focused pane: a draft eats the key
// into its form, a session gets the raw bytes.
let effects = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
{
self.handle_draft_input(&key)
} else {
self.forward_to_session(&key).into_iter().collect()
};
let effects = self.forward_to_session(&key).into_iter().collect();
self.perform(effects).await;
}
Ok(false)
@@ -273,13 +270,11 @@ impl App {
Effect::Attach(id) => self.attach(id),
Effect::Spawn(req) => self.submit_spawn(req).await,
Effect::Kill(id) => self.submit_kill(id).await,
Effect::LoadDraft(tab_idx) => self.spawn_load_draft(tab_idx),
Effect::LoadDraft => self.spawn_load_draft(),
Effect::FastForward => self.spawn_fast_forward(),
Effect::RefillNames(tab_idx) => self.spawn_refill_names(tab_idx),
Effect::ProbeGitRoot { tab, seq, path } => {
self.spawn_probe_git_root(tab, seq, path)
}
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
Effect::RefillNames => self.spawn_refill_names(),
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
Effect::LoadBranches => self.spawn_load_branches(),
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) {
@@ -292,6 +287,15 @@ impl App {
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
match self.state.overlay().cloned() {
// The form owns every key while it is up, Escape aside — the
// keymap must not see `s`, `j` or `x` as commands mid-typing.
Some(OverlayKind::Spawn) => match key {
InputKey::Esc => {
self.dismiss_spawn_modal();
(OverlayDecision::Consumed, Vec::new())
}
other => (OverlayDecision::Consumed, self.handle_draft_input(other)),
},
Some(OverlayKind::KillConfirm(id)) => match key {
InputKey::Char { c: 'y', .. } => {
self.state.dismiss_overlay();
@@ -363,12 +367,9 @@ impl App {
};
self.state.show_overlay(OverlayKind::Help);
}
Action::NewDraftPane => {
if let Some(sid) = self.state.active_session() {
self.release_session(sid);
}
self.state.open_draft_pane();
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
Action::OpenSpawnModal => {
self.open_spawn_modal();
effects.push(Effect::LoadDraft);
}
Action::FastForwardSpawn => effects.push(Effect::FastForward),
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
@@ -571,12 +572,24 @@ impl App {
Some(None)
}
/// Feed one keystroke into the active tab's draft form. Returns the spawn
/// effect when the user accepted a valid form; an invalid one names the
/// missing field inside the form (#64) instead of posting.
/// Put the spawn form up with a fresh draft behind it.
fn open_spawn_modal(&mut self) {
self.draft = Some(DraftPane::default());
self.state.open_spawn_modal();
}
/// Close the form and drop its half-filled state — reopening starts clean,
/// as the web modal does.
fn dismiss_spawn_modal(&mut self) {
self.state.dismiss_overlay();
self.draft = None;
}
/// Feed one keystroke into the spawn form. Returns the spawn effect when
/// the user accepted a valid form; an invalid one names the missing field
/// inside the form (#64) instead of posting.
fn handle_draft_input(&mut self, key: &InputKey) -> Vec<Effect> {
let tab_idx = self.state.active_tab_idx;
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
let Some(draft) = self.draft.as_mut() else {
return Vec::new();
};
let mut effects = Vec::new();
@@ -590,48 +603,37 @@ impl App {
}
}
if draft.take_name_refill() {
effects.push(Effect::RefillNames(tab_idx));
effects.push(Effect::RefillNames);
}
if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
effects.push(Effect::ProbeGitRoot {
tab: tab_idx,
seq,
path,
});
effects.push(Effect::ProbeGitRoot { seq, path });
}
if draft.take_branch_load().is_some() {
effects.push(Effect::LoadBranches(tab_idx));
effects.push(Effect::LoadBranches);
}
effects
}
/// Fire any draft gitroot probes whose debounce window has elapsed (#74).
/// The 30 Hz heartbeat calls this so a burst of typing issues its one
/// probe even after the last keystroke.
/// Fire any gitroot probe whose debounce window has elapsed (#74). The
/// 30 Hz heartbeat calls this so a burst of typing issues its one probe
/// even after the last keystroke.
fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
let mut effects = Vec::new();
for tab_idx in 0..self.state.tabs.len() {
let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else {
continue;
};
if let Some((seq, path)) = draft.take_gitroot_probe(now) {
effects.push(Effect::ProbeGitRoot {
tab: tab_idx,
seq,
path,
});
}
}
effects
let Some(draft) = self.draft.as_mut() else {
return Vec::new();
};
draft
.take_gitroot_probe(now)
.map(|(seq, path)| Effect::ProbeGitRoot { seq, path })
.into_iter()
.collect()
}
/// Populate a freshly opened draft: presets from `/api/info` (falling back
/// Populate a freshly opened form: presets from `/api/info` (falling back
/// to the built-ins already in place), projects from `/api/projects`, and
/// a first name batch. Applies only if that tab still shows the draft —
/// a slow response must never clobber what replaced it.
/// Kick off the draft's initial data load without blocking the loop.
fn spawn_load_draft(&self, tab: usize) {
let Some(draft) = self.draft_id(tab) else {
/// a first name batch. Applies only if the same form is still up — a slow
/// response must never clobber the one that replaced it.
fn spawn_load_draft(&self) {
let Some(draft) = self.draft_id() else {
return;
};
let (daemon, http, base, tx) = self.loader_handles();
@@ -647,8 +649,8 @@ impl App {
});
}
fn spawn_refill_names(&self, tab: usize) {
let Some(draft) = self.draft_id(tab) else {
fn spawn_refill_names(&self) {
let Some(draft) = self.draft_id() else {
return;
};
let (_, http, base, tx) = self.loader_handles();
@@ -658,8 +660,8 @@ impl App {
});
}
fn spawn_probe_git_root(&self, tab: usize, seq: u32, path: PathBuf) {
let Some(draft) = self.draft_id(tab) else {
fn spawn_probe_git_root(&self, seq: u32, path: PathBuf) {
let Some(draft) = self.draft_id() else {
return;
};
let (daemon, _, _, tx) = self.loader_handles();
@@ -673,20 +675,11 @@ impl App {
});
}
fn spawn_load_branches(&self, tab: usize) {
let Some(draft) = self.draft_id(tab) else {
fn spawn_load_branches(&self) {
let Some(draft) = self.draft_id() else {
return;
};
let Some(cwd) = self
.state
.tabs
.get(tab)
.and_then(|t| t.pane.as_ref())
.and_then(|p| match p {
PaneRef::Draft(d) => d.cwd(),
_ => None,
})
else {
let Some(cwd) = self.draft.as_ref().and_then(DraftPane::cwd) else {
return;
};
let (_, http, base, tx) = self.loader_handles();
@@ -700,12 +693,9 @@ impl App {
});
}
/// The draft pane living in `tab`, by identity.
fn draft_id(&self, tab: usize) -> Option<u64> {
match self.state.tabs.get(tab).and_then(|t| t.pane.as_ref()) {
Some(PaneRef::Draft(d)) => Some(d.id),
_ => None,
}
/// Identity of the form currently up, if any.
fn draft_id(&self) -> Option<u64> {
self.draft.as_ref().map(|d| d.id)
}
fn loader_handles(
@@ -743,24 +733,9 @@ impl App {
// Handled above; it targets no particular draft.
DraftUpdate::FastForward { .. } => unreachable!(),
};
// Find it by identity: closing a tab shifts every index to its right,
// so an in-flight load addressed by index would be dropped or land on
// whatever pane inherited the slot (#67 review).
let Some(tab_idx) = self
.state
.tabs
.iter()
.position(|t| matches!(&t.pane, Some(PaneRef::Draft(d)) if d.id == want))
else {
// The draft went away while the request was in flight.
return;
};
let Some(PaneRef::Draft(draft)) = self
.state
.tabs
.get_mut(tab_idx)
.and_then(|t| t.pane.as_mut())
else {
// Match by identity: dismissing the form and opening another while a
// load is in flight must not let the old answer land in the new form.
let Some(draft) = self.draft.as_mut().filter(|d| d.id == want) else {
return;
};
let mut follow_up_branches = false;
@@ -799,7 +774,7 @@ impl App {
DraftUpdate::FastForward { .. } => unreachable!(),
}
if follow_up_branches {
self.spawn_load_branches(tab_idx);
self.spawn_load_branches();
}
}
@@ -807,7 +782,7 @@ impl App {
/// 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
/// known yet, no command, no name — downgrades to the spawn form seeded
/// with whatever did resolve.
/// Load what the fast path needs without blocking the loop — the same
/// reason the draft loaders were moved off it (#67 review): a cold
@@ -850,9 +825,9 @@ impl App {
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 {
// Unresolved defaults: the form, pre-filled with what did resolve.
self.open_spawn_modal();
let Some(draft) = self.draft.as_mut() else {
return;
};
draft.home_dir = home_dir;
@@ -869,12 +844,13 @@ impl App {
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
match self.daemon.create_session(&req).await {
Ok(session) => {
// Fill the pane in place with the new session (web parity,
// #57) and attach to it right away.
// The form closes and the new session takes the pane, attached
// right away — the same landing the web modal gives it.
let sid = session.id;
if let Some(previous) = self.state.active_session() {
self.release_session(previous);
}
self.draft = None;
self.state.complete_spawn(sid);
self.attach(sid);
}
@@ -885,7 +861,7 @@ impl App {
.daemon_message()
.map(String::from)
.unwrap_or_else(|| e.to_string());
if let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() {
if let Some(draft) = self.draft.as_mut() {
draft.error = Some(format!("spawn failed: {message}"));
} else {
self.state.toast(format!("spawn failed: {e}"));
@@ -933,18 +909,13 @@ impl App {
focused: matches!(self.state.focus, Focus::Pane),
})
});
let draft = match self.state.active_tab().pane.as_ref() {
Some(PaneRef::Draft(d)) => Some(d),
_ => None,
};
let pane_title = match self.state.active_tab().pane.as_ref() {
Some(PaneRef::Session(sid)) => snap
let pane_title = match active_sid {
Some(sid) => snap
.sessions
.iter()
.find(|session| session.id == *sid)
.find(|session| session.id == sid)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into()),
Some(PaneRef::Draft(_)) => "new session".into(),
None => "session".into(),
};
@@ -954,7 +925,7 @@ impl App {
pane_title: &pane_title,
pane: pane.as_ref(),
state: &self.state,
draft,
draft: self.draft.as_ref(),
help: &self.help,
rename_input: &self.rename_input,
};
@@ -1235,36 +1206,52 @@ mod tests {
assert!(help.contains("Create a session"));
assert!(help.contains("Expand or collapse directory"));
assert!(!help.contains("EnterInsert"));
assert!(!help.contains("NewDraftPane"));
assert!(!help.contains("OpenSpawnModal"));
assert!(!help.contains("ToggleSidebarGroup"));
}
#[test]
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
fn open_spawn_modal_puts_the_form_up_and_asks_for_its_data() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
assert!(matches!(
app.state.active_tab().pane,
Some(PaneRef::Draft(_))
));
assert_eq!(app.state.focus, Focus::Pane);
assert_eq!(app.state.mode, Mode::Insert);
assert!(app.state.overlay().is_none(), "no overlay involved");
let effects = app.dispatch(Action::OpenSpawnModal);
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
assert!(app.draft.is_some());
assert_eq!(effects, vec![Effect::LoadDraft]);
}
/// The modal covers the attached session rather than evicting it: Escape
/// gives back exactly what was there, still attached.
#[test]
fn new_draft_pane_over_a_session_reuses_the_pane() {
fn spawning_over_a_session_leaves_it_attached_behind_the_modal() {
let sid = Uuid::from_u128(10);
let mut app = app_with_highlighted_session(sid);
app.dispatch(Action::AttachHighlighted);
app.dispatch(Action::NewDraftPane);
assert_eq!(app.state.tabs.len(), 1);
assert_eq!(app.state.active_tab_idx, 0);
assert!(matches!(
app.state.active_tab().pane,
Some(PaneRef::Draft(_))
));
app.dispatch(Action::OpenSpawnModal);
assert_eq!(app.state.active_session(), Some(sid));
app.handle_overlay_input(&InputKey::Esc);
assert!(app.state.overlay().is_none());
assert!(app.draft.is_none());
assert_eq!(app.state.active_session(), Some(sid));
}
/// While the form is up the keymap must not see its keystrokes: `x` is a
/// kill binding in Normal mode, and typing it into a name would fire it.
#[test]
fn the_modal_swallows_keys_the_keymap_would_otherwise_claim() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
c: 'x',
ctrl: false,
alt: false,
shift: false,
});
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
}
/// Seed the catalog with one session and highlight it.
@@ -1507,11 +1494,11 @@ mod tests {
}
#[test]
fn new_draft_pane_schedules_a_data_load() {
fn opening_the_spawn_modal_schedules_a_data_load() {
let mut app = make_app();
assert_eq!(
app.dispatch(Action::NewDraftPane),
vec![Effect::LoadDraft(0)]
app.dispatch(Action::OpenSpawnModal),
vec![Effect::LoadDraft]
);
}
@@ -1602,7 +1589,7 @@ mod tests {
}
/// No projects known yet: nothing can resolve, so the keystroke opens a
/// draft pane (focused, Insert mode) instead of guessing (#67).
/// spawn form instead of guessing (#67).
#[tokio::test]
async fn unresolvable_defaults_open_a_prefilled_draft_instead() {
use axum::routing::get;
@@ -1641,15 +1628,12 @@ mod tests {
.expect("channel open");
app.apply_draft_update(update).await;
assert!(
matches!(app.state.active_tab().pane, Some(PaneRef::Draft(_))),
"the fallback is a draft pane"
assert_eq!(
app.state.overlay(),
Some(&OverlayKind::Spawn),
"the fallback is the spawn modal"
);
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");
};
let draft = app.draft.as_ref().expect("a form behind the modal");
// 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");
@@ -1660,12 +1644,10 @@ mod tests {
/// itself is covered in `draft.rs`), select the custom tile, type a
/// command, then submit from the Spawn row.
#[test]
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
fn typing_into_the_form_then_enter_carries_the_typed_fields() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
panic!("expected a draft pane");
};
app.dispatch(Action::OpenSpawnModal);
let draft = app.draft.as_mut().unwrap();
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
@@ -1703,10 +1685,8 @@ mod tests {
#[test]
fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
panic!("expected a draft pane");
};
app.dispatch(Action::OpenSpawnModal);
let draft = app.draft.as_mut().unwrap();
draft.cwd_raw_visible = true;
draft.focused_field = DraftField::CwdRaw;
@@ -1720,7 +1700,7 @@ mod tests {
let due = std::time::Instant::now()
+ std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10;
match app.poll_due_probes_at(due).as_slice() {
[Effect::ProbeGitRoot { tab: 0, path, .. }] => {
[Effect::ProbeGitRoot { path, .. }] => {
assert_eq!(path, &std::path::PathBuf::from("/opt/tooling"));
}
other => panic!("expected one ProbeGitRoot effect, got {other:?}"),
@@ -1736,10 +1716,8 @@ mod tests {
#[test]
fn switching_to_existing_branch_mode_schedules_a_branch_load() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
panic!("expected a draft pane");
};
app.dispatch(Action::OpenSpawnModal);
let draft = app.draft.as_mut().unwrap();
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo"));
draft.base_repo = Some(std::path::PathBuf::from("/srv/repo"));
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo"));
@@ -1747,7 +1725,7 @@ mod tests {
let effects = app.handle_draft_input(&InputKey::Right);
assert!(
matches!(effects.as_slice(), [Effect::LoadBranches(0)]),
matches!(effects.as_slice(), [Effect::LoadBranches]),
"{effects:?}"
);
}
@@ -1757,31 +1735,24 @@ mod tests {
#[test]
fn submitting_an_empty_form_reports_the_missing_field_instead_of_posting() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
app.dispatch(Action::OpenSpawnModal);
// Walk to Submit without picking anything.
for _ in 0..4 {
app.handle_draft_input(&InputKey::Tab);
}
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!("expected a draft pane");
};
assert_eq!(
draft.error.as_deref(),
app.draft.as_ref().unwrap().error.as_deref(),
Some("Pick a working directory."),
"the refusal shows in the form"
);
// With a directory but an empty custom command, it names the command.
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
panic!();
};
let draft = app.draft.as_mut().unwrap();
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
draft.selected_preset = draft.presets.len() - 1; // custom, empty text
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!();
};
let draft = app.draft.as_ref().unwrap();
assert_eq!(
draft.error.as_deref(),
Some("Pick a command (or fill the custom command).")
@@ -1802,7 +1773,7 @@ mod tests {
Duration::from_millis(500),
),
);
app.dispatch(Action::NewDraftPane);
app.dispatch(Action::OpenSpawnModal);
let req = CreateSessionRequest {
cwd: std::path::PathBuf::from("/srv/work"),
command: "bash".into(),
@@ -1812,17 +1783,16 @@ mod tests {
};
app.submit_spawn(req).await;
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!("expected a draft pane");
};
let draft = app.draft.as_ref().expect("the form stays up on failure");
let error = draft
.error
.as_deref()
.expect("spawn failure must reach the form");
assert!(error.starts_with("spawn failed"), "{error}");
assert!(
matches!(app.state.overlay(), None | Some(OverlayKind::Toast(_))),
"no kill-confirm style overlay took over"
assert_eq!(
app.state.overlay(),
Some(&OverlayKind::Spawn),
"the form stays up so the error is readable next to the fields"
);
}
@@ -1866,8 +1836,8 @@ mod tests {
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base);
let effects = app.dispatch(Action::NewDraftPane);
assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)]));
let effects = app.dispatch(Action::OpenSpawnModal);
assert!(matches!(effects.as_slice(), [Effect::LoadDraft]));
// `perform` only spawns the load — it must not block the event loop on
// the network (#67 review). The result arrives on the draft channel,
// which the run loop drains; do that by hand here.
@@ -1878,9 +1848,7 @@ mod tests {
.expect("channel open");
app.apply_draft_update(update).await;
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!("expected a draft pane");
};
let draft = app.draft.as_ref().unwrap();
assert_eq!(draft.command(), "aider");
assert_eq!(draft.groups.len(), 1);
assert_eq!(draft.groups[0].main.name, "maestro");
@@ -1888,9 +1856,9 @@ mod tests {
assert_eq!(draft.name.value(), "crimson-otter");
}
/// Keys fed while no draft exists must be ignored, not panic.
/// Keys fed while the form is not up must be ignored, not panic.
#[test]
fn draft_input_is_a_no_op_without_a_draft() {
fn draft_input_is_a_no_op_without_a_form() {
let mut app = make_app();
assert!(app.handle_draft_input(&InputKey::char('x')).is_empty());
}
@@ -1923,14 +1891,6 @@ mod tests {
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
/// A draft pane is not a session: forwarding must not try to reach it.
#[test]
fn forwarding_is_a_no_op_with_a_draft_pane() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
#[test]
fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
use crate::session_task::{SessionHandle, SessionState};
@@ -1948,7 +1908,7 @@ mod tests {
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
};
let mut app = make_app();
app.state.active_tab_mut().pane = Some(PaneRef::Session(sid));
app.state.active_tab_mut().pane = Some(sid);
app.emulators.insert(sid, handle);
app.scroll_active_pane(3);
+3 -3
View File
@@ -48,8 +48,8 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("c", "ToggleSidebarGroup"),
// Catalog actions
("enter", "AttachHighlighted"),
("s", "NewDraftPane"),
// Fast-forward spawn (#67): the draft-pane key's neighbour does the same
("s", "OpenSpawnModal"),
// Fast-forward spawn (#67): the spawn key's neighbour does the same
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
@@ -204,7 +204,7 @@ mod tests {
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "s"),
Some(Action::NewDraftPane),
Some(Action::OpenSpawnModal),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "S"),
+5 -5
View File
@@ -1,9 +1,9 @@
//! Draft pane — the spawn form living in an ordinary pane (issues #63/#64).
//! The spawn form (issues #63/#64), drawn inside the spawn modal.
//!
//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands
//! the caller a built request and the pane is replaced with the new session,
//! matching the web client's inline draft panes (#57). Field editing goes
//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields.
//! Submitting hands the caller a built request; the modal closes and the new
//! session takes the pane, matching the web client's spawn modal. Field
//! editing goes through [`crate::text_input::TextInput`]; Tab / BackTab walk
//! the fields.
//!
//! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`):
//! command presets from `/api/info` (with an implicit `custom` free-text
+3 -3
View File
@@ -1,11 +1,11 @@
//! Fast-forward spawn (#67): one keystroke, every default resolved, no form.
//!
//! The defaults mirror what a draft pane would settle on after the operator
//! The defaults mirror what the spawn form 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
//! name — downgrades the keystroke to the spawn form pre-filled with whatever
//! did resolve ([`FastForwardDefaults::apply_to_draft`]).
use std::path::PathBuf;
@@ -85,7 +85,7 @@ impl FastForwardDefaults {
})
}
/// Seed a fresh draft pane with whatever did resolve — the fallback when
/// Seed a fresh spawn form 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 {
+37 -5
View File
@@ -1,8 +1,5 @@
//! Overlay widgets: kill-confirm prompt, rename prompt, help overlay,
//! transient toast.
//!
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
//! panes ([`crate::draft::DraftPane`]).
//! Overlay widgets: spawn modal, kill-confirm prompt, rename prompt, help
//! overlay, transient toast.
//!
//! All overlays are centered popups drawn over the rest of the UI; the
//! `Clear` widget wipes the underlying buffer before drawing.
@@ -15,6 +12,27 @@ use ratatui::Frame;
use crate::text_input::TextInput;
/// Chrome for the spawn form. The form itself ([`crate::draft::DraftPane`])
/// draws into the rect this returns, so the two can be sized independently.
#[derive(Debug, Clone, Copy)]
pub struct SpawnModal;
impl SpawnModal {
/// Draw the frame and return the area the form should render into.
pub fn render(&self, f: &mut Frame, parent: Rect) -> (Rect, Rect) {
// Tall enough for the whole form — presets, picker, name and the
// advanced section — without pushing past small terminals.
let area = centered(parent, 72, parent.height.saturating_sub(4).min(26));
f.render_widget(Clear, area);
let block = Block::default()
.title(" new session ")
.borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
(area, inner)
}
}
#[derive(Debug, Clone)]
pub struct KillConfirm {
pub label: String,
@@ -168,6 +186,20 @@ mod tests {
out
}
#[test]
fn spawn_modal_frames_an_inner_area_inside_its_border() {
let mut inner = None;
let rendered = render_to_string(90, 30, |f| {
let (area, i) = SpawnModal.render(f, f.area());
inner = Some((area, i));
f.render_widget(Paragraph::new("form goes here"), i);
});
let (area, i) = inner.unwrap();
assert!(i.x > area.x && i.y > area.y, "{area:?} vs {i:?}");
assert!(rendered.contains("new session"), "{rendered}");
assert!(rendered.contains("form goes here"), "{rendered}");
}
#[test]
fn kill_confirm_renders_prompt() {
let modal = KillConfirm {
+1 -1
View File
@@ -1,4 +1,4 @@
//! Pure helpers behind the draft pane's project picker (#64): staleness,
//! Pure helpers behind the spawn form's project picker (#64): staleness,
//! recency ordering, worktree grouping, and free-text filtering. Mirrors
//! `web/src/projectList.ts` so both clients order and dim identically — the
//! daemon reports the timestamps; what "stale" means is a client decision
+23 -22
View File
@@ -10,7 +10,7 @@ use ratatui::Frame;
use crate::input::Region;
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, Toast};
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, SpawnModal, Toast};
use crate::palette;
use crate::pane::Pane;
use crate::sidebar::Sidebar;
@@ -77,7 +77,7 @@ pub struct RenderInputs<'a> {
pub home_dir: &'a str,
pub pane_title: &'a str,
pub pane: Option<&'a Pane>,
/// Inline spawn form when the pane is a draft (issue #63).
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
pub help: &'a HelpOverlay,
@@ -139,9 +139,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
if let Some(p) = inputs.pane {
p.render(f, pane_area);
}
if let Some(d) = inputs.draft {
d.render(f, pane_area, pane_focused);
}
// `RegionMap::pane` is also the PTY resize source. The title bar is
// chrome; every remaining cell belongs to the emulator.
map.pane = Some(pane_area);
@@ -151,6 +148,15 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
render_status_bar(f, status_area, inputs.state);
match inputs.state.overlay() {
Some(OverlayKind::Spawn) => {
if let Some(d) = inputs.draft {
let (area, inner) = SpawnModal.render(f, area);
// The modal owns the keyboard while it is up, so the form
// always draws focused.
d.render(f, inner, true);
map.modal = Some(area);
}
}
Some(OverlayKind::KillConfirm(id)) => {
// #83: surface what the watcher last saw in the worktree, in
// InTheWay's own words, before the operator commits to the kill.
@@ -224,7 +230,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
Focus::Pane => "pane",
};
let hint = match state.mode {
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
Mode::Insert => "^a: return to sidebar",
};
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
@@ -325,20 +331,16 @@ mod tests {
);
}
/// Issue #63: an empty pane is a draft — the frame must show the spawn
/// form where the terminal would otherwise be.
/// The spawn form is a modal: the frame shows it over whatever the pane
/// was displaying, framed by its own border.
#[test]
fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() {
fn the_spawn_overlay_renders_the_form_in_a_modal() {
use crate::overlays::HelpOverlay;
use crate::state::PaneRef;
use std::collections::HashSet;
let mut state = AppState::new();
state.open_draft_pane();
let draft = match &state.tabs[0].pane {
Some(PaneRef::Draft(d)) => d,
other => panic!("expected a draft pane, got {other:?}"),
};
state.open_spawn_modal();
let draft = crate::draft::DraftPane::default();
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
@@ -348,9 +350,9 @@ mod tests {
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
pane_title: "new session",
pane_title: "session",
pane: None,
draft: Some(draft),
draft: Some(&draft),
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
@@ -358,11 +360,10 @@ mod tests {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.unwrap();
let mut map = None;
terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
// Mouse clicks inside the form must not fall through to the pane.
assert!(map.unwrap().modal.is_some());
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
+33 -55
View File
@@ -1,9 +1,8 @@
//! `AppState` — the top-level mutable state of the running TUI.
//!
//! v1 keeps it small on purpose: one pane containing a session or draft form,
//! focus split between sidebar and pane, the current input mode, and an optional overlay (kill confirm,
//! help, transient toast). The spawn form is no longer an overlay — an empty
//! pane is a draft pane (issue #63).
//! v1 keeps it small on purpose: one pane containing a session, focus split
//! between sidebar and pane, the current input mode, and an optional overlay
//! (spawn form, kill confirm, rename, help, transient toast).
//!
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
@@ -12,7 +11,6 @@ use std::path::PathBuf;
use uuid::Uuid;
use crate::draft::DraftPane;
use crate::keymap::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -31,34 +29,24 @@ pub enum SidebarItem {
Session(Uuid),
}
/// What a tab's pane slot holds: a live session, or an inline draft form.
// The draft variant dwarfs `Session(Uuid)`; the pane is moved rarely enough
// that boxing every access costs more than the copy does.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
pub enum PaneRef {
Session(Uuid),
Draft(DraftPane),
}
#[derive(Debug, Clone)]
pub struct Tab {
pub name: String,
pub pane: Option<PaneRef>,
pub pane: Option<Uuid>,
}
impl Tab {
/// The session this tab shows, if it shows one. Draft panes have none.
/// The session this tab shows, if it shows one.
pub fn session(&self) -> Option<Uuid> {
match &self.pane {
Some(PaneRef::Session(sid)) => Some(*sid),
_ => None,
}
self.pane
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind {
/// The spawn form. Its [`crate::draft::DraftPane`] lives on `App` for the
/// same reason the rename buffer does — so this stays comparable.
Spawn,
KillConfirm(Uuid),
/// Rename prompt for this session (#125). The edit buffer lives on `App`,
/// so this stays comparable.
@@ -138,26 +126,23 @@ impl AppState {
/// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal.
pub fn attach_to_focused(&mut self, session_id: Uuid) {
self.active_tab_mut().pane = Some(PaneRef::Session(session_id));
self.active_tab_mut().pane = Some(session_id);
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// Replace the pane with a draft form. Focus lands in the pane, Insert
/// mode on, so typing starts immediately, same flow as
/// [`Self::attach_to_focused`].
pub fn open_draft_pane(&mut self) {
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
/// Put the spawn form up. Whatever the pane was showing stays behind it,
/// so dismissing gives that back untouched — the modal owns the keyboard
/// while it is up, so focus and mode are left alone.
pub fn open_spawn_modal(&mut self) {
self.show_overlay(OverlayKind::Spawn);
}
/// A draft submit succeeded: replace whatever the active tab showed with
/// the new session in place, matching the web client's behaviour (#57).
/// A spawn submit succeeded: the modal closes and the new session takes
/// the pane, attached and ready to type into.
pub fn complete_spawn(&mut self, sid: Uuid) {
self.active_tab_mut().pane = Some(PaneRef::Session(sid));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
self.dismiss_overlay();
self.attach_to_focused(sid);
}
/// The session shown by the active tab's pane, if any.
@@ -385,35 +370,28 @@ mod tests {
assert!(state.overlay().is_none());
}
/// Issue #63: `s` over an empty pane turns that pane into a draft —
/// focused, Insert mode on, typing starts immediately.
/// The modal covers the pane rather than replacing it: an attached
/// session is still there once the form is dismissed.
#[test]
fn open_draft_reuses_the_empty_active_pane() {
fn open_spawn_modal_leaves_the_pane_alone() {
let mut state = AppState::new();
state.open_draft_pane();
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
assert_eq!(state.focus, Focus::Pane);
assert_eq!(state.mode, Mode::Insert);
assert_eq!(state.tabs.len(), 1);
let sid = Uuid::from_u128(9);
state.attach_to_focused(sid);
state.open_spawn_modal();
assert_eq!(state.overlay(), Some(&OverlayKind::Spawn));
assert_eq!(state.active_tab().session(), Some(sid));
state.dismiss_overlay();
assert_eq!(state.active_tab().session(), Some(sid));
}
#[test]
fn open_draft_over_a_session_reuses_the_pane() {
fn complete_spawn_dismisses_the_modal_and_attaches() {
let mut state = AppState::new();
state.attach_to_focused(Uuid::from_u128(9));
state.open_draft_pane();
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.active_tab_idx, 0);
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
}
/// Web parity (#57): submitting fills the pane in place.
#[test]
fn complete_spawn_replaces_the_draft_with_the_session() {
let mut state = AppState::new();
state.open_draft_pane();
state.open_spawn_modal();
let sid = Uuid::from_u128(21);
state.complete_spawn(sid);
assert!(state.overlay().is_none());
assert_eq!(state.active_tab().session(), Some(sid));
assert_eq!(state.focus, Focus::Pane);
assert_eq!(state.mode, Mode::Insert);
+14
View File
@@ -57,6 +57,10 @@ enum Command {
/// Kill a session by ID.
#[cfg(feature = "cli")]
Kill(maestro_cli::cli::KillArgs),
/// Type text into a running session, as if it had been typed on an
/// attached terminal.
#[cfg(feature = "cli")]
Send(maestro_cli::cli::SendArgs),
/// Install or update the agent activity hook (#106).
#[cfg(feature = "cli")]
Hooks(maestro_cli::cli::HooksArgs),
@@ -144,6 +148,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
.await?
}
#[cfg(feature = "cli")]
Command::Send(args) => {
maestro_cli::dispatch(
maestro_cli::cli::Command::Send(args),
cli.config,
cli.daemon_url,
)
.await?
}
#[cfg(feature = "cli")]
Command::Hooks(args) => {
maestro_cli::dispatch(
maestro_cli::cli::Command::Hooks(args),
@@ -190,6 +203,7 @@ fn is_cli_subcommand(command: &Option<Command>) -> bool {
| Some(Command::Spawn(_))
| Some(Command::Attach(_))
| Some(Command::Kill(_))
| Some(Command::Send(_))
| Some(Command::Hooks(_))
)
}
+1 -1
View File
@@ -140,7 +140,7 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
[tui.keymap.normal]
"i" = "EnterInsert"
"s" = "NewDraftPane"
"s" = "OpenSpawnModal"
"x" = "KillHighlighted"
"r" = "RenameHighlighted"
"?" = "ShowHelp"
+3 -2
View File
@@ -22,8 +22,9 @@ src/
events.ts # `/api/events` WebSocket subscriber
catalog.ts # session tree view (groups by working directory)
attach.ts # xterm.js + attach WS view
multiview.ts # tabs + split panes; draft panes host the spawn form
multiview.ts # tabs + split panes
spawnForm.ts # shared spawn form (preset tiles, project picker, worktree)
spawnModal.ts # dialog chrome around the spawn form
theme.ts # dark/light theme toggle + persistence
types.ts # mirror of `maestro-protocol` JSON shapes
styles.css # design-system tokens + component styles
@@ -41,7 +42,7 @@ follows the OS by default and can be pinned via the header toggle (persisted in
`mock.ts` is a DEV-only fixture (stripped from production builds): visit the dev
server with `?mock` to seed a representative catalog without a running daemon,
`?mock&spawn` to open a draft pane, and `?theme=light|dark` to pin a theme.
`?mock&spawn` to open the spawn modal, and `?theme=light|dark` to pin a theme.
Routing is hash-based:
- `#/` → catalog
+4 -4
View File
@@ -26,7 +26,7 @@ import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
/** Ask the shell to open a new draft pane (#62). Handled in main.ts, where the
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
* multi-view controller lives; dispatched from every Spawn affordance. */
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
export function requestOpenSpawn(): void {
@@ -38,7 +38,7 @@ export function requestOpenSpawn(): void {
* connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to open a draft pane seeded from an existing session
/** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
@@ -395,7 +395,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
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.title = "Spawn form: n — fast-forward spawn: N";
spawnBtn.addEventListener("click", () => requestOpenSpawn());
footer.appendChild(spawnBtn);
el.appendChild(footer);
@@ -578,7 +578,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
document.addEventListener("catalog-rerender", rerenderHandler);
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
// multi-view controller can seed a draft pane with the session's dir/command.
// multi-view controller can seed the spawn modal with the session's dir/command.
const unsubscribe = store.subscribe(refresh);
+3 -3
View File
@@ -9,7 +9,7 @@
// ↑/↓ move the highlight
// Enter accept the highlighted entry (and suppress form submit)
// Esc close the dropdown, refocus the input, and swallow the key so it
// can't also discard a surrounding draft pane
// can't also discard the surrounding spawn modal
//
// Once dismissed (Esc or blur) the popup stays closed until the operator types
// again — refocusing alone doesn't reopen it.
@@ -181,8 +181,8 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
if (e.key === "Escape") {
if (open) {
e.preventDefault();
// Swallow the key: an Escape that bubbles would also discard the
// draft pane hosting this form.
// Swallow the key: an Escape that bubbles would also close the
// spawn modal hosting this form.
e.stopPropagation();
dismiss();
input.focus();
+5 -5
View File
@@ -1,12 +1,12 @@
// 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.
// and falls back to a seeded spawn form 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". */
* form pre-filled with the rest instead of spawning". */
export interface FastForwardResolution {
project?: Project;
command?: string;
@@ -47,7 +47,7 @@ export function buildFastForwardRequest(
};
}
/** Seed for the fallback draft pane: whatever did resolve goes into the
/** Seed for the fallback spawn form: whatever did resolve goes into the
* form; the rest stays with its own defaults. */
export function fastForwardSeed(res: FastForwardResolution) {
return {
@@ -55,8 +55,8 @@ export function fastForwardSeed(res: FastForwardResolution) {
command: res.command ?? "",
name: res.name ?? "",
// Left undefined rather than false when nothing resolved: the form
// defaults the toggle on, and `false` would stick, so the fallback draft
// would disagree with both a normally-opened draft and the TUI's fast path
// defaults the toggle on, and `false` would stick, so the fallback form
// would disagree with both a normally-opened form and the TUI's fast path
// (#67 review).
worktreeEnabled: res.project ? true : undefined,
worktreeBranch:
+1 -1
View File
@@ -17,7 +17,7 @@ export const SHORTCUTS: ShortcutGroup[] = [
{
title: "Sessions",
items: [
{ keys: ["n"], description: "Open a spawn draft pane" },
{ keys: ["n"], description: "Open the spawn form" },
{ keys: ["N"], description: "Spawn a session with every default resolved" },
],
},
+2 -17
View File
@@ -11,23 +11,8 @@ import type { Uuid } from "./types.js";
export type Dir = "row" | "col";
/** Half-filled spawn-form state for a draft pane (issue #57): an empty leaf
* renders the spawn form instead of a placeholder, and each pane's values
* hang off its own leaf so two drafts stay independent. Never persisted —
* a reload gives back an empty form (persistence strips it). */
export interface Draft {
command: string;
cwd: string;
name: string;
worktreeEnabled?: boolean;
worktreeBranch?: string;
worktreeCleanup?: boolean;
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
cwdRawVisible?: boolean;
}
export type Node =
| { kind: "leaf"; id: string; sessionId: Uuid | null; draft?: Draft | null }
| { kind: "leaf"; id: string; sessionId: Uuid | null }
| { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number };
export type Leaf = Extract<Node, { kind: "leaf" }>;
@@ -104,7 +89,7 @@ export function splitLeaf(
dir: Dir,
after: boolean,
): string {
const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId, draft: null };
const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId };
const rec = (n: Node): Node => {
if (n.kind === "leaf") {
if (n.id !== targetId) return n;
+28 -20
View File
@@ -21,6 +21,7 @@ import {
resolveFastForward,
} from "./fastForward.js";
import { FALLBACK_PRESETS } from "./spawnForm.js";
import { openSpawnModal } from "./spawnModal.js";
import { spawnSimilarSeed } from "./spawnSimilar.js";
import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
@@ -219,8 +220,8 @@ mv = createMultiView({
infoFor,
onDisconnect: closePane,
renderWelcome,
// A draft-pane submit (#57) fills its leaf in place; the shell route may flip
// from catalog (no sessions) to attach.
// A spawn fills a pane with its new session; the shell route may flip from
// catalog (no sessions) to attach.
onOpenSessionsChange: updateShellRoute,
onLayoutChange: scheduleSave,
});
@@ -234,32 +235,37 @@ function showMultiView(): void {
updateShellRoute();
}
// Spawn affordances (#62): the sidebar's footer button, its empty state, and
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the
// Spawn affordances: the sidebar's footer button, its empty state, and
// `?mock&spawn` all open the spawn modal; "Spawn similar" seeds one from the
// cloned session (see spawnSimilar.ts).
function requestOpenSpawn(): void {
showMultiView();
// A new tab, not a split of the pane you were reading (#100). "Spawn
// similar" and the fast-forward key below deliberately keep the old
// behaviour: both act on the session in front of you, so a split beside it
// is the point.
mv.openDraft(undefined, { newTab: true });
// The session lands in a new tab, not a split of the pane you were reading
// (#100). "Spawn similar" and the fast-forward key below deliberately keep
// the old behaviour: both act on the session in front of you, so a split
// beside it is the point.
openSpawnModal({ onSuccess: (session) => mountSpawned(session, { newTab: true }) });
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
const s = (ev as CustomEvent<Session>).detail;
showMultiView();
mv.openDraft(spawnSimilarSeed(s));
openSpawnModal({ seed: spawnSimilarSeed(s), onSuccess: (session) => mountSpawned(session) });
});
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
/** Show a freshly spawned session: bring the multi-view up (the shell may still
* be on the catalog route) and give the session a pane. */
function mountSpawned(session: Session, placement?: { newTab?: boolean }): void {
showMultiView();
mv.openSessionBeside(session.id, placement);
updateShellRoute();
}
// Fast-forward spawn (#67): the spawn modal'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
// known yet, no command, no generated name) opens the form 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)
@@ -281,18 +287,20 @@ async function requestFastForward(): Promise<void> {
// Mount it, the way the TUI's fast path attaches. Without this the
// keystroke creates a session and leaves the UI untouched, so it reads
// as a dead key (#67 review).
mv.openSessionBeside(session.id);
updateShellRoute();
mountSpawned(session);
return;
} catch {
// A failed POST degrades to the same pre-filled draft as an
// A failed POST degrades to the same pre-filled form as an
// unresolvable default — never a silent dead keystroke.
}
}
mv.openDraft(fastForwardSeed(resolved));
openSpawnModal({
seed: fastForwardSeed(resolved),
onSuccess: (session) => mountSpawned(session),
});
}
// Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn.
// Keyboard pair (#67): `n` opens the spawn modal, `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) => {
+1 -1
View File
@@ -147,7 +147,7 @@ export function seedMock(store: CatalogStore): void {
sessions,
});
// `?mock&spawn` auto-opens a draft pane for visual review, with the spawn
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
// backend endpoints stubbed so presets/projects/gitroot all render.
if (location.search.includes("spawn")) {
stubSpawnApi();
+24 -161
View File
@@ -55,12 +55,12 @@ function makeMv(layout: Layout): MultiView {
});
}
/** Drain the microtask queue so the draft form's async seed application lands. */
/** Drain the microtask queue so any pending async work lands. */
async function flush(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
describe("multiview openDraft (#62)", () => {
describe("multiview empty panes", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
@@ -70,144 +70,23 @@ describe("multiview openDraft (#62)", () => {
vi.unstubAllGlobals();
});
it("turns an empty tab into a draft pane", async () => {
const layout = emptyLayout();
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.openDraft();
await flush();
const ls = leaves(layout.tabs[0].root);
expect(ls).toHaveLength(1);
expect(ls[0].sessionId).toBeNull();
expect(ls[0].draft).not.toBeNull();
expect(mv.el.querySelector(".mv-draft")).toBeTruthy();
});
it("splits the focused session pane into a fresh draft", async () => {
it("renders a placeholder, not a spawn form", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = first;
tab.focusedLeafId = splitLeaf(tab, first, null, "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openDraft();
await flush();
const ls = leaves(tab.root);
expect(ls).toHaveLength(2);
expect(ls[0].sessionId).toBe("s1");
const draftLeaf = ls.find((l) => l.sessionId === null)!;
expect(draftLeaf.draft).not.toBeNull();
expect(tab.focusedLeafId).toBe(draftLeaf.id);
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
});
it("reuses the focused pane when it is already empty", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openDraft();
await flush();
const ls = leaves(tab.root);
expect(ls).toHaveLength(2);
const target = ls.find((l) => l.id === empty)!;
expect(target.draft).not.toBeNull();
expect(tab.focusedLeafId).toBe(empty);
});
it("keeps the draft form instance when an unrelated session closes (#68)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const s1 = splitLeaf(tab, "", "s1", "row", true);
const draftLeaf = splitLeaf(tab, s1, null, "row", true);
tab.focusedLeafId = draftLeaf;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement;
nameInput.value = "wip";
nameInput.dispatchEvent(new Event("input"));
await flush();
expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip");
const formBefore = mv.el.querySelector(".mv-draft");
mv.closeSession("s1");
await flush();
expect(mv.el.querySelector(".mv-draft")).toBe(formBefore);
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
});
it("reuses an already-synced empty pane instead of splitting (#72)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
// Let the mounted form's loadProjects() settle and auto-sync its picked
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
await flush();
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
mv.openDraft();
await flush();
expect(leaves(tab.root)).toHaveLength(2);
expect(tab.focusedLeafId).toBe(empty);
});
it("keeps a seed after reusing an already-synced empty pane (#72)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
await flush();
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
expect(leaf.draft?.cwd).toBe("/home/user/proj");
expect(leaf.draft?.command).toBe("claude");
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
expect(cwdRaw.value).toBe("/home/user/proj");
});
it("seeds a clone's cwd and command into the draft", async () => {
const layout = emptyLayout();
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
await flush();
const leaf = leaves(layout.tabs[0].root)[0];
expect(leaf.draft?.cwd).toBe("/home/user/proj");
expect(leaf.draft?.command).toBe("claude");
expect(mv.el.querySelector(".mv-pane-empty")).toBeTruthy();
expect(mv.el.querySelector(".spawn-form")).toBeNull();
});
});
describe("multiview pane-drag swap (#73)", () => {
describe("multiview pane-drag swap", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
@@ -223,8 +102,8 @@ describe("multiview pane-drag swap (#73)", () => {
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
// The real drag-start gate requires the pointerdown target to sit inside
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
// no such element, so stand one in for the gate check itself.
// an `.attach-header` (attach.ts's session title bar), which the fake pool
// never mounts; stand one in for the gate check itself.
const marker = document.createElement("div");
marker.className = "attach-header";
sourcePane.appendChild(marker);
@@ -254,40 +133,26 @@ describe("multiview pane-drag swap (#73)", () => {
await flush();
}
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
it("swaps the two panes' sessions when one is dropped on the other's centre", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const leaf1 = splitLeaf(tab, "", null, "row", true);
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
const leaf1 = splitLeaf(tab, "", "s1", "row", true);
const leaf2 = splitLeaf(tab, leaf1, "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
nameInputOf(leaf1).value = "west";
nameInputOf(leaf1).dispatchEvent(new Event("input"));
nameInputOf(leaf2).value = "east";
nameInputOf(leaf2).dispatchEvent(new Event("input"));
await flush();
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
// The leaf data swaps (already correct pre-fix)...
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
// ...and the mounted forms must follow, not stay behind showing the old
// pane's values (#73).
expect(nameInputOf(leaf1).value).toBe("east");
expect(nameInputOf(leaf2).value).toBe("west");
expect(leaves(tab.root).find((l) => l.id === leaf1)?.sessionId).toBe("s2");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.sessionId).toBe("s1");
expect(tab.focusedLeafId).toBe(leaf2);
});
});
describe("openDraft newTab (#100)", () => {
describe("openSessionBeside newTab (#100)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
@@ -306,7 +171,7 @@ describe("openDraft newTab (#100)", () => {
document.body.appendChild(mv.el);
mv.render();
mv.openDraft(undefined, { newTab: true });
mv.openSessionBeside("s2", { newTab: true });
await flush();
expect(layout.tabs).toHaveLength(2);
@@ -314,11 +179,10 @@ describe("openDraft newTab (#100)", () => {
const original = leaves(layout.tabs[0].root);
expect(original).toHaveLength(1);
expect(original[0].sessionId).toBe("s1");
// The draft is alone in the new, now-active tab.
// The new session is alone in the new, now-active tab.
const fresh = leaves(layout.tabs[1].root);
expect(fresh).toHaveLength(1);
expect(fresh[0].sessionId).toBeNull();
expect(fresh[0].draft).not.toBeNull();
expect(fresh[0].sessionId).toBe("s2");
expect(layout.activeTabId).toBe(layout.tabs[1].id);
});
@@ -330,7 +194,7 @@ describe("openDraft newTab (#100)", () => {
document.body.appendChild(mv.el);
mv.render();
mv.openDraft();
mv.openSessionBeside("s2");
await flush();
expect(layout.tabs).toHaveLength(1);
@@ -399,7 +263,7 @@ describe("multiview closeSession tab cleanup (#137)", () => {
expect(leaves(tab.root)).toHaveLength(1);
});
it("keeps a tab holding a draft pane", async () => {
it("keeps a tab holding an empty pane", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const s1 = splitLeaf(tab, "", "s1", "row", true);
@@ -478,7 +342,7 @@ describe("multiview mobile single pane", () => {
expect(layout.tabs).toHaveLength(1);
});
it("keeps a spawn draft in the current pane rather than a new tab", async () => {
it("keeps a spawned session in the current pane rather than a new tab", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
@@ -486,14 +350,13 @@ describe("multiview mobile single pane", () => {
document.body.appendChild(mv.el);
mv.render();
mv.openDraft(undefined, { newTab: true });
mv.openSessionBeside("s2", { newTab: true });
await flush();
expect(layout.tabs).toHaveLength(1);
const ls = leaves(tab.root);
expect(ls).toHaveLength(1);
expect(ls[0].sessionId).toBeNull();
expect(ls[0].draft).not.toBeNull();
expect(ls[0].sessionId).toBe("s2");
});
it("renders one pane and no pane pills for a multi-leaf tab", () => {
+22 -129
View File
@@ -1,9 +1,8 @@
// The multi-view renderer/controller (issues #30 splits, #31 tabs, #33
// highlights). Owns the tab bar + canvas inside the content pane, builds the
// active tab's split tree into DOM, and mounts a live AttachConnection into each
// session leaf via the pool. Empty leaves are draft panes (#57): they render
// the spawn form inline, and submitting replaces the draft with that session's
// terminal in place. Drag-to-dock is issue #32; mobile projection is #35.
// session leaf via the pool. Empty leaves (freshly split, not yet filled) show a
// placeholder. Drag-to-dock is issue #32; mobile projection is #35.
import type { SidebarHandle } from "./catalog.js";
import type { ConnectionPool } from "./connections.js";
@@ -13,7 +12,6 @@ import {
closeTab,
findLeaf,
type Dir,
type Draft,
type Layout,
leaves,
locateSession,
@@ -23,7 +21,6 @@ import {
splitLeaf,
} from "./layout.js";
import type { AttachInfo } from "./attach.js";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
export interface MultiViewOptions {
pool: ConnectionPool;
@@ -35,8 +32,8 @@ export interface MultiViewOptions {
onDisconnect: (sessionId: string) => void;
/** Welcome content for an empty tab. */
renderWelcome: () => HTMLElement;
/** Called when a draft-pane submit turns an empty pane into a session pane,
* so the shell can re-evaluate its route (sidebar vs attach layout). */
/** Called when a spawn fills a pane with a new session, so the shell can
* re-evaluate its route (sidebar vs attach layout). */
onOpenSessionsChange?: () => void;
/** Called after any layout change (structure, focus, or divider resize) so the
* shell can persist it (issue #34). */
@@ -48,15 +45,11 @@ export interface MultiView {
/** Bring a session into the layout: focus it if already open, else fill the
* focused pane (or open the first pane in an empty tab). */
openSession: (sessionId: string) => void;
openSessionBeside: (sessionId: string) => void;
openSessionBeside: (sessionId: string, opts?: { newTab?: boolean }) => void;
/** Drop the pane showing `sessionId` (the session was disconnected/killed). */
closeSession: (sessionId: string) => void;
/** Every session currently shown in some pane, across all tabs. */
openSessions: () => Set<string>;
/** Open a draft pane (issue #62): reuse the focused pane if it is already
* empty, otherwise split the focused pane. `seed` pre-fills the form
* ("Spawn similar" clones a session's cwd + command). */
openDraft: (seed?: Partial<Draft>, opts?: { newTab?: boolean }) => void;
/** Start dragging a sidebar session into the canvas (issue #32). The
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
* drag shows drop zones to place it. */
@@ -70,12 +63,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
const persist = () => opts.onLayoutChange?.();
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms
// whose leaves are gone are destroyed on each render. Forms for draft leaves
// that survive the render are reused in place (#68): rebuilding one mid-edit
// would drop focus, caret and selection, and re-fetch info/projects/names.
const draftForms = new Map<string, SpawnFormHandle>();
const el = document.createElement("div");
el.className = "mv";
const tabBar = document.createElement("div");
@@ -141,38 +128,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const host = document.createElement("div");
host.className = "mv-pane-host";
if (!leaf.sessionId) {
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
// leaf (never persisted) so each draft keeps its own half-filled state;
// submitting fills this leaf with the new session in place.
const form = draftForms.get(leaf.id);
if (form) {
host.appendChild(form.el);
} else {
const created = createSpawnForm({
seed: leaf.draft ?? undefined,
class: "mv-draft",
onChange: (values) => {
leaf.draft = values;
},
onSuccess: (session) => {
// The pane can be closed while the POST is in flight. Mounting the
// session onto a leaf no longer in the tree would strand it in no
// pane at all, so only claim the leaf while it is still there —
// searching every tab, since the operator may have switched tabs
// while the request was in flight.
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
if (host) {
leaf.sessionId = session.id;
leaf.draft = null;
host.focusedLeafId = leaf.id;
}
render();
opts.onOpenSessionsChange?.();
},
});
draftForms.set(leaf.id, created);
host.appendChild(created.el);
}
const empty = document.createElement("div");
empty.className = "mv-pane-empty";
empty.textContent = "Empty pane — pick a session from the sidebar, or press n to spawn.";
host.appendChild(empty);
}
const tools = document.createElement("div");
@@ -365,7 +324,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// Fill the focused pane (VS Code style: open replaces the active group).
const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0];
target.sessionId = sessionId;
target.draft = null;
tab.focusedLeafId = target.id;
render();
}
@@ -375,9 +333,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
/// `openSession` deliberately replaces the focused pane (VS Code style) for
/// a sidebar click, but the fast path must never do that: pressing the key
/// while attached would drop the running session out of the layout
/// entirely. Follows `openDraft`'s rule instead — reuse an empty pane, else
/// split beside the focused one (#67 review).
function openSessionBeside(sessionId: string): void {
/// entirely. Reuses an empty pane, else splits beside the focused one
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
/// operator was reading (#100) — what the Spawn button asks for.
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
@@ -385,6 +344,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
render();
return;
}
// Mobile keeps a single tab and a single pane, so a new tab is not an
// option there: the session takes over the pane below.
if (placement?.newTab && !isMobile) {
const fresh = addTab(layout);
fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true);
render();
opts.onOpenSessionsChange?.();
return;
}
const tab = activeTab(layout);
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
@@ -397,7 +365,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// evicted session keeps running and stays a tap away in the sidebar.
if (!target.sessionId || isMobile) {
target.sessionId = sessionId;
target.draft = null;
} else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
}
@@ -405,66 +372,8 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
opts.onOpenSessionsChange?.();
}
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
// Mobile keeps a single tab and a single pane: the spawn form takes over the
// current pane instead of opening a tab or a split.
if (opts?.newTab && !isMobile) {
// Spawn opens its own tab rather than halving whatever you were reading
// (#100). The empty-pane reuse below still applies within a tab.
const fresh = addTab(layout);
fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true);
findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft();
render();
return;
}
const tab = activeTab(layout);
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
// Empty tab (or stale focus): the draft becomes the first pane.
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
findLeaf(tab.root!, tab.focusedLeafId)!.draft = draft();
render();
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId || isMobile) {
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
// picked defaults into `draft` once ready, so that's never reliably null.
target.sessionId = null;
target.draft = draft();
const stale = draftForms.get(target.id);
if (stale) {
stale.destroy();
draftForms.delete(target.id);
}
} else {
// Split a fresh draft beside the focused pane.
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
findLeaf(tab.root, tab.focusedLeafId)!.draft = draft();
}
render();
}
// ---- top-level render ----
/** Destroy forms whose leaf is no longer a draft (leaf removed or filled by
* a session). Surviving draft leaves keep their form instance (#68). */
function pruneForms(tab: { root: Node | null }): void {
const alive = new Set(
tab.root
? leaves(tab.root)
.filter((l) => !l.sessionId)
.map((l) => l.id)
: [],
);
for (const [id, f] of draftForms) {
if (!alive.has(id)) {
f.destroy();
draftForms.delete(id);
}
}
}
function render(): void {
renderTabs();
canvas.replaceChildren();
@@ -478,7 +387,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
if (!tab.root) {
pruneForms(tab);
pool.setVisible([]);
pool.setFocus(null);
canvas.appendChild(renderWelcome());
@@ -494,14 +402,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (isMobile) {
renderMobile(tab);
pruneForms(tab);
persist();
return;
}
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
canvas.appendChild(buildTree(tab.root, hosts));
pruneForms(tab);
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
syncFocus();
persist();
@@ -697,17 +603,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (source.kind === "pane" && self) {
const dragged = findLeaf(tab.root, self)!;
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
[dragged.draft, target.draft] = [target.draft, dragged.draft];
// The leaf data moved but each pane's mounted form is still keyed to
// its old leaf (#73) — tear both down like openDraft's reuse case
// (#72) so render() remounts fresh forms seeded from the swapped data.
for (const id of [dragged.id, target.id]) {
const stale = draftForms.get(id);
if (stale) {
stale.destroy();
draftForms.delete(id);
}
}
tab.focusedLeafId = target.id;
} else if (sessionId) {
const loc = locateSession(layout, sessionId);
@@ -715,7 +610,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const t2 = findLeaf(tab.root, targetLeafId);
if (t2) {
t2.sessionId = sessionId;
t2.draft = null;
tab.focusedLeafId = t2.id;
}
}
@@ -741,7 +635,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
el,
openSession,
openSessionBeside,
openDraft,
closeSession,
openSessions: () => openSessions(layout),
beginSessionDrag,
+4 -33
View File
@@ -62,7 +62,7 @@ describe("hydrate", () => {
expect(leaves(layout!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s"]);
});
it("never restores draft state, even if a stale blob carries it", () => {
it("ignores leaf fields a stale blob carries that the layout no longer has", () => {
const layout = hydrate({
tabs: [
{ id: "t", root: { kind: "leaf", id: "l1", sessionId: null, draft: { command: "zsh", cwd: "/x" } } },
@@ -74,7 +74,7 @@ describe("hydrate", () => {
});
describe("serializeLayout", () => {
it("strips draft state so a reload gives back an empty form", () => {
it("round-trips the tree, its sessions and its split ratios", () => {
const layout = hydrate({
tabs: [
{
@@ -82,6 +82,7 @@ describe("serializeLayout", () => {
root: {
kind: "split",
dir: "row",
ratio: 0.3,
a: { kind: "leaf", id: "l1", sessionId: "s1" },
b: { kind: "leaf", id: "l2", sessionId: null },
},
@@ -89,40 +90,10 @@ describe("serializeLayout", () => {
],
activeTabId: "t",
}) as Layout;
const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!;
draftLeaf.draft = { command: "claude", cwd: "/home/user/project", name: "amber-fox" };
const restored = hydrate(JSON.parse(serializeLayout(layout)).layout);
expect(restored).not.toBeNull();
for (const leaf of leaves(restored!.tabs[0].root)) {
expect(leaf.draft ?? null).toBeNull();
}
expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]);
});
it("keeps the draft out of the stored blob itself, not just out of hydrate", () => {
// A draft pane only exists once a tab has split, so the strip has to walk
// into split branches; asserting on the restored layout alone passes even
// when the draft was written to localStorage, because hydrate drops it.
const layout = hydrate({
tabs: [
{
id: "t",
root: {
kind: "split",
dir: "row",
a: { kind: "leaf", id: "l1", sessionId: "s1" },
b: { kind: "leaf", id: "l2", sessionId: null },
},
},
],
activeTabId: "t",
}) as Layout;
const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!;
draftLeaf.draft = { command: "claude", cwd: "/home/user/secret-project", name: "amber-fox" };
const raw = serializeLayout(layout);
expect(raw).not.toContain("secret-project");
expect(raw).not.toContain("amber-fox");
expect((restored!.tabs[0].root as { ratio: number }).ratio).toBe(0.3);
});
});
+1 -20
View File
@@ -20,27 +20,8 @@ export function saveLayout(layout: Layout): void {
}
}
/** Serialise the layout for storage with draft state stripped (issue #57): a
* reload gives back an empty form, never a stale half-filled one. */
export function serializeLayout(layout: Layout): string {
const clean: Layout = {
...layout,
tabs: layout.tabs.map((t) => ({
...t,
root: stripDrafts(t.root),
})),
};
return JSON.stringify({ v: VERSION, layout: clean });
}
function stripDrafts(node: Node | null): Node | null {
if (!node) return node;
// Recurse: a draft pane only exists once a tab has split, so a shallow strip
// would persist every half-filled form it was meant to drop.
if (node.kind === "split") {
return { ...node, a: stripDrafts(node.a)!, b: stripDrafts(node.b)! };
}
return { ...node, draft: null };
return JSON.stringify({ v: VERSION, layout });
}
export function loadLayout(): Layout | null {
+11 -17
View File
@@ -18,9 +18,9 @@ import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset }
* from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing";
/** Seed values for the spawn form — used by "clone" to open a draft with the
* same cwd, command, and name as an existing session, and by draft panes to
* restore their own half-filled state after a re-render. */
/** Seed values for the spawn form — used by "Spawn similar" to open the modal
* with the same cwd, command, and name as an existing session, and by the
* fast-forward fallback to pre-fill whatever it did resolve. */
export interface SpawnFormSeed {
command?: string;
cwd?: string;
@@ -35,8 +35,7 @@ export interface SpawnFormSeed {
cwdRawVisible?: boolean;
}
/** The form's current values, reported on every change so a caller (the draft
* pane) can hang them off its layout leaf. */
/** The form's current values, reported on every change. */
export interface SpawnFormValues {
command: string;
cwd: string;
@@ -109,8 +108,6 @@ export interface SpawnFormHandle {
export interface SpawnFormOptions {
seed?: SpawnFormSeed;
/** Extra class(es) for the <form> root (e.g. `mv-draft` in a draft pane). */
class?: string;
/** Fired whenever any value changes (input, tile pick, checkbox). */
onChange?: (values: SpawnFormValues) => void;
/** Called with the created session after a successful submit. */
@@ -118,12 +115,12 @@ export interface SpawnFormOptions {
}
/** The spawn form itself — preset tiles, project picker, name, worktree.
* Mounted inline by draft panes (issue #57). */
* Mounted inside the spawn modal (spawnModal.ts). */
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const prefill = opts.seed ?? {};
const form = document.createElement("form");
form.className = `spawn-form${opts.class ? ` ${opts.class}` : ""}`;
form.className = "spawn-form";
form.innerHTML = `
<div class="field">
<span class="field-label">Command</span>
@@ -193,8 +190,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<button type="submit" class="btn btn-primary">Spawn</button>
</div>
`;
// Field queries are scoped to this form instance — several forms can be
// mounted at once (one per draft pane), so ids would collide.
// Field queries are scoped to this form instance rather than by id, so a
// second form mounted before the first is torn down cannot collide with it.
const errEl = form.querySelector(".form-error") as HTMLElement;
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
@@ -241,9 +238,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// `emit` stays silent until the initial load settles and goes silent again
// once the form is destroyed. Construction runs with `cwd === ""` and the
// seed is only restored two fetches later, so emitting in between would
// blank the leaf's draft; and `multiview.render()` rebuilds forms while the
// old one's fetches are still in flight, so a destroyed form emitting would
// clobber the live one's leaf (#57 review).
// report blank values as if the operator had cleared the form.
let ready = false;
let destroyed = false;
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
@@ -341,7 +336,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// ---- command tiles ---------------------------------------------------
// `focus` is false for the two initial applications (construction, and again
// when `/api/info` resolves): a draft pane must not grab the caret out from
// when `/api/info` resolves): the form must not grab the caret out from
// under the operator seconds after it mounts.
function selectPreset(id: string, focus = true): void {
selectedPresetId = id;
@@ -792,8 +787,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
/** Fetch the target repo's branches once per cwd. Like every async load in
* this form: token-guarded, and nothing touches the DOM (or emits) after
* destroy() — a late response into a rebuilt form would corrupt that
* form's draft (#57 review). */
* destroy() — a late response into a closed modal would throw. */
async function loadBranches(): Promise<void> {
if (!cwd || !inferredBaseRepo) return;
const token = ++branchToken;
+132
View File
@@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { closeSpawnModal, openSpawnModal } from "./spawnModal.js";
import type { Session } from "./types.js";
function stubFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: url.includes("/api/projects")
? []
: url.includes("/api/names")
? { names: ["cello"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
/** Drain the microtask queue so the form's initial loads land. */
async function flush(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
const modal = () => document.getElementById("modal-host");
describe("spawn modal", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
closeSpawnModal();
vi.unstubAllGlobals();
});
it("mounts the shared spawn form inside a dialog", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
const host = modal()!;
expect(host).toBeTruthy();
expect(host.querySelector('[role="dialog"]')).toBeTruthy();
expect(host.querySelector("form.spawn-form")).toBeTruthy();
expect(host.querySelector("#spawn-title")?.textContent).toBe("Spawn session");
// The modal adds the Cancel the shared form doesn't ship.
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
});
it("titles a seeded open as a clone", async () => {
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
await flush();
expect(modal()!.querySelector("#spawn-title")?.textContent).toBe("Spawn similar");
});
it("closes on Escape, on Cancel and on a backdrop click", async () => {
for (const dismiss of [
() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
() => (modal()!.querySelector(".form-actions .cancel") as HTMLButtonElement).click(),
() => modal()!.dispatchEvent(new MouseEvent("click", { bubbles: true })),
() => (modal()!.querySelector(".close") as HTMLButtonElement).click(),
]) {
openSpawnModal({ onSuccess: () => {} });
await flush();
dismiss();
expect(modal()).toBeNull();
}
});
it("leaves no stray Escape handler behind after closing", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
closeSpawnModal();
// A second Escape with no modal up must not throw on the torn-down form.
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(modal()).toBeNull();
});
it("opening twice tears the first one down rather than stacking dialogs", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
openSpawnModal({ onSuccess: () => {} });
await flush();
expect(document.querySelectorAll("#modal-host")).toHaveLength(1);
expect(document.querySelectorAll("form.spawn-form")).toHaveLength(1);
});
it("closes before handing the created session to its caller", async () => {
const seen: Array<{ session: Session; stillOpen: boolean }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/sessions") && init?.method === "POST") {
return new Response(JSON.stringify({ id: "s1", cwd: "/x", command: "zsh" }), { status: 200 });
}
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: url.includes("/api/projects")
? []
: url.includes("/api/names")
? { names: ["cello"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
openSpawnModal({
// A plain, worktree-free spawn: the point here is where the created
// session is handed over, not what the form validates.
seed: { command: "zsh", cwd: "/x", cwdRawVisible: true, worktreeEnabled: false },
onSuccess: (session) => seen.push({ session, stillOpen: modal() != null }),
});
await flush();
(modal()!.querySelector("form.spawn-form") as HTMLFormElement).dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
await flush();
expect(seen).toHaveLength(1);
expect(seen[0].session.id).toBe("s1");
expect(seen[0].stillOpen).toBe(false);
});
});
+96
View File
@@ -0,0 +1,96 @@
// The spawn modal: dialog chrome around the shared spawn form (spawnForm.ts).
//
// One modal at a time — opening while one is up replaces it. Escape, the ✕ and
// a click on the backdrop all close it; the form's own Escape handlers (the
// directory autocomplete) swallow the key first, so dismissing a dropdown never
// also discards the form.
import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js";
import type { Session } from "./types.js";
/** Seed values for the spawn form — used by "Spawn similar" to open the dialog
* with the same cwd, command and name as an existing session. */
export type SpawnPrefill = SpawnFormSeed;
export interface SpawnModalOptions {
seed?: SpawnPrefill;
/** Called with the created session after a successful submit; the modal has
* already closed by then, so the caller decides where it lands. */
onSuccess: (session: Session) => void;
}
/** Closer for the modal currently up, so opening a second one tears the first
* one down properly — dropping its node alone would strand the form's global
* listeners (dir autocomplete) and the Escape handler. */
let closeCurrent: (() => void) | null = null;
export function openSpawnModal(opts: SpawnModalOptions): void {
closeSpawnModal();
const host = document.createElement("div");
host.id = "modal-host";
host.className = "modal-backdrop";
const dialog = document.createElement("div");
dialog.className = "modal spawn-modal";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-labelledby", "spawn-title");
const header = document.createElement("header");
header.className = "modal-header";
const title = document.createElement("h2");
title.id = "spawn-title";
// "Spawn similar" arrives with a seed; the title says which act this is.
title.textContent = opts.seed ? "Spawn similar" : "Spawn session";
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "btn btn-icon close";
closeBtn.setAttribute("aria-label", "Close");
closeBtn.textContent = "✕";
header.append(title, closeBtn);
dialog.appendChild(header);
host.appendChild(dialog);
document.body.appendChild(host);
const form = createSpawnForm({
seed: opts.seed,
onSuccess: (session) => {
close();
opts.onSuccess(session);
},
});
dialog.appendChild(form.el);
function close(): void {
if (closeCurrent === close) closeCurrent = null;
window.removeEventListener("keydown", onKeydown);
form.destroy();
host.remove();
}
closeCurrent = close;
function onKeydown(e: KeyboardEvent): void {
if (e.key === "Escape") {
e.preventDefault();
close();
}
}
// The modal keeps its Cancel affordance; the shared form ships only Submit.
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn btn-ghost cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", close);
form.el.querySelector(".form-actions")?.prepend(cancel);
closeBtn.addEventListener("click", close);
host.addEventListener("click", (e) => {
if (e.target === host) close();
});
window.addEventListener("keydown", onKeydown);
}
/** Close whatever spawn modal is up. No-op when none is. */
export function closeSpawnModal(): void {
closeCurrent?.();
}
+5 -5
View File
@@ -1,10 +1,10 @@
// "Spawn similar" (#28): the seed a cloned session hands to a draft pane.
// Pure — the caller owns opening the pane.
// "Spawn similar" (#28): the seed a cloned session hands to the spawn modal.
// Pure — the caller owns opening the dialog.
import type { Draft } from "./layout.js";
import type { SpawnFormSeed } from "./spawnForm.js";
import type { Session } from "./types.js";
/** Clone a session into draft values: same project, same command, same
/** Clone a session into form values: same project, same command, same
* worktree decision, fresh name.
*
* A worktree session seeds its *base repository*, not the checkout the daemon
@@ -19,7 +19,7 @@ import type { Session } from "./types.js";
*
* The name is left out on purpose: a clone is a new session and takes its own
* generated handle. */
export function spawnSimilarSeed(s: Session): Partial<Draft> {
export function spawnSimilarSeed(s: Session): SpawnFormSeed {
const wt = s.worktree ?? null;
return {
command: s.command,
+100 -6
View File
@@ -1448,6 +1448,77 @@ input.leaf-rename:focus {
border-color: var(--accent-strong);
}
/* ---------- modal ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: oklch(0.12 0.01 256 / 0.6);
backdrop-filter: blur(3px);
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0;
z-index: var(--z-modal);
animation: fade var(--dur) var(--ease-out);
}
@media (min-width: 600px) {
.modal-backdrop {
align-items: center;
padding: var(--sp-6);
}
}
.modal {
background: var(--surface);
border-radius: var(--r-3) var(--r-3) 0 0;
border: 1px solid var(--border);
width: 100%;
max-width: 560px;
max-height: 92vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-2);
animation: sheet-up var(--dur-slow) var(--ease-out);
}
@media (min-width: 600px) {
.modal {
border-radius: var(--r-3);
animation: pop var(--dur) var(--ease-out);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3);
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
margin: 0;
font-size: var(--fs-15);
font-weight: 600;
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.modal-header h2::before {
content: "+";
color: var(--accent);
font-weight: 700;
}
/* The form is the modal's scrolling body: it must be allowed to shrink below
its content height for `max-height` on the dialog to bite. */
.modal .spawn-form {
min-height: 0;
}
/* ---------- forms ---------- */
.spawn-form {
@@ -1878,7 +1949,7 @@ input.leaf-rename:focus {
.dir-ac {
position: fixed;
z-index: var(--z-toast); /* above the draft pane it completes within */
z-index: var(--z-toast); /* above the modal it completes within */
margin: 0;
padding: var(--sp-1);
list-style: none;
@@ -2015,6 +2086,26 @@ input.leaf-rename:focus {
}
}
@keyframes fade {
from {
opacity: 0;
}
}
@keyframes sheet-up {
from {
transform: translateY(16px);
opacity: 0;
}
}
@keyframes pop {
from {
transform: scale(0.98);
opacity: 0;
}
}
@keyframes blink {
0%,
50% {
@@ -2336,12 +2427,15 @@ input.leaf-rename:focus {
.mv-pane:not(.is-focused) .attach-header {
opacity: 0.7;
}
/* Draft pane (#57): an empty pane renders the shared spawn form inline.
The host is already a flex column; the form fills and scrolls within it. */
.mv-pane-host > .mv-draft {
/* A pane with no session yet: a quiet hint rather than blank terminal black. */
.mv-pane-empty {
flex: 1;
min-width: 0;
min-height: 0;
display: grid;
place-items: center;
padding: var(--sp-3);
text-align: center;
color: var(--faint);
font-size: var(--fs-13);
}
/* pane tools cluster (disconnect / split / close), top-right corner. The attach