Compare commits

...
4 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
Miguel Palhas 766eabbcb6 fix(maestro): bind terminal touch scroll to .xterm
ci / web (push) Successful in 38s
ci / rust (push) Successful in 4m8s
ci / flake (push) Successful in 6m39s
The touch handlers sat on `.xterm-viewport`, but xterm appends the
absolutely-positioned viewport before the `position: relative` screen, so
`.xterm-screen` paints over it and takes every touch. The viewport is not an
ancestor of the screen, so the listeners never fired and mobile drags did
nothing. Moves them, and `touch-action: none`, to `.xterm`.
2026-09-01 13:10:18 +01:00
44 changed files with 1366 additions and 759 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 maestro daemon tmux wrapper + session API on 127.0.0.1:7070
├── GET/POST /api/sessions list / spawn ├── GET/POST /api/sessions list / spawn
├── DELETE /api/sessions/:id kill ├── 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/info hostname, version, home dir, spawn presets
├── GET /api/dirs directory listing (spawn autocomplete) ├── GET /api/dirs directory listing (spawn autocomplete)
├── GET /api/recent-dirs spawn history ├── 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/events Snapshot + session deltas
└── WS /api/attach/:id raw PTY bytes └── 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 tui ratatui client
maestro-web serves the SPA, reverse-proxies /api/* to the daemon 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. no-sessions, no-search-match.
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over - **Attach** — header (back, `session <id>`, live/closed/error state pill) over
a framed dark xterm with a green cursor. a framed dark xterm with a green cursor.
- **Draft pane** — the spawn form mounted inline in an empty split pane, - **Spawn modal** — the spawn form in a centered dialog (a bottom sheet below
focusable like any session pane; mono inputs with green focus ring; grouped 600px) over a blurred backdrop; mono inputs with green focus ring; grouped
worktree fieldset. Spawn opens one (reusing an empty focused pane, else worktree fieldset. Escape, Cancel, ✕ and a backdrop click all dismiss it.
splitting), and "Spawn similar" seeds one from a session's dir + command. 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 Interactive elements ship default / hover / focus-visible / active / disabled
states; the accent green is used only for action, selection, focus, and live 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) ## Dev review aids (DEV-only, stripped from production)
`web/src/mock.ts` + query params seed a representative catalog without a running `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`. `?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
+51
View File
@@ -41,6 +41,9 @@ pub enum Command {
Attach(AttachArgs), Attach(AttachArgs),
/// Kill a session by ID. /// Kill a session by ID.
Kill(KillArgs), 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). /// Install or update the agent activity hook (#106).
Hooks(HooksArgs), Hooks(HooksArgs),
} }
@@ -103,6 +106,20 @@ pub struct AttachArgs {
pub detach_key: String, 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)] #[derive(Debug, clap::Args)]
pub struct KillArgs { pub struct KillArgs {
/// Session ID to kill. /// 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] #[test]
fn rejects_env_without_equals() { fn rejects_env_without_equals() {
let err = Cli::try_parse_from([ 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::attach::{parse_detach_key, run_attach_url};
use crate::cli::Command; 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}; use maestro_config::{default_config_path, MaestroConfigFile};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -24,6 +24,10 @@ pub enum CliError {
Attach(#[from] crate::attach::AttachError), Attach(#[from] crate::attach::AttachError),
#[error("{0}")] #[error("{0}")]
Hooks(#[from] maestro_hooks::HooksError), 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?)")] #[error("could not determine the home directory ($HOME unset?)")]
NoHome, NoHome,
#[error("{0}")] #[error("{0}")]
@@ -82,6 +86,10 @@ pub async fn dispatch(
run_kill(&daemon, &args).await?; run_kill(&daemon, &args).await?;
println!("killed {}", args.session_id); 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"), Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
} }
Ok(()) Ok(())
+147 -3
View File
@@ -5,12 +5,13 @@ use std::io::Write;
use maestro_protocol::display::abbreviate_dir; use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{ use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session, ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
SessionStatus, WorktreeSpec, Session, SessionStatus, WorktreeSpec,
}; };
use serde_json::Value; use serde_json::Value;
use crate::cli::{KillArgs, ListArgs, SpawnArgs}; use crate::cli::{KillArgs, ListArgs, SendArgs, SpawnArgs};
use crate::dispatch::CliError;
/// `maestro list` /// `maestro list`
pub async fn run_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 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)] #[allow(clippy::write_literal)]
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) { fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
if sessions.is_empty() { if sessions.is_empty() {
@@ -218,6 +305,63 @@ mod tests {
assert!(out.contains("bash [claude]"), "{out}"); 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] #[test]
fn empty_sessions() { fn empty_sessions() {
let mut buf = Vec::new(); let mut buf = Vec::new();
@@ -108,3 +108,110 @@ async fn spawn_round_trips_through_the_daemon() {
.expect("kill"); .expect("kill");
assert_eq!(mock.session_count(), 0); 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_config::SpawnPreset;
use maestro_protocol::{ use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION,
}; };
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@@ -44,6 +44,7 @@ pub fn router(state: LocalApiState) -> Router {
Router::new() Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity)) .route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route( .route(
routes::patterns::SESSION, routes::patterns::SESSION,
delete(kill_session).patch(rename_session), delete(kill_session).patch(rename_session),
@@ -100,6 +101,9 @@ fn session_error_status(code: &str) -> StatusCode {
"invalid_env" => StatusCode::BAD_REQUEST, "invalid_env" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79). // A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT, "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, _ => StatusCode::BAD_GATEWAY,
} }
} }
@@ -137,7 +141,7 @@ struct NamesQuery {
} }
/// Fresh session handles for the spawn form's instant reroll (#53). The web /// 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> { async fn names(Query(q): Query<NamesQuery>) -> Json<NameBatch> {
let count = q.count.unwrap_or(1).clamp(1, maestro_names::MAX_BATCH); let count = q.count.unwrap_or(1).clamp(1, maestro_names::MAX_BATCH);
Json(NameBatch { 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 { fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(ApiError::new(code, message))).into_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. /// triggers a rescan.
/// ///
/// A scan walks the roots and runs one `git worktree list` per hit, holding /// 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 /// re-mounts — which `multiview.render()` does on unrelated events. Keep this
/// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is /// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is
/// what refreshes, and a request only rescans when that timer has failed to. /// 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. /// attempt, which is all #83 needs.
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15); 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`. /// Per-session daemon-local bookkeeping that complements the public `Session`.
#[derive(Debug)] #[derive(Debug)]
pub struct SessionRecord { pub struct SessionRecord {
@@ -78,6 +87,11 @@ pub enum SessionError {
NotFound(Uuid), NotFound(Uuid),
#[error("invalid environment variable name: {0:?}")] #[error("invalid environment variable name: {0:?}")]
InvalidEnv(String), 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. /// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched, /// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work. /// so it stays the operator's handle on their own unfinished work.
@@ -94,6 +108,7 @@ impl SessionError {
SessionError::Io(_) => "io_failed", SessionError::Io(_) => "io_failed",
SessionError::NotFound(_) => "not_found", SessionError::NotFound(_) => "not_found",
SessionError::InvalidEnv(_) => "invalid_env", SessionError::InvalidEnv(_) => "invalid_env",
SessionError::NotRunning(_) => "not_running",
SessionError::KillGuarded(_) => "kill_guarded", SessionError::KillGuarded(_) => "kill_guarded",
}; };
let unsaved_work = match self { let unsaved_work = match self {
@@ -671,6 +686,33 @@ impl SessionManager {
.map_err(SessionError::Tmux) .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 /// Resize a session's window. No-op for a dead session, as with
/// [`Self::send_keys`]. /// [`Self::send_keys`].
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> { 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")] #[tokio::test(flavor = "multi_thread")]
async fn cli_list_fails_when_daemon_is_down() { async fn cli_list_fails_when_daemon_is_down() {
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port()); 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::routes::{self, UrlError};
use crate::{ use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, 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. /// Time budget for establishing a TCP connection to the daemon.
@@ -155,6 +155,17 @@ impl DaemonClient {
Ok(()) 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> { pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await self.get(routes::INFO, &[]).await
} }
+9
View File
@@ -7,6 +7,15 @@ use std::path::{Path, PathBuf};
use crate::Session; 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 /// Collapse a daemon's home dir to `~`. `home` empty leaves the path
/// untouched. Mirrors the web's `abbreviateCwd`. /// untouched. Mirrors the web's `abbreviateCwd`.
pub fn abbreviate_dir(path: &Path, home: &str) -> String { pub fn abbreviate_dir(path: &Path, home: &str) -> String {
+18
View File
@@ -512,6 +512,24 @@ pub struct NameBatch {
pub names: Vec<String>, 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 /// 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. /// sidebar card's line 1; past this it only truncates on every client anyway.
pub const MAX_SESSION_NAME_LEN: usize = 64; 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") 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. /// `WS /api/attach/:id` — raw PTY bytes.
pub fn attach(id: Uuid) -> String { pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}") format!("{API_PREFIX}/attach/{id}")
@@ -59,6 +64,7 @@ pub fn attach(id: Uuid) -> String {
pub mod patterns { pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id"; pub const SESSION: &str = "/api/sessions/:id";
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity"; 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"; pub const ATTACH: &str = "/api/attach/:id";
} }
@@ -170,6 +176,10 @@ mod tests {
session_activity(id()), session_activity(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity" "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
); );
assert_eq!(
session_input(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input"
);
assert_eq!( assert_eq!(
attach(id()), attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -192,6 +202,10 @@ mod tests {
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"), patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
session(id()).trim_end_matches(&id().to_string()) 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!( assert_eq!(
patterns::ATTACH.trim_end_matches(":id"), patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string()) 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::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get}; use axum::routing::{any, delete, get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use chrono::Utc; use chrono::Utc;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{ use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing, 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::net::TcpListener;
use tokio::sync::{broadcast, oneshot}; use tokio::sync::{broadcast, oneshot};
@@ -55,6 +55,8 @@ struct Inner {
info: Option<DaemonInfo>, info: Option<DaemonInfo>,
/// When set, every route answers with this instead of its normal response. /// When set, every route answers with this instead of its normal response.
forced_error: Option<(u16, ApiError)>, forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -86,6 +88,7 @@ impl MockDaemon {
let app = Router::new() let app = Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session)) .route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route(routes::INFO, get(daemon_info)) .route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir)) .route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs)) .route(routes::RECENT_DIRS, get(recent_dirs))
@@ -167,6 +170,11 @@ impl MockDaemon {
self.state.inner.lock().expect("poisoned").forced_error = None; 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 { pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len() 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() 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 { async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) { if let Some(r) = forced(&s) {
return r; return r;
+13 -12
View File
@@ -43,9 +43,9 @@ pub enum Action {
// Catalog actions on the highlighted sidebar row // Catalog actions on the highlighted sidebar row
AttachHighlighted, AttachHighlighted,
ToggleAttachHighlighted, ToggleAttachHighlighted,
NewDraftPane, OpenSpawnModal,
/// Fast-forward spawn (#67): spawn immediately with every default /// 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, FastForwardSpawn,
KillHighlighted, KillHighlighted,
/// Rename the highlighted session (#125): opens a prompt pre-filled with /// 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::ToggleSidebarGroup => "Expand or collapse directory".into(),
Action::AttachHighlighted => "Open selected item".into(), Action::AttachHighlighted => "Open selected item".into(),
Action::ToggleAttachHighlighted => "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::FastForwardSpawn => "Create session with defaults".into(),
Action::KillHighlighted => "Kill selected session".into(), Action::KillHighlighted => "Kill selected session".into(),
Action::RenameHighlighted => "Rename selected session".into(), Action::RenameHighlighted => "Rename selected session".into(),
@@ -116,7 +116,7 @@ impl Action {
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup), "ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
"AttachHighlighted" => return Ok(Action::AttachHighlighted), "AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted), "ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"NewDraftPane" => return Ok(Action::NewDraftPane), "OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted), "KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted), "RenameHighlighted" => return Ok(Action::RenameHighlighted),
@@ -179,7 +179,9 @@ mod tests {
fn help_labels_are_plain_language() { fn help_labels_are_plain_language() {
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session"); assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
assert_eq!(Action::AttachHighlighted.help_label(), "Open selected item"); assert_eq!(Action::AttachHighlighted.help_label(), "Open selected item");
assert!(!Action::NewDraftPane.help_label().contains("NewDraftPane")); assert!(!Action::OpenSpawnModal
.help_label()
.contains("OpenSpawnModal"));
} }
#[test] #[test]
@@ -214,7 +216,7 @@ mod tests {
assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into())); 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] #[test]
fn fast_forward_spawn_round_trips() { fn fast_forward_spawn_round_trips() {
assert_eq!( assert_eq!(
@@ -243,7 +245,7 @@ mod tests {
Action::EnterInsert, Action::EnterInsert,
Action::RenameHighlighted, Action::RenameHighlighted,
Action::Quit, Action::Quit,
Action::NewDraftPane, Action::OpenSpawnModal,
Action::ScrollPanePageDown, Action::ScrollPanePageDown,
Action::FocusPane(Direction::Right), Action::FocusPane(Direction::Right),
] { ] {
@@ -252,13 +254,12 @@ mod tests {
} }
} }
/// The spawn modal is gone (issue #63); its old action string must not /// Draft panes are gone; their old action string must not resolve.
/// resolve to anything.
#[test] #[test]
fn the_removed_spawn_modal_action_no_longer_parses() { fn the_removed_draft_pane_action_no_longer_parses() {
assert_eq!( assert_eq!(
Action::parse("OpenSpawnModal").unwrap_err(), Action::parse("NewDraftPane").unwrap_err(),
ActionParseError::Unknown("OpenSpawnModal".into()) ActionParseError::Unknown("NewDraftPane".into())
); );
} }
} }
+164 -204
View File
@@ -6,9 +6,9 @@
//! changes, and a 30Hz heartbeat. //! changes, and a 30Hz heartbeat.
//! //!
//! Actions mutate `AppState` directly. Side effects that talk to the daemon //! 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 //! 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::collections::{HashMap, HashSet};
use std::io::Stdout; use std::io::Stdout;
@@ -26,7 +26,7 @@ use uuid::Uuid;
use crate::action::Action; use crate::action::Action;
use crate::catalog::CatalogStore; use crate::catalog::CatalogStore;
use crate::config::TuiConfig; use crate::config::TuiConfig;
use crate::draft::DraftInputResult; use crate::draft::{DraftInputResult, DraftPane};
use crate::emulators::SessionEmulators; use crate::emulators::SessionEmulators;
use crate::input::{ use crate::input::{
canonicalise, ChordResult, ChordState, ClickTracker, InputKey, MouseKind, Region, canonicalise, ChordResult, ChordState, ClickTracker, InputKey, MouseKind, Region,
@@ -37,7 +37,7 @@ use crate::pane::{Pane, PaneStatus};
use crate::render::{draw, RegionMap, RenderInputs}; use crate::render::{draw, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask}; use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar; 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::text_input::TextInput;
use crate::TuiError; use crate::TuiError;
@@ -58,6 +58,10 @@ pub struct App {
help: HelpOverlay, help: HelpOverlay,
chord: ChordState, chord: ChordState,
daemon: DaemonClient, 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. /// Results of draft data loads, delivered back to the event loop.
/// ///
/// These are network calls — `/api/projects` on a cold cache walks all of /// These are network calls — `/api/projects` on a cold cache walks all of
@@ -99,22 +103,21 @@ pub enum Effect {
id: Uuid, id: Uuid,
bytes: Vec<u8>, bytes: Vec<u8>,
}, },
/// Fetch presets / projects / names for a freshly opened draft (#64). /// Fetch presets / projects / names for a freshly opened form (#64).
LoadDraft(usize), LoadDraft,
/// Fast-forward spawn (#67): resolve every default from the daemon and /// 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, FastForward,
/// A reroll drained the draft's local name batch (#64). /// A reroll drained the form's local name batch (#64).
RefillNames(usize), RefillNames,
/// The draft's target changed: probe `/api/gitroot` so the worktree /// The form's target changed: probe `/api/gitroot` so the worktree
/// section knows its base repo (#65). `seq` guards stale answers. /// section knows its base repo (#65). `seq` guards stale answers.
ProbeGitRoot { ProbeGitRoot {
tab: usize,
seq: u32, seq: u32,
path: PathBuf, path: PathBuf,
}, },
/// Fetch the draft target's branches from `/api/branches` (#65). /// Fetch the form target's branches from `/api/branches` (#65).
LoadBranches(usize), LoadBranches,
/// Rename a session (#125). An empty name clears the custom one, so the /// Rename a session (#125). An empty name clears the custom one, so the
/// card falls back to the command. /// card falls back to the command.
Rename { Rename {
@@ -141,6 +144,7 @@ impl App {
catalog, catalog,
emulators: SessionEmulators::default(), emulators: SessionEmulators::default(),
state: AppState::new(), state: AppState::new(),
draft: None,
help, help,
chord: ChordState::new(chord_timeout), chord: ChordState::new(chord_timeout),
daemon, daemon,
@@ -222,14 +226,7 @@ impl App {
self.perform(effects).await; self.perform(effects).await;
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane) } else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
{ {
// Insert mode over a focused pane: a draft eats the key let effects = self.forward_to_session(&key).into_iter().collect();
// 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()
};
self.perform(effects).await; self.perform(effects).await;
} }
Ok(false) Ok(false)
@@ -273,13 +270,11 @@ impl App {
Effect::Attach(id) => self.attach(id), Effect::Attach(id) => self.attach(id),
Effect::Spawn(req) => self.submit_spawn(req).await, Effect::Spawn(req) => self.submit_spawn(req).await,
Effect::Kill(id) => self.submit_kill(id).await, Effect::Kill(id) => self.submit_kill(id).await,
Effect::LoadDraft(tab_idx) => self.spawn_load_draft(tab_idx), Effect::LoadDraft => self.spawn_load_draft(),
Effect::FastForward => self.spawn_fast_forward(), Effect::FastForward => self.spawn_fast_forward(),
Effect::RefillNames(tab_idx) => self.spawn_refill_names(tab_idx), Effect::RefillNames => self.spawn_refill_names(),
Effect::ProbeGitRoot { tab, seq, path } => { Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
self.spawn_probe_git_root(tab, seq, path) Effect::LoadBranches => self.spawn_load_branches(),
}
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
Effect::Rename { id, name } => self.submit_rename(id, &name).await, Effect::Rename { id, name } => self.submit_rename(id, &name).await,
Effect::Forward { id, bytes } => { Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) { 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>) { fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
match self.state.overlay().cloned() { 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 { Some(OverlayKind::KillConfirm(id)) => match key {
InputKey::Char { c: 'y', .. } => { InputKey::Char { c: 'y', .. } => {
self.state.dismiss_overlay(); self.state.dismiss_overlay();
@@ -363,12 +367,9 @@ impl App {
}; };
self.state.show_overlay(OverlayKind::Help); self.state.show_overlay(OverlayKind::Help);
} }
Action::NewDraftPane => { Action::OpenSpawnModal => {
if let Some(sid) = self.state.active_session() { self.open_spawn_modal();
self.release_session(sid); effects.push(Effect::LoadDraft);
}
self.state.open_draft_pane();
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
} }
Action::FastForwardSpawn => effects.push(Effect::FastForward), Action::FastForwardSpawn => effects.push(Effect::FastForward),
Action::AttachHighlighted | Action::ToggleAttachHighlighted => { Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
@@ -571,12 +572,24 @@ impl App {
Some(None) Some(None)
} }
/// Feed one keystroke into the active tab's draft form. Returns the spawn /// Put the spawn form up with a fresh draft behind it.
/// effect when the user accepted a valid form; an invalid one names the fn open_spawn_modal(&mut self) {
/// missing field inside the form (#64) instead of posting. 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> { fn handle_draft_input(&mut self, key: &InputKey) -> Vec<Effect> {
let tab_idx = self.state.active_tab_idx; let Some(draft) = self.draft.as_mut() else {
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else {
return Vec::new(); return Vec::new();
}; };
let mut effects = Vec::new(); let mut effects = Vec::new();
@@ -590,48 +603,37 @@ impl App {
} }
} }
if draft.take_name_refill() { 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()) { if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
effects.push(Effect::ProbeGitRoot { effects.push(Effect::ProbeGitRoot { seq, path });
tab: tab_idx,
seq,
path,
});
} }
if draft.take_branch_load().is_some() { if draft.take_branch_load().is_some() {
effects.push(Effect::LoadBranches(tab_idx)); effects.push(Effect::LoadBranches);
} }
effects effects
} }
/// Fire any draft gitroot probes whose debounce window has elapsed (#74). /// Fire any gitroot probe whose debounce window has elapsed (#74). The
/// The 30 Hz heartbeat calls this so a burst of typing issues its one /// 30 Hz heartbeat calls this so a burst of typing issues its one probe
/// probe even after the last keystroke. /// even after the last keystroke.
fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec<Effect> { fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
let mut effects = Vec::new(); let Some(draft) = self.draft.as_mut() else {
for tab_idx in 0..self.state.tabs.len() { return Vec::new();
let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else { };
continue; draft
}; .take_gitroot_probe(now)
if let Some((seq, path)) = draft.take_gitroot_probe(now) { .map(|(seq, path)| Effect::ProbeGitRoot { seq, path })
effects.push(Effect::ProbeGitRoot { .into_iter()
tab: tab_idx, .collect()
seq,
path,
});
}
}
effects
} }
/// 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 /// 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 first name batch. Applies only if the same form is still up — a slow
/// a slow response must never clobber what replaced it. /// response must never clobber the one that replaced it.
/// Kick off the draft's initial data load without blocking the loop. fn spawn_load_draft(&self) {
fn spawn_load_draft(&self, tab: usize) { let Some(draft) = self.draft_id() else {
let Some(draft) = self.draft_id(tab) else {
return; return;
}; };
let (daemon, http, base, tx) = self.loader_handles(); let (daemon, http, base, tx) = self.loader_handles();
@@ -647,8 +649,8 @@ impl App {
}); });
} }
fn spawn_refill_names(&self, tab: usize) { fn spawn_refill_names(&self) {
let Some(draft) = self.draft_id(tab) else { let Some(draft) = self.draft_id() else {
return; return;
}; };
let (_, http, base, tx) = self.loader_handles(); 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) { fn spawn_probe_git_root(&self, seq: u32, path: PathBuf) {
let Some(draft) = self.draft_id(tab) else { let Some(draft) = self.draft_id() else {
return; return;
}; };
let (daemon, _, _, tx) = self.loader_handles(); let (daemon, _, _, tx) = self.loader_handles();
@@ -673,20 +675,11 @@ impl App {
}); });
} }
fn spawn_load_branches(&self, tab: usize) { fn spawn_load_branches(&self) {
let Some(draft) = self.draft_id(tab) else { let Some(draft) = self.draft_id() else {
return; return;
}; };
let Some(cwd) = self let Some(cwd) = self.draft.as_ref().and_then(DraftPane::cwd) else {
.state
.tabs
.get(tab)
.and_then(|t| t.pane.as_ref())
.and_then(|p| match p {
PaneRef::Draft(d) => d.cwd(),
_ => None,
})
else {
return; return;
}; };
let (_, http, base, tx) = self.loader_handles(); let (_, http, base, tx) = self.loader_handles();
@@ -700,12 +693,9 @@ impl App {
}); });
} }
/// The draft pane living in `tab`, by identity. /// Identity of the form currently up, if any.
fn draft_id(&self, tab: usize) -> Option<u64> { fn draft_id(&self) -> Option<u64> {
match self.state.tabs.get(tab).and_then(|t| t.pane.as_ref()) { self.draft.as_ref().map(|d| d.id)
Some(PaneRef::Draft(d)) => Some(d.id),
_ => None,
}
} }
fn loader_handles( fn loader_handles(
@@ -743,24 +733,9 @@ impl App {
// Handled above; it targets no particular draft. // Handled above; it targets no particular draft.
DraftUpdate::FastForward { .. } => unreachable!(), DraftUpdate::FastForward { .. } => unreachable!(),
}; };
// Find it by identity: closing a tab shifts every index to its right, // Match by identity: dismissing the form and opening another while a
// so an in-flight load addressed by index would be dropped or land on // load is in flight must not let the old answer land in the new form.
// whatever pane inherited the slot (#67 review). let Some(draft) = self.draft.as_mut().filter(|d| d.id == want) else {
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 {
return; return;
}; };
let mut follow_up_branches = false; let mut follow_up_branches = false;
@@ -799,7 +774,7 @@ impl App {
DraftUpdate::FastForward { .. } => unreachable!(), DraftUpdate::FastForward { .. } => unreachable!(),
} }
if follow_up_branches { 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 /// form. The most recent project, its last command (or the first
/// non-custom preset), a freshly generated name, worktree on with a new /// non-custom preset), a freshly generated name, worktree on with a new
/// branch named after that name. Anything unresolvable — no projects /// 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. /// with whatever did resolve.
/// Load what the fast path needs without blocking the loop — the same /// Load what the fast path needs without blocking the loop — the same
/// reason the draft loaders were moved off it (#67 review): a cold /// reason the draft loaders were moved off it (#67 review): a cold
@@ -850,9 +825,9 @@ impl App {
self.submit_spawn(req).await; self.submit_spawn(req).await;
return; return;
} }
// Unresolved defaults: a draft pre-filled with what did resolve. // Unresolved defaults: the form, pre-filled with what did resolve.
self.state.open_draft_pane(); self.open_spawn_modal();
let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else { let Some(draft) = self.draft.as_mut() else {
return; return;
}; };
draft.home_dir = home_dir; draft.home_dir = home_dir;
@@ -869,12 +844,13 @@ impl App {
async fn submit_spawn(&mut self, req: CreateSessionRequest) { async fn submit_spawn(&mut self, req: CreateSessionRequest) {
match self.daemon.create_session(&req).await { match self.daemon.create_session(&req).await {
Ok(session) => { Ok(session) => {
// Fill the pane in place with the new session (web parity, // The form closes and the new session takes the pane, attached
// #57) and attach to it right away. // right away — the same landing the web modal gives it.
let sid = session.id; let sid = session.id;
if let Some(previous) = self.state.active_session() { if let Some(previous) = self.state.active_session() {
self.release_session(previous); self.release_session(previous);
} }
self.draft = None;
self.state.complete_spawn(sid); self.state.complete_spawn(sid);
self.attach(sid); self.attach(sid);
} }
@@ -885,7 +861,7 @@ impl App {
.daemon_message() .daemon_message()
.map(String::from) .map(String::from)
.unwrap_or_else(|| e.to_string()); .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}")); draft.error = Some(format!("spawn failed: {message}"));
} else { } else {
self.state.toast(format!("spawn failed: {e}")); self.state.toast(format!("spawn failed: {e}"));
@@ -933,18 +909,13 @@ impl App {
focused: matches!(self.state.focus, Focus::Pane), focused: matches!(self.state.focus, Focus::Pane),
}) })
}); });
let draft = match self.state.active_tab().pane.as_ref() { let pane_title = match active_sid {
Some(PaneRef::Draft(d)) => Some(d), Some(sid) => snap
_ => None,
};
let pane_title = match self.state.active_tab().pane.as_ref() {
Some(PaneRef::Session(sid)) => snap
.sessions .sessions
.iter() .iter()
.find(|session| session.id == *sid) .find(|session| session.id == sid)
.map(crate::sidebar::name_for) .map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into()), .unwrap_or_else(|| "session".into()),
Some(PaneRef::Draft(_)) => "new session".into(),
None => "session".into(), None => "session".into(),
}; };
@@ -954,7 +925,7 @@ impl App {
pane_title: &pane_title, pane_title: &pane_title,
pane: pane.as_ref(), pane: pane.as_ref(),
state: &self.state, state: &self.state,
draft, draft: self.draft.as_ref(),
help: &self.help, help: &self.help,
rename_input: &self.rename_input, rename_input: &self.rename_input,
}; };
@@ -1235,36 +1206,52 @@ mod tests {
assert!(help.contains("Create a session")); assert!(help.contains("Create a session"));
assert!(help.contains("Expand or collapse directory")); assert!(help.contains("Expand or collapse directory"));
assert!(!help.contains("EnterInsert")); assert!(!help.contains("EnterInsert"));
assert!(!help.contains("NewDraftPane")); assert!(!help.contains("OpenSpawnModal"));
assert!(!help.contains("ToggleSidebarGroup")); assert!(!help.contains("ToggleSidebarGroup"));
} }
#[test] #[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(); let mut app = make_app();
app.dispatch(Action::NewDraftPane); let effects = app.dispatch(Action::OpenSpawnModal);
assert!(matches!( assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
app.state.active_tab().pane, assert!(app.draft.is_some());
Some(PaneRef::Draft(_)) assert_eq!(effects, vec![Effect::LoadDraft]);
));
assert_eq!(app.state.focus, Focus::Pane);
assert_eq!(app.state.mode, Mode::Insert);
assert!(app.state.overlay().is_none(), "no overlay involved");
} }
/// The modal covers the attached session rather than evicting it: Escape
/// gives back exactly what was there, still attached.
#[test] #[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 sid = Uuid::from_u128(10);
let mut app = app_with_highlighted_session(sid); let mut app = app_with_highlighted_session(sid);
app.dispatch(Action::AttachHighlighted); app.dispatch(Action::AttachHighlighted);
app.dispatch(Action::NewDraftPane); app.dispatch(Action::OpenSpawnModal);
assert_eq!(app.state.tabs.len(), 1); assert_eq!(app.state.active_session(), Some(sid));
assert_eq!(app.state.active_tab_idx, 0);
assert!(matches!( app.handle_overlay_input(&InputKey::Esc);
app.state.active_tab().pane, assert!(app.state.overlay().is_none());
Some(PaneRef::Draft(_)) 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. /// Seed the catalog with one session and highlight it.
@@ -1507,11 +1494,11 @@ mod tests {
} }
#[test] #[test]
fn new_draft_pane_schedules_a_data_load() { fn opening_the_spawn_modal_schedules_a_data_load() {
let mut app = make_app(); let mut app = make_app();
assert_eq!( assert_eq!(
app.dispatch(Action::NewDraftPane), app.dispatch(Action::OpenSpawnModal),
vec![Effect::LoadDraft(0)] vec![Effect::LoadDraft]
); );
} }
@@ -1602,7 +1589,7 @@ mod tests {
} }
/// No projects known yet: nothing can resolve, so the keystroke opens a /// 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] #[tokio::test]
async fn unresolvable_defaults_open_a_prefilled_draft_instead() { async fn unresolvable_defaults_open_a_prefilled_draft_instead() {
use axum::routing::get; use axum::routing::get;
@@ -1641,15 +1628,12 @@ mod tests {
.expect("channel open"); .expect("channel open");
app.apply_draft_update(update).await; app.apply_draft_update(update).await;
assert!( assert_eq!(
matches!(app.state.active_tab().pane, Some(PaneRef::Draft(_))), app.state.overlay(),
"the fallback is a draft pane" Some(&OverlayKind::Spawn),
"the fallback is the spawn modal"
); );
assert_eq!(app.state.focus, Focus::Pane); let draft = app.draft.as_ref().expect("a form behind the modal");
assert_eq!(app.state.mode, Mode::Insert);
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else {
panic!("expected a draft pane");
};
// Whatever did resolve (nothing project-side) is seeded; the // Whatever did resolve (nothing project-side) is seeded; the
// generated name still lands so the form is ready to go. // generated name still lands so the form is ready to go.
assert_eq!(draft.name.value(), "amber-falcon"); 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 /// itself is covered in `draft.rs`), select the custom tile, type a
/// command, then submit from the Spawn row. /// command, then submit from the Spawn row.
#[test] #[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(); let mut app = make_app();
app.dispatch(Action::NewDraftPane); app.dispatch(Action::OpenSpawnModal);
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { let draft = app.draft.as_mut().unwrap();
panic!("expected a draft pane");
};
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work")); draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
draft.base_repo = Some(std::path::PathBuf::from("/srv/work")); draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
@@ -1703,10 +1685,8 @@ mod tests {
#[test] #[test]
fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() { fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() {
let mut app = make_app(); let mut app = make_app();
app.dispatch(Action::NewDraftPane); app.dispatch(Action::OpenSpawnModal);
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { let draft = app.draft.as_mut().unwrap();
panic!("expected a draft pane");
};
draft.cwd_raw_visible = true; draft.cwd_raw_visible = true;
draft.focused_field = DraftField::CwdRaw; draft.focused_field = DraftField::CwdRaw;
@@ -1720,7 +1700,7 @@ mod tests {
let due = std::time::Instant::now() let due = std::time::Instant::now()
+ std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10; + std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10;
match app.poll_due_probes_at(due).as_slice() { 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")); assert_eq!(path, &std::path::PathBuf::from("/opt/tooling"));
} }
other => panic!("expected one ProbeGitRoot effect, got {other:?}"), other => panic!("expected one ProbeGitRoot effect, got {other:?}"),
@@ -1736,10 +1716,8 @@ mod tests {
#[test] #[test]
fn switching_to_existing_branch_mode_schedules_a_branch_load() { fn switching_to_existing_branch_mode_schedules_a_branch_load() {
let mut app = make_app(); let mut app = make_app();
app.dispatch(Action::NewDraftPane); app.dispatch(Action::OpenSpawnModal);
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { let draft = app.draft.as_mut().unwrap();
panic!("expected a draft pane");
};
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo")); draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo"));
draft.base_repo = 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")); draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo"));
@@ -1747,7 +1725,7 @@ mod tests {
let effects = app.handle_draft_input(&InputKey::Right); let effects = app.handle_draft_input(&InputKey::Right);
assert!( assert!(
matches!(effects.as_slice(), [Effect::LoadBranches(0)]), matches!(effects.as_slice(), [Effect::LoadBranches]),
"{effects:?}" "{effects:?}"
); );
} }
@@ -1757,31 +1735,24 @@ mod tests {
#[test] #[test]
fn submitting_an_empty_form_reports_the_missing_field_instead_of_posting() { fn submitting_an_empty_form_reports_the_missing_field_instead_of_posting() {
let mut app = make_app(); let mut app = make_app();
app.dispatch(Action::NewDraftPane); app.dispatch(Action::OpenSpawnModal);
// Walk to Submit without picking anything. // Walk to Submit without picking anything.
for _ in 0..4 { for _ in 0..4 {
app.handle_draft_input(&InputKey::Tab); app.handle_draft_input(&InputKey::Tab);
} }
assert!(app.handle_draft_input(&InputKey::Enter).is_empty()); 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!( assert_eq!(
draft.error.as_deref(), app.draft.as_ref().unwrap().error.as_deref(),
Some("Pick a working directory."), Some("Pick a working directory."),
"the refusal shows in the form" "the refusal shows in the form"
); );
// With a directory but an empty custom command, it names the command. // 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 { let draft = app.draft.as_mut().unwrap();
panic!();
};
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work")); draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
draft.selected_preset = draft.presets.len() - 1; // custom, empty text draft.selected_preset = draft.presets.len() - 1; // custom, empty text
assert!(app.handle_draft_input(&InputKey::Enter).is_empty()); assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { let draft = app.draft.as_ref().unwrap();
panic!();
};
assert_eq!( assert_eq!(
draft.error.as_deref(), draft.error.as_deref(),
Some("Pick a command (or fill the custom command).") Some("Pick a command (or fill the custom command).")
@@ -1802,7 +1773,7 @@ mod tests {
Duration::from_millis(500), Duration::from_millis(500),
), ),
); );
app.dispatch(Action::NewDraftPane); app.dispatch(Action::OpenSpawnModal);
let req = CreateSessionRequest { let req = CreateSessionRequest {
cwd: std::path::PathBuf::from("/srv/work"), cwd: std::path::PathBuf::from("/srv/work"),
command: "bash".into(), command: "bash".into(),
@@ -1812,17 +1783,16 @@ mod tests {
}; };
app.submit_spawn(req).await; app.submit_spawn(req).await;
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { let draft = app.draft.as_ref().expect("the form stays up on failure");
panic!("expected a draft pane");
};
let error = draft let error = draft
.error .error
.as_deref() .as_deref()
.expect("spawn failure must reach the form"); .expect("spawn failure must reach the form");
assert!(error.starts_with("spawn failed"), "{error}"); assert!(error.starts_with("spawn failed"), "{error}");
assert!( assert_eq!(
matches!(app.state.overlay(), None | Some(OverlayKind::Toast(_))), app.state.overlay(),
"no kill-confirm style overlay took over" 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() }); tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base); let mut app = make_app_at(&base);
let effects = app.dispatch(Action::NewDraftPane); let effects = app.dispatch(Action::OpenSpawnModal);
assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)])); assert!(matches!(effects.as_slice(), [Effect::LoadDraft]));
// `perform` only spawns the load — it must not block the event loop on // `perform` only spawns the load — it must not block the event loop on
// the network (#67 review). The result arrives on the draft channel, // the network (#67 review). The result arrives on the draft channel,
// which the run loop drains; do that by hand here. // which the run loop drains; do that by hand here.
@@ -1878,9 +1848,7 @@ mod tests {
.expect("channel open"); .expect("channel open");
app.apply_draft_update(update).await; app.apply_draft_update(update).await;
let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { let draft = app.draft.as_ref().unwrap();
panic!("expected a draft pane");
};
assert_eq!(draft.command(), "aider"); assert_eq!(draft.command(), "aider");
assert_eq!(draft.groups.len(), 1); assert_eq!(draft.groups.len(), 1);
assert_eq!(draft.groups[0].main.name, "maestro"); assert_eq!(draft.groups[0].main.name, "maestro");
@@ -1888,9 +1856,9 @@ mod tests {
assert_eq!(draft.name.value(), "crimson-otter"); 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] #[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(); let mut app = make_app();
assert!(app.handle_draft_input(&InputKey::char('x')).is_empty()); 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()); 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] #[test]
fn pane_scrollback_moves_and_user_input_returns_to_live_output() { fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
use crate::session_task::{SessionHandle, SessionState}; use crate::session_task::{SessionHandle, SessionState};
@@ -1948,7 +1908,7 @@ mod tests {
state: Arc::new(AtomicU8::new(SessionState::Live as u8)), state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
}; };
let mut app = make_app(); 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.emulators.insert(sid, handle);
app.scroll_active_pane(3); app.scroll_active_pane(3);
+3 -3
View File
@@ -48,8 +48,8 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("c", "ToggleSidebarGroup"), ("c", "ToggleSidebarGroup"),
// Catalog actions // Catalog actions
("enter", "AttachHighlighted"), ("enter", "AttachHighlighted"),
("s", "NewDraftPane"), ("s", "OpenSpawnModal"),
// Fast-forward spawn (#67): the draft-pane key's neighbour does the same // Fast-forward spawn (#67): the spawn key's neighbour does the same
// thing with every default resolved and no form. // thing with every default resolved and no form.
("S", "FastForwardSpawn"), ("S", "FastForwardSpawn"),
("x", "KillHighlighted"), ("x", "KillHighlighted"),
@@ -204,7 +204,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
cfg.keymap.resolve(Mode::Normal, "s"), cfg.keymap.resolve(Mode::Normal, "s"),
Some(Action::NewDraftPane), Some(Action::OpenSpawnModal),
); );
assert_eq!( assert_eq!(
cfg.keymap.resolve(Mode::Normal, "S"), 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 //! Submitting hands the caller a built request; the modal closes and the new
//! the caller a built request and the pane is replaced with the new session, //! session takes the pane, matching the web client's spawn modal. Field
//! matching the web client's inline draft panes (#57). Field editing goes //! editing goes through [`crate::text_input::TextInput`]; Tab / BackTab walk
//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields. //! the fields.
//! //!
//! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`): //! Field groups (#64), mirroring the web form (`web/src/spawnForm.ts`):
//! command presets from `/api/info` (with an implicit `custom` free-text //! 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. //! 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: //! picked the most recent project and kept their hands off everything else:
//! its last command (or the first non-custom preset), a freshly generated //! 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 //! name, and a worktree with a branch named after that name. Any default
//! that cannot be resolved — no projects yet, no command, no generated //! 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`]). //! did resolve ([`FastForwardDefaults::apply_to_draft`]).
use std::path::PathBuf; 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. /// [`Self::build_request`] is `None`. The operator fills in the rest.
pub fn apply_to_draft(&self, draft: &mut DraftPane) { pub fn apply_to_draft(&self, draft: &mut DraftPane) {
if let Some(project) = &self.project { if let Some(project) = &self.project {
+37 -5
View File
@@ -1,8 +1,5 @@
//! Overlay widgets: kill-confirm prompt, rename prompt, help overlay, //! Overlay widgets: spawn modal, kill-confirm prompt, rename prompt, help
//! transient toast. //! overlay, transient toast.
//!
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
//! panes ([`crate::draft::DraftPane`]).
//! //!
//! All overlays are centered popups drawn over the rest of the UI; the //! All overlays are centered popups drawn over the rest of the UI; the
//! `Clear` widget wipes the underlying buffer before drawing. //! `Clear` widget wipes the underlying buffer before drawing.
@@ -15,6 +12,27 @@ use ratatui::Frame;
use crate::text_input::TextInput; 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)] #[derive(Debug, Clone)]
pub struct KillConfirm { pub struct KillConfirm {
pub label: String, pub label: String,
@@ -168,6 +186,20 @@ mod tests {
out 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] #[test]
fn kill_confirm_renders_prompt() { fn kill_confirm_renders_prompt() {
let modal = KillConfirm { 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 //! recency ordering, worktree grouping, and free-text filtering. Mirrors
//! `web/src/projectList.ts` so both clients order and dim identically — the //! `web/src/projectList.ts` so both clients order and dim identically — the
//! daemon reports the timestamps; what "stale" means is a client decision //! 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::input::Region;
use crate::keymap::Mode; use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, Toast}; use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, SpawnModal, Toast};
use crate::palette; use crate::palette;
use crate::pane::Pane; use crate::pane::Pane;
use crate::sidebar::Sidebar; use crate::sidebar::Sidebar;
@@ -77,7 +77,7 @@ pub struct RenderInputs<'a> {
pub home_dir: &'a str, pub home_dir: &'a str,
pub pane_title: &'a str, pub pane_title: &'a str,
pub pane: Option<&'a Pane>, 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 draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState, pub state: &'a AppState,
pub help: &'a HelpOverlay, pub help: &'a HelpOverlay,
@@ -139,9 +139,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
if let Some(p) = inputs.pane { if let Some(p) = inputs.pane {
p.render(f, pane_area); 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 // `RegionMap::pane` is also the PTY resize source. The title bar is
// chrome; every remaining cell belongs to the emulator. // chrome; every remaining cell belongs to the emulator.
map.pane = Some(pane_area); 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); render_status_bar(f, status_area, inputs.state);
match inputs.state.overlay() { 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)) => { Some(OverlayKind::KillConfirm(id)) => {
// #83: surface what the watcher last saw in the worktree, in // #83: surface what the watcher last saw in the worktree, in
// InTheWay's own words, before the operator commits to the kill. // 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", Focus::Pane => "pane",
}; };
let hint = match state.mode { 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", Mode::Insert => "^a: return to sidebar",
}; };
let mode_style = palette::active().mode_style(state.mode == Mode::Insert); 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 /// The spawn form is a modal: the frame shows it over whatever the pane
/// form where the terminal would otherwise be. /// was displaying, framed by its own border.
#[test] #[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::overlays::HelpOverlay;
use crate::state::PaneRef;
use std::collections::HashSet; use std::collections::HashSet;
let mut state = AppState::new(); let mut state = AppState::new();
state.open_draft_pane(); state.open_spawn_modal();
let draft = match &state.tabs[0].pane { let draft = crate::draft::DraftPane::default();
Some(PaneRef::Draft(d)) => d,
other => panic!("expected a draft pane, got {other:?}"),
};
let sidebar = Sidebar { let sidebar = Sidebar {
sessions: Vec::new(), sessions: Vec::new(),
highlight: None, highlight: None,
@@ -348,9 +350,9 @@ mod tests {
let inputs = RenderInputs { let inputs = RenderInputs {
sidebar: &sidebar, sidebar: &sidebar,
home_dir: "", home_dir: "",
pane_title: "new session", pane_title: "session",
pane: None, pane: None,
draft: Some(draft), draft: Some(&draft),
state: &state, state: &state,
help: &help, help: &help,
rename_input: &crate::text_input::TextInput::default(), rename_input: &crate::text_input::TextInput::default(),
@@ -358,11 +360,10 @@ mod tests {
let backend = ratatui::backend::TestBackend::new(80, 24); let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap(); let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal let mut map = None;
.draw(|f| { terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
draw(f, inputs); // Mouse clicks inside the form must not fall through to the pane.
}) assert!(map.unwrap().modal.is_some());
.unwrap();
let buf = terminal.backend().buffer().clone(); let buf = terminal.backend().buffer().clone();
let mut out = String::new(); let mut out = String::new();
for y in 0..buf.area.height { 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. //! `AppState` — the top-level mutable state of the running TUI.
//! //!
//! v1 keeps it small on purpose: one pane containing a session or draft form, //! v1 keeps it small on purpose: one pane containing a session, focus split
//! focus split between sidebar and pane, the current input mode, and an optional overlay (kill confirm, //! between sidebar and pane, the current input mode, and an optional overlay
//! help, transient toast). The spawn form is no longer an overlay — an empty //! (spawn form, kill confirm, rename, help, transient toast).
//! pane is a draft pane (issue #63).
//! //!
//! v1.5 will replace `Tab::pane`'s single slot with a split tree. //! 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 uuid::Uuid;
use crate::draft::DraftPane;
use crate::keymap::Mode; use crate::keymap::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -31,34 +29,24 @@ pub enum SidebarItem {
Session(Uuid), 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)] #[derive(Debug, Clone)]
pub struct Tab { pub struct Tab {
pub name: String, pub name: String,
pub pane: Option<PaneRef>, pub pane: Option<Uuid>,
} }
impl Tab { 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> { pub fn session(&self) -> Option<Uuid> {
match &self.pane { self.pane
Some(PaneRef::Session(sid)) => Some(*sid),
_ => None,
}
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind { 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), KillConfirm(Uuid),
/// Rename prompt for this session (#125). The edit buffer lives on `App`, /// Rename prompt for this session (#125). The edit buffer lives on `App`,
/// so this stays comparable. /// so this stays comparable.
@@ -138,26 +126,23 @@ impl AppState {
/// drop straight into Insert mode so keystrokes go to the session /// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal. /// without an extra `i` press. The leader returns to Normal.
pub fn attach_to_focused(&mut self, session_id: Uuid) { 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.focus = Focus::Pane;
self.mode = Mode::Insert; self.mode = Mode::Insert;
} }
/// Replace the pane with a draft form. Focus lands in the pane, Insert /// Put the spawn form up. Whatever the pane was showing stays behind it,
/// mode on, so typing starts immediately, same flow as /// so dismissing gives that back untouched — the modal owns the keyboard
/// [`Self::attach_to_focused`]. /// while it is up, so focus and mode are left alone.
pub fn open_draft_pane(&mut self) { pub fn open_spawn_modal(&mut self) {
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default())); self.show_overlay(OverlayKind::Spawn);
self.focus = Focus::Pane;
self.mode = Mode::Insert;
} }
/// A draft submit succeeded: replace whatever the active tab showed with /// A spawn submit succeeded: the modal closes and the new session takes
/// the new session in place, matching the web client's behaviour (#57). /// the pane, attached and ready to type into.
pub fn complete_spawn(&mut self, sid: Uuid) { pub fn complete_spawn(&mut self, sid: Uuid) {
self.active_tab_mut().pane = Some(PaneRef::Session(sid)); self.dismiss_overlay();
self.focus = Focus::Pane; self.attach_to_focused(sid);
self.mode = Mode::Insert;
} }
/// The session shown by the active tab's pane, if any. /// The session shown by the active tab's pane, if any.
@@ -385,35 +370,28 @@ mod tests {
assert!(state.overlay().is_none()); assert!(state.overlay().is_none());
} }
/// Issue #63: `s` over an empty pane turns that pane into a draft — /// The modal covers the pane rather than replacing it: an attached
/// focused, Insert mode on, typing starts immediately. /// session is still there once the form is dismissed.
#[test] #[test]
fn open_draft_reuses_the_empty_active_pane() { fn open_spawn_modal_leaves_the_pane_alone() {
let mut state = AppState::new(); let mut state = AppState::new();
state.open_draft_pane(); let sid = Uuid::from_u128(9);
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_)))); state.attach_to_focused(sid);
assert_eq!(state.focus, Focus::Pane); state.open_spawn_modal();
assert_eq!(state.mode, Mode::Insert); assert_eq!(state.overlay(), Some(&OverlayKind::Spawn));
assert_eq!(state.tabs.len(), 1); assert_eq!(state.active_tab().session(), Some(sid));
state.dismiss_overlay();
assert_eq!(state.active_tab().session(), Some(sid));
} }
#[test] #[test]
fn open_draft_over_a_session_reuses_the_pane() { fn complete_spawn_dismisses_the_modal_and_attaches() {
let mut state = AppState::new(); let mut state = AppState::new();
state.attach_to_focused(Uuid::from_u128(9)); state.open_spawn_modal();
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();
let sid = Uuid::from_u128(21); let sid = Uuid::from_u128(21);
state.complete_spawn(sid); state.complete_spawn(sid);
assert!(state.overlay().is_none());
assert_eq!(state.active_tab().session(), Some(sid)); assert_eq!(state.active_tab().session(), Some(sid));
assert_eq!(state.focus, Focus::Pane); assert_eq!(state.focus, Focus::Pane);
assert_eq!(state.mode, Mode::Insert); assert_eq!(state.mode, Mode::Insert);
+14
View File
@@ -57,6 +57,10 @@ enum Command {
/// Kill a session by ID. /// Kill a session by ID.
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
Kill(maestro_cli::cli::KillArgs), 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). /// Install or update the agent activity hook (#106).
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
Hooks(maestro_cli::cli::HooksArgs), Hooks(maestro_cli::cli::HooksArgs),
@@ -144,6 +148,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
.await? .await?
} }
#[cfg(feature = "cli")] #[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) => { Command::Hooks(args) => {
maestro_cli::dispatch( maestro_cli::dispatch(
maestro_cli::cli::Command::Hooks(args), maestro_cli::cli::Command::Hooks(args),
@@ -190,6 +203,7 @@ fn is_cli_subcommand(command: &Option<Command>) -> bool {
| Some(Command::Spawn(_)) | Some(Command::Spawn(_))
| Some(Command::Attach(_)) | Some(Command::Attach(_))
| Some(Command::Kill(_)) | Some(Command::Kill(_))
| Some(Command::Send(_))
| Some(Command::Hooks(_)) | 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] [tui.keymap.normal]
"i" = "EnterInsert" "i" = "EnterInsert"
"s" = "NewDraftPane" "s" = "OpenSpawnModal"
"x" = "KillHighlighted" "x" = "KillHighlighted"
"r" = "RenameHighlighted" "r" = "RenameHighlighted"
"?" = "ShowHelp" "?" = "ShowHelp"
+3 -2
View File
@@ -22,8 +22,9 @@ src/
events.ts # `/api/events` WebSocket subscriber events.ts # `/api/events` WebSocket subscriber
catalog.ts # session tree view (groups by working directory) catalog.ts # session tree view (groups by working directory)
attach.ts # xterm.js + attach WS view 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) 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 theme.ts # dark/light theme toggle + persistence
types.ts # mirror of `maestro-protocol` JSON shapes types.ts # mirror of `maestro-protocol` JSON shapes
styles.css # design-system tokens + component styles 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 `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, 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: Routing is hash-based:
- `#/` → catalog - `#/` → catalog
+6 -4
View File
@@ -143,8 +143,10 @@ export function createAttachConnection(
// • alt screen with no mouse tracking → nothing to do; the app owns input. // • alt screen with no mouse tracking → nothing to do; the app owns input.
// `touch-action: none` (CSS) keeps the browser from cancelling the custom // `touch-action: none` (CSS) keeps the browser from cancelling the custom
// gesture stream; only the terminal surface opts out of native page panning. // gesture stream; only the terminal surface opts out of native page panning.
// Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a
// later sibling of the viewport, so it paints over it and takes every touch.
{ {
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null; const root = term.element as HTMLElement | undefined;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null; const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
const cellH = () => { const cellH = () => {
const core = (term as unknown as { const core = (term as unknown as {
@@ -152,11 +154,11 @@ export function createAttachConnection(
})._core; })._core;
return core?._renderService?.dimensions?.css?.cell?.height || 16; return core?._renderService?.dimensions?.css?.cell?.height || 16;
}; };
if (vp) { if (root) {
let lastY = 0; let lastY = 0;
let lastX = 0; let lastX = 0;
let remainder = 0; let remainder = 0;
vp.addEventListener( root.addEventListener(
"touchstart", "touchstart",
(e) => { (e) => {
if (e.touches.length !== 1) return; if (e.touches.length !== 1) return;
@@ -166,7 +168,7 @@ export function createAttachConnection(
}, },
{ passive: true }, { passive: true },
); );
vp.addEventListener( root.addEventListener(
"touchmove", "touchmove",
(e) => { (e) => {
if (e.touches.length !== 1) return; if (e.touches.length !== 1) return;
+4 -4
View File
@@ -26,7 +26,7 @@ import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js"; import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.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. */ * multi-view controller lives; dispatched from every Spawn affordance. */
export const OPEN_SPAWN_EVENT = "catalog-open-spawn"; export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
export function requestOpenSpawn(): void { export function requestOpenSpawn(): void {
@@ -38,7 +38,7 @@ export function requestOpenSpawn(): void {
* connection pool. */ * connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane"; 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. */ * ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; 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.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn"; spawnBtn.textContent = "+ Spawn";
// The keyboard pair sits right here in the tooltip (#67). // 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()); spawnBtn.addEventListener("click", () => requestOpenSpawn());
footer.appendChild(spawnBtn); footer.appendChild(spawnBtn);
el.appendChild(footer); el.appendChild(footer);
@@ -578,7 +578,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
document.addEventListener("catalog-rerender", rerenderHandler); document.addEventListener("catalog-rerender", rerenderHandler);
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the // "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); const unsubscribe = store.subscribe(refresh);
+3 -3
View File
@@ -9,7 +9,7 @@
// ↑/↓ move the highlight // ↑/↓ move the highlight
// Enter accept the highlighted entry (and suppress form submit) // Enter accept the highlighted entry (and suppress form submit)
// Esc close the dropdown, refocus the input, and swallow the key so it // 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 // Once dismissed (Esc or blur) the popup stays closed until the operator types
// again — refocusing alone doesn't reopen it. // again — refocusing alone doesn't reopen it.
@@ -181,8 +181,8 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
if (e.key === "Escape") { if (e.key === "Escape") {
if (open) { if (open) {
e.preventDefault(); e.preventDefault();
// Swallow the key: an Escape that bubbles would also discard the // Swallow the key: an Escape that bubbles would also close the
// draft pane hosting this form. // spawn modal hosting this form.
e.stopPropagation(); e.stopPropagation();
dismiss(); dismiss();
input.focus(); input.focus();
+5 -5
View File
@@ -1,12 +1,12 @@
// Fast-forward spawn (#67): one keystroke, every default resolved, no form. // Fast-forward spawn (#67): one keystroke, every default resolved, no form.
// Pure resolution only — the caller (main.ts) owns the fetches and the POST, // 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 type { CreateSessionRequest, Project, SpawnPreset } from "./types.js";
import { sortProjects } from "./projectList.js"; import { sortProjects } from "./projectList.js";
/** What one fast-forward keystroke resolved. Anything absent means "open a /** 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 { export interface FastForwardResolution {
project?: Project; project?: Project;
command?: string; 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. */ * form; the rest stays with its own defaults. */
export function fastForwardSeed(res: FastForwardResolution) { export function fastForwardSeed(res: FastForwardResolution) {
return { return {
@@ -55,8 +55,8 @@ export function fastForwardSeed(res: FastForwardResolution) {
command: res.command ?? "", command: res.command ?? "",
name: res.name ?? "", name: res.name ?? "",
// Left undefined rather than false when nothing resolved: the form // Left undefined rather than false when nothing resolved: the form
// defaults the toggle on, and `false` would stick, so the fallback draft // defaults the toggle on, and `false` would stick, so the fallback form
// would disagree with both a normally-opened draft and the TUI's fast path // would disagree with both a normally-opened form and the TUI's fast path
// (#67 review). // (#67 review).
worktreeEnabled: res.project ? true : undefined, worktreeEnabled: res.project ? true : undefined,
worktreeBranch: worktreeBranch:
+1 -1
View File
@@ -17,7 +17,7 @@ export const SHORTCUTS: ShortcutGroup[] = [
{ {
title: "Sessions", title: "Sessions",
items: [ 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" }, { 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"; 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 = 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 }; | { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number };
export type Leaf = Extract<Node, { kind: "leaf" }>; export type Leaf = Extract<Node, { kind: "leaf" }>;
@@ -104,7 +89,7 @@ export function splitLeaf(
dir: Dir, dir: Dir,
after: boolean, after: boolean,
): string { ): 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 => { const rec = (n: Node): Node => {
if (n.kind === "leaf") { if (n.kind === "leaf") {
if (n.id !== targetId) return n; if (n.id !== targetId) return n;
+28 -20
View File
@@ -21,6 +21,7 @@ import {
resolveFastForward, resolveFastForward,
} from "./fastForward.js"; } from "./fastForward.js";
import { FALLBACK_PRESETS } from "./spawnForm.js"; import { FALLBACK_PRESETS } from "./spawnForm.js";
import { openSpawnModal } from "./spawnModal.js";
import { spawnSimilarSeed } from "./spawnSimilar.js"; import { spawnSimilarSeed } from "./spawnSimilar.js";
import { mountHud, type Hud } from "./hud.js"; import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js";
@@ -219,8 +220,8 @@ mv = createMultiView({
infoFor, infoFor,
onDisconnect: closePane, onDisconnect: closePane,
renderWelcome, renderWelcome,
// A draft-pane submit (#57) fills its leaf in place; the shell route may flip // A spawn fills a pane with its new session; the shell route may flip from
// from catalog (no sessions) to attach. // catalog (no sessions) to attach.
onOpenSessionsChange: updateShellRoute, onOpenSessionsChange: updateShellRoute,
onLayoutChange: scheduleSave, onLayoutChange: scheduleSave,
}); });
@@ -234,32 +235,37 @@ function showMultiView(): void {
updateShellRoute(); updateShellRoute();
} }
// Spawn affordances (#62): the sidebar's footer button, its empty state, and // Spawn affordances: the sidebar's footer button, its empty state, and
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the // `?mock&spawn` all open the spawn modal; "Spawn similar" seeds one from the
// cloned session (see spawnSimilar.ts). // cloned session (see spawnSimilar.ts).
function requestOpenSpawn(): void { function requestOpenSpawn(): void {
showMultiView(); // The session lands in a new tab, not a split of the pane you were reading
// A new tab, not a split of the pane you were reading (#100). "Spawn // (#100). "Spawn similar" and the fast-forward key below deliberately keep
// similar" and the fast-forward key below deliberately keep the old // the old behaviour: both act on the session in front of you, so a split
// behaviour: both act on the session in front of you, so a split beside it // beside it is the point.
// is the point. openSpawnModal({ onSuccess: (session) => mountSpawned(session, { newTab: true }) });
mv.openDraft(undefined, { newTab: true });
} }
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => { document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
const s = (ev as CustomEvent<Session>).detail; const s = (ev as CustomEvent<Session>).detail;
showMultiView(); openSpawnModal({ seed: spawnSimilarSeed(s), onSuccess: (session) => mountSpawned(session) });
mv.openDraft(spawnSimilarSeed(s));
}); });
// 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 // immediately with every default resolved — most recent project, its last
// command (or the first non-custom preset), a fresh name, worktree on with // command (or the first non-custom preset), a fresh name, worktree on with
// a new branch named after that name. Anything unresolvable (no projects // 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. // what did resolve instead.
async function requestFastForward(): Promise<void> { async function requestFastForward(): Promise<void> {
showMultiView();
const [discovered, name] = await Promise.all([ const [discovered, name] = await Promise.all([
fetchProjects().catch(() => [] as Project[]), fetchProjects().catch(() => [] as Project[]),
fetchNames(1) fetchNames(1)
@@ -281,18 +287,20 @@ async function requestFastForward(): Promise<void> {
// Mount it, the way the TUI's fast path attaches. Without this the // 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 // keystroke creates a session and leaves the UI untouched, so it reads
// as a dead key (#67 review). // as a dead key (#67 review).
mv.openSessionBeside(session.id); mountSpawned(session);
updateShellRoute();
return; return;
} catch { } 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. // 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 // Ignored while typing anywhere (form fields, the terminal's hidden
// textarea, search) so an attached session's keys are never hijacked. // textarea, search) so an attached session's keys are never hijacked.
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
+1 -1
View File
@@ -147,7 +147,7 @@ export function seedMock(store: CatalogStore): void {
sessions, 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. // backend endpoints stubbed so presets/projects/gitroot all render.
if (location.search.includes("spawn")) { if (location.search.includes("spawn")) {
stubSpawnApi(); 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> { async function flush(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve(); for (let i = 0; i < 20; i++) await Promise.resolve();
} }
describe("multiview openDraft (#62)", () => { describe("multiview empty panes", () => {
beforeEach(() => { beforeEach(() => {
stubFetch(); stubFetch();
document.body.replaceChildren(); document.body.replaceChildren();
@@ -70,144 +70,23 @@ describe("multiview openDraft (#62)", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("turns an empty tab into a draft pane", async () => { it("renders a placeholder, not a spawn form", 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 () => {
const layout = emptyLayout(); const layout = emptyLayout();
const tab = layout.tabs[0]; const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true); const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = first; tab.focusedLeafId = splitLeaf(tab, first, null, "row", true);
const mv = makeMv(layout); const mv = makeMv(layout);
document.body.appendChild(mv.el); document.body.appendChild(mv.el);
mv.render(); mv.render();
mv.openDraft();
await flush(); 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); expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
}); expect(mv.el.querySelector(".mv-pane-empty")).toBeTruthy();
expect(mv.el.querySelector(".spawn-form")).toBeNull();
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");
}); });
}); });
describe("multiview pane-drag swap (#73)", () => { describe("multiview pane-drag swap", () => {
beforeEach(() => { beforeEach(() => {
stubFetch(); stubFetch();
document.body.replaceChildren(); document.body.replaceChildren();
@@ -223,8 +102,8 @@ describe("multiview pane-drag swap (#73)", () => {
* updateDock() in multiview.ts, since neither is exposed on MultiView. */ * updateDock() in multiview.ts, since neither is exposed on MultiView. */
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> { async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
// The real drag-start gate requires the pointerdown target to sit inside // The real drag-start gate requires the pointerdown target to sit inside
// an `.attach-header` (attach.ts's session title bar). Draft panes mount // an `.attach-header` (attach.ts's session title bar), which the fake pool
// no such element, so stand one in for the gate check itself. // never mounts; stand one in for the gate check itself.
const marker = document.createElement("div"); const marker = document.createElement("div");
marker.className = "attach-header"; marker.className = "attach-header";
sourcePane.appendChild(marker); sourcePane.appendChild(marker);
@@ -254,40 +133,26 @@ describe("multiview pane-drag swap (#73)", () => {
await flush(); 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 layout = emptyLayout();
const tab = layout.tabs[0]; const tab = layout.tabs[0];
const leaf1 = splitLeaf(tab, "", null, "row", true); const leaf1 = splitLeaf(tab, "", "s1", "row", true);
const leaf2 = splitLeaf(tab, leaf1, null, "row", true); const leaf2 = splitLeaf(tab, leaf1, "s2", "row", true);
const mv = makeMv(layout); const mv = makeMv(layout);
document.body.appendChild(mv.el); document.body.appendChild(mv.el);
mv.render(); mv.render();
await flush(); await flush();
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement; 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)); await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
// The leaf data swaps (already correct pre-fix)... expect(leaves(tab.root).find((l) => l.id === leaf1)?.sessionId).toBe("s2");
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east"); expect(leaves(tab.root).find((l) => l.id === leaf2)?.sessionId).toBe("s1");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west"); expect(tab.focusedLeafId).toBe(leaf2);
// ...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");
}); });
}); });
describe("openDraft newTab (#100)", () => { describe("openSessionBeside newTab (#100)", () => {
beforeEach(() => { beforeEach(() => {
stubFetch(); stubFetch();
document.body.replaceChildren(); document.body.replaceChildren();
@@ -306,7 +171,7 @@ describe("openDraft newTab (#100)", () => {
document.body.appendChild(mv.el); document.body.appendChild(mv.el);
mv.render(); mv.render();
mv.openDraft(undefined, { newTab: true }); mv.openSessionBeside("s2", { newTab: true });
await flush(); await flush();
expect(layout.tabs).toHaveLength(2); expect(layout.tabs).toHaveLength(2);
@@ -314,11 +179,10 @@ describe("openDraft newTab (#100)", () => {
const original = leaves(layout.tabs[0].root); const original = leaves(layout.tabs[0].root);
expect(original).toHaveLength(1); expect(original).toHaveLength(1);
expect(original[0].sessionId).toBe("s1"); 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); const fresh = leaves(layout.tabs[1].root);
expect(fresh).toHaveLength(1); expect(fresh).toHaveLength(1);
expect(fresh[0].sessionId).toBeNull(); expect(fresh[0].sessionId).toBe("s2");
expect(fresh[0].draft).not.toBeNull();
expect(layout.activeTabId).toBe(layout.tabs[1].id); expect(layout.activeTabId).toBe(layout.tabs[1].id);
}); });
@@ -330,7 +194,7 @@ describe("openDraft newTab (#100)", () => {
document.body.appendChild(mv.el); document.body.appendChild(mv.el);
mv.render(); mv.render();
mv.openDraft(); mv.openSessionBeside("s2");
await flush(); await flush();
expect(layout.tabs).toHaveLength(1); expect(layout.tabs).toHaveLength(1);
@@ -399,7 +263,7 @@ describe("multiview closeSession tab cleanup (#137)", () => {
expect(leaves(tab.root)).toHaveLength(1); 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 layout = emptyLayout();
const tab = layout.tabs[0]; const tab = layout.tabs[0];
const s1 = splitLeaf(tab, "", "s1", "row", true); const s1 = splitLeaf(tab, "", "s1", "row", true);
@@ -478,7 +342,7 @@ describe("multiview mobile single pane", () => {
expect(layout.tabs).toHaveLength(1); 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 layout = emptyLayout();
const tab = layout.tabs[0]; const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
@@ -486,14 +350,13 @@ describe("multiview mobile single pane", () => {
document.body.appendChild(mv.el); document.body.appendChild(mv.el);
mv.render(); mv.render();
mv.openDraft(undefined, { newTab: true }); mv.openSessionBeside("s2", { newTab: true });
await flush(); await flush();
expect(layout.tabs).toHaveLength(1); expect(layout.tabs).toHaveLength(1);
const ls = leaves(tab.root); const ls = leaves(tab.root);
expect(ls).toHaveLength(1); expect(ls).toHaveLength(1);
expect(ls[0].sessionId).toBeNull(); expect(ls[0].sessionId).toBe("s2");
expect(ls[0].draft).not.toBeNull();
}); });
it("renders one pane and no pane pills for a multi-leaf tab", () => { 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 // The multi-view renderer/controller (issues #30 splits, #31 tabs, #33
// highlights). Owns the tab bar + canvas inside the content pane, builds the // 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 // 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 // session leaf via the pool. Empty leaves (freshly split, not yet filled) show a
// the spawn form inline, and submitting replaces the draft with that session's // placeholder. Drag-to-dock is issue #32; mobile projection is #35.
// terminal in place. Drag-to-dock is issue #32; mobile projection is #35.
import type { SidebarHandle } from "./catalog.js"; import type { SidebarHandle } from "./catalog.js";
import type { ConnectionPool } from "./connections.js"; import type { ConnectionPool } from "./connections.js";
@@ -13,7 +12,6 @@ import {
closeTab, closeTab,
findLeaf, findLeaf,
type Dir, type Dir,
type Draft,
type Layout, type Layout,
leaves, leaves,
locateSession, locateSession,
@@ -23,7 +21,6 @@ import {
splitLeaf, splitLeaf,
} from "./layout.js"; } from "./layout.js";
import type { AttachInfo } from "./attach.js"; import type { AttachInfo } from "./attach.js";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
export interface MultiViewOptions { export interface MultiViewOptions {
pool: ConnectionPool; pool: ConnectionPool;
@@ -35,8 +32,8 @@ export interface MultiViewOptions {
onDisconnect: (sessionId: string) => void; onDisconnect: (sessionId: string) => void;
/** Welcome content for an empty tab. */ /** Welcome content for an empty tab. */
renderWelcome: () => HTMLElement; renderWelcome: () => HTMLElement;
/** Called when a draft-pane submit turns an empty pane into a session pane, /** Called when a spawn fills a pane with a new session, so the shell can
* so the shell can re-evaluate its route (sidebar vs attach layout). */ * re-evaluate its route (sidebar vs attach layout). */
onOpenSessionsChange?: () => void; onOpenSessionsChange?: () => void;
/** Called after any layout change (structure, focus, or divider resize) so the /** Called after any layout change (structure, focus, or divider resize) so the
* shell can persist it (issue #34). */ * 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 /** 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). */ * focused pane (or open the first pane in an empty tab). */
openSession: (sessionId: string) => void; 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). */ /** Drop the pane showing `sessionId` (the session was disconnected/killed). */
closeSession: (sessionId: string) => void; closeSession: (sessionId: string) => void;
/** Every session currently shown in some pane, across all tabs. */ /** Every session currently shown in some pane, across all tabs. */
openSessions: () => Set<string>; 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 /** 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 * controller owns drag-vs-tap: a tap lets the row's normal click open it, a
* drag shows drop zones to place it. */ * 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 { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
const persist = () => opts.onLayoutChange?.(); 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"); const el = document.createElement("div");
el.className = "mv"; el.className = "mv";
const tabBar = document.createElement("div"); const tabBar = document.createElement("div");
@@ -141,38 +128,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const host = document.createElement("div"); const host = document.createElement("div");
host.className = "mv-pane-host"; host.className = "mv-pane-host";
if (!leaf.sessionId) { if (!leaf.sessionId) {
// Draft pane (#57): an empty pane is a spawn form. Values hang off the const empty = document.createElement("div");
// leaf (never persisted) so each draft keeps its own half-filled state; empty.className = "mv-pane-empty";
// submitting fills this leaf with the new session in place. empty.textContent = "Empty pane — pick a session from the sidebar, or press n to spawn.";
const form = draftForms.get(leaf.id); host.appendChild(empty);
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 tools = document.createElement("div"); 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). // 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]; const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0];
target.sessionId = sessionId; target.sessionId = sessionId;
target.draft = null;
tab.focusedLeafId = target.id; tab.focusedLeafId = target.id;
render(); render();
} }
@@ -375,9 +333,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
/// `openSession` deliberately replaces the focused pane (VS Code style) for /// `openSession` deliberately replaces the focused pane (VS Code style) for
/// a sidebar click, but the fast path must never do that: pressing the key /// 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 /// while attached would drop the running session out of the layout
/// entirely. Follows `openDraft`'s rule instead — reuse an empty pane, else /// entirely. Reuses an empty pane, else splits beside the focused one
/// split beside the focused one (#67 review). /// (#67 review). `newTab` opens its own tab instead of halving whatever the
function openSessionBeside(sessionId: string): void { /// operator was reading (#100) — what the Spawn button asks for.
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
const loc = locateSession(layout, sessionId); const loc = locateSession(layout, sessionId);
if (loc) { if (loc) {
layout.activeTabId = loc.tab.id; layout.activeTabId = loc.tab.id;
@@ -385,6 +344,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
render(); render();
return; 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); const tab = activeTab(layout);
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true); 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. // evicted session keeps running and stays a tap away in the sidebar.
if (!target.sessionId || isMobile) { if (!target.sessionId || isMobile) {
target.sessionId = sessionId; target.sessionId = sessionId;
target.draft = null;
} else { } else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true); tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
} }
@@ -405,66 +372,8 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
opts.onOpenSessionsChange?.(); 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 ---- // ---- 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 { function render(): void {
renderTabs(); renderTabs();
canvas.replaceChildren(); canvas.replaceChildren();
@@ -478,7 +387,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1); el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
if (!tab.root) { if (!tab.root) {
pruneForms(tab);
pool.setVisible([]); pool.setVisible([]);
pool.setFocus(null); pool.setFocus(null);
canvas.appendChild(renderWelcome()); canvas.appendChild(renderWelcome());
@@ -494,14 +402,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (isMobile) { if (isMobile) {
renderMobile(tab); renderMobile(tab);
pruneForms(tab);
persist(); persist();
return; return;
} }
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = []; const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
canvas.appendChild(buildTree(tab.root, hosts)); canvas.appendChild(buildTree(tab.root, hosts));
pruneForms(tab);
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info }))); pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
syncFocus(); syncFocus();
persist(); persist();
@@ -697,17 +603,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (source.kind === "pane" && self) { if (source.kind === "pane" && self) {
const dragged = findLeaf(tab.root, self)!; const dragged = findLeaf(tab.root, self)!;
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId]; [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; tab.focusedLeafId = target.id;
} else if (sessionId) { } else if (sessionId) {
const loc = locateSession(layout, sessionId); const loc = locateSession(layout, sessionId);
@@ -715,7 +610,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const t2 = findLeaf(tab.root, targetLeafId); const t2 = findLeaf(tab.root, targetLeafId);
if (t2) { if (t2) {
t2.sessionId = sessionId; t2.sessionId = sessionId;
t2.draft = null;
tab.focusedLeafId = t2.id; tab.focusedLeafId = t2.id;
} }
} }
@@ -741,7 +635,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
el, el,
openSession, openSession,
openSessionBeside, openSessionBeside,
openDraft,
closeSession, closeSession,
openSessions: () => openSessions(layout), openSessions: () => openSessions(layout),
beginSessionDrag, beginSessionDrag,
+4 -33
View File
@@ -62,7 +62,7 @@ describe("hydrate", () => {
expect(leaves(layout!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s"]); 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({ const layout = hydrate({
tabs: [ tabs: [
{ id: "t", root: { kind: "leaf", id: "l1", sessionId: null, draft: { command: "zsh", cwd: "/x" } } }, { id: "t", root: { kind: "leaf", id: "l1", sessionId: null, draft: { command: "zsh", cwd: "/x" } } },
@@ -74,7 +74,7 @@ describe("hydrate", () => {
}); });
describe("serializeLayout", () => { 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({ const layout = hydrate({
tabs: [ tabs: [
{ {
@@ -82,6 +82,7 @@ describe("serializeLayout", () => {
root: { root: {
kind: "split", kind: "split",
dir: "row", dir: "row",
ratio: 0.3,
a: { kind: "leaf", id: "l1", sessionId: "s1" }, a: { kind: "leaf", id: "l1", sessionId: "s1" },
b: { kind: "leaf", id: "l2", sessionId: null }, b: { kind: "leaf", id: "l2", sessionId: null },
}, },
@@ -89,40 +90,10 @@ describe("serializeLayout", () => {
], ],
activeTabId: "t", activeTabId: "t",
}) as Layout; }) 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); const restored = hydrate(JSON.parse(serializeLayout(layout)).layout);
expect(restored).not.toBeNull(); 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]); expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]);
}); expect((restored!.tabs[0].root as { ratio: number }).ratio).toBe(0.3);
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");
}); });
}); });
+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 { export function serializeLayout(layout: Layout): string {
const clean: Layout = { return JSON.stringify({ v: VERSION, 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 };
} }
export function loadLayout(): Layout | null { 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). */ * from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing"; export type WorktreeBranchMode = "new" | "existing";
/** Seed values for the spawn form — used by "clone" to open a draft with the /** Seed values for the spawn form — used by "Spawn similar" to open the modal
* same cwd, command, and name as an existing session, and by draft panes to * with the same cwd, command, and name as an existing session, and by the
* restore their own half-filled state after a re-render. */ * fast-forward fallback to pre-fill whatever it did resolve. */
export interface SpawnFormSeed { export interface SpawnFormSeed {
command?: string; command?: string;
cwd?: string; cwd?: string;
@@ -35,8 +35,7 @@ export interface SpawnFormSeed {
cwdRawVisible?: boolean; cwdRawVisible?: boolean;
} }
/** The form's current values, reported on every change so a caller (the draft /** The form's current values, reported on every change. */
* pane) can hang them off its layout leaf. */
export interface SpawnFormValues { export interface SpawnFormValues {
command: string; command: string;
cwd: string; cwd: string;
@@ -109,8 +108,6 @@ export interface SpawnFormHandle {
export interface SpawnFormOptions { export interface SpawnFormOptions {
seed?: SpawnFormSeed; 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). */ /** Fired whenever any value changes (input, tile pick, checkbox). */
onChange?: (values: SpawnFormValues) => void; onChange?: (values: SpawnFormValues) => void;
/** Called with the created session after a successful submit. */ /** 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. /** 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 { export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const prefill = opts.seed ?? {}; const prefill = opts.seed ?? {};
const form = document.createElement("form"); const form = document.createElement("form");
form.className = `spawn-form${opts.class ? ` ${opts.class}` : ""}`; form.className = "spawn-form";
form.innerHTML = ` form.innerHTML = `
<div class="field"> <div class="field">
<span class="field-label">Command</span> <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> <button type="submit" class="btn btn-primary">Spawn</button>
</div> </div>
`; `;
// Field queries are scoped to this form instance — several forms can be // Field queries are scoped to this form instance rather than by id, so a
// mounted at once (one per draft pane), so ids would collide. // second form mounted before the first is torn down cannot collide with it.
const errEl = form.querySelector(".form-error") as HTMLElement; const errEl = form.querySelector(".form-error") as HTMLElement;
const cmdTiles = form.querySelector(".cmd-tiles") 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 // `emit` stays silent until the initial load settles and goes silent again
// once the form is destroyed. Construction runs with `cwd === ""` and the // once the form is destroyed. Construction runs with `cwd === ""` and the
// seed is only restored two fetches later, so emitting in between would // seed is only restored two fetches later, so emitting in between would
// blank the leaf's draft; and `multiview.render()` rebuilds forms while the // report blank values as if the operator had cleared the form.
// old one's fetches are still in flight, so a destroyed form emitting would
// clobber the live one's leaf (#57 review).
let ready = false; let ready = false;
let destroyed = false; let destroyed = false;
const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value); const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value);
@@ -341,7 +336,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// ---- command tiles --------------------------------------------------- // ---- command tiles ---------------------------------------------------
// `focus` is false for the two initial applications (construction, and again // `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. // under the operator seconds after it mounts.
function selectPreset(id: string, focus = true): void { function selectPreset(id: string, focus = true): void {
selectedPresetId = id; 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 /** 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 * this form: token-guarded, and nothing touches the DOM (or emits) after
* destroy() — a late response into a rebuilt form would corrupt that * destroy() — a late response into a closed modal would throw. */
* form's draft (#57 review). */
async function loadBranches(): Promise<void> { async function loadBranches(): Promise<void> {
if (!cwd || !inferredBaseRepo) return; if (!cwd || !inferredBaseRepo) return;
const token = ++branchToken; 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. // "Spawn similar" (#28): the seed a cloned session hands to the spawn modal.
// Pure — the caller owns opening the pane. // 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"; 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. * worktree decision, fresh name.
* *
* A worktree session seeds its *base repository*, not the checkout the daemon * 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 * The name is left out on purpose: a clone is a new session and takes its own
* generated handle. */ * generated handle. */
export function spawnSimilarSeed(s: Session): Partial<Draft> { export function spawnSimilarSeed(s: Session): SpawnFormSeed {
const wt = s.worktree ?? null; const wt = s.worktree ?? null;
return { return {
command: s.command, command: s.command,
+106 -10
View File
@@ -1332,10 +1332,12 @@ input.leaf-rename:focus {
.attach-body .xterm .xterm-viewport { .attach-body .xterm .xterm-viewport {
overflow-y: auto; overflow-y: auto;
scrollbar-width: none; scrollbar-width: none;
/* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands }
the gesture to the browser, which may cancel the event stream before the
terminal sees it; `none` keeps a drag inside the terminal under our /* Maestro drives scrollback from touchmove (see attach.ts), so the browser must
control while the rest of the page retains native scrolling. */ not claim the gesture. On `.xterm`, not the viewport: `.xterm-screen` paints
over the viewport and is what a finger lands on. */
.attach-body .xterm {
touch-action: none; touch-action: none;
} }
@@ -1446,6 +1448,77 @@ input.leaf-rename:focus {
border-color: var(--accent-strong); 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 ---------- */ /* ---------- forms ---------- */
.spawn-form { .spawn-form {
@@ -1876,7 +1949,7 @@ input.leaf-rename:focus {
.dir-ac { .dir-ac {
position: fixed; 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; margin: 0;
padding: var(--sp-1); padding: var(--sp-1);
list-style: none; list-style: none;
@@ -2013,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 { @keyframes blink {
0%, 0%,
50% { 50% {
@@ -2334,12 +2427,15 @@ input.leaf-rename:focus {
.mv-pane:not(.is-focused) .attach-header { .mv-pane:not(.is-focused) .attach-header {
opacity: 0.7; opacity: 0.7;
} }
/* Draft pane (#57): an empty pane renders the shared spawn form inline. /* A pane with no session yet: a quiet hint rather than blank terminal black. */
The host is already a flex column; the form fills and scrolls within it. */ .mv-pane-empty {
.mv-pane-host > .mv-draft {
flex: 1; flex: 1;
min-width: 0; display: grid;
min-height: 0; 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 /* pane tools cluster (disconnect / split / close), top-right corner. The attach