Compare commits

...
12 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
Miguel PalhasandClaude Opus 5 1c349e5858 ci(maestro): cancel superseded runs
ci / web (push) Successful in 1m30s
ci / rust (push) Successful in 3m42s
ci / flake (push) Successful in 5m50s
Pushes queued a new run without stopping the old one. Group by ref and
cancel in-progress, except on main where runs finish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 10:35:31 +01:00
Miguel Palhas 9fc21b4306 Merge remote-tracking branch 'origin/main' into moderato-motif
ci / flake (push) Has been cancelled
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
2026-09-01 10:00:32 +01:00
naps62 19162f9952 Merge pull request 'feat(maestro): hide tab bar when a single tab' (#129) from marcato-lute into main
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
ci / rust (push) Has been cancelled
Reviewed-on: #129
2026-09-01 09:54:58 +01:00
Miguel PalhasandClaude Opus 5 2fcfc06c3c feat(maestro): drop the global app header
The header carried only the brand and a session tally; app actions had
already moved to the sidebar footer. The mobile ☰ it hosted moves to the
pane header (replacing the redundant back arrow) and to the welcome
state, so the session drawer stays reachable without a swipe. The DEV
`?kit` link moves to the sidebar footer.

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