Compare commits

...
6 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
Miguel Palhas c67fa2bd4e feat(maestro): force-kill prompt in the web client
ci / web (push) Successful in 35s
ci / rust (push) Successful in 4m1s
ci / flake (push) Successful in 6m55s
The kill button now sends the unforced DELETE immediately instead of
asking first. Only the daemon's guard refusal raises a prompt, and it is
a real dialog rather than confirm(), so it can offer the override the
daemon already implements.

The API error envelope is decoded into ApiRequestError, keeping the code
and the unsaved-work breakdown so the dialog names what is in the way.
2026-09-01 11:51:45 +01:00
Miguel Palhas 88b6bba9f2 feat(maestro): show only the program on session cards
ci / web (push) Successful in 34s
ci / rust (push) Successful in 3m56s
ci / flake (push) Successful in 7m8s
The command fragment named the full command line, so a session spawned
with `claude --dangerously-skip-permissions` spent the whole line on a
flag. Cards now name the program only — its icon in the web client, its
bare name in the TUI — and keep the full command line in the tooltip.

Adds `maestro_protocol::display::command_program`, the Rust counterpart
of the web's `splitCommand`.
2026-09-01 11:11:39 +01:00
54 changed files with 1838 additions and 857 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
} }
+90
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 {
@@ -140,6 +149,63 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
cur cur
} }
/// Command wrappers that run another program: the program is what follows.
const WRAPPERS: [&str; 8] = [
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
];
/// The program a command line runs, basenamed and stripped of its arguments —
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
pub fn command_program(command: &str) -> String {
let words: Vec<&str> = command.split_whitespace().collect();
let mut i = 0;
while i < words.len() {
let word = words[i];
if is_env_assignment(word) {
i += 1;
continue;
}
let wrapper = basename(word).to_lowercase();
if WRAPPERS.contains(&wrapper.as_str()) {
i += 1;
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
i += 1;
}
continue;
}
break;
}
match words.get(i) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there rather than
// rendering an empty fragment.
None => command.trim().to_string(),
}
}
/// `FOO=bar`, the shell's own prefix for one command's environment.
fn is_env_assignment(word: &str) -> bool {
let mut chars = word.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
let rest: String = chars.collect();
match rest.split_once('=') {
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
None => false,
}
}
fn basename(word: &str) -> &str {
match word.rsplit_once('/') {
Some((_, tail)) => tail,
None => word,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -318,4 +384,28 @@ mod tests {
}; };
assert_eq!(nested.name, "a/b"); assert_eq!(nested.name, "a/b");
} }
#[test]
fn command_program_drops_arguments() {
assert_eq!(
command_program("claude --dangerously-skip-permissions"),
"claude"
);
assert_eq!(command_program(" zsh -l "), "zsh");
assert_eq!(command_program("hx src/main.rs"), "hx");
}
#[test]
fn command_program_looks_through_paths_env_and_wrappers() {
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
assert_eq!(command_program("npx opencode"), "opencode");
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
}
#[test]
fn command_program_keeps_a_line_that_is_only_wrappers() {
assert_eq!(command_program("sudo"), "sudo");
assert_eq!(command_program(""), "");
}
} }
+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 {
+29 -4
View File
@@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use chrono::Utc; use chrono::Utc;
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path}; use maestro_protocol::display::{
abbreviate_dir, abbreviate_folder_name, command_program, session_tree_path,
};
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind}; use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
@@ -297,7 +299,8 @@ impl Sidebar {
if dead { if dead {
tail.push(Span::styled(" exited", muted)); tail.push(Span::styled(" exited", muted));
} }
let command = Span::styled(format!(" {}", s.command), muted); // docs/SIDEBAR.md: the card names the program, never its arguments.
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
// Reserve the triggered status fragments before detail and command. // Reserve the triggered status fragments before detail and command.
// If the row is exceptionally narrow, keep one column for the // If the row is exceptionally narrow, keep one column for the
@@ -874,12 +877,34 @@ mod tests {
); );
} }
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
#[test]
fn meta_line_drops_the_commands_arguments() {
let mut s = session_named(1, "handle");
s.command = "claude --dangerously-skip-permissions".into();
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 60, 6));
let meta = content(&all[3]);
assert!(
meta.contains("claude"),
"line 2 must name the program: {meta:?}"
);
assert!(
!meta.contains("dangerously"),
"line 2 must not carry arguments: {meta:?}"
);
}
/// The sidebar is narrow: the command truncates with `…` and never wraps /// The sidebar is narrow: the command truncates with `…` and never wraps
/// onto a third row (docs/SIDEBAR.md). /// onto a third row (docs/SIDEBAR.md).
#[test] #[test]
fn meta_line_command_truncates_instead_of_wrapping() { fn meta_line_command_truncates_instead_of_wrapping() {
let mut s = session_named(1, "claude"); let mut s = session_named(1, "claude");
s.command = "a command that cannot fit".into(); s.command = "a-program-name-that-cannot-fit".into();
s.activity_since = Utc::now() - Duration::seconds(5); s.activity_since = Utc::now() - Duration::seconds(5);
s.blocked_work = Some(InTheWay { s.blocked_work = Some(InTheWay {
uncommitted_files: 120, uncommitted_files: 120,
@@ -1099,7 +1124,7 @@ mod tests {
#[test] #[test]
fn blocked_warning_survives_before_a_truncated_command() { fn blocked_warning_survives_before_a_truncated_command() {
let mut s = session_named(1, "name"); let mut s = session_named(1, "name");
s.command = "a command that cannot fit".into(); s.command = "a-program-name-that-cannot-fit".into();
s.blocked_work = Some(InTheWay { s.blocked_work = Some(InTheWay {
uncommitted_files: 1, uncommitted_files: 1,
..Default::default() ..Default::default()
+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"
+19 -14
View File
@@ -88,21 +88,26 @@ of `maestro_protocol::InTheWay`'s `Display` does not disappear: it is the web
card's tooltip and the wording of the kill refusal, so no two clients describe card's tooltip and the wording of the kill refusal, so no two clients describe
the same tree differently. the same tree differently.
`<command>` is unconditional and muted: the session's command line, shown on `<command>` is unconditional and muted: it names the **program** the session
every card in both clients — no omission in the TUI, no name-vs-command runs — never its arguments. `claude --dangerously-skip-permissions` renders as
comparison gating it. A session named by its command line then shows the same `claude`. Flags say how a session was invoked, which is not what a card is for,
string on both lines; that is accepted, the price of a row that never changes and a long one crowds out every other fragment on the line. It shows on every
height. card in both clients — no omission in the TUI, no name-vs-command comparison
gating it. A session named by its program then shows the same string on both
lines; that is accepted, the price of a row that never changes height.
In the **web client** the command's leading program is replaced by that tool's The program is found by looking through a path, leading `FOO=bar` assignments
icon when it is one we have a mark for — Claude Code, Codex, opencode, Gemini and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename:
CLI, Aider, Cursor, pi, and any shell — and only the remaining words of the `/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers
command line follow as text. Recognition looks through a path, leading is shown as written rather than as an empty fragment.
`FOO=bar` assignments and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …); an
unrecognised program keeps its full text, so the card never shows a mark In the **web client** the program is replaced by that tool's icon when it is
standing for the wrong tool. The full command line stays in the card's tooltip one we have a mark for — Claude Code, Codex, opencode, Gemini CLI, Aider,
either way. The **TUI** keeps the command as text: a terminal cannot be assumed Cursor, pi, and any shell — and nothing follows it as text. An unrecognised
to have a font carrying tool glyphs. program keeps its name as text, so the card never shows a mark standing for the
wrong tool. The full command line, arguments and all, stays in the card's
tooltip either way. The **TUI** keeps the program as text: a terminal cannot be
assumed to have a font carrying tool glyphs.
## Colour per activity state ## Colour per activity state
+5
View File
@@ -79,6 +79,11 @@ would have blocked. A tree whose guard is clear never needed the override, so a
forced kill removes it as usual — force changes what happens to blocked work, forced kill removes it as usual — force changes what happens to blocked work,
not ordinary cleanup. not ordinary cleanup.
In the web client the kill button sends the unforced `DELETE` straight away —
there is no pre-confirmation, because the daemon is the one that decides. Only
the refusal raises a prompt, and that prompt names what is in the way and offers
"Force kill", which repeats the request with `force=true`.
Ending a session and destroying its work are two separate acts. Maestro only Ending a session and destroying its work are two separate acts. Maestro only
performs the first one. Removing a tree it left behind is the operator's own performs the first one. Removing a tree it left behind is the operator's own
`git worktree remove`, chosen while looking at exactly what would be lost. `git worktree remove`, chosen while looking at exactly what would be lost.
+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
+32 -7
View File
@@ -1,6 +1,7 @@
import * as routes from "./routes.js"; import * as routes from "./routes.js";
import type { import type {
ApiError, ApiError,
InTheWay,
Branches, Branches,
CreateSessionRequest, CreateSessionRequest,
DaemonInfo, DaemonInfo,
@@ -13,19 +14,38 @@ import type {
Uuid, Uuid,
} from "./types.js"; } from "./types.js";
/** A `/api/*` failure with the daemon's own error envelope kept intact, so a
* caller can branch on `code` — the kill guard's `kill_guarded` (#79) — and
* render `unsavedWork` rather than re-parsing the message. */
export class ApiRequestError extends Error {
readonly status: number;
readonly code: string;
readonly detail: string;
readonly unsavedWork: InTheWay | null;
constructor(res: Response, info?: ApiError["error"], text?: string) {
const detail = info ? `${info.code}: ${info.message}` : (text ?? "");
super(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
this.name = "ApiRequestError";
this.status = res.status;
this.code = info?.code ?? "";
this.detail = info?.message ?? text ?? "";
this.unsavedWork = info?.unsaved_work ?? null;
}
}
async function jsonOrError<T>(res: Response): Promise<T> { async function jsonOrError<T>(res: Response): Promise<T> {
if (res.ok) { if (res.ok) {
if (res.status === 204) return undefined as unknown as T; if (res.status === 204) return undefined as unknown as T;
return (await res.json()) as T; return (await res.json()) as T;
} }
let detail = "";
try { try {
const body = (await res.json()) as ApiError; const body = (await res.json()) as ApiError;
detail = `${body.error.code}: ${body.error.message}`; throw new ApiRequestError(res, body.error);
} catch { } catch (e) {
detail = await res.text(); if (e instanceof ApiRequestError) throw e;
throw new ApiRequestError(res, undefined, await res.text().catch(() => ""));
} }
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
} }
export async function listSessions(): Promise<Session[]> { export async function listSessions(): Promise<Session[]> {
@@ -57,9 +77,14 @@ export async function renameSession(id: Uuid, name: string): Promise<Session> {
); );
} }
export async function killSession(id: Uuid): Promise<void> { /** Kill a session. With `force` (#81) the daemon overrides its refusal over a
* worktree holding unsaved work and leaves that worktree on disk. */
export async function killSession(id: Uuid, force = false): Promise<void> {
const url = force
? `${routes.session(id)}?${routes.FORCE_QUERY}=true`
: routes.session(id);
await jsonOrError( await jsonOrError(
await fetch(routes.session(id), { await fetch(url, {
method: "DELETE", method: "DELETE",
credentials: "same-origin", credentials: "same-origin",
}), }),
+11 -8
View File
@@ -56,12 +56,13 @@ export interface AttachInfo {
command: string; command: string;
} }
/** Header echoes the sidebar: the command's leading program becomes its tool /** Header echoes the sidebar: the program becomes its tool icon when we have a
* icon when we have a mark for it, remaining words stay as text. */ * mark for it and stays as its bare name when we do not. Arguments are
* dropped, as on the card. */
function commandFragment(command: string): string { function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command); const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null; const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command; const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`; return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
} }
@@ -142,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 {
@@ -151,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;
@@ -165,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;
+79 -28
View File
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js"; import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js"; import type { CatalogListener, CatalogStore } from "./events.js";
@@ -16,6 +16,10 @@ beforeAll(() => {
window.fetch = () => Promise.reject(new Error("offline")); window.fetch = () => Promise.reject(new Error("offline"));
}); });
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
});
function session(overrides: Partial<Session>): Session { function session(overrides: Partial<Session>): Session {
return { return {
id: "11111111-2222-3333-4444-555555555555", id: "11111111-2222-3333-4444-555555555555",
@@ -46,6 +50,11 @@ function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`); return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
} }
/** Let the kill chain's awaits (fetch, its json body) settle. */
async function flush(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
describe("blocked-work indicator (#83)", () => { describe("blocked-work indicator (#83)", () => {
it("marks the row of a blocked session in diff notation", () => { it("marks the row of a blocked session in diff notation", () => {
const blocked = session({ const blocked = session({
@@ -89,37 +98,77 @@ describe("blocked-work indicator (#83)", () => {
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull(); expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
}); });
it("restates the warning at the kill confirm", async () => { it("kills straight away, without a first confirmation", async () => {
const confirmations: string[] = []; const calls: string[] = [];
window.confirm = (msg?: string) => { window.fetch = ((url: string, init?: RequestInit) => {
confirmations.push(msg ?? ""); calls.push(`${init?.method ?? "GET"} ${url}`);
return false; return Promise.resolve(new Response(null, { status: 204 }));
}; }) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
});
it("offers the override when the daemon refuses a guarded kill", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "kill_guarded",
message: "worktree has unsaved work",
unsaved_work: { untracked_files: 1 },
},
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
const blocked = session({ const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000004", id: "aaaaaaaa-0000-0000-0000-000000000004",
command: "bash", command: "bash",
blocked_work: { untracked_files: 1 }, blocked_work: { untracked_files: 1 },
}); });
const el = renderSidebar(storeWith([blocked])).el; const el = renderSidebar(storeWith([blocked])).el;
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill"); rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
kill?.click(); await flush();
await Promise.resolve(); const dialog = document.querySelector(".kill-dialog");
expect(confirmations).toHaveLength(1); expect(dialog?.textContent).toContain("1 untracked file");
expect(confirmations[0]).toContain("1 untracked file"); dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
expect(confirmations[0]).toContain("refused unless forced"); await flush();
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
expect(document.querySelector(".kill-dialog")).toBeNull();
}); });
it("confirms plainly when nothing blocks", async () => { it("closes the override prompt without killing on cancel", async () => {
const confirmations: string[] = []; const calls: string[] = [];
window.confirm = (msg?: string) => { window.fetch = ((url: string, init?: RequestInit) => {
confirmations.push(msg ?? ""); calls.push(`${init?.method ?? "GET"} ${url}`);
return false; return Promise.resolve(
}; new Response(
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" }); JSON.stringify({
const el = renderSidebar(storeWith([plain])).el; error: { code: "kill_guarded", message: "worktree has unsaved work" },
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click(); }),
await Promise.resolve(); { status: 409, headers: { "content-type": "application/json" } },
expect(confirmations).toEqual(['Kill session "claude"?']); ),
);
}) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000006",
blocked_work: { unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
}); });
}); });
@@ -209,15 +258,16 @@ describe("session cards (#107)", () => {
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull(); expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
}); });
it("keeps the command's arguments as text beside the icon", () => { it("drops the command's arguments, keeping them in the tooltip", () => {
const s = session({ const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001c", id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --resume", command: "claude --dangerously-skip-permissions",
}); });
const el = renderSidebar(storeWith([s])).el; const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd"); const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude"); expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("--resume"); expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
}); });
it("keeps an unknown command as plain text", () => { it("keeps an unknown command as plain text", () => {
@@ -228,7 +278,8 @@ describe("session cards (#107)", () => {
const el = renderSidebar(storeWith([s])).el; const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd"); const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")).toBeNull(); expect(cmd?.querySelector(".tool-icon")).toBeNull();
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx src/main.rs"); expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
}); });
it("maps each activity state to its contract slug", () => { it("maps each activity state to its contract slug", () => {
+30 -19
View File
@@ -1,5 +1,6 @@
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { getDaemonInfo, killSession, renameSession } from "./api.js"; import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js"; import { openContextMenu } from "./contextMenu.js";
import { isTypingTarget, toggleHelp } from "./help.js"; import { isTypingTarget, toggleHelp } from "./help.js";
import { CatalogStore } from "./events.js"; import { CatalogStore } from "./events.js";
@@ -25,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 {
@@ -37,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";
@@ -96,13 +97,14 @@ function blockedFragment(blockers: InTheWay): string {
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`; return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
} }
/** docs/SIDEBAR.md: the command's leading program becomes its tool icon when /** docs/SIDEBAR.md: the card names the program only — arguments never reach
* we have a mark for it, and only the remaining words stay as text. The full * the card. The program becomes its tool icon when we have a mark for it, and
* command line stays in the tooltip either way. */ * stays as its bare name when we do not. The full command line, arguments and
* all, stays in the tooltip either way. */
function commandFragment(command: string): string { function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command); const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null; const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command; const text = icon ? "" : program;
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`; return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
} }
@@ -179,7 +181,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
killBtn.addEventListener("click", (ev) => { killBtn.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
void confirmAndKill(s, name); void requestKill(s, name);
}); });
row.addEventListener("contextmenu", (ev) => { row.addEventListener("contextmenu", (ev) => {
ev.preventDefault(); ev.preventDefault();
@@ -191,22 +193,31 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
onSelect: () => onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })), document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
}, },
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) }, { label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
]); ]);
}); });
return row; return row;
} }
/** #83: the confirm restates the warning at the moment of decision. */ /**
async function confirmAndKill(s: Session, name: string): Promise<void> { * Kill on the first click (#81): the daemon is the one that decides whether a
const blockers = sessionBlockers(s); * session may go, and it refuses over a worktree holding unsaved work. Only
const question = blockers * that refusal is worth a prompt, and it needs one `confirm()` cannot give —
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.` * a forced kill. Anything else surfaces as an alert.
: `Kill session "${name}"?`; */
if (!confirm(question)) return; async function requestKill(s: Session, name: string): Promise<void> {
try { try {
await killSession(s.id); await killSession(s.id);
} catch (e) { } catch (e) {
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
openForceKillDialog({
name,
blockers: e.unsavedWork ?? sessionBlockers(s),
reason: e.detail,
onForce: () => killSession(s.id, true),
});
return;
}
alert(`Failed to kill: ${(e as Error).message}`); alert(`Failed to kill: ${(e as Error).message}`);
} }
} }
@@ -384,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);
@@ -567,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" },
], ],
}, },
+104
View File
@@ -0,0 +1,104 @@
/**
* Forced-kill prompt (#81).
*
* The daemon refuses to kill a session whose worktree still holds uncommitted
* or unpushed work. `confirm()` can only ask the same question twice, so the
* refusal gets its own dialog: it names what is in the way and offers the
* override the daemon's `force=true` already implements. A kill the daemon
* accepts never reaches here — there is nothing to warn about.
*/
import { formatInTheWay, type InTheWay } from "./types.js";
export interface ForceKillPrompt {
/** Session name, as the sidebar shows it. */
name: string;
/** What the daemon reported in the way, when it reported anything. */
blockers: InTheWay | null;
/** Message from the daemon's refusal, shown when there is no breakdown. */
reason: string;
/** Run the forced kill. A rejection is rendered in the dialog, which stays
* open so the override can be retried. */
onForce: () => Promise<void>;
}
let open: { el: HTMLElement; onKeydown: (ev: KeyboardEvent) => void } | null = null;
export function closeForceKillDialog(): void {
if (!open) return;
window.removeEventListener("keydown", open.onKeydown, true);
open.el.remove();
open = null;
}
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
* can never stack two dialogs. */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
closeForceKillDialog();
const el = document.createElement("div");
el.className = "kill-dialog";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", `Kill ${p.name}`);
const panel = document.createElement("div");
panel.className = "kill-dialog-panel";
const title = document.createElement("h2");
title.className = "kill-dialog-title";
title.textContent = `Kill "${p.name}"?`;
const body = document.createElement("p");
body.className = "kill-dialog-body";
body.textContent = p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
const error = document.createElement("p");
error.className = "kill-dialog-error";
error.hidden = true;
const actions = document.createElement("div");
actions.className = "kill-dialog-actions";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn btn-ghost kill-dialog-cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", closeForceKillDialog);
const force = document.createElement("button");
force.type = "button";
force.className = "btn btn-danger kill-dialog-force";
force.textContent = "Force kill";
force.addEventListener("click", () => {
force.disabled = true;
void p
.onForce()
.then(closeForceKillDialog)
.catch((e: unknown) => {
force.disabled = false;
error.hidden = false;
error.textContent = `Failed to kill: ${(e as Error).message}`;
});
});
actions.append(cancel, force);
panel.append(title, body, error, actions);
// A press on the scrim cancels; one inside the panel is left alone.
el.addEventListener("click", (ev) => {
if (!panel.contains(ev.target as Node)) closeForceKillDialog();
});
el.appendChild(panel);
document.body.appendChild(el);
const onKeydown = (ev: KeyboardEvent): void => {
if (ev.key !== "Escape") return;
ev.preventDefault();
ev.stopPropagation();
closeForceKillDialog();
};
window.addEventListener("keydown", onKeydown, true);
open = { el, onKeydown };
cancel.focus();
return el;
}
+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 {
+4
View File
@@ -18,6 +18,10 @@ export const EVENTS = "/api/events";
/** Name of the query parameter `DIRS` and `GITROOT` both take. */ /** Name of the query parameter `DIRS` and `GITROOT` both take. */
export const PATH_QUERY = "path"; export const PATH_QUERY = "path";
/** Name of the query parameter `DELETE /api/sessions/:id` takes (#81):
* `force=true` overrides the kill refusal over a guarded worktree. */
export const FORCE_QUERY = "force";
/** Name of the query parameter `NAMES` takes. */ /** Name of the query parameter `NAMES` takes. */
export const COUNT_QUERY = "count"; export const COUNT_QUERY = "count";
+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,
+164 -10
View File
@@ -1058,6 +1058,64 @@ input.leaf-rename:focus {
border-color: color-mix(in oklab, var(--danger) 40%, transparent); border-color: color-mix(in oklab, var(--danger) 40%, transparent);
} }
/* ---------- forced-kill prompt (#81) ---------- */
.kill-dialog {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: grid;
place-items: center;
padding: var(--sp-4);
background: oklch(0 0 0 / 0.5);
backdrop-filter: blur(4px);
}
.kill-dialog-panel {
width: min(400px, 100%);
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
box-shadow: var(--shadow-2);
}
.kill-dialog-title {
margin: 0 0 var(--sp-2);
font-family: var(--font-mono);
font-size: var(--fs-15);
color: var(--ink);
}
.kill-dialog-body {
margin: 0;
color: var(--muted);
font-size: var(--fs-13);
line-height: 1.5;
}
.kill-dialog-error {
margin: var(--sp-2) 0 0;
color: var(--danger);
font-size: var(--fs-13);
}
.kill-dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.btn-danger {
color: var(--danger);
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
}
.btn-danger:hover {
background: var(--danger-tint);
border-color: var(--danger);
}
/* ---------- empty / loading ---------- */ /* ---------- empty / loading ---------- */
.empty { .empty {
@@ -1274,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;
} }
@@ -1388,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 {
@@ -1818,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;
@@ -1955,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% {
@@ -2276,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
+7 -7
View File
@@ -20,12 +20,12 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
} }
}); });
it("keeps everything after the program as text", () => { it("drops everything after the program", () => {
expect(splitCommand("claude --resume -p hi")).toEqual({ expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
tool: "claude", tool: "claude",
rest: "--resume -p hi", program: "claude",
}); });
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", rest: "-l" }); expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", program: "zsh" });
}); });
it("looks through paths, env assignments and wrappers", () => { it("looks through paths, env assignments and wrappers", () => {
@@ -33,13 +33,13 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude"); expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
expect(splitCommand("npx opencode").tool).toBe("opencode"); expect(splitCommand("npx opencode").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode"); expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").rest).toBe("run"); expect(splitCommand("pnpm dlx opencode run").program).toBe("opencode");
}); });
it("leaves an unrecognised program as text, mark-less", () => { it("leaves an unrecognised program as its bare name, mark-less", () => {
expect(splitCommand("hx src/main.rs")).toEqual({ expect(splitCommand("hx src/main.rs")).toEqual({
tool: null, tool: null,
rest: "hx src/main.rs", program: "hx",
}); });
// A wrapper with nothing after it is not a tool either. // A wrapper with nothing after it is not a tool either.
expect(splitCommand("sudo").tool).toBeNull(); expect(splitCommand("sudo").tool).toBeNull();
+17 -18
View File
@@ -2,11 +2,11 @@
* *
* A session's command line starts with the program that runs it, and that * A session's command line starts with the program that runs it, and that
* program is nearly always one of a handful of agents or a shell. Rendering it * program is nearly always one of a handful of agents or a shell. Rendering it
* as its own mark buys back most of the fragment's width, which the sidebar * as its own mark buys back the whole fragment's width — the arguments are
* spends on the rest of the command line instead. * dropped, so a card says which tool runs, not how it was invoked.
* *
* Recognition is deliberately narrow: an unrecognised program keeps its text, * Recognition is deliberately narrow: an unrecognised program keeps its name
* so the fragment never renders a mark that stands for the wrong tool. * as text, so the fragment never renders a mark that stands for the wrong tool.
*/ */
/** Command wrappers that run another program: the tool is what follows. */ /** Command wrappers that run another program: the tool is what follows. */
@@ -48,15 +48,15 @@ export const TOOL_LABELS: Record<string, string> = {
export interface CommandParts { export interface CommandParts {
/** Icon id, or `null` when the program is not one we have a mark for. */ /** Icon id, or `null` when the program is not one we have a mark for. */
tool: string | null; tool: string | null;
/** What is left of the command line once the icon stands for the program. /** The program the command runs, basenamed and stripped of its arguments —
* Empty when the icon says everything the command line said. */ * what a card shows when there is no icon to show instead. */
rest: string; program: string;
} }
/** Split a command line into the tool its icon stands for and the remainder. /** Split a command line into the tool its icon stands for and the program name.
* *
* `claude --resume` → `{tool: "claude", rest: "--resume"}`; an unrecognised * `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
* `hx src/` keeps the whole line as `rest` with no tool. */ * `hx src/` keeps `hx` as the program with no tool. */
export function splitCommand(command: string): CommandParts { export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean); const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0; let i = 0;
@@ -68,23 +68,22 @@ export function splitCommand(command: string): CommandParts {
i += 1; i += 1;
continue; continue;
} }
const program = basename(word); const wrapper = basename(word).toLowerCase();
if (WRAPPERS.has(program)) { if (WRAPPERS.has(wrapper)) {
i += 1; i += 1;
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1; if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
continue; continue;
} }
break; break;
} }
if (i >= words.length) return { tool: null, rest: command.trim() }; if (i >= words.length) return { tool: null, program: command.trim() };
const tool = BY_PROGRAM[basename(words[i])] ?? null; const program = basename(words[i]);
if (!tool) return { tool: null, rest: command.trim() }; return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
return { tool, rest: words.slice(i + 1).join(" ") };
} }
function basename(word: string): string { function basename(word: string): string {
const cut = word.lastIndexOf("/"); const cut = word.lastIndexOf("/");
return (cut >= 0 ? word.slice(cut + 1) : word).toLowerCase(); return cut >= 0 ? word.slice(cut + 1) : word;
} }
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */ /** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
+6 -1
View File
@@ -253,7 +253,12 @@ export type ClientControl =
| { type: "Error"; code: string; message: string }; | { type: "Error"; code: string; message: string };
export interface ApiError { export interface ApiError {
error: { code: string; message: string }; error: {
code: string;
message: string;
/** Set only by the kill guard (#79): what blocked the unforced kill. */
unsaved_work?: InTheWay;
};
} }
export function sessionName(s: Session): string { export function sessionName(s: Session): string {