Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28778b2224 | ||
|
|
766eabbcb6 | ||
|
|
c67fa2bd4e | ||
|
|
88b6bba9f2 | ||
|
|
1c349e5858 | ||
|
|
9fc21b4306 | ||
|
|
19162f9952 | ||
|
|
2fcfc06c3c | ||
|
|
f82ab45766 | ||
|
|
d304171d7c | ||
|
|
95b459bd65 | ||
|
|
c9cfdb5668 | ||
|
|
31158cdb1a | ||
|
|
7b04575ed5 | ||
|
|
80af2e6816 | ||
|
|
52abfc6eea | ||
|
|
eb9b4b4ae8 | ||
|
|
1f543d16d1 | ||
|
|
f6cfcbb2c4 | ||
|
|
5920bbf2ec | ||
|
|
eb5d28d3a6 | ||
|
|
511ee3be4e | ||
|
|
a92202302a | ||
|
|
44c67fcee7 | ||
|
|
d8d26d181a | ||
|
|
aec7f4cae6 | ||
|
|
7f3d450f63 | ||
|
|
0d088c2581 | ||
|
|
1257ec6eba | ||
|
|
2dac56b43d | ||
|
|
39e8239a7d | ||
|
|
1afe079ca0 | ||
|
|
5c391f12a6 | ||
|
|
8cf9bd0586 | ||
|
|
0421a153c2 | ||
|
|
1589d58ca0 | ||
|
|
a503ec7c2b | ||
|
|
904a665432 | ||
|
|
8e1f26ac8d | ||
|
|
f0851a62ed |
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -273,7 +273,7 @@ impl Tmux {
|
||||
"-t",
|
||||
&name,
|
||||
"-F",
|
||||
"#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag}",
|
||||
SCREEN_GEOMETRY_FORMAT,
|
||||
";",
|
||||
"capture-pane",
|
||||
"-p",
|
||||
@@ -515,6 +515,52 @@ fn new_session_args(
|
||||
/// frame huge.
|
||||
const CAPTURE_HISTORY_LINES: usize = 300;
|
||||
|
||||
/// `display-message -F` request behind [`parse_screen_geometry`]. tmux expands
|
||||
/// a name it does not know to the empty string rather than failing, so a field
|
||||
/// missing on an older tmux shifts everything after it — hence the parser keys
|
||||
/// the optional tail off the field count.
|
||||
const SCREEN_GEOMETRY_FORMAT: &str = concat!(
|
||||
"#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag} ",
|
||||
"#{mouse_standard_flag} #{mouse_button_flag} #{mouse_all_flag} ",
|
||||
"#{mouse_utf8_flag} #{mouse_sgr_flag}",
|
||||
);
|
||||
|
||||
/// Field count of [`SCREEN_GEOMETRY_FORMAT`]; fewer means an older tmux did not
|
||||
/// know one of the names, and the mouse tail cannot be trusted by position.
|
||||
const SCREEN_GEOMETRY_FIELDS: usize = 10;
|
||||
|
||||
/// Mouse-reporting modes a pane has turned on. A full-screen program sets these
|
||||
/// once, at startup, so a client attaching later never sees the sequences —
|
||||
/// [`render_screen_snapshot`] replays them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
struct MouseModes {
|
||||
/// `?1000` — report button press and release.
|
||||
standard: bool,
|
||||
/// `?1002` — also report motion while a button is held.
|
||||
button: bool,
|
||||
/// `?1003` — report all motion.
|
||||
all: bool,
|
||||
/// `?1005` — UTF-8 coordinates (superseded by SGR, still replayed if on).
|
||||
utf8: bool,
|
||||
/// `?1006` — SGR-encoded coordinates.
|
||||
sgr: bool,
|
||||
}
|
||||
|
||||
impl MouseModes {
|
||||
/// DECSET parameters to re-enable these modes, private-mode order.
|
||||
fn decset_params(self) -> impl Iterator<Item = u16> {
|
||||
[
|
||||
(self.standard, 1000),
|
||||
(self.button, 1002),
|
||||
(self.all, 1003),
|
||||
(self.utf8, 1005),
|
||||
(self.sgr, 1006),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(on, param)| on.then_some(param))
|
||||
}
|
||||
}
|
||||
|
||||
/// Cursor position and screen geometry of a pane, from `display-message`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct ScreenGeometry {
|
||||
@@ -523,17 +569,31 @@ struct ScreenGeometry {
|
||||
pane_height: usize,
|
||||
alternate: bool,
|
||||
cursor_visible: bool,
|
||||
mouse: MouseModes,
|
||||
}
|
||||
|
||||
fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
|
||||
let line = s.lines().next()?;
|
||||
let mut it = line.split_whitespace();
|
||||
let cursor_x = it.next()?.parse().ok()?;
|
||||
let cursor_y = it.next()?.parse().ok()?;
|
||||
let pane_height: usize = it.next()?.parse().ok()?;
|
||||
let alternate = it.next()? == "1";
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
let cursor_x = fields.first()?.parse().ok()?;
|
||||
let cursor_y = fields.get(1)?.parse().ok()?;
|
||||
let pane_height: usize = fields.get(2)?.parse().ok()?;
|
||||
let alternate = *fields.get(3)? == "1";
|
||||
// Older tmux without `cursor_flag` just means "assume visible".
|
||||
let cursor_visible = it.next().map(|f| f == "1").unwrap_or(true);
|
||||
let cursor_visible = fields.get(4).map(|f| *f == "1").unwrap_or(true);
|
||||
// Only trust the mouse tail when every field expanded; a short line means
|
||||
// some name is unknown here and the remaining ones sit at other offsets.
|
||||
let mouse = if fields.len() >= SCREEN_GEOMETRY_FIELDS {
|
||||
MouseModes {
|
||||
standard: fields[5] == "1",
|
||||
button: fields[6] == "1",
|
||||
all: fields[7] == "1",
|
||||
utf8: fields[8] == "1",
|
||||
sgr: fields[9] == "1",
|
||||
}
|
||||
} else {
|
||||
MouseModes::default()
|
||||
};
|
||||
if pane_height == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -543,6 +603,7 @@ fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
|
||||
pane_height,
|
||||
alternate,
|
||||
cursor_visible,
|
||||
mouse,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -572,6 +633,12 @@ fn render_screen_snapshot(lines: &[&str], geom: ScreenGeometry) -> Vec<u8> {
|
||||
if geom.alternate {
|
||||
out.extend_from_slice(b"\x1b[?1049h");
|
||||
}
|
||||
// Mouse reporting is set up once, before this client existed. Without it
|
||||
// the client cannot tell a mouse-driven app from a plain alternate screen,
|
||||
// and its wheel and touch gestures have nowhere to go.
|
||||
for param in geom.mouse.decset_params() {
|
||||
out.extend_from_slice(format!("\x1b[?{param}h").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"\x1b[H\x1b[2J\x1b[m");
|
||||
for (i, line) in lines[..=last].iter().enumerate() {
|
||||
if i > 0 {
|
||||
@@ -645,7 +712,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_screen_geometry_reads_cursor_and_alternate_flag() {
|
||||
let g = parse_screen_geometry("12 3 40 1 0\n").unwrap();
|
||||
let g = parse_screen_geometry("12 3 40 1 0 0 1 0 0 1\n").unwrap();
|
||||
assert_eq!(
|
||||
g,
|
||||
ScreenGeometry {
|
||||
@@ -654,6 +721,11 @@ mod tests {
|
||||
pane_height: 40,
|
||||
alternate: true,
|
||||
cursor_visible: false,
|
||||
mouse: MouseModes {
|
||||
button: true,
|
||||
sgr: true,
|
||||
..MouseModes::default()
|
||||
},
|
||||
}
|
||||
);
|
||||
let g = parse_screen_geometry("0 0 24 0").unwrap();
|
||||
@@ -663,6 +735,31 @@ mod tests {
|
||||
assert!(parse_screen_geometry("garbage").is_none());
|
||||
}
|
||||
|
||||
/// A tmux that does not know one of the mouse names expands it to nothing,
|
||||
/// so the tail is one field short and every flag in it has slid. Reading it
|
||||
/// by position would claim modes the pane never set.
|
||||
#[test]
|
||||
fn parse_screen_geometry_drops_a_short_mouse_tail() {
|
||||
let g = parse_screen_geometry("0 0 24 1 1 0 0 1 1").unwrap();
|
||||
assert_eq!(g.mouse, MouseModes::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_modes_map_to_private_mode_parameters() {
|
||||
let all = MouseModes {
|
||||
standard: true,
|
||||
button: true,
|
||||
all: true,
|
||||
utf8: true,
|
||||
sgr: true,
|
||||
};
|
||||
assert_eq!(
|
||||
all.decset_params().collect::<Vec<_>>(),
|
||||
vec![1000, 1002, 1003, 1005, 1006]
|
||||
);
|
||||
assert_eq!(MouseModes::default().decset_params().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_clears_prints_lines_and_parks_the_cursor() {
|
||||
// Two scrollback lines, then a 3-row screen with the cursor on row 1.
|
||||
@@ -675,6 +772,7 @@ mod tests {
|
||||
pane_height: 3,
|
||||
alternate: false,
|
||||
cursor_visible: true,
|
||||
mouse: MouseModes::default(),
|
||||
},
|
||||
);
|
||||
let out = String::from_utf8(out).unwrap();
|
||||
@@ -695,6 +793,7 @@ mod tests {
|
||||
pane_height: 3,
|
||||
alternate: true,
|
||||
cursor_visible: false,
|
||||
mouse: MouseModes::default(),
|
||||
},
|
||||
);
|
||||
let out = String::from_utf8(out).unwrap();
|
||||
@@ -716,12 +815,40 @@ mod tests {
|
||||
pane_height: 3,
|
||||
alternate: false,
|
||||
cursor_visible: true,
|
||||
mouse: MouseModes::default(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(out, "\x1b[H\x1b[2J\x1b[mtext\x1b[m\r\x1b[4C");
|
||||
}
|
||||
|
||||
/// A client attaching to a running full-screen app never saw it turn mouse
|
||||
/// reporting on, so the snapshot has to. Without this the client reads the
|
||||
/// pane as a plain alternate screen and its scroll gestures go nowhere.
|
||||
#[test]
|
||||
fn snapshot_replays_the_panes_mouse_modes() {
|
||||
let out = String::from_utf8(render_screen_snapshot(
|
||||
&["app"],
|
||||
ScreenGeometry {
|
||||
cursor_x: 0,
|
||||
cursor_y: 0,
|
||||
pane_height: 1,
|
||||
alternate: true,
|
||||
cursor_visible: true,
|
||||
mouse: MouseModes {
|
||||
button: true,
|
||||
sgr: true,
|
||||
..MouseModes::default()
|
||||
},
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.starts_with("\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every phrasing must be recognised: an unmatched one makes
|
||||
/// `list_sessions` fail, and a failed pass reaps nothing.
|
||||
#[test]
|
||||
|
||||
@@ -45,6 +45,25 @@ pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
||||
breakdown.uncommitted_files += 1;
|
||||
}
|
||||
}
|
||||
// The line delta behind the sidebar's `+N −M` (docs/SIDEBAR.md). Diffing
|
||||
// the working tree against HEAD covers staged and unstaged changes in one
|
||||
// pass, so a `git add` never makes the counter drop.
|
||||
let numstat = run_git(path, ["diff", "--numstat", "HEAD"]);
|
||||
if let Ok(numstat) = numstat {
|
||||
for line in String::from_utf8_lossy(&numstat.stdout).lines() {
|
||||
let mut cols = line.split('\t');
|
||||
// A binary file reports `-` for both columns; it has no lines to
|
||||
// add to either total.
|
||||
let added: usize = cols.next().and_then(|c| c.parse().ok()).unwrap_or(0);
|
||||
let removed: usize = cols.next().and_then(|c| c.parse().ok()).unwrap_or(0);
|
||||
breakdown.insertions += added;
|
||||
breakdown.deletions += removed;
|
||||
}
|
||||
}
|
||||
// A repo with no commits yet has no HEAD to diff against, and the counts
|
||||
// stay zero rather than failing the whole probe — the file counts above
|
||||
// already reported the work.
|
||||
|
||||
// Resolved inside the worktree so a detached HEAD still has a starting
|
||||
// point; asking the base repo for `refs/heads/<branch>` fails there.
|
||||
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
|
||||
@@ -210,6 +229,67 @@ mod tests {
|
||||
assert_eq!(dirty.untracked_files, 1);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: the sidebar renders `+N −M`, so the probe carries the
|
||||
/// line delta of the tracked changes — staged and unstaged together,
|
||||
/// against HEAD.
|
||||
#[test]
|
||||
fn staged_and_unstaged_lines_both_count() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("staged.txt"), b"a\nb\n").unwrap();
|
||||
git(&r.wt(), &["add", "staged.txt"]);
|
||||
// README loses its one line and gains three, unstaged.
|
||||
std::fs::write(r.wt().join("README"), b"x\ny\nz\n").unwrap();
|
||||
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown.insertions, 5, "2 staged + 3 unstaged");
|
||||
assert_eq!(breakdown.deletions, 1);
|
||||
}
|
||||
|
||||
/// Untracked files are counted as files and never as lines — reading each
|
||||
/// one off disk is the cost the sidebar contract declines to pay.
|
||||
#[test]
|
||||
fn untracked_files_contribute_no_lines() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("scratch.txt"), b"a\nb\nc\n").unwrap();
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown.untracked_files, 1);
|
||||
assert_eq!(breakdown.insertions, 0);
|
||||
assert_eq!(breakdown.deletions, 0);
|
||||
}
|
||||
|
||||
/// A binary file's numstat is `-\t-`. It must not poison the totals, and
|
||||
/// it must not abort the probe either.
|
||||
#[test]
|
||||
fn binary_changes_add_no_lines() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("blob.bin"), [0u8, 159, 0, 12]).unwrap();
|
||||
git(&r.wt(), &["add", "blob.bin"]);
|
||||
git(&r.wt(), &["commit", "-qm", "blob"]);
|
||||
std::fs::write(r.wt().join("blob.bin"), [0u8, 1, 2, 3, 4]).unwrap();
|
||||
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
assert_eq!(breakdown.insertions, 0);
|
||||
assert_eq!(breakdown.deletions, 0);
|
||||
}
|
||||
|
||||
/// A line delta alone never decides `is_clear` — the file counts do — so a
|
||||
/// mode change with no line delta still blocks a kill.
|
||||
#[test]
|
||||
fn a_line_less_change_still_blocks() {
|
||||
let r = repo_with_worktree();
|
||||
let script = r.wt().join("run.sh");
|
||||
std::fs::write(&script, b"echo hi\n").unwrap();
|
||||
git(&r.wt(), &["add", "run.sh"]);
|
||||
git(&r.wt(), &["commit", "-qm", "script"]);
|
||||
git(&r.wt(), &["update-index", "--chmod=+x", "run.sh"]);
|
||||
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown.insertions, 0);
|
||||
assert_eq!(breakdown.deletions, 0);
|
||||
assert!(!breakdown.is_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_files_never_count() {
|
||||
let r = repo_with_worktree();
|
||||
|
||||
@@ -232,17 +232,14 @@ async fn projects_returns_empty_for_a_disabled_scanner() {
|
||||
assert!(projects.is_empty());
|
||||
}
|
||||
|
||||
/// #53: every returned handle splits into a known adjective and noun.
|
||||
/// #53: every returned handle is one word from the shared list.
|
||||
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
|
||||
for name in &batch.names {
|
||||
let (adj, noun) = name.split_once('-').unwrap_or_else(|| {
|
||||
panic!("handle {name:?} is not adjective-noun");
|
||||
});
|
||||
assert!(!name.contains('-'), "handle {name:?} is not one word");
|
||||
assert!(
|
||||
maestro_names::ADJECTIVES.contains(&adj),
|
||||
"{adj} not in ADJECTIVES"
|
||||
maestro_names::WORDS.contains(&name.as_str()),
|
||||
"{name} not in WORDS"
|
||||
);
|
||||
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -246,6 +246,59 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
||||
fx.manager.kill(s.id, false).await.unwrap();
|
||||
}
|
||||
|
||||
/// A full-screen app turns mouse reporting on once, at startup. A client that
|
||||
/// attaches later never sees those sequences, so it reads the pane as a plain
|
||||
/// alternate screen and its wheel and touch gestures have nowhere to go. The
|
||||
/// snapshot must carry the pane's mouse modes with it.
|
||||
#[tokio::test]
|
||||
async fn attach_replay_carries_the_panes_mouse_modes() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
// Alternate screen, then button-event tracking (?1002) with SGR
|
||||
// coordinates (?1006) — what tmux and agent TUIs actually request.
|
||||
command: "sh -c 'printf \"\\\\033[?1049h\\\\033[?1002h\\\\033[?1006h\\\\033[2J\\\\033[HMOUSE_MARKER\"; exec sleep 30'"
|
||||
.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let want = b"MOUSE_MARKER";
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
let mut replay = Vec::new();
|
||||
while std::time::Instant::now() < deadline {
|
||||
let (bytes, _rx) = fx
|
||||
.manager
|
||||
.subscribe_pty_with_replay(s.id)
|
||||
.await
|
||||
.expect("subscribe");
|
||||
replay = bytes;
|
||||
if replay.windows(want.len()).any(|w| w == want) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&replay).into_owned();
|
||||
assert!(
|
||||
text.contains("MOUSE_MARKER"),
|
||||
"replay never reached the live screen: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
replay.starts_with(b"\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"),
|
||||
"replay dropped the pane's mouse modes: {text:?}"
|
||||
);
|
||||
|
||||
fx.manager.kill(s.id, false).await.unwrap();
|
||||
}
|
||||
|
||||
/// Regression for NAP-228: after `recover`, PTY output must reach new
|
||||
/// subscribers. Before the fix tmux's stale `pipe-pane` (with `-o`) silently
|
||||
/// kept feeding the old FIFO and the revived manager's subscribers saw
|
||||
|
||||
@@ -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());
|
||||
|
||||
+233
-29
@@ -1,10 +1,195 @@
|
||||
//! The one home of the session-handle wordlists (#53).
|
||||
//! The one home of the session-handle wordlist (#53).
|
||||
//!
|
||||
//! Previously duplicated in `web/src/names.ts`; every client now gets handles
|
||||
//! from here, served by `GET /api/names`.
|
||||
|
||||
/// Musical adjectives — tempo, dynamics, and articulation markings.
|
||||
pub const ADJECTIVES: &[&str] = &[
|
||||
/// Every word a handle can be: instruments, forms, and the vocabulary of
|
||||
/// tempo, dynamics, notation and theory. One word per handle — the list is
|
||||
/// wide enough that a single word still reads as distinct.
|
||||
pub const WORDS: &[&str] = &[
|
||||
// Instruments
|
||||
"cello",
|
||||
"violin",
|
||||
"viola",
|
||||
"oboe",
|
||||
"flute",
|
||||
"piccolo",
|
||||
"clarinet",
|
||||
"bassoon",
|
||||
"trumpet",
|
||||
"trombone",
|
||||
"tuba",
|
||||
"horn",
|
||||
"cornet",
|
||||
"bugle",
|
||||
"flugelhorn",
|
||||
"euphonium",
|
||||
"saxophone",
|
||||
"recorder",
|
||||
"timpani",
|
||||
"harp",
|
||||
"piano",
|
||||
"organ",
|
||||
"lyre",
|
||||
"lute",
|
||||
"mandolin",
|
||||
"banjo",
|
||||
"ukulele",
|
||||
"guitar",
|
||||
"sitar",
|
||||
"koto",
|
||||
"shamisen",
|
||||
"balalaika",
|
||||
"zither",
|
||||
"dulcimer",
|
||||
"kantele",
|
||||
"erhu",
|
||||
"guzheng",
|
||||
"pipa",
|
||||
"duduk",
|
||||
"ocarina",
|
||||
"kalimba",
|
||||
"harmonica",
|
||||
"accordion",
|
||||
"bandoneon",
|
||||
"bagpipe",
|
||||
"harpsichord",
|
||||
"clavichord",
|
||||
"celesta",
|
||||
"theremin",
|
||||
"marimba",
|
||||
"xylophone",
|
||||
"vibraphone",
|
||||
"glockenspiel",
|
||||
"snare",
|
||||
"cymbal",
|
||||
"tambourine",
|
||||
"castanet",
|
||||
"djembe",
|
||||
"tabla",
|
||||
"bodhran",
|
||||
"gong",
|
||||
"chimes",
|
||||
"contrabass",
|
||||
"shawm",
|
||||
"crumhorn",
|
||||
"sackbut",
|
||||
"didgeridoo",
|
||||
// Forms and pieces
|
||||
"sonata",
|
||||
"fugue",
|
||||
"canon",
|
||||
"prelude",
|
||||
"overture",
|
||||
"rondo",
|
||||
"nocturne",
|
||||
"scherzo",
|
||||
"etude",
|
||||
"ballade",
|
||||
"barcarolle",
|
||||
"bagatelle",
|
||||
"caprice",
|
||||
"chaconne",
|
||||
"passacaglia",
|
||||
"toccata",
|
||||
"intermezzo",
|
||||
"interlude",
|
||||
"minuet",
|
||||
"gavotte",
|
||||
"sarabande",
|
||||
"gigue",
|
||||
"allemande",
|
||||
"courante",
|
||||
"mazurka",
|
||||
"polonaise",
|
||||
"waltz",
|
||||
"bolero",
|
||||
"habanera",
|
||||
"tarantella",
|
||||
"fandango",
|
||||
"serenade",
|
||||
"aubade",
|
||||
"requiem",
|
||||
"cantata",
|
||||
"oratorio",
|
||||
"madrigal",
|
||||
"motet",
|
||||
"chorale",
|
||||
"hymn",
|
||||
"anthem",
|
||||
"lullaby",
|
||||
"elegy",
|
||||
"rhapsody",
|
||||
"symphony",
|
||||
"concerto",
|
||||
"fantasia",
|
||||
"divertimento",
|
||||
"quartet",
|
||||
"quintet",
|
||||
"sextet",
|
||||
"septet",
|
||||
"octet",
|
||||
"trio",
|
||||
// Structure, notation and theory
|
||||
"octave",
|
||||
"chord",
|
||||
"cadence",
|
||||
"cadenza",
|
||||
"coda",
|
||||
"motif",
|
||||
"refrain",
|
||||
"verse",
|
||||
"chorus",
|
||||
"bridge",
|
||||
"finale",
|
||||
"arpeggio",
|
||||
"glissando",
|
||||
"ostinato",
|
||||
"counterpoint",
|
||||
"descant",
|
||||
"harmony",
|
||||
"melody",
|
||||
"rhythm",
|
||||
"tempo",
|
||||
"timbre",
|
||||
"treble",
|
||||
"clef",
|
||||
"staff",
|
||||
"quaver",
|
||||
"crotchet",
|
||||
"semibreve",
|
||||
"fermata",
|
||||
"trill",
|
||||
"mordent",
|
||||
"appoggiatura",
|
||||
"portamento",
|
||||
"vibrato",
|
||||
"triplet",
|
||||
"tuplet",
|
||||
"downbeat",
|
||||
"upbeat",
|
||||
"anacrusis",
|
||||
"syncopation",
|
||||
"modulation",
|
||||
"transposition",
|
||||
"unison",
|
||||
"semitone",
|
||||
"tritone",
|
||||
"diapason",
|
||||
"tonic",
|
||||
"dominant",
|
||||
"mediant",
|
||||
"ionian",
|
||||
"dorian",
|
||||
"phrygian",
|
||||
"lydian",
|
||||
"mixolydian",
|
||||
"aeolian",
|
||||
"locrian",
|
||||
"pentatonic",
|
||||
"chromatic",
|
||||
"diatonic",
|
||||
// Tempo, dynamics and articulation
|
||||
"allegro",
|
||||
"andante",
|
||||
"adagio",
|
||||
@@ -15,6 +200,7 @@ pub const ADJECTIVES: &[&str] = &[
|
||||
"lento",
|
||||
"moderato",
|
||||
"crescendo",
|
||||
"diminuendo",
|
||||
"staccato",
|
||||
"legato",
|
||||
"forte",
|
||||
@@ -35,60 +221,78 @@ pub const ADJECTIVES: &[&str] = &[
|
||||
"sotto",
|
||||
"vivo",
|
||||
"feroce",
|
||||
];
|
||||
|
||||
/// Musical nouns — instruments and forms from the concert hall.
|
||||
pub const NOUNS: &[&str] = &[
|
||||
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
|
||||
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
|
||||
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
|
||||
"motif", "overture",
|
||||
"giocoso",
|
||||
"affettuoso",
|
||||
"risoluto",
|
||||
"leggiero",
|
||||
"pesante",
|
||||
"morendo",
|
||||
"accelerando",
|
||||
"ritardando",
|
||||
];
|
||||
|
||||
/// Upper bound for a `GET /api/names?count=N` batch.
|
||||
pub const MAX_BATCH: usize = 100;
|
||||
|
||||
/// A fresh `adjective-noun` handle, e.g. `allegro-cello`.
|
||||
/// A fresh one-word handle, e.g. `nocturne`.
|
||||
pub fn generate() -> String {
|
||||
use rand::seq::SliceRandom;
|
||||
let mut rng = rand::thread_rng();
|
||||
format!(
|
||||
"{}-{}",
|
||||
ADJECTIVES.choose(&mut rng).unwrap(),
|
||||
NOUNS.choose(&mut rng).unwrap()
|
||||
)
|
||||
(*WORDS.choose(&mut rng).unwrap()).to_string()
|
||||
}
|
||||
|
||||
/// `count` fresh handles (the caller clamps; this only trusts its input).
|
||||
/// `count` fresh handles, distinct while the wordlist lasts (the caller
|
||||
/// clamps; this only trusts its input).
|
||||
pub fn generate_batch(count: usize) -> Vec<String> {
|
||||
(0..count).map(|_| generate()).collect()
|
||||
use rand::seq::SliceRandom;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut pool: Vec<&str> = WORDS.to_vec();
|
||||
pool.shuffle(&mut rng);
|
||||
(0..count)
|
||||
.map(|i| match pool.get(i) {
|
||||
// Past the wordlist a batch has to repeat itself.
|
||||
Some(word) => (*word).to_string(),
|
||||
None => generate(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn generated_name_is_adjective_dash_noun() {
|
||||
fn generated_name_is_a_single_known_word() {
|
||||
let name = generate();
|
||||
let (adj, noun) = name.split_once('-').expect("name contains a dash");
|
||||
assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES");
|
||||
assert!(NOUNS.contains(&noun), "{noun} not in NOUNS");
|
||||
assert!(!name.contains('-'), "{name} is not one word");
|
||||
assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_returns_the_requested_count() {
|
||||
fn the_wordlist_has_no_duplicates_and_covers_a_full_batch() {
|
||||
let unique: HashSet<&&str> = WORDS.iter().collect();
|
||||
assert_eq!(unique.len(), WORDS.len(), "WORDS has duplicates");
|
||||
assert!(
|
||||
WORDS.len() >= MAX_BATCH,
|
||||
"a full batch would have to repeat"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_returns_the_requested_count_without_repeats() {
|
||||
assert_eq!(generate_batch(1).len(), 1);
|
||||
assert_eq!(generate_batch(50).len(), 50);
|
||||
for name in generate_batch(10) {
|
||||
let (adj, noun) = name.split_once('-').unwrap();
|
||||
assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun));
|
||||
let batch = generate_batch(MAX_BATCH);
|
||||
assert_eq!(batch.len(), MAX_BATCH);
|
||||
for name in &batch {
|
||||
assert!(WORDS.contains(&name.as_str()), "{name} not in WORDS");
|
||||
}
|
||||
let unique: HashSet<&String> = batch.iter().collect();
|
||||
assert_eq!(unique.len(), batch.len(), "batch repeated a handle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerolls_can_differ() {
|
||||
// 30x30=900 combinations; 20 draws all matching has probability ~1e-40.
|
||||
let first = generate();
|
||||
let differs = (0..20).any(|_| generate() != first);
|
||||
assert!(differs);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,12 +236,71 @@ pub struct InTheWay {
|
||||
/// Commits on the branch reachable from no remote-tracking ref.
|
||||
#[serde(default)]
|
||||
pub unpushed_commits: usize,
|
||||
/// Lines added across the tracked changes — staged and unstaged alike,
|
||||
/// measured against `HEAD`. Untracked files contribute nothing: counting
|
||||
/// their lines means reading every one off disk, and they already have
|
||||
/// their own count above (docs/SIDEBAR.md).
|
||||
#[serde(default)]
|
||||
pub insertions: usize,
|
||||
/// Lines removed across the same tracked changes.
|
||||
#[serde(default)]
|
||||
pub deletions: usize,
|
||||
}
|
||||
|
||||
impl InTheWay {
|
||||
/// Nothing blocks any more — the work has since been committed and pushed.
|
||||
///
|
||||
/// The line counts are not consulted: they describe the same tracked
|
||||
/// changes `uncommitted_files` counts, and a change with no line delta —
|
||||
/// a mode change, a rename — still blocks.
|
||||
pub fn is_clear(&self) -> bool {
|
||||
*self == Self::default()
|
||||
self.uncommitted_files == 0 && self.untracked_files == 0 && self.unpushed_commits == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Which fragment of the compact blocked-work stat a value is, and so which
|
||||
/// colour the client paints it (docs/SIDEBAR.md).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StatKind {
|
||||
/// Lines added.
|
||||
Added,
|
||||
/// Lines removed.
|
||||
Removed,
|
||||
/// Changed tracked files, when the change carries no line delta.
|
||||
Files,
|
||||
/// Untracked files.
|
||||
Untracked,
|
||||
/// Unpushed commits.
|
||||
Unpushed,
|
||||
}
|
||||
|
||||
impl InTheWay {
|
||||
/// The breakdown as diff notation: `+124`, `-18`, `?3`, `↑2`.
|
||||
///
|
||||
/// The line counts cover tracked changes only, so a tree whose only
|
||||
/// tracked change carries no line delta — a mode change, a rename —
|
||||
/// reports the file count (`~2`) rather than claiming `+0 -0`. Both
|
||||
/// sidebars render exactly these fragments; [`Display`](std::fmt::Display)
|
||||
/// remains the spelled-out phrasing for the kill refusal and the tooltip.
|
||||
pub fn stats(&self) -> Vec<(StatKind, String)> {
|
||||
let mut out = Vec::new();
|
||||
if self.insertions > 0 || self.deletions > 0 {
|
||||
if self.insertions > 0 {
|
||||
out.push((StatKind::Added, format!("+{}", self.insertions)));
|
||||
}
|
||||
if self.deletions > 0 {
|
||||
out.push((StatKind::Removed, format!("-{}", self.deletions)));
|
||||
}
|
||||
} else if self.uncommitted_files > 0 {
|
||||
out.push((StatKind::Files, format!("~{}", self.uncommitted_files)));
|
||||
}
|
||||
if self.untracked_files > 0 {
|
||||
out.push((StatKind::Untracked, format!("?{}", self.untracked_files)));
|
||||
}
|
||||
if self.unpushed_commits > 0 {
|
||||
out.push((StatKind::Unpushed, format!("↑{}", self.unpushed_commits)));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,12 +506,30 @@ pub struct DaemonInfo {
|
||||
}
|
||||
|
||||
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
||||
/// `adjective-noun` session handles generated by the `maestro-names` crate.
|
||||
/// one-word session handles generated by the `maestro-names` crate.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
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;
|
||||
@@ -544,6 +621,50 @@ mod tests {
|
||||
assert!(is_compatible("unknown"));
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: the sidebars render the breakdown as diff notation.
|
||||
#[test]
|
||||
fn stats_read_as_a_diff() {
|
||||
let w = InTheWay {
|
||||
uncommitted_files: 3,
|
||||
untracked_files: 2,
|
||||
unpushed_commits: 1,
|
||||
insertions: 124,
|
||||
deletions: 18,
|
||||
};
|
||||
let text: Vec<String> = w.stats().into_iter().map(|(_, t)| t).collect();
|
||||
assert_eq!(text, ["+124", "-18", "?2", "↑1"]);
|
||||
let kinds: Vec<StatKind> = w.stats().into_iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
[
|
||||
StatKind::Added,
|
||||
StatKind::Removed,
|
||||
StatKind::Untracked,
|
||||
StatKind::Unpushed
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A change with no line delta — a mode change — still has to say
|
||||
/// something, and `+0 -0` would be a lie about the tree.
|
||||
#[test]
|
||||
fn stats_fall_back_to_the_file_count_without_a_line_delta() {
|
||||
let w = InTheWay {
|
||||
uncommitted_files: 2,
|
||||
..InTheWay::default()
|
||||
};
|
||||
let text: Vec<String> = w.stats().into_iter().map(|(_, t)| t).collect();
|
||||
assert_eq!(text, ["~2"]);
|
||||
assert!(!w.is_clear());
|
||||
}
|
||||
|
||||
/// A clear tree says nothing at all — the fragment is omitted, not empty.
|
||||
#[test]
|
||||
fn a_clear_tree_has_no_stats() {
|
||||
assert!(InTheWay::default().stats().is_empty());
|
||||
assert!(InTheWay::default().is_clear());
|
||||
}
|
||||
|
||||
fn round_trip<T>(value: &T) -> T
|
||||
where
|
||||
T: Serialize + for<'de> Deserialize<'de>,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -353,22 +353,8 @@ impl App {
|
||||
Action::ScrollPanePageDown => {
|
||||
self.scroll_active_pane(-(self.pane_page_rows() as isize))
|
||||
}
|
||||
Action::SidebarNext => {
|
||||
let order = visible_sidebar_order(
|
||||
&self.catalog.snapshot(),
|
||||
&self.state.collapsed_sidebar_groups,
|
||||
);
|
||||
self.state.move_sidebar_highlight(&order, 1);
|
||||
self.state.focus = Focus::Sidebar;
|
||||
}
|
||||
Action::SidebarPrev => {
|
||||
let order = visible_sidebar_order(
|
||||
&self.catalog.snapshot(),
|
||||
&self.state.collapsed_sidebar_groups,
|
||||
);
|
||||
self.state.move_sidebar_highlight(&order, -1);
|
||||
self.state.focus = Focus::Sidebar;
|
||||
}
|
||||
Action::SidebarNext => self.step_sidebar_highlight(1),
|
||||
Action::SidebarPrev => self.step_sidebar_highlight(-1),
|
||||
Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(),
|
||||
Action::ToggleSidebar => self.state.toggle_sidebar(),
|
||||
Action::ShowHelp => {
|
||||
@@ -459,6 +445,21 @@ impl App {
|
||||
snap.sessions.first().map(|s| s.id)
|
||||
}
|
||||
|
||||
/// `j`/`k`: step the sidebar highlight one row. Group headers are skipped
|
||||
/// — a directory is not something to focus, and the cards under it are one
|
||||
/// row away anyway. A *collapsed* group is the exception: its header is the
|
||||
/// only row it has, so skipping it would make the group unreachable.
|
||||
fn step_sidebar_highlight(&mut self, delta: i32) {
|
||||
let collapsed = self.state.collapsed_sidebar_groups.clone();
|
||||
let order = visible_sidebar_order(&self.catalog.snapshot(), &collapsed);
|
||||
self.state
|
||||
.move_sidebar_highlight(&order, delta, |item| match item {
|
||||
SidebarItem::Group(key) => collapsed.contains(key),
|
||||
SidebarItem::Session(_) => true,
|
||||
});
|
||||
self.state.focus = Focus::Sidebar;
|
||||
}
|
||||
|
||||
fn toggle_highlighted_sidebar_group(&mut self) {
|
||||
let key = match self.state.sidebar_highlight.clone() {
|
||||
Some(SidebarItem::Group(key)) => Some(key),
|
||||
@@ -1338,11 +1339,9 @@ mod tests {
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![test_session(sid, "/repo")],
|
||||
});
|
||||
app.dispatch(Action::SidebarNext);
|
||||
assert_eq!(
|
||||
app.state.sidebar_highlight,
|
||||
Some(SidebarItem::Group(PathBuf::from("/repo")))
|
||||
);
|
||||
// `j` never lands on an expanded group, so reach it the way a click
|
||||
// would.
|
||||
app.state.sidebar_highlight = Some(SidebarItem::Group(PathBuf::from("/repo")));
|
||||
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
|
||||
assert!(app
|
||||
.state
|
||||
@@ -1357,6 +1356,58 @@ mod tests {
|
||||
assert!(app.state.overlay().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_navigation_skips_expanded_group_headers() {
|
||||
use maestro_protocol::CatalogEvent;
|
||||
|
||||
let a = Uuid::from_u128(20);
|
||||
let b = Uuid::from_u128(21);
|
||||
let mut app = make_app();
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![test_session(a, "/one"), test_session(b, "/two")],
|
||||
});
|
||||
|
||||
// Two groups, one card each: `j` visits the cards and nothing else.
|
||||
app.dispatch(Action::SidebarNext);
|
||||
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||
app.dispatch(Action::SidebarNext);
|
||||
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||
app.dispatch(Action::SidebarNext);
|
||||
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||
app.dispatch(Action::SidebarPrev);
|
||||
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||
}
|
||||
|
||||
/// A collapsed group hides its cards, so its header is the only row left
|
||||
/// to land on — skipping it would strand the group.
|
||||
#[test]
|
||||
fn sidebar_navigation_stops_on_a_collapsed_group() {
|
||||
use maestro_protocol::CatalogEvent;
|
||||
|
||||
let a = Uuid::from_u128(22);
|
||||
let b = Uuid::from_u128(23);
|
||||
let mut app = make_app();
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![test_session(a, "/one"), test_session(b, "/two")],
|
||||
});
|
||||
app.dispatch(Action::SidebarNext);
|
||||
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||
// `c` on a card collapses the group that holds it.
|
||||
app.dispatch(Action::ToggleSidebarGroup);
|
||||
assert_eq!(
|
||||
app.state.sidebar_highlight,
|
||||
Some(SidebarItem::Group(PathBuf::from("/one")))
|
||||
);
|
||||
|
||||
app.dispatch(Action::SidebarNext);
|
||||
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||
app.dispatch(Action::SidebarNext);
|
||||
assert_eq!(
|
||||
app.state.sidebar_highlight,
|
||||
Some(SidebarItem::Group(PathBuf::from("/one")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsing_the_group_containing_a_card_highlight_keeps_a_visible_group_highlight() {
|
||||
let sid = Uuid::from_u128(15);
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
//!
|
||||
//! The picker itself is two steps (#96), verbatim from the rules in
|
||||
//! `web/src/spawnForm.ts`'s "working-directory picker (#95)" comment:
|
||||
//! 1. Step one is one box per repository (name, then full path). A worktree
|
||||
//! is a box only when its main checkout is missing from the scan.
|
||||
//! 1. Step one is one box per repository (name, then its parent directory).
|
||||
//! A worktree is a box only when its main checkout is missing from the
|
||||
//! scan.
|
||||
//! 2. The filter keeps a repository when the query matches it or any of its
|
||||
//! worktrees, so a worktree name still finds its box.
|
||||
//! 3. Picking a box reveals step two: "+ new", then that repository's
|
||||
@@ -32,6 +33,8 @@
|
||||
//! these are separate acts.
|
||||
//! 6. Neither step probes `/api/gitroot`; the scan already reports
|
||||
//! repository membership. Only the free-text hatch pays for a probe.
|
||||
//! 7. Neither step touches the command: the directory and what runs in it
|
||||
//! are independent choices.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -39,6 +42,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
@@ -128,6 +132,15 @@ pub struct DraftWorktreeRow {
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// A box's second column: the directory holding the project, `~`-abbreviated.
|
||||
/// Only the stem — the last segment is the name column beside it.
|
||||
fn parent_dir_label(path: &Path, home: &str) -> String {
|
||||
match path.parent() {
|
||||
Some(parent) => abbreviate_dir(parent, home),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The repository key a [`DraftGroup`] was formed under — same key
|
||||
/// `projects::group_projects` groups by, kept separately because the main
|
||||
/// box's own path only equals it when the main checkout itself was found.
|
||||
@@ -893,13 +906,6 @@ impl DraftPane {
|
||||
self.worktree_choice = choice.clone();
|
||||
self.picked_cwd = Some(target.path.clone());
|
||||
self.cwd_raw_visible = false;
|
||||
if let Some(cmd) = target.last_command.as_deref() {
|
||||
if !cmd.is_empty() {
|
||||
if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) {
|
||||
self.selected_preset = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
match choice {
|
||||
WorktreeChoice::New => {
|
||||
self.worktree_enabled = true;
|
||||
@@ -1200,9 +1206,9 @@ impl DraftPane {
|
||||
cursor = Some((col, filter_row.y));
|
||||
}
|
||||
|
||||
// Step one (#96 rule 1): one box per repository, name then the FULL
|
||||
// path — not the abbreviated form other lists use — so two projects
|
||||
// sharing a basename stay distinguishable.
|
||||
// Step one (#96 rule 1): one box per repository, name then the parent
|
||||
// directory it sits in. The last segment is the name column already;
|
||||
// the stem is what keeps two projects sharing a basename apart.
|
||||
let visible = self.visible_groups();
|
||||
let sel = self.selection();
|
||||
let count = visible.len() + 1;
|
||||
@@ -1241,7 +1247,10 @@ impl DraftPane {
|
||||
Span::styled(if active { ">" } else { " " }, base),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{:<name_width$}", group.main.name), base),
|
||||
Span::styled(format!(" {}", group.main.path.display()), base),
|
||||
Span::styled(
|
||||
format!(" {}", parent_dir_label(&group.main.path, &self.home_dir)),
|
||||
base,
|
||||
),
|
||||
])
|
||||
} else {
|
||||
let mut style = Style::default();
|
||||
@@ -1741,8 +1750,9 @@ mod tests {
|
||||
let stale_pos = rendered.find("stale-repo").unwrap();
|
||||
assert!(fresh_pos < stale_pos, "fresh before stale:\n{rendered}");
|
||||
assert!(
|
||||
rendered.contains("/home/u/new/repo"),
|
||||
"the full path, not an abbreviation (#96 rule 1):\n{rendered}"
|
||||
rendered.contains("~/new") && !rendered.contains("~/new/repo"),
|
||||
"the parent directory only — the name column is the last segment \
|
||||
(#96 rule 1):\n{rendered}"
|
||||
);
|
||||
|
||||
// Staleness dims the row's cells.
|
||||
@@ -1787,7 +1797,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
draft.command(),
|
||||
"claude",
|
||||
"pick pre-selects the last command"
|
||||
"the preset is the operator's own choice — picking a project leaves it alone"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2640,8 +2650,8 @@ mod tests {
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
|
||||
assert!(
|
||||
rendered.contains("/home/u/repos/main"),
|
||||
"step one shows the full repository path:\n{rendered}"
|
||||
rendered.contains("~/repos"),
|
||||
"step one shows the directory holding the repository:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("+ new"),
|
||||
|
||||
@@ -8,8 +8,10 @@ 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::{ActivityState, Session, SessionStatus};
|
||||
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};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -283,12 +285,22 @@ impl Sidebar {
|
||||
}
|
||||
let mut tail: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(b) = blocked {
|
||||
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(highlighted && focused);
|
||||
for (kind, text) in b.stats() {
|
||||
let style = match kind {
|
||||
StatKind::Added => muted.fg(surface.active),
|
||||
StatKind::Removed => muted.fg(surface.error),
|
||||
_ => muted,
|
||||
};
|
||||
tail.push(Span::styled(format!(" {text}"), style));
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -627,14 +639,18 @@ mod tests {
|
||||
}
|
||||
|
||||
/// #83 via docs/SIDEBAR.md: blocked work lives on the meta line now, not
|
||||
/// on the name row; a clear or unprobed worktree never warns.
|
||||
/// on the name row, and reads as diff notation; a clear or unprobed
|
||||
/// worktree says nothing.
|
||||
#[test]
|
||||
fn blocked_session_warns_on_its_meta_line() {
|
||||
fn blocked_session_states_its_diff_on_the_meta_line() {
|
||||
let mut blocked = session_named(1, "claude");
|
||||
blocked.activity_since = Utc::now() - Duration::seconds(5);
|
||||
blocked.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 2,
|
||||
untracked_files: 3,
|
||||
unpushed_commits: 4,
|
||||
..InTheWay::default()
|
||||
insertions: 124,
|
||||
deletions: 18,
|
||||
});
|
||||
let mut clear = session_named(2, "bash");
|
||||
clear.blocked_work = Some(InTheWay::default());
|
||||
@@ -644,31 +660,66 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 10));
|
||||
let all = rows(&draw(&sb, 44, 10));
|
||||
|
||||
let meta = all
|
||||
.iter()
|
||||
.find(|l| l.contains("⚠"))
|
||||
.expect("blocked card must warn somewhere");
|
||||
assert!(
|
||||
meta.contains("4 unpushed commits"),
|
||||
"warning must carry InTheWay's summary: {meta:?}"
|
||||
);
|
||||
.find(|l| l.contains("+124"))
|
||||
.expect("blocked card must state its diff somewhere");
|
||||
for fragment in ["-18", "?3", "↑4"] {
|
||||
assert!(
|
||||
meta.contains(fragment),
|
||||
"meta line must carry {fragment}: {meta:?}"
|
||||
);
|
||||
}
|
||||
let name_row = row_containing(&all, "claude");
|
||||
assert!(
|
||||
!name_row.contains('⚠'),
|
||||
"warning moved off the name row: {name_row:?}"
|
||||
!name_row.contains("+124"),
|
||||
"the diff stays off the name row: {name_row:?}"
|
||||
);
|
||||
for name in ["bash", "vim"] {
|
||||
let idx = all.iter().position(|l| l.contains(name)).unwrap();
|
||||
assert!(
|
||||
!all[idx + 1].contains('⚠'),
|
||||
"{name} must not warn: {:?}",
|
||||
!all[idx + 1].contains('+') && !all[idx + 1].contains('?'),
|
||||
"{name} must not state a diff: {:?}",
|
||||
all[idx + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The additions and deletions are the only coloured fragments: they are
|
||||
/// what a reader scans for, and colouring the rest would make the line
|
||||
/// compete with the activity dot.
|
||||
#[test]
|
||||
fn the_diff_colours_only_its_additions_and_deletions() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 1,
|
||||
untracked_files: 0,
|
||||
unpushed_commits: 2,
|
||||
insertions: 7,
|
||||
deletions: 3,
|
||||
});
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 44, 8);
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(false);
|
||||
let colour_of = |needle: char| {
|
||||
(0..buf.area.width)
|
||||
.flat_map(|x| (0..buf.area.height).map(move |y| (x, y)))
|
||||
.find(|&(x, y)| buf[(x, y)].symbol() == needle.to_string())
|
||||
.map(|(x, y)| buf[(x, y)].fg)
|
||||
.expect("fragment must be drawn")
|
||||
};
|
||||
assert_eq!(colour_of('+'), surface.active);
|
||||
assert_eq!(colour_of('-'), surface.error);
|
||||
assert_eq!(colour_of('↑'), surface.muted);
|
||||
}
|
||||
|
||||
/// A fresh session still gets its unconditional second line, but no
|
||||
/// duration until it has been held in state for 30 seconds.
|
||||
#[test]
|
||||
@@ -826,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,
|
||||
@@ -929,7 +1002,7 @@ mod tests {
|
||||
let meta = content(&all[idx + 1]);
|
||||
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
|
||||
assert!(
|
||||
meta.contains("2 unpushed commits"),
|
||||
meta.contains("↑2"),
|
||||
"blocked work stays on the line: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -1051,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()
|
||||
@@ -1062,7 +1135,7 @@ mod tests {
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 29, 6));
|
||||
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
|
||||
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
||||
let meta = content(&all[3]);
|
||||
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
|
||||
}
|
||||
@@ -1081,7 +1154,7 @@ mod tests {
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
|
||||
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
||||
assert!(
|
||||
all[3].contains("bash"),
|
||||
"command must survive: {:?}",
|
||||
|
||||
@@ -165,21 +165,42 @@ impl AppState {
|
||||
self.active_tab().session()
|
||||
}
|
||||
|
||||
/// Move the sidebar highlight by `delta` (positive = down) within the
|
||||
/// rendered item sequence. Wraps. No-op on empty.
|
||||
pub fn move_sidebar_highlight(&mut self, order: &[SidebarItem], delta: i32) {
|
||||
if order.is_empty() {
|
||||
/// Move the sidebar highlight one step through the rendered item
|
||||
/// sequence, in the direction of `delta` (positive = down), landing only
|
||||
/// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.
|
||||
/// No-op when `order` holds no stop.
|
||||
///
|
||||
/// Anchoring walks the *full* rendered order, not just the stops, so a
|
||||
/// highlight parked on a skipped row — an expanded group header, which a
|
||||
/// mouse click can still select — steps to its neighbour instead of
|
||||
/// jumping to the far end of the list.
|
||||
pub fn move_sidebar_highlight(
|
||||
&mut self,
|
||||
order: &[SidebarItem],
|
||||
delta: i32,
|
||||
is_stop: impl Fn(&SidebarItem) -> bool,
|
||||
) {
|
||||
if order.is_empty() || delta == 0 {
|
||||
return;
|
||||
}
|
||||
let len = order.len() as i32;
|
||||
let step = delta.signum();
|
||||
let cur = self
|
||||
.sidebar_highlight
|
||||
.as_ref()
|
||||
.and_then(|id| order.iter().position(|x| x == id))
|
||||
.map(|i| i as i32)
|
||||
.unwrap_or(-1);
|
||||
let next = (cur + delta).rem_euclid(len) as usize;
|
||||
self.sidebar_highlight = Some(order[next].clone());
|
||||
// Nothing highlighted: start one off the end we walk from, so the
|
||||
// first step lands on the first (or last) item.
|
||||
.unwrap_or(if step > 0 { -1 } else { 0 });
|
||||
let mut idx = cur;
|
||||
for _ in 0..len {
|
||||
idx = (idx + step).rem_euclid(len);
|
||||
if is_stop(&order[idx as usize]) {
|
||||
self.sidebar_highlight = Some(order[idx as usize].clone());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a group. When collapsing a group containing the current card,
|
||||
@@ -232,28 +253,86 @@ mod tests {
|
||||
];
|
||||
|
||||
// No prior highlight + delta +1 lands on first item.
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
state.move_sidebar_highlight(&order, 1, |_| true);
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
state.move_sidebar_highlight(&order, 1, |_| true);
|
||||
assert_eq!(
|
||||
state.sidebar_highlight,
|
||||
Some(SidebarItem::Group("repo".into()))
|
||||
);
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
state.move_sidebar_highlight(&order, 1, |_| true);
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||
// Wraps forward.
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
state.move_sidebar_highlight(&order, 1, |_| true);
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
|
||||
// Wraps forward.
|
||||
state.move_sidebar_highlight(&order, 1, |_| true);
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||
// Wraps backward.
|
||||
state.move_sidebar_highlight(&order, -1);
|
||||
state.move_sidebar_highlight(&order, -1, |_| true);
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_sidebar_highlight_skips_rows_the_filter_rejects() {
|
||||
let mut state = AppState::new();
|
||||
let a = Uuid::from_u128(1);
|
||||
let b = Uuid::from_u128(2);
|
||||
let order = vec![
|
||||
SidebarItem::Group("repo".into()),
|
||||
SidebarItem::Session(a),
|
||||
SidebarItem::Group("other".into()),
|
||||
SidebarItem::Session(b),
|
||||
];
|
||||
let sessions_only = |item: &SidebarItem| matches!(item, SidebarItem::Session(_));
|
||||
|
||||
state.move_sidebar_highlight(&order, 1, sessions_only);
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||
state.move_sidebar_highlight(&order, 1, sessions_only);
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||
state.move_sidebar_highlight(&order, -1, sessions_only);
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||
|
||||
// Backwards from nothing lands on the last stop, not the last row.
|
||||
state.sidebar_highlight = None;
|
||||
state.move_sidebar_highlight(&order, -1, sessions_only);
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||
}
|
||||
|
||||
/// A highlight sitting on a skipped row still moves to its neighbour.
|
||||
#[test]
|
||||
fn move_sidebar_highlight_anchors_on_a_skipped_row() {
|
||||
let mut state = AppState::new();
|
||||
let a = Uuid::from_u128(1);
|
||||
let b = Uuid::from_u128(2);
|
||||
let order = vec![
|
||||
SidebarItem::Session(a),
|
||||
SidebarItem::Group("repo".into()),
|
||||
SidebarItem::Session(b),
|
||||
];
|
||||
let sessions_only = |item: &SidebarItem| matches!(item, SidebarItem::Session(_));
|
||||
|
||||
state.sidebar_highlight = Some(SidebarItem::Group("repo".into()));
|
||||
state.move_sidebar_highlight(&order, 1, sessions_only);
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||
|
||||
state.sidebar_highlight = Some(SidebarItem::Group("repo".into()));
|
||||
state.move_sidebar_highlight(&order, -1, sessions_only);
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_sidebar_highlight_is_noop_when_nothing_is_a_stop() {
|
||||
let mut state = AppState::new();
|
||||
let order = vec![SidebarItem::Group("repo".into())];
|
||||
state.move_sidebar_highlight(&order, 1, |_| false);
|
||||
assert_eq!(state.sidebar_highlight, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_sidebar_highlight_is_noop_on_empty_order() {
|
||||
let mut state = AppState::new();
|
||||
state.move_sidebar_highlight(&[], 1);
|
||||
state.move_sidebar_highlight(&[], 1, |_| true);
|
||||
assert_eq!(state.sidebar_highlight, None);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(_))
|
||||
)
|
||||
}
|
||||
|
||||
+68
-9
@@ -37,7 +37,7 @@ itself is not.
|
||||
Line 2 reads left to right:
|
||||
|
||||
```
|
||||
<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>
|
||||
<dot> [<duration>] [<detail>] [<diff stat> | exited] <command>
|
||||
```
|
||||
|
||||
Its minimum content is therefore the dot and the command.
|
||||
@@ -64,11 +64,50 @@ truncates with `…` ahead of the trailing fragments — a long detail never
|
||||
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
||||
client ever lets it wrap.
|
||||
|
||||
`<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.
|
||||
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
||||
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
||||
while something would block an unforced kill, and reads:
|
||||
|
||||
```
|
||||
+<insertions> -<deletions> ?<untracked files> ↑<unpushed commits>
|
||||
```
|
||||
|
||||
Each fragment is omitted when its count is zero. The line counts cover
|
||||
**tracked** changes only — staged and unstaged together, measured against
|
||||
`HEAD` — so a `git add` never makes the counter drop. Untracked files are
|
||||
counted as files, not lines: counting their lines means reading every one off
|
||||
disk, and `?3` already says what is there. A tree whose only tracked change
|
||||
carries no line delta — a mode change, a rename — shows `~<changed files>`
|
||||
instead of the additions and deletions, since `+0 -0` would describe it
|
||||
wrongly.
|
||||
|
||||
Only `+` and `-` take colour (`--live` / `--danger` in the web client, the
|
||||
surface's `active` / `error` roles in the TUI); `~`, `?` and `↑` stay muted, so
|
||||
the fragment reads as a diff rather than as an alarm. The spelled-out phrasing
|
||||
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: 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -95,9 +134,10 @@ surface; a colour chosen for the card background is never painted onto the
|
||||
selection background. Contrast is a property of the palette (values are RGB)
|
||||
and is asserted in the palette's tests, not left to the terminal theme.
|
||||
|
||||
The duration text inherits its state's colour; the blocked-work, exited and
|
||||
command fragments use the surface's `muted` role, since the ⚠ glyph already
|
||||
signals severity.
|
||||
The duration text inherits its state's colour; the exited and command
|
||||
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
|
||||
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
|
||||
take the `active` and `error` roles of the same surface.
|
||||
|
||||
## Duration format
|
||||
|
||||
@@ -146,3 +186,22 @@ dropping the whole session would be worse than dimming one card. A report
|
||||
arriving at `POST /api/sessions/:id/activity` is parsed strictly and an unknown
|
||||
state is a 4xx: an agent posting a state the daemon does not know is a bug in
|
||||
the agent, and answering 204 would hide it.
|
||||
|
||||
## Keyboard walk
|
||||
|
||||
`j`/`k` (and the arrow keys) move a highlight through the sidebar. Both clients
|
||||
follow one rule for what the walk stops on:
|
||||
|
||||
- **Session cards, always.**
|
||||
- **Folder headers, only while collapsed.** An expanded folder's cards are one
|
||||
row away, so its header is nothing to land on; a collapsed folder's header is
|
||||
the only row it has, and skipping it would leave the folder unreachable.
|
||||
|
||||
Enter opens the highlighted session, or expands the highlighted folder and
|
||||
lands on the first row it was hiding. A highlight parked on a skipped row —
|
||||
reachable by clicking a folder header — still steps to its neighbour rather
|
||||
than jumping to the end of the list.
|
||||
|
||||
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
|
||||
same chord back): an attached terminal owns every plain keystroke. The TUI uses
|
||||
`ctrl-w h`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
|
||||
hash = "sha256-AvIrpXipbcp7hRujh/L4UAc6tAJOxe67P/iji6L/oL0=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
},
|
||||
|
||||
Generated
+8
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@fontsource/jetbrains-mono':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||
@@ -168,6 +171,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@fontsource/jetbrains-mono@5.3.0':
|
||||
resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
@@ -631,6 +637,8 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@fontsource/jetbrains-mono@5.3.0': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.60.4':
|
||||
|
||||
+32
-7
@@ -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",
|
||||
}),
|
||||
|
||||
+32
-9
@@ -6,6 +6,8 @@ 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";
|
||||
|
||||
/**
|
||||
@@ -27,6 +29,8 @@ export interface AttachConnection {
|
||||
onMount(): void;
|
||||
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
|
||||
onUnmount(): void;
|
||||
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
|
||||
focusTerminal(): void;
|
||||
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -52,6 +56,16 @@ export interface AttachInfo {
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** 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, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
const text = icon ? "" : program;
|
||||
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
||||
}
|
||||
|
||||
export function createAttachConnection(
|
||||
sessionId: Uuid,
|
||||
opts: AttachOptions = {},
|
||||
@@ -63,14 +77,12 @@ export function createAttachConnection(
|
||||
// tooltip for when you really need it.
|
||||
const titleHtml = info
|
||||
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
|
||||
(info.command && info.command !== info.name
|
||||
? `<span class="at-cmd">${escapeHtml(info.command)}</span>`
|
||||
: "")
|
||||
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
|
||||
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
|
||||
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>
|
||||
@@ -90,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);
|
||||
});
|
||||
@@ -97,7 +115,7 @@ export function createAttachConnection(
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: 13,
|
||||
convertEol: false,
|
||||
theme: {
|
||||
@@ -125,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 {
|
||||
@@ -134,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;
|
||||
@@ -148,7 +168,7 @@ export function createAttachConnection(
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
vp.addEventListener(
|
||||
root.addEventListener(
|
||||
"touchmove",
|
||||
(e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
@@ -382,6 +402,9 @@ export function createAttachConnection(
|
||||
onUnmount() {
|
||||
mounted = false;
|
||||
},
|
||||
focusTerminal() {
|
||||
term.focus();
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
+135
-35
@@ -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,16 +50,39 @@ 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 with the shared phrasing", () => {
|
||||
it("marks the row of a blocked session in diff notation", () => {
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
|
||||
blocked_work: {
|
||||
uncommitted_files: 3,
|
||||
unpushed_commits: 2,
|
||||
insertions: 124,
|
||||
deletions: 18,
|
||||
},
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
|
||||
expect(badge?.textContent).toContain("⚠");
|
||||
expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
|
||||
expect(badge?.textContent).toBe("+124-18↑2");
|
||||
// The spelled-out breakdown survives in the tooltip.
|
||||
expect(badge?.getAttribute("title")).toContain(
|
||||
"3 uncommitted files, 2 unpushed commits",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the file count when tracked changes carry no lines", () => {
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001a",
|
||||
blocked_work: { uncommitted_files: 2, untracked_files: 1 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
|
||||
expect(badge?.textContent).toBe("~2?1");
|
||||
});
|
||||
|
||||
it("does not mark clear or unprobed sessions", () => {
|
||||
@@ -71,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,7 +228,7 @@ describe("session cards (#107)", () => {
|
||||
expect(line2).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")).toBeNull();
|
||||
expect(line2?.querySelector(".leaf-cmd")?.textContent).toBe("claude");
|
||||
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows dot + floored duration once the state has been held past 30s", () => {
|
||||
@@ -177,14 +244,42 @@ describe("session cards (#107)", () => {
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
||||
});
|
||||
|
||||
it("renders the command when the session name equals it", () => {
|
||||
it("renders the command as its tool icon when the tool is known", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001b",
|
||||
command: "claude",
|
||||
metadata: { name: "claude" },
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd")?.textContent).toBe("claude");
|
||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
||||
expect(cmd?.getAttribute("title")).toBe("claude");
|
||||
// The icon said it all: no text left over.
|
||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the command's arguments, keeping them in the tooltip", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001c",
|
||||
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")).toBeNull();
|
||||
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
|
||||
});
|
||||
|
||||
it("keeps an unknown command as plain text", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001d",
|
||||
command: "hx src/main.rs",
|
||||
});
|
||||
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");
|
||||
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
|
||||
});
|
||||
|
||||
it("maps each activity state to its contract slug", () => {
|
||||
@@ -222,15 +317,20 @@ describe("session cards (#107)", () => {
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
|
||||
});
|
||||
|
||||
it("moves blocked work onto line 2 in the shared phrasing", () => {
|
||||
it("moves blocked work onto line 2 as diff notation", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000017",
|
||||
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
|
||||
blocked_work: {
|
||||
uncommitted_files: 3,
|
||||
unpushed_commits: 2,
|
||||
insertions: 124,
|
||||
deletions: 18,
|
||||
},
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.textContent).toContain("⚠");
|
||||
expect(line2?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
|
||||
expect(line2?.textContent).toContain("+124");
|
||||
expect(line2?.textContent).toContain("-18");
|
||||
// Line 1 carries no badges.
|
||||
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
|
||||
});
|
||||
|
||||
+193
-92
@@ -1,10 +1,13 @@
|
||||
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";
|
||||
import type { Session } from "./types.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
import {
|
||||
abbreviateFolderName,
|
||||
blockerStats,
|
||||
customSessionName,
|
||||
formatInTheWay,
|
||||
MAX_SESSION_NAME_LEN,
|
||||
@@ -12,6 +15,7 @@ import {
|
||||
sessionBlockers,
|
||||
sessionName,
|
||||
} from "./types.js";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
import {
|
||||
activityColorSlug,
|
||||
activityDurationLabel,
|
||||
@@ -29,6 +33,22 @@ export function requestOpenSpawn(): void {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||
}
|
||||
|
||||
/** Ask the shell to hand the keyboard back to the focused pane's terminal —
|
||||
* what Escape in the sidebar list does. Handled in main.ts, which owns the
|
||||
* connection pool. */
|
||||
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
||||
|
||||
/** Ask the shell to open a draft pane seeded from an existing session
|
||||
* ("Spawn similar"). Detail is the session being cloned. */
|
||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||
|
||||
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
|
||||
* folder headers are skipped: a directory is not something to focus, and the
|
||||
* cards under it are one row away anyway. A *collapsed* folder still counts —
|
||||
* its header is the only row it has, so skipping it would leave the folder
|
||||
* unreachable. Same rule as the TUI sidebar. */
|
||||
const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]';
|
||||
|
||||
const collapsed = new Set<string>();
|
||||
|
||||
/** Session whose card is currently being renamed inline (#125). The tree
|
||||
@@ -60,15 +80,34 @@ function metaLine(s: Session, now: number): string {
|
||||
s.activity_detail
|
||||
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
|
||||
: "",
|
||||
blockers
|
||||
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
|
||||
: "",
|
||||
blockers ? blockedFragment(blockers) : "",
|
||||
dead ? `<span class="card-flag">exited</span>` : "",
|
||||
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
|
||||
commandFragment(s.command),
|
||||
];
|
||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||
}
|
||||
|
||||
/** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2`
|
||||
* — with the spelled-out breakdown kept in the tooltip. */
|
||||
function blockedFragment(blockers: InTheWay): string {
|
||||
const stats = blockerStats(blockers)
|
||||
.map((st) => `<span class="blocked-stat" data-stat="${st.kind}">${escapeHtml(st.text)}</span>`)
|
||||
.join("");
|
||||
const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`;
|
||||
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
||||
}
|
||||
|
||||
/** 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, program } = splitCommand(command);
|
||||
const icon = tool ? toolIconSvg(tool) : null;
|
||||
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>`;
|
||||
}
|
||||
|
||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
if (node.kind === "folder") {
|
||||
const wrap = document.createElement("div");
|
||||
@@ -81,6 +120,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
header.style.paddingLeft = `${depth * 16}px`;
|
||||
const isCollapsed = collapsed.has(node.path);
|
||||
header.setAttribute("aria-expanded", String(!isCollapsed));
|
||||
header.dataset.navKey = `folder:${node.path}`;
|
||||
// Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
|
||||
// glyph and the rotation never fight each other.
|
||||
// A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading
|
||||
@@ -110,6 +150,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||
row.dataset.sessionId = s.id;
|
||||
row.dataset.navKey = `session:${s.id}`;
|
||||
const running = s.status === "Running";
|
||||
row.dataset.status = running ? "running" : "dead";
|
||||
const name = sessionName(s);
|
||||
@@ -134,13 +175,13 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s }));
|
||||
});
|
||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void confirmAndKill(s, name);
|
||||
void requestKill(s, name);
|
||||
});
|
||||
row.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
@@ -150,24 +191,33 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
{
|
||||
label: "Spawn similar",
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })),
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
|
||||
},
|
||||
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
|
||||
{ label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
|
||||
]);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
/** #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}`);
|
||||
}
|
||||
}
|
||||
@@ -227,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";
|
||||
@@ -263,67 +301,11 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* The global app header (full width, above the sidebar/content split): brand +
|
||||
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
|
||||
* stay put and reachable regardless of which pane is showing.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
actions.appendChild(createThemeToggle());
|
||||
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.addEventListener("click", () => requestOpenSpawn());
|
||||
actions.appendChild(spawnBtn);
|
||||
return header;
|
||||
}
|
||||
|
||||
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
|
||||
* pane (quiet), which one is focused (loud), and a stable per-session colour so
|
||||
* a row maps to its pane. */
|
||||
* pane (quiet), and which one is focused (loud). */
|
||||
export interface SidebarHighlights {
|
||||
open: Set<string>;
|
||||
focused: string | null;
|
||||
colors: Map<string, string>;
|
||||
}
|
||||
|
||||
/** Live handle to the persistent sidebar (built once, survives navigation). */
|
||||
@@ -333,8 +315,12 @@ export interface SidebarHandle {
|
||||
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
|
||||
* Back-compat shim over {@link setHighlights}. */
|
||||
setActive(id: string | null): void;
|
||||
/** Apply the full open/focused/colour highlight state. */
|
||||
/** Apply the full open/focused highlight state. */
|
||||
setHighlights(h: SidebarHighlights): void;
|
||||
/** Hand the keyboard to the session list, highlighting a row to walk from. */
|
||||
focusList(): void;
|
||||
/** Whether the session list currently holds DOM focus. */
|
||||
hasListFocus(): boolean;
|
||||
/** Tear down the store subscription + listeners. */
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -380,6 +366,40 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
treeHost.className = "tree";
|
||||
el.appendChild(treeHost);
|
||||
|
||||
// App-level actions (help, theme, spawn) live pinned to the sidebar's foot
|
||||
// rather than the sidebar's own scrolling content, so they stay reachable
|
||||
// regardless of tree scroll position.
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "sidebar-footer";
|
||||
const helpBtn = document.createElement("button");
|
||||
helpBtn.type = "button";
|
||||
helpBtn.className = "btn btn-ghost btn-icon help-btn";
|
||||
helpBtn.textContent = "?";
|
||||
helpBtn.title = "Keyboard shortcuts (?)";
|
||||
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
|
||||
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.addEventListener("click", () => requestOpenSpawn());
|
||||
footer.appendChild(spawnBtn);
|
||||
el.appendChild(footer);
|
||||
|
||||
// Drag a session row into the canvas (issue #32). Delegated so it survives
|
||||
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
|
||||
treeHost.addEventListener("pointerdown", (e) => {
|
||||
@@ -442,7 +462,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
refresh();
|
||||
});
|
||||
|
||||
let highlights: SidebarHighlights = { open: new Set(), focused: null, colors: new Map() };
|
||||
let highlights: SidebarHighlights = { open: new Set(), focused: null };
|
||||
const applyHighlights = () => {
|
||||
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
|
||||
const id = n.dataset.sessionId;
|
||||
@@ -451,12 +471,80 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
const open = highlights.open.has(id);
|
||||
n.classList.toggle("is-active", focused);
|
||||
n.classList.toggle("is-open", open && !focused);
|
||||
const color = highlights.colors.get(id);
|
||||
if (color && (open || focused)) n.style.setProperty("--mv-color", color);
|
||||
else n.style.removeProperty("--mv-color");
|
||||
}
|
||||
};
|
||||
|
||||
// ---- keyboard navigation ----
|
||||
// The walk reads the rendered DOM rather than a parallel model, so it can
|
||||
// never disagree with what is on screen. Rows carry a `data-nav-key`
|
||||
// (`session:<id>` / `folder:<path>`) that survives a re-render.
|
||||
let navKey: string | null = null;
|
||||
|
||||
const navRows = (): HTMLElement[] => [
|
||||
...treeHost.querySelectorAll<HTMLElement>(NAV_SELECTOR),
|
||||
];
|
||||
|
||||
const rowFor = (key: string | null): HTMLElement | undefined =>
|
||||
key === null ? undefined : navRows().find((r) => r.dataset.navKey === key);
|
||||
|
||||
const applyNav = () => {
|
||||
const rows = navRows();
|
||||
// A row that went away (session killed, folder collapsed) drops the
|
||||
// highlight rather than leaving it pointing at nothing.
|
||||
if (navKey !== null && !rows.some((r) => r.dataset.navKey === navKey)) navKey = null;
|
||||
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
|
||||
};
|
||||
|
||||
const moveNav = (delta: number) => {
|
||||
const rows = navRows();
|
||||
if (rows.length === 0) return;
|
||||
const cur = rows.findIndex((r) => r.dataset.navKey === navKey);
|
||||
// Nothing highlighted: start one off the end we are walking from, so the
|
||||
// first step lands on the first (or last) row.
|
||||
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
|
||||
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
|
||||
navKey = next.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
next.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
/** Enter: open the highlighted session, or expand the highlighted folder. */
|
||||
const activateNav = () => {
|
||||
const row = rowFor(navKey);
|
||||
if (!row) return;
|
||||
if (row.classList.contains("tree-leaf")) {
|
||||
row.click();
|
||||
return;
|
||||
}
|
||||
// Expanding replaces the folder's header in the walk with the rows it was
|
||||
// hiding, so land on the first of them instead of losing the highlight.
|
||||
const key = navKey;
|
||||
row.click();
|
||||
const header = [...treeHost.querySelectorAll<HTMLElement>(".tree-folder-header")].find(
|
||||
(h) => h.dataset.navKey === key,
|
||||
);
|
||||
const revealed = header?.parentElement
|
||||
? [...header.parentElement.querySelectorAll<HTMLElement>(NAV_SELECTOR)][0]
|
||||
: undefined;
|
||||
navKey = revealed?.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
};
|
||||
|
||||
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
|
||||
// keyboard stays here while j/k runs.
|
||||
treeHost.tabIndex = -1;
|
||||
treeHost.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
// The inline rename field lives inside a row and owns its own keys.
|
||||
if (isTypingTarget(e.target)) return;
|
||||
if (e.key === "j" || e.key === "ArrowDown") moveNav(1);
|
||||
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
|
||||
else if (e.key === "Enter") activateNav();
|
||||
else if (e.key === "Escape") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
|
||||
else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
// Rebuilding the tree now would blow away the field being typed into.
|
||||
if (renamingId !== null) return;
|
||||
@@ -474,6 +562,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
|
||||
}
|
||||
applyHighlights();
|
||||
applyNav();
|
||||
};
|
||||
|
||||
void getDaemonInfo()
|
||||
@@ -506,7 +595,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
highlights = {
|
||||
open: id ? new Set([id]) : new Set(),
|
||||
focused: id,
|
||||
colors: new Map(),
|
||||
};
|
||||
applyHighlights();
|
||||
},
|
||||
@@ -514,6 +602,19 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
highlights = h;
|
||||
applyHighlights();
|
||||
},
|
||||
focusList() {
|
||||
// Start from the session on screen — that is where the user's attention
|
||||
// already is — falling back to the standing highlight, then the top row.
|
||||
const preferred = highlights.focused ? `session:${highlights.focused}` : null;
|
||||
const start = rowFor(preferred) ?? rowFor(navKey) ?? navRows()[0];
|
||||
navKey = start?.dataset.navKey ?? null;
|
||||
applyNav();
|
||||
treeHost.focus();
|
||||
start?.scrollIntoView({ block: "nearest" });
|
||||
},
|
||||
hasListFocus() {
|
||||
return treeHost.contains(document.activeElement);
|
||||
},
|
||||
dispose() {
|
||||
clearInterval(clock);
|
||||
// An unfinished rename would otherwise leave every later sidebar
|
||||
|
||||
@@ -140,6 +140,13 @@ export class ConnectionPool {
|
||||
this.setFocus(null);
|
||||
}
|
||||
|
||||
/** Put the keyboard back into the focused session's terminal. No-op when
|
||||
* nothing is focused (welcome pane) or its connection is gone. */
|
||||
focusTerminal(): void {
|
||||
if (this.focusedId === null) return;
|
||||
this.cache.get(this.focusedId)?.focusTerminal();
|
||||
}
|
||||
|
||||
/** Fully disconnect + forget a session. */
|
||||
close(id: Uuid): void {
|
||||
const conn = this.cache.get(id);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
|
||||
import { installDrawer, type Drawer } from "./drawer.js";
|
||||
|
||||
function mount(): { shell: HTMLElement; sidebar: HTMLElement; drawer: Drawer } {
|
||||
const shell = document.createElement("div");
|
||||
const sidebar = document.createElement("aside");
|
||||
const backdrop = document.createElement("div");
|
||||
const row = document.createElement("div");
|
||||
row.className = "tree-leaf";
|
||||
sidebar.appendChild(row);
|
||||
shell.append(sidebar, backdrop);
|
||||
document.body.replaceChildren(shell);
|
||||
const drawer = installDrawer(shell, sidebar, backdrop);
|
||||
drawer.open();
|
||||
return { shell, sidebar, drawer };
|
||||
}
|
||||
|
||||
describe("mobile drawer", () => {
|
||||
beforeEach(() => document.body.replaceChildren());
|
||||
|
||||
it("closes when a session row is tapped", () => {
|
||||
const { shell, sidebar } = mount();
|
||||
(sidebar.querySelector(".tree-leaf") as HTMLElement).click();
|
||||
expect(shell.classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("closes when a spawn is requested", () => {
|
||||
const { shell } = mount();
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||
expect(shell.classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("closes when spawn similar is requested", () => {
|
||||
const { shell } = mount();
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
|
||||
expect(shell.classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the backdrop click closing it", () => {
|
||||
const { shell } = mount();
|
||||
(shell.lastElementChild as HTMLElement).click();
|
||||
expect(shell.classList.contains("drawer-open")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Mobile off-canvas sidebar (issue #35); on desktop the `drawer-open` class is
|
||||
// inert (CSS gates the drawer styling).
|
||||
//
|
||||
// Anything that puts a new view in the content area must close the drawer, or
|
||||
// it renders behind the sidebar and the tap reads as a dead button. "Spawn
|
||||
// similar" sits inside a session row but stops propagation, so it never reaches
|
||||
// the row's own close — both spawn paths arrive here via document events.
|
||||
|
||||
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;
|
||||
toggle(): void;
|
||||
isOpen(): boolean;
|
||||
}
|
||||
|
||||
export function installDrawer(
|
||||
shellEl: HTMLElement,
|
||||
sidebarEl: HTMLElement,
|
||||
backdropEl: HTMLElement,
|
||||
): Drawer {
|
||||
const drawer: Drawer = {
|
||||
open: () => shellEl.classList.add("drawer-open"),
|
||||
close: () => shellEl.classList.remove("drawer-open"),
|
||||
toggle: () => shellEl.classList.toggle("drawer-open"),
|
||||
isOpen: () => shellEl.classList.contains("drawer-open"),
|
||||
};
|
||||
|
||||
backdropEl.addEventListener("click", () => drawer.close());
|
||||
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());
|
||||
|
||||
return drawer;
|
||||
}
|
||||
|
||||
/** Horizontal swipes drive the drawer (right opens, left closes) rather than
|
||||
* the browser's back/forward navigation. Only a clearly horizontal gesture past
|
||||
* a threshold counts, so vertical terminal scrolling (which owns its own touch
|
||||
* handler) is untouched. */
|
||||
export function installDrawerSwipe(drawer: Drawer): void {
|
||||
const mobileMq = window.matchMedia("(max-width: 859px)");
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let tracking = false;
|
||||
document.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
tracking = mobileMq.matches && e.touches.length === 1;
|
||||
if (tracking) {
|
||||
sx = e.touches[0].clientX;
|
||||
sy = e.touches[0].clientY;
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
document.addEventListener(
|
||||
"touchend",
|
||||
(e) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - sx;
|
||||
const dy = t.clientY - sy;
|
||||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||
if (dx > 0 && !drawer.isOpen()) drawer.open();
|
||||
else if (dx < 0 && drawer.isOpen()) drawer.close();
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SHORTCUTS,
|
||||
closeHelp,
|
||||
installHelpKeybind,
|
||||
isHelpOpen,
|
||||
isTypingTarget,
|
||||
openHelp,
|
||||
} from "./help.js";
|
||||
|
||||
beforeEach(() => {
|
||||
installHelpKeybind();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeHelp();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
function press(key: string, init: KeyboardEventInit = {}): void {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
|
||||
}
|
||||
|
||||
function rows(): string[] {
|
||||
return [...document.querySelectorAll(".help-row")].map((r) =>
|
||||
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
describe("help overlay", () => {
|
||||
it("renders every registered shortcut, grouped", () => {
|
||||
openHelp();
|
||||
const groups = [...document.querySelectorAll(".help-group-title")].map((h) => h.textContent);
|
||||
expect(groups).toEqual(SHORTCUTS.map((g) => g.title));
|
||||
expect(rows()).toEqual(SHORTCUTS.flatMap((g) => g.items.map((s) => s.keys.join(" "))));
|
||||
expect(document.querySelectorAll(".help-desc")).toHaveLength(
|
||||
SHORTCUTS.reduce((n, g) => n + g.items.length, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens on ? and closes on a second ?", () => {
|
||||
press("?", { shiftKey: true });
|
||||
expect(isHelpOpen()).toBe(true);
|
||||
press("?", { shiftKey: true });
|
||||
expect(isHelpOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("closes on Escape, on the close button, and on a click outside the panel", () => {
|
||||
openHelp();
|
||||
press("Escape");
|
||||
expect(isHelpOpen()).toBe(false);
|
||||
|
||||
openHelp();
|
||||
(document.querySelector(".help-close") as HTMLButtonElement).click();
|
||||
expect(isHelpOpen()).toBe(false);
|
||||
|
||||
openHelp();
|
||||
(document.querySelector(".help-overlay") as HTMLElement).dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
expect(isHelpOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("stays open for a click inside the panel", () => {
|
||||
openHelp();
|
||||
(document.querySelector(".help-panel") as HTMLElement).dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
expect(isHelpOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("opening twice leaves one overlay", () => {
|
||||
openHelp();
|
||||
openHelp();
|
||||
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores ? while typing and when a non-shift modifier is held", () => {
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true, shiftKey: true }));
|
||||
expect(isHelpOpen()).toBe(false);
|
||||
|
||||
press("?", { ctrlKey: true });
|
||||
expect(isHelpOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("installing the keybind twice still toggles once", () => {
|
||||
installHelpKeybind();
|
||||
press("?", { shiftKey: true });
|
||||
expect(isHelpOpen()).toBe(true);
|
||||
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTypingTarget", () => {
|
||||
it("is true for form fields and editable nodes, false otherwise", () => {
|
||||
const el = (html: string) => {
|
||||
const d = document.createElement("div");
|
||||
d.innerHTML = html;
|
||||
return d.firstElementChild as HTMLElement;
|
||||
};
|
||||
expect(isTypingTarget(el("<input>"))).toBe(true);
|
||||
expect(isTypingTarget(el("<textarea></textarea>"))).toBe(true);
|
||||
expect(isTypingTarget(el("<select></select>"))).toBe(true);
|
||||
expect(isTypingTarget(el('<div contenteditable="true"></div>'))).toBe(true);
|
||||
expect(isTypingTarget(el("<button></button>"))).toBe(false);
|
||||
expect(isTypingTarget(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
|
||||
* overlay. One registry drives both the rendering and what we claim is bound,
|
||||
* so a binding added to `main.ts` without an entry here reads as missing. */
|
||||
|
||||
export interface Shortcut {
|
||||
/** Keycaps, rendered left to right as separate <kbd>. */
|
||||
keys: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ShortcutGroup {
|
||||
title: string;
|
||||
items: Shortcut[];
|
||||
}
|
||||
|
||||
export const SHORTCUTS: ShortcutGroup[] = [
|
||||
{
|
||||
title: "Sessions",
|
||||
items: [
|
||||
{ keys: ["n"], description: "Open a spawn draft pane" },
|
||||
{ keys: ["N"], description: "Spawn a session with every default resolved" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Session list",
|
||||
items: [
|
||||
{
|
||||
keys: ["Ctrl", "Shift", "H"],
|
||||
description: "Move the keyboard between the session list and the pane",
|
||||
},
|
||||
{ keys: ["j"], description: "Highlight the next session" },
|
||||
{ keys: ["k"], description: "Highlight the previous session" },
|
||||
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
|
||||
{ keys: ["Esc"], description: "Hand the keyboard back to the pane" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
items: [
|
||||
{ keys: ["?"], description: "Show or hide this help" },
|
||||
{ keys: ["Esc"], description: "Close the help overlay or an open menu" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Debug",
|
||||
items: [{ keys: ["Ctrl", "Shift", "D"], description: "Toggle the debug HUD" }],
|
||||
},
|
||||
];
|
||||
|
||||
/** True when the event target swallows plain keystrokes — a form field, or the
|
||||
* hidden textarea xterm types into. Shared with `main.ts` so every global
|
||||
* single-key binding is deaf in the same places. */
|
||||
export function isTypingTarget(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el || typeof el.tagName !== "string") return false;
|
||||
return (
|
||||
el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
el.tagName === "SELECT" ||
|
||||
el.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
let open: { el: HTMLElement; dispose: () => void } | null = null;
|
||||
|
||||
export function isHelpOpen(): boolean {
|
||||
return open !== null;
|
||||
}
|
||||
|
||||
export function closeHelp(): void {
|
||||
open?.dispose();
|
||||
}
|
||||
|
||||
export function toggleHelp(): void {
|
||||
if (open) closeHelp();
|
||||
else openHelp();
|
||||
}
|
||||
|
||||
/** Mount the overlay. Opening while already open is a no-op re-render, so a
|
||||
* stray second call can never stack two dialogs. */
|
||||
export function openHelp(): HTMLElement {
|
||||
closeHelp();
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "help-overlay";
|
||||
el.setAttribute("role", "dialog");
|
||||
el.setAttribute("aria-modal", "true");
|
||||
el.setAttribute("aria-label", "Keyboard shortcuts");
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "help-panel";
|
||||
|
||||
const head = document.createElement("header");
|
||||
head.className = "help-head";
|
||||
const title = document.createElement("h2");
|
||||
title.className = "help-title";
|
||||
title.textContent = "Keyboard shortcuts";
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "help-close";
|
||||
close.setAttribute("aria-label", "Close");
|
||||
close.textContent = "✕";
|
||||
close.addEventListener("click", closeHelp);
|
||||
head.append(title, close);
|
||||
panel.appendChild(head);
|
||||
|
||||
for (const group of SHORTCUTS) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "help-group";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "help-group-title";
|
||||
heading.textContent = group.title;
|
||||
const list = document.createElement("dl");
|
||||
list.className = "help-list";
|
||||
for (const item of group.items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "help-row";
|
||||
const keys = document.createElement("dt");
|
||||
keys.className = "help-keys";
|
||||
for (const key of item.keys) {
|
||||
const cap = document.createElement("kbd");
|
||||
cap.textContent = key;
|
||||
keys.appendChild(cap);
|
||||
}
|
||||
const desc = document.createElement("dd");
|
||||
desc.className = "help-desc";
|
||||
desc.textContent = item.description;
|
||||
row.append(keys, desc);
|
||||
list.appendChild(row);
|
||||
}
|
||||
section.append(heading, list);
|
||||
panel.appendChild(section);
|
||||
}
|
||||
|
||||
// A press on the scrim closes; one inside the panel is left alone.
|
||||
el.addEventListener("click", (ev) => {
|
||||
if (!panel.contains(ev.target as Node)) closeHelp();
|
||||
});
|
||||
el.appendChild(panel);
|
||||
document.body.appendChild(el);
|
||||
|
||||
const dispose = () => {
|
||||
if (open?.el !== el) return;
|
||||
open = null;
|
||||
el.remove();
|
||||
};
|
||||
open = { el, dispose };
|
||||
close.focus();
|
||||
return el;
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
|
||||
* second call during a hot reload doesn't double-toggle. */
|
||||
export function installHelpKeybind(): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && open) {
|
||||
e.preventDefault();
|
||||
closeHelp();
|
||||
return;
|
||||
}
|
||||
// Shift is how `?` is typed on most layouts; the others would be a
|
||||
// different binding entirely.
|
||||
if (e.key !== "?" || e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
if (isTypingTarget(e.target)) return;
|
||||
e.preventDefault();
|
||||
toggleHelp();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
+53
-71
@@ -1,6 +1,12 @@
|
||||
import { renderSidebar, renderTopbar } from "./catalog.js";
|
||||
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 } from "./catalog.js";
|
||||
import { ConnectionPool } from "./connections.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";
|
||||
import { shouldRetryClose } from "./reconnect.js";
|
||||
import {
|
||||
@@ -15,11 +21,12 @@ import {
|
||||
resolveFastForward,
|
||||
} from "./fastForward.js";
|
||||
import { FALLBACK_PRESETS } from "./spawnForm.js";
|
||||
import { spawnSimilarSeed } from "./spawnSimilar.js";
|
||||
import { mountHud, type Hud } from "./hud.js";
|
||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import { loadLayout, saveLayout } from "./persistence.js";
|
||||
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
||||
import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
|
||||
import { initTheme } from "./theme.js";
|
||||
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
||||
import { installViewportSizing } from "./viewport.js";
|
||||
@@ -28,6 +35,7 @@ import "./styles.css";
|
||||
initTheme();
|
||||
installViewportSizing();
|
||||
installHudKeybind();
|
||||
installHelpKeybind();
|
||||
registerServiceWorker();
|
||||
|
||||
// Register the PWA service worker so the app is installable and the shell loads
|
||||
@@ -85,63 +93,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; a backdrop covers the content while it's open, and tapping a
|
||||
// session closes it. On desktop these are inert (CSS gates the drawer styling).
|
||||
// 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";
|
||||
function closeDrawer(): void {
|
||||
shellEl.classList.remove("drawer-open");
|
||||
}
|
||||
function openDrawer(): void {
|
||||
shellEl.classList.add("drawer-open");
|
||||
}
|
||||
function toggleDrawer(): void {
|
||||
shellEl.classList.toggle("drawer-open");
|
||||
}
|
||||
backdrop.addEventListener("click", closeDrawer);
|
||||
sidebar.el.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).closest(".tree-leaf")) closeDrawer();
|
||||
});
|
||||
|
||||
// Mobile: horizontal swipes drive the drawer (right opens, left closes) rather
|
||||
// than the browser's back/forward navigation. We only act on a clearly
|
||||
// horizontal gesture past a threshold, so vertical terminal scrolling (which
|
||||
// owns its own touch handler) is untouched.
|
||||
{
|
||||
const mobileMq = window.matchMedia("(max-width: 859px)");
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let tracking = false;
|
||||
document.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
tracking = mobileMq.matches && e.touches.length === 1;
|
||||
if (tracking) {
|
||||
sx = e.touches[0].clientX;
|
||||
sy = e.touches[0].clientY;
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
document.addEventListener(
|
||||
"touchend",
|
||||
(e) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - sx;
|
||||
const dy = t.clientY - sy;
|
||||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||
const open = shellEl.classList.contains("drawer-open");
|
||||
if (dx > 0 && !open) openDrawer();
|
||||
else if (dx < 0 && open) closeDrawer();
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
}
|
||||
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
|
||||
installDrawerSwipe(drawer);
|
||||
|
||||
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.
|
||||
@@ -219,14 +179,24 @@ function renderWelcome(): HTMLElement {
|
||||
Pick a session from the list, or start a new one. Open sessions keep
|
||||
streaming in the background — switching back is instant.
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -264,9 +234,9 @@ 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 with the
|
||||
// cloned session's dir + command.
|
||||
// Spawn affordances (#62): the sidebar's footer button, its empty state, and
|
||||
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the
|
||||
// cloned session (see spawnSimilar.ts).
|
||||
function requestOpenSpawn(): void {
|
||||
showMultiView();
|
||||
// A new tab, not a split of the pane you were reading (#100). "Spawn
|
||||
@@ -276,10 +246,10 @@ function requestOpenSpawn(): void {
|
||||
mv.openDraft(undefined, { newTab: true });
|
||||
}
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||
document.addEventListener("catalog-spawn-similar", (ev) => {
|
||||
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
showMultiView();
|
||||
mv.openDraft({ cwd: s.cwd, command: s.command });
|
||||
mv.openDraft(spawnSimilarSeed(s));
|
||||
});
|
||||
|
||||
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
|
||||
@@ -327,16 +297,9 @@ async function requestFastForward(): Promise<void> {
|
||||
// textarea, search) so an attached session's keys are never hijacked.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isTypingTarget(e.target)) return;
|
||||
// The help overlay owns the keyboard while it is up.
|
||||
if (isHelpOpen()) return;
|
||||
if (e.key === "n") {
|
||||
e.preventDefault();
|
||||
requestOpenSpawn();
|
||||
@@ -346,6 +309,25 @@ document.addEventListener("keydown", (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
|
||||
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
|
||||
// terminal owns every plain keystroke, so nothing unmodified could ever get
|
||||
// the focus back out of it.
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
|
||||
if (e.key !== "H" && e.key !== "h") return;
|
||||
e.preventDefault();
|
||||
if (sidebar.hasListFocus()) {
|
||||
pool.focusTerminal();
|
||||
} else {
|
||||
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
|
||||
sidebar.focusList();
|
||||
}
|
||||
});
|
||||
|
||||
// Escape in the session list hands the keyboard back to the pane.
|
||||
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
|
||||
|
||||
type Route =
|
||||
| { kind: "catalog" }
|
||||
| { kind: "attach"; sessionId: string }
|
||||
|
||||
+15
-3
@@ -14,6 +14,7 @@ function sess(
|
||||
status: "Running" | "Dead",
|
||||
cwd: string,
|
||||
name?: string,
|
||||
blocked_work?: Session["blocked_work"],
|
||||
): Session {
|
||||
const seed = parseInt(id.slice(-1), 16) || 0;
|
||||
return {
|
||||
@@ -30,6 +31,7 @@ function sess(
|
||||
last_activity_at: new Date(Date.now() - seed * 11_000).toISOString(),
|
||||
// The session tree nests by current_cwd; default it to the spawn dir.
|
||||
current_cwd: cwd,
|
||||
blocked_work,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,16 +111,26 @@ function stubSpawnApi(): void {
|
||||
export function seedMock(store: CatalogStore): void {
|
||||
const sessions: Session[] = [
|
||||
// ~/git/maestro/web/* — deep dir chain; "~/git/maestro" collapses to one node.
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign"),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign", {
|
||||
uncommitted_files: 4,
|
||||
untracked_files: 2,
|
||||
unpushed_commits: 1,
|
||||
insertions: 312,
|
||||
deletions: 47,
|
||||
}),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000002", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000003", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000004", "claude", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes"),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000004", "codex --resume", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes", {
|
||||
uncommitted_files: 1,
|
||||
}),
|
||||
// ~/git/maestro/crates/* — sibling subtree under the same repo.
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000005", "claude", "Running", "/home/dev/git/maestro/crates/daemon", "daemon-work"),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000006", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/daemon", "test-loop"),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000007", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"),
|
||||
// ~/work/acme/* — a separate top-level dir on the same host.
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000008", "claude", "Running", "/home/dev/work/acme/api", "billing-api"),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000008", "gemini", "Running", "/home/dev/work/acme/api", "billing-api", {
|
||||
untracked_files: 3,
|
||||
}),
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000009", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"),
|
||||
// ~/git/dotfiles — single session, fully collapsed chain.
|
||||
sess("a1b2c3d4-0000-0000-0000-000000000010", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"),
|
||||
|
||||
+131
-1
@@ -17,7 +17,7 @@ function stubFetch(): void {
|
||||
: url.includes("/api/projects")
|
||||
? []
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello"] }
|
||||
? { names: ["cello"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
@@ -36,6 +36,10 @@ function fakeSidebar(): SidebarHandle {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights() {},
|
||||
focusList() {},
|
||||
hasListFocus() {
|
||||
return false;
|
||||
},
|
||||
dispose() {},
|
||||
};
|
||||
}
|
||||
@@ -428,3 +432,129 @@ describe("multiview closeSession tab cleanup (#137)", () => {
|
||||
expect(layout.activeTabId).toBe(tab.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiview mobile single pane", () => {
|
||||
/** Force the mobile media query on, whatever happy-dom's viewport says. */
|
||||
function forceMobile(): void {
|
||||
vi.spyOn(window, "matchMedia").mockImplementation(
|
||||
(query: string) =>
|
||||
({
|
||||
matches: query.includes("859"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
forceMobile();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("replaces the focused pane instead of splitting beside it", () => {
|
||||
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();
|
||||
|
||||
mv.openSessionBeside("s2");
|
||||
|
||||
const ls = leaves(tab.root);
|
||||
expect(ls).toHaveLength(1);
|
||||
expect(ls[0].sessionId).toBe("s2");
|
||||
expect(layout.tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a spawn draft 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);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft(undefined, { 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();
|
||||
});
|
||||
|
||||
it("renders one pane and no pane pills for a multi-leaf tab", () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(1);
|
||||
expect(mv.el.querySelector(".mv-panebar")).toBeNull();
|
||||
expect(mv.el.querySelector(".mv-pill")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses the tab bar even with several tabs", () => {
|
||||
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-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);
|
||||
});
|
||||
});
|
||||
|
||||
+26
-42
@@ -22,7 +22,6 @@ import {
|
||||
removeLeaf,
|
||||
splitLeaf,
|
||||
} from "./layout.js";
|
||||
import { sessionColor } from "./sessioncolor.js";
|
||||
import type { AttachInfo } from "./attach.js";
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
@@ -85,9 +84,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
canvas.className = "mv-canvas";
|
||||
el.append(tabBar, canvas);
|
||||
|
||||
// Mobile (issue #35): below 860px the tree projects to a one-pane-at-a-time
|
||||
// strip of pills — splits/drag/resize are desktop-only. The tree is untouched,
|
||||
// so widening restores the splits.
|
||||
// Mobile (issue #35): below 860px the layout collapses to exactly one visible
|
||||
// pane and no tab bar — splits/drag/resize are desktop-only, and opening or
|
||||
// spawning replaces the pane rather than adding a second thing to navigate.
|
||||
// The sidebar drawer is the only navigation. The tree is untouched, so
|
||||
// widening restores whatever splits and tabs a desktop session built.
|
||||
const mq = window.matchMedia("(max-width: 859px)");
|
||||
let isMobile = mq.matches;
|
||||
mq.addEventListener("change", (e) => {
|
||||
@@ -97,12 +98,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
// ---- highlight helpers ----
|
||||
|
||||
function colorMap(): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
for (const id of openSessions(layout)) m.set(id, sessionColor(id));
|
||||
return m;
|
||||
}
|
||||
|
||||
function focusedLeafSession(): string | null {
|
||||
const tab = activeTab(layout);
|
||||
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
|
||||
@@ -120,7 +115,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
sidebar.setHighlights({
|
||||
open: openSessions(layout),
|
||||
focused: focusedLeafSession(),
|
||||
colors: colorMap(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,10 +136,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const pane = document.createElement("div");
|
||||
pane.className = "mv-pane";
|
||||
pane.dataset.leafId = leaf.id;
|
||||
if (leaf.sessionId) {
|
||||
pane.dataset.sessionId = leaf.sessionId;
|
||||
pane.style.setProperty("--mv-color", sessionColor(leaf.sessionId));
|
||||
}
|
||||
if (leaf.sessionId) pane.dataset.sessionId = leaf.sessionId;
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.className = "mv-pane-host";
|
||||
@@ -402,7 +393,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId) {
|
||||
// Mobile shows one pane, so there is no "beside": take over the pane. The
|
||||
// evicted session keeps running and stays a tap away in the sidebar.
|
||||
if (!target.sessionId || isMobile) {
|
||||
target.sessionId = sessionId;
|
||||
target.draft = null;
|
||||
} else {
|
||||
@@ -414,7 +407,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
|
||||
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
|
||||
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
||||
if (opts?.newTab) {
|
||||
// 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);
|
||||
@@ -432,9 +427,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId) {
|
||||
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) {
|
||||
@@ -473,16 +469,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
renderTabs();
|
||||
canvas.replaceChildren();
|
||||
const tab = activeTab(layout);
|
||||
// Mobile single-tab: the tab bar carries no information (one tab, one pane),
|
||||
// so CSS collapses it and we float a ☰ instead (rendered in renderMobile).
|
||||
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
|
||||
// Mobile: the tab bar is a second navigation surface we don't want, so CSS
|
||||
// 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());
|
||||
sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() });
|
||||
sidebar.setHighlights({ open: openSessions(layout), focused: null });
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
@@ -507,7 +507,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
persist();
|
||||
}
|
||||
|
||||
// Mobile: a pill strip of the active tab's leaves + one full-screen pane.
|
||||
// Mobile: the focused leaf, full screen, alone. Panes a desktop session left
|
||||
// behind stay in the tree but are reached only by tapping the session in the
|
||||
// sidebar drawer — mobile shows no pane switcher of its own.
|
||||
function renderMobile(tab: { root: Node | null; focusedLeafId: string | null }): void {
|
||||
const ls = leaves(tab.root);
|
||||
const focused = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || ls[0];
|
||||
@@ -515,24 +517,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "mv-mobile";
|
||||
|
||||
if (ls.length > 1) {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "mv-panebar";
|
||||
for (const l of ls) {
|
||||
const pill = document.createElement("button");
|
||||
pill.type = "button";
|
||||
pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : "");
|
||||
pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty";
|
||||
if (l.sessionId) pill.style.setProperty("--mv-color", sessionColor(l.sessionId));
|
||||
pill.addEventListener("click", () => {
|
||||
tab.focusedLeafId = l.id;
|
||||
render();
|
||||
});
|
||||
bar.appendChild(pill);
|
||||
}
|
||||
wrap.appendChild(bar);
|
||||
}
|
||||
|
||||
const pane = buildPane(focused);
|
||||
pane.el.classList.add("is-focused"); // the only pane — never dimmed
|
||||
wrap.appendChild(pane.el);
|
||||
@@ -543,7 +527,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
|
||||
);
|
||||
pool.setFocus(focused.sessionId ?? null);
|
||||
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null, colors: colorMap() });
|
||||
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
|
||||
}
|
||||
|
||||
function closeSession(sessionId: string): void {
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { longestCommonPrefix, splitPath } from "./paths.js";
|
||||
import { longestCommonPrefix, parentDir, splitPath } from "./paths.js";
|
||||
|
||||
describe("splitPath", () => {
|
||||
it("splits on the last slash, keeping it with the directory", () => {
|
||||
@@ -29,3 +29,19 @@ describe("longestCommonPrefix", () => {
|
||||
expect(longestCommonPrefix(["Alpha", "alpha"])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parentDir", () => {
|
||||
it("drops the last segment", () => {
|
||||
expect(parentDir("/home/user/tea/maestro")).toBe("/home/user/tea");
|
||||
expect(parentDir("/home/user/tea/maestro/")).toBe("/home/user/tea");
|
||||
});
|
||||
|
||||
it("keeps the root for a top-level path", () => {
|
||||
expect(parentDir("/maestro")).toBe("/");
|
||||
expect(parentDir("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("is empty for a bare name", () => {
|
||||
expect(parentDir("maestro")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,3 +19,12 @@ export function longestCommonPrefix(items: string[]): string {
|
||||
}
|
||||
return lcp;
|
||||
}
|
||||
|
||||
/** Everything above the last segment of an absolute path: `/a/b/c` → `/a/b`.
|
||||
* A top-level path keeps `/`, and a trailing slash is ignored. */
|
||||
export function parentDir(path: string): string {
|
||||
const trimmed = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
const slash = trimmed.lastIndexOf("/");
|
||||
if (slash <= 0) return slash === 0 ? "/" : "";
|
||||
return trimmed.slice(0, slash);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ function stubFetch(info: unknown, projects: Project[]): void {
|
||||
: url.includes("/api/projects")
|
||||
? projects
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
|
||||
? { names: ["cello", "fugue", "lyre"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
@@ -79,14 +79,22 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
||||
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the name as the primary line and the full path unabbreviated", async () => {
|
||||
it("shows the name as the primary line and its parent directory below", async () => {
|
||||
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
||||
const form = (await mount([p])).el;
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
||||
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
|
||||
// projects sharing a basename (#95).
|
||||
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
|
||||
// Only the stem: repeating the last segment would just echo the name. Not
|
||||
// `~/t`, since the stem is what distinguishes two projects sharing a
|
||||
// basename (#95).
|
||||
expect(box.querySelector(".project-path")!.textContent).toBe("~/tea");
|
||||
});
|
||||
|
||||
it("keeps the last command out of the box", async () => {
|
||||
const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude" });
|
||||
const form = (await mount([p])).el;
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
expect(box.textContent).not.toContain("claude");
|
||||
});
|
||||
|
||||
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
||||
@@ -126,11 +134,11 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("picking a project selects its cwd and pre-selects its last command", async () => {
|
||||
it("picking a project selects its cwd and leaves the command alone", async () => {
|
||||
const p = project({
|
||||
name: "maestro",
|
||||
path: "/home/user/maestro",
|
||||
last_command: "claude",
|
||||
last_command: "aider",
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
stubFetch(
|
||||
@@ -157,6 +165,8 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||
const last = onChange.mock.lastCall![0];
|
||||
expect(last.cwd).toBe("/home/user/maestro");
|
||||
// The directory and the command are independent choices: the preset stays
|
||||
// where it was, whatever the project was last run with.
|
||||
expect(last.command).toBe("claude");
|
||||
expect(values).toBeTruthy();
|
||||
// The raw input stays hidden — the picked box is the source of truth.
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Stable per-session accent colour, used to map a sidebar row to the pane
|
||||
// showing that session (issue #33). Hash the session id into a fixed OKLCH
|
||||
// palette tuned to the maestro tokens; collisions are tolerated when there are
|
||||
// more open sessions than hues — the colour is a hint, not an identity.
|
||||
|
||||
import type { Uuid } from "./types.js";
|
||||
|
||||
const PALETTE = [
|
||||
"oklch(0.80 0.13 152)", // green (the house accent)
|
||||
"oklch(0.80 0.12 90)", // amber
|
||||
"oklch(0.76 0.13 30)", // coral
|
||||
"oklch(0.78 0.11 210)", // teal
|
||||
"oklch(0.74 0.13 265)", // blue
|
||||
"oklch(0.76 0.14 320)", // magenta
|
||||
"oklch(0.80 0.11 60)", // gold
|
||||
"oklch(0.78 0.12 185)", // cyan
|
||||
];
|
||||
|
||||
export function sessionColor(id: Uuid): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return PALETTE[h % PALETTE.length];
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { FOCUS_PANE_EVENT, renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
window.fetch = () => Promise.reject(new Error("offline"));
|
||||
});
|
||||
|
||||
function session(id: string, cwd: string): Session {
|
||||
return {
|
||||
id,
|
||||
cwd,
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
} as Session;
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
const A = "aaaaaaaa-0000-0000-0000-00000000000a";
|
||||
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
|
||||
|
||||
/** Two sessions in sibling directories: two folder headers, two cards. */
|
||||
function twoFolders(): { el: HTMLElement; handle: ReturnType<typeof renderSidebar> } {
|
||||
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]));
|
||||
// Mounted: `focusList` moves real DOM focus, which needs a live document.
|
||||
document.body.replaceChildren(handle.el);
|
||||
return { el: handle.el, handle };
|
||||
}
|
||||
|
||||
function press(el: HTMLElement, key: string, init: KeyboardEventInit = {}): void {
|
||||
el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
|
||||
}
|
||||
|
||||
function highlighted(el: HTMLElement): string | null {
|
||||
return el.querySelector<HTMLElement>(".is-highlighted")?.dataset.navKey ?? null;
|
||||
}
|
||||
|
||||
function tree(el: HTMLElement): HTMLElement {
|
||||
return el.querySelector<HTMLElement>(".tree")!;
|
||||
}
|
||||
|
||||
describe("sidebar keyboard navigation", () => {
|
||||
it("walks the cards and never stops on an expanded folder", () => {
|
||||
const { el } = twoFolders();
|
||||
// The folders are there — the walk just refuses to land on them.
|
||||
expect(el.querySelectorAll(".tree-folder-header")).toHaveLength(2);
|
||||
|
||||
press(tree(el), "j");
|
||||
expect(highlighted(el)).toBe(`session:${A}`);
|
||||
press(tree(el), "j");
|
||||
expect(highlighted(el)).toBe(`session:${B}`);
|
||||
// Wraps.
|
||||
press(tree(el), "j");
|
||||
expect(highlighted(el)).toBe(`session:${A}`);
|
||||
press(tree(el), "k");
|
||||
expect(highlighted(el)).toBe(`session:${B}`);
|
||||
});
|
||||
|
||||
it("accepts the arrow keys as well as j/k", () => {
|
||||
const { el } = twoFolders();
|
||||
press(tree(el), "ArrowDown");
|
||||
expect(highlighted(el)).toBe(`session:${A}`);
|
||||
press(tree(el), "ArrowUp");
|
||||
// Backwards from the first card wraps to the last.
|
||||
expect(highlighted(el)).toBe(`session:${B}`);
|
||||
});
|
||||
|
||||
it("stops on a collapsed folder, since its cards are gone", () => {
|
||||
const { el } = twoFolders();
|
||||
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
|
||||
header.click(); // collapse /one
|
||||
|
||||
press(tree(el), "j");
|
||||
expect(highlighted(el)).toBe("folder:one");
|
||||
press(tree(el), "j");
|
||||
expect(highlighted(el)).toBe(`session:${B}`);
|
||||
|
||||
// Enter expands it again and lands on the card it was hiding.
|
||||
press(tree(el), "j");
|
||||
expect(highlighted(el)).toBe("folder:one");
|
||||
press(tree(el), "Enter");
|
||||
expect(highlighted(el)).toBe(`session:${A}`);
|
||||
});
|
||||
|
||||
it("focusList starts from the session on screen", () => {
|
||||
const { el, handle } = twoFolders();
|
||||
handle.setHighlights({ open: new Set([B]), focused: B });
|
||||
handle.focusList();
|
||||
expect(highlighted(el)).toBe(`session:${B}`);
|
||||
expect(handle.hasListFocus()).toBe(true);
|
||||
});
|
||||
|
||||
it("Escape asks the shell for the pane back", () => {
|
||||
const { el } = twoFolders();
|
||||
let asked = 0;
|
||||
document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1));
|
||||
press(tree(el), "Escape");
|
||||
expect(asked).toBe(1);
|
||||
});
|
||||
|
||||
it("leaves the keyboard alone while a row is being renamed", () => {
|
||||
const { el } = twoFolders();
|
||||
press(tree(el), "j");
|
||||
const input = document.createElement("input");
|
||||
tree(el).appendChild(input);
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true }));
|
||||
expect(highlighted(el)).toBe(`session:${A}`);
|
||||
});
|
||||
});
|
||||
+17
-12
@@ -8,8 +8,10 @@ import {
|
||||
gitRoot,
|
||||
} from "./api.js";
|
||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import { parentDir } from "./paths.js";
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { ProjectGroup } from "./projectList.js";
|
||||
import { abbreviateCwd } from "./types.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
|
||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||
@@ -440,8 +442,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
|
||||
// ---- working-directory picker (#95) ----------------------------------
|
||||
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
|
||||
// 1. Step one is one box per repository (name, then full path). A worktree
|
||||
// is a box only when its main checkout is missing from the scan.
|
||||
// 1. Step one is one box per repository (name, then its parent directory).
|
||||
// A worktree is a box only when its main checkout is missing from the
|
||||
// scan.
|
||||
// 2. The filter keeps a repository when the query matches it or any of its
|
||||
// worktrees, so a worktree name still finds its box.
|
||||
// 3. Picking a box reveals step two: "+ new", then that repository's
|
||||
@@ -454,6 +457,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// these are separate acts — #91's rule, now an explicit choice.
|
||||
// 6. Neither step probes /api/gitroot; the scan already reports repository
|
||||
// membership. Only the free-text hatch pays for a probe.
|
||||
// 7. Neither step touches the command: the directory and what runs in it
|
||||
// are independent choices.
|
||||
|
||||
/** "new" (create a worktree) or the path of an existing worktree to run in. */
|
||||
type WorktreeChoice = "new" | string;
|
||||
@@ -544,9 +549,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
wtHint.hidden = true;
|
||||
}
|
||||
emit();
|
||||
// The chosen directory pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
|
||||
if (match) selectPreset(match.id);
|
||||
}
|
||||
|
||||
/** Groups with at least one member (itself or a worktree) matching `query`. */
|
||||
@@ -589,7 +591,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwd = seedCwd;
|
||||
cwdRaw.value = seedCwd;
|
||||
// Restoring must not touch the worktree toggle — the seed carries it.
|
||||
if (!restoreSelection(seedCwd)) clearProjectSelection();
|
||||
// A path no box matches (the project is gone, or the scan never saw
|
||||
// it) falls back to the raw input showing that path: an empty picker
|
||||
// with an invisible cwd reads as a form that lost the directory.
|
||||
if (!restoreSelection(seedCwd)) showRawCwd(seedCwd, false);
|
||||
} else {
|
||||
showRawCwd(seedCwd, false);
|
||||
}
|
||||
@@ -631,14 +636,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||
box.dataset.cwd = project.path;
|
||||
const trees = worktreesOf(group).length;
|
||||
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
|
||||
// tells two projects sharing a basename apart (#95).
|
||||
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
// The parent directory only — the last segment is the name line above it.
|
||||
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
||||
// sharing a basename apart (#95).
|
||||
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
||||
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
|
||||
box.innerHTML = `
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="project-path">${escapeHtml(project.path)}</span>
|
||||
<span class="project-path">${escapeHtml(parent)}</span>
|
||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
||||
box.addEventListener("click", () => selectProject(group));
|
||||
return box;
|
||||
|
||||
@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"];
|
||||
const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"];
|
||||
const BATCH_A = ["cello", "fugue", "lyre"];
|
||||
const BATCH_B = ["oboe", "tuba", "harp"];
|
||||
|
||||
/** Stub the three fetches the form's initial load makes. Records every
|
||||
* /api/names URL so tests can assert how many batches were fetched. */
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spawnSimilarSeed } from "./spawnSimilar.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "s1",
|
||||
cwd: "/repos/app",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
describe("spawnSimilarSeed (#28)", () => {
|
||||
it("seeds the project picker, not the free-text hatch", () => {
|
||||
const seed = spawnSimilarSeed(session());
|
||||
expect(seed.cwd).toBe("/repos/app");
|
||||
expect(seed.command).toBe("claude");
|
||||
expect(seed.cwdRawVisible).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the worktree toggle off for a session that has no worktree", () => {
|
||||
expect(spawnSimilarSeed(session()).worktreeEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("clones a worktree session as a new worktree of its base repo", () => {
|
||||
const seed = spawnSimilarSeed(
|
||||
session({
|
||||
cwd: "/repos/app-worktrees/feature",
|
||||
worktree: { path: "/repos/app-worktrees/feature", base_repo: "/repos/app" },
|
||||
}),
|
||||
);
|
||||
expect(seed.cwd).toBe("/repos/app");
|
||||
expect(seed.worktreeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("never carries the cloned session's name", () => {
|
||||
const seed = spawnSimilarSeed(session({ metadata: { name: "amber-falcon" } }));
|
||||
expect(seed.name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// "Spawn similar" (#28): the seed a cloned session hands to a draft pane.
|
||||
// Pure — the caller owns opening the pane.
|
||||
|
||||
import type { Draft } from "./layout.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
/** Clone a session into draft values: same project, same command, same
|
||||
* worktree decision, fresh name.
|
||||
*
|
||||
* A worktree session seeds its *base repository*, not the checkout the daemon
|
||||
* made for it: "similar" is another session of that project, and the daemon
|
||||
* never adopts an existing worktree (docs/WORKTREES.md), so seeding the
|
||||
* checkout would ask it to build a worktree out of a worktree. The picker
|
||||
* then lands on that repository's box with "+ new" active.
|
||||
*
|
||||
* `cwdRawVisible: false` keeps the two-step project picker on screen with the
|
||||
* cloned project selected, instead of dropping the path into the free-text
|
||||
* escape hatch.
|
||||
*
|
||||
* 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> {
|
||||
const wt = s.worktree ?? null;
|
||||
return {
|
||||
command: s.command,
|
||||
cwd: wt ? wt.base_repo : s.cwd,
|
||||
worktreeEnabled: wt != null,
|
||||
cwdRawVisible: false,
|
||||
};
|
||||
}
|
||||
+261
-207
@@ -8,7 +8,7 @@
|
||||
:root {
|
||||
/* ---- type ---- */
|
||||
--font-mono:
|
||||
ui-monospace, "SF Mono", "SFMono-Regular", "Cascadia Code", "JetBrains Mono",
|
||||
"JetBrains Mono", ui-monospace, "SF Mono", "SFMono-Regular", "Cascadia Code",
|
||||
"Roboto Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--font-sans:
|
||||
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
@@ -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,19 +412,29 @@ textarea {
|
||||
.sidebar-resize {
|
||||
display: none;
|
||||
}
|
||||
.app-status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar's own slim header: just the session search (app-level actions live
|
||||
in the global top bar). */
|
||||
/* Sidebar's own slim header: just the session search. */
|
||||
/* The search input IS the header — it spans edge to edge, flush, with just a
|
||||
bottom rule for separation. No wrapper padding, no boxed border/radius. */
|
||||
.sidebar-search {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* App-level actions (help, theme, spawn), pinned to the sidebar's foot. */
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 0 0 auto;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-footer .spawn-btn {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sidebar-search .search {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
@@ -625,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 {
|
||||
@@ -736,7 +643,7 @@ textarea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 1px var(--sp-2);
|
||||
margin: 1px 0;
|
||||
padding: 4px var(--sp-3);
|
||||
border-radius: var(--r-1);
|
||||
color: var(--ink);
|
||||
@@ -761,7 +668,19 @@ textarea {
|
||||
|
||||
.tree-leaf:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px var(--accent-ring);
|
||||
background: color-mix(in oklab, var(--accent) 16%, var(--surface-2));
|
||||
}
|
||||
|
||||
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
|
||||
is a cursor, not a statement about what is mounted, so it reads as a ring
|
||||
rather than a fill and the two can sit on the same row. */
|
||||
.tree-leaf.is-highlighted,
|
||||
.tree-folder-header.is-highlighted {
|
||||
box-shadow: inset 0 0 0 2px var(--accent-ring);
|
||||
}
|
||||
|
||||
.tree {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The session currently mounted in the content pane. */
|
||||
@@ -876,6 +795,113 @@ input.leaf-rename:focus {
|
||||
background: var(--danger-tint);
|
||||
}
|
||||
|
||||
/* ---------- help overlay (`?`) ---------- */
|
||||
.help-overlay {
|
||||
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);
|
||||
}
|
||||
|
||||
.help-panel {
|
||||
width: min(440px, 100%);
|
||||
max-height: min(80vh, 640px);
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-4);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--r-3);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.help-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.help-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-15);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.help-close {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: var(--r-2);
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-close:hover,
|
||||
.help-close:focus-visible {
|
||||
background: var(--surface-3);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.help-group + .help-group {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.help-group-title {
|
||||
margin: 0 0 var(--sp-2);
|
||||
font-size: var(--fs-11);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.help-list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Keycaps in a fixed column so the descriptions line up down the list. */
|
||||
.help-row {
|
||||
display: grid;
|
||||
grid-template-columns: 14ch 1fr;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
|
||||
.help-keys {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--sp-1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-keys kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-11);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: var(--r-1);
|
||||
padding: 1px 6px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.help-desc {
|
||||
margin: 0;
|
||||
font-size: var(--fs-13);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
|
||||
state word itself is never spelled out here; the colour carries it. */
|
||||
.card-line2 {
|
||||
@@ -940,6 +966,46 @@ input.leaf-rename:focus {
|
||||
truncation target when even the complete trailing set cannot fit. */
|
||||
.leaf-cmd {
|
||||
flex: 0 1 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The tool's mark stands in for the program the command line starts with
|
||||
(docs/SIDEBAR.md). It keeps its own colours where the tool has brand
|
||||
colours, and inherits the muted fragment colour where it does not. */
|
||||
.tool-icon {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.leaf-cmd-rest {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Blocked work as diff notation: `+124 -18 ?3 ↑2` (docs/SIDEBAR.md). The
|
||||
spelled-out breakdown is the tooltip; only the additions and deletions take
|
||||
colour, so the ⚠-less fragment still reads as a diff and not as an alarm. */
|
||||
.leaf-blocked {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.blocked-stat[data-stat="add"] {
|
||||
color: var(--live);
|
||||
}
|
||||
.blocked-stat[data-stat="del"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
.blocked-stat[data-stat="files"],
|
||||
.blocked-stat[data-stat="untracked"],
|
||||
.blocked-stat[data-stat="unpushed"] {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* Actions live in line 1's flow at its right edge — never overlaid on the
|
||||
@@ -992,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 {
|
||||
@@ -1075,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;
|
||||
}
|
||||
}
|
||||
@@ -1120,6 +1245,9 @@ input.leaf-rename:focus {
|
||||
|
||||
.attach-title .at-cmd {
|
||||
color: var(--muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.attach-title .at-cmd::before {
|
||||
@@ -1204,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;
|
||||
}
|
||||
|
||||
@@ -1912,9 +2042,6 @@ input.leaf-rename:focus {
|
||||
max-width: none;
|
||||
order: -1;
|
||||
}
|
||||
.statusbar .spacer {
|
||||
display: none;
|
||||
}
|
||||
.tree-leaf .leaf-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -2027,9 +2154,10 @@ input.leaf-rename:focus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Mobile, single tab: the tab bar is pure chrome — collapse it (a floating ☰
|
||||
inside the pane keeps the sidebar drawer reachable). */
|
||||
.mv-mobile-solo > .mv-tabs {
|
||||
/* 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-solo-tab > .mv-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -2106,7 +2234,7 @@ input.leaf-rename:focus {
|
||||
.mv-canvas > .mv-split,
|
||||
.mv-canvas > .mv-pane {
|
||||
position: absolute;
|
||||
inset: var(--sp-2);
|
||||
inset: 0;
|
||||
}
|
||||
.mv-canvas > .content-welcome {
|
||||
position: absolute;
|
||||
@@ -2194,20 +2322,9 @@ input.leaf-rename:focus {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
/* per-session colour strip — maps a pane to its sidebar row */
|
||||
.mv-pane::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 2px;
|
||||
background: var(--mv-color, transparent);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* focused pane: accent ring + glow, full strength */
|
||||
.mv-pane.is-focused {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent), 0 0 22px -8px var(--accent-ring);
|
||||
/* focused pane: titlebar tints instead of a ring, full strength */
|
||||
.mv-pane.is-focused .attach-header {
|
||||
background: color-mix(in oklab, var(--accent) 14%, var(--surface));
|
||||
}
|
||||
/* unfocused panes recede so the eye finds the live one */
|
||||
.mv-pane:not(.is-focused) {
|
||||
@@ -2219,11 +2336,6 @@ 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 {
|
||||
@@ -2310,35 +2422,16 @@ input.leaf-rename:focus {
|
||||
border-radius: var(--r-1);
|
||||
}
|
||||
|
||||
/* sidebar two-tier highlight + colour dot (issue #33).
|
||||
/* sidebar two-tier highlight (issue #33).
|
||||
.is-active = focused pane (loud, from existing rule); .is-open = open
|
||||
elsewhere (quiet). The dot uses the session's colour so a row maps to a pane. */
|
||||
elsewhere (quiet). */
|
||||
.tree-leaf.is-open {
|
||||
background: color-mix(in oklab, var(--accent) 6%, transparent);
|
||||
}
|
||||
.tree-leaf.is-open::after,
|
||||
.tree-leaf.is-active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
top: 50%;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-top: -3.5px;
|
||||
border-radius: 50%;
|
||||
background: var(--mv-color, var(--accent));
|
||||
pointer-events: none;
|
||||
}
|
||||
.tree-leaf.is-open::after {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.tree-leaf.is-active::after {
|
||||
box-shadow: 0 0 8px var(--mv-color, var(--accent-ring));
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
multi-view mobile (#35): below 860px the split tree projects to a pill strip
|
||||
with one pane visible; the sidebar becomes an off-canvas drawer (☰).
|
||||
multi-view mobile (#35): below 860px only the focused pane is shown — no tab
|
||||
bar, no pane switcher; the sidebar drawer (☰) is the navigation.
|
||||
========================================================================= */
|
||||
.mv-backdrop {
|
||||
display: none;
|
||||
@@ -2346,7 +2439,6 @@ input.leaf-rename:focus {
|
||||
|
||||
.mv-mobile {
|
||||
position: absolute;
|
||||
/* Flush to the edges — a single full-screen pane needs no canvas inset. */
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2359,50 +2451,12 @@ input.leaf-rename:focus {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.mv-panebar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex: none;
|
||||
}
|
||||
.mv-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 7px var(--sp-3);
|
||||
border-radius: var(--r-1);
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
font: 600 var(--fs-12) / 1 var(--font-mono);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.mv-pill::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--mv-color, var(--faint));
|
||||
flex: none;
|
||||
}
|
||||
.mv-pill.is-active {
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-strong);
|
||||
border-color: var(--accent-ring);
|
||||
}
|
||||
.mv-mobile > .mv-pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { splitCommand, toolIconSvg } from "./toolIcon.js";
|
||||
|
||||
describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
||||
it("recognises the agents and shells sessions are spawned with", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["claude", "claude"],
|
||||
["codex", "codex"],
|
||||
["opencode", "opencode"],
|
||||
["gemini", "gemini"],
|
||||
["aider", "aider"],
|
||||
["cursor-agent", "cursor"],
|
||||
["pi", "pi"],
|
||||
["zsh", "shell"],
|
||||
["bash", "shell"],
|
||||
["fish", "shell"],
|
||||
];
|
||||
for (const [command, tool] of cases) {
|
||||
expect(splitCommand(command).tool, command).toBe(tool);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops everything after the program", () => {
|
||||
expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
|
||||
tool: "claude",
|
||||
program: "claude",
|
||||
});
|
||||
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", program: "zsh" });
|
||||
});
|
||||
|
||||
it("looks through paths, env assignments and wrappers", () => {
|
||||
expect(splitCommand("/usr/local/bin/claude").tool).toBe("claude");
|
||||
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").program).toBe("opencode");
|
||||
});
|
||||
|
||||
it("leaves an unrecognised program as its bare name, mark-less", () => {
|
||||
expect(splitCommand("hx src/main.rs")).toEqual({
|
||||
tool: null,
|
||||
program: "hx",
|
||||
});
|
||||
// A wrapper with nothing after it is not a tool either.
|
||||
expect(splitCommand("sudo").tool).toBeNull();
|
||||
expect(splitCommand("").tool).toBeNull();
|
||||
});
|
||||
|
||||
it("gives every recognised tool a labelled mark", () => {
|
||||
for (const tool of ["claude", "codex", "opencode", "gemini", "aider", "cursor", "pi", "shell"]) {
|
||||
const svg = toolIconSvg(tool);
|
||||
expect(svg, tool).toContain("<svg");
|
||||
expect(svg, tool).toContain(`data-tool="${tool}"`);
|
||||
expect(svg, tool).toContain("aria-label=");
|
||||
}
|
||||
expect(toolIconSvg("nonesuch")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/** Tool icons for the sidebar's command fragment (docs/SIDEBAR.md).
|
||||
*
|
||||
* 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 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 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. */
|
||||
const WRAPPERS = new Set(["sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command"]);
|
||||
|
||||
/** Program name (already basenamed and lowercased) → icon id. */
|
||||
const BY_PROGRAM: Record<string, string> = {
|
||||
claude: "claude",
|
||||
"claude-code": "claude",
|
||||
codex: "codex",
|
||||
opencode: "opencode",
|
||||
gemini: "gemini",
|
||||
aider: "aider",
|
||||
cursor: "cursor",
|
||||
"cursor-agent": "cursor",
|
||||
pi: "pi",
|
||||
bash: "shell",
|
||||
zsh: "shell",
|
||||
sh: "shell",
|
||||
fish: "shell",
|
||||
dash: "shell",
|
||||
ksh: "shell",
|
||||
nu: "shell",
|
||||
xonsh: "shell",
|
||||
};
|
||||
|
||||
/** Human name behind an icon id, for the tooltip and the aria label. */
|
||||
export const TOOL_LABELS: Record<string, string> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
opencode: "opencode",
|
||||
gemini: "Gemini CLI",
|
||||
aider: "Aider",
|
||||
cursor: "Cursor",
|
||||
pi: "pi",
|
||||
shell: "Shell",
|
||||
};
|
||||
|
||||
export interface CommandParts {
|
||||
/** Icon id, or `null` when the program is not one we have a mark for. */
|
||||
tool: string | null;
|
||||
/** 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 program name.
|
||||
*
|
||||
* `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;
|
||||
// Leading `FOO=bar` assignments and wrappers stand in front of the real
|
||||
// program; `pnpm dlx x` / `yarn dlx x` need one more word skipped.
|
||||
while (i < words.length) {
|
||||
const word = words[i];
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const wrapper = basename(word).toLowerCase();
|
||||
if (WRAPPERS.has(wrapper)) {
|
||||
i += 1;
|
||||
if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
|
||||
function burst(): string {
|
||||
const rays = Array.from({ length: 8 }, (_, k) => {
|
||||
const long = k % 2 === 0;
|
||||
const tip = long ? 1.1 : 3.4;
|
||||
const waist = long ? 6.3 : 6.6;
|
||||
const half = long ? 1.05 : 0.85;
|
||||
return `<path d="M8 ${tip} L${8 + half} ${waist} L8 8 L${8 - half} ${waist} Z" transform="rotate(${k * 45} 8 8)"/>`;
|
||||
}).join("");
|
||||
return `<g fill="#d97757">${rays}</g>`;
|
||||
}
|
||||
|
||||
const SVGS: Record<string, string> = {
|
||||
claude: burst(),
|
||||
// A hexafoil knot in the shape of OpenAI's mark. The petals stay thin and
|
||||
// wide apart: three fatter loops merge into a blob at 13px.
|
||||
codex: `<g fill="none" stroke="currentColor" stroke-width="1.05">
|
||||
<ellipse cx="8" cy="8" rx="2.5" ry="6.7"/>
|
||||
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(60 8 8)"/>
|
||||
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(120 8 8)"/>
|
||||
</g>`,
|
||||
gemini: `<path fill="#4285f4" d="M8 0.8C8.6 4.6 11.4 7.4 15.2 8C11.4 8.6 8.6 11.4 8 15.2C7.4 11.4 4.6 8.6 0.8 8C4.6 7.4 7.4 4.6 8 0.8Z"/>`,
|
||||
// An isometric cube, the shape Cursor's mark is built from.
|
||||
cursor: `<g fill="currentColor">
|
||||
<path d="M8 1 L14.5 4.75 L8 8.5 L1.5 4.75 Z" opacity="0.95"/>
|
||||
<path d="M14.5 4.75 L14.5 12.25 L8 16 L8 8.5 Z" opacity="0.5"/>
|
||||
<path d="M1.5 4.75 L8 8.5 L8 16 L1.5 12.25 Z" opacity="0.75"/>
|
||||
</g>`,
|
||||
shell: `<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.6 4 L6.4 8 L2.6 12"/>
|
||||
<path d="M8.4 12.6 H13.4"/>
|
||||
</g>`,
|
||||
opencode: monogram("oc"),
|
||||
aider: monogram("ai"),
|
||||
pi: monogram("π"),
|
||||
};
|
||||
|
||||
/** Tools whose own mark is a wordmark get their initials instead — inventing
|
||||
* a logo for them would be worse than saying which letters they start with.
|
||||
* No chip around the letters: a border this small eats the height the letters
|
||||
* need to stay apart. */
|
||||
function monogram(text: string): string {
|
||||
return `<text x="8" y="8.6" fill="currentColor" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="11" font-weight="700" letter-spacing="-0.6" text-anchor="middle" dominant-baseline="central">${text}</text>`;
|
||||
}
|
||||
|
||||
/** The `<svg>` for an icon id, or `null` when the id has no mark. */
|
||||
export function toolIconSvg(tool: string): string | null {
|
||||
const body = SVGS[tool];
|
||||
if (!body) return null;
|
||||
const label = TOOL_LABELS[tool] ?? tool;
|
||||
return `<svg class="tool-icon" data-tool="${tool}" viewBox="0 0 16 16" width="13" height="13" role="img" aria-label="${label}"><title>${label}</title>${body}</svg>`;
|
||||
}
|
||||
@@ -39,6 +39,16 @@ describe("buildTree", () => {
|
||||
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
|
||||
});
|
||||
|
||||
it("folds the sole root folder into each of its own children when it branches", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]);
|
||||
});
|
||||
|
||||
it("stops collapsing where a directory branches", () => {
|
||||
const root = buildTree(
|
||||
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
||||
|
||||
@@ -59,6 +59,15 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
|
||||
for (const c of n.children) if (c.kind === "folder") sortRec(c);
|
||||
};
|
||||
sortRec(root);
|
||||
// The root's sole child (usually `~`) has no sibling to distinguish it
|
||||
// from, so it folds into every one of its own children, not just a lone
|
||||
// one — unlike the chain merge below, and only here at the very top.
|
||||
if (root.children.length === 1 && root.children[0].kind === "folder") {
|
||||
const only = root.children[0];
|
||||
root.children = only.children.map((c) =>
|
||||
c.kind === "folder" ? { ...c, name: `${only.name}/${c.name}` } : c,
|
||||
);
|
||||
}
|
||||
// Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as
|
||||
// one compact folder rather than four nested ones.
|
||||
root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c));
|
||||
|
||||
+42
-1
@@ -164,6 +164,42 @@ export interface InTheWay {
|
||||
uncommitted_files?: number;
|
||||
untracked_files?: number;
|
||||
unpushed_commits?: number;
|
||||
/** Lines added across the tracked changes, staged and unstaged together. */
|
||||
insertions?: number;
|
||||
/** Lines removed across the same tracked changes. */
|
||||
deletions?: number;
|
||||
}
|
||||
|
||||
/** One fragment of the sidebar's compact blocked-work stat (docs/SIDEBAR.md).
|
||||
* `kind` picks the colour; `text` is what the client prints. */
|
||||
export interface BlockerStat {
|
||||
kind: "add" | "del" | "files" | "untracked" | "unpushed";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`.
|
||||
*
|
||||
* The line counts cover tracked changes only, so a tree whose only tracked
|
||||
* change carries no line delta — a mode change, a rename — falls back to the
|
||||
* file count (`~2`) rather than claiming `+0 -0`. Both clients render exactly
|
||||
* these fragments; the spelled-out {@link formatInTheWay} survives in the
|
||||
* tooltip and in the kill refusal. */
|
||||
export function blockerStats(w: InTheWay): BlockerStat[] {
|
||||
const stats: BlockerStat[] = [];
|
||||
const added = w.insertions ?? 0;
|
||||
const removed = w.deletions ?? 0;
|
||||
const changed = w.uncommitted_files ?? 0;
|
||||
if (added > 0 || removed > 0) {
|
||||
if (added > 0) stats.push({ kind: "add", text: `+${added}` });
|
||||
if (removed > 0) stats.push({ kind: "del", text: `-${removed}` });
|
||||
} else if (changed > 0) {
|
||||
stats.push({ kind: "files", text: `~${changed}` });
|
||||
}
|
||||
if ((w.untracked_files ?? 0) > 0)
|
||||
stats.push({ kind: "untracked", text: `?${w.untracked_files}` });
|
||||
if ((w.unpushed_commits ?? 0) > 0)
|
||||
stats.push({ kind: "unpushed", text: `\u2191${w.unpushed_commits}` });
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by
|
||||
@@ -217,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 {
|
||||
|
||||
@@ -240,4 +240,35 @@ describe("spawnForm worktree step (#95)", () => {
|
||||
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
||||
expect(toggle(form).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("lands a 'spawn similar' seed on the cloned project with '+ new' active", async () => {
|
||||
urls = stubFetch([MAESTRO, WT_A, WT_B]);
|
||||
handle = createSpawnForm({
|
||||
onSuccess() {},
|
||||
seed: { cwd: MAESTRO.path, cwdRawVisible: false, worktreeEnabled: true },
|
||||
});
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
const form = handle.el;
|
||||
|
||||
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
||||
expect(activeChoice(form)!.dataset.kind).toBe("new");
|
||||
expect(toggle(form).checked).toBe(true);
|
||||
expect(form.querySelector<HTMLElement>(".cwd-raw-wrap")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the raw input when the seeded directory matches no project", async () => {
|
||||
urls = stubFetch([MAESTRO, WT_A]);
|
||||
handle = createSpawnForm({
|
||||
onSuccess() {},
|
||||
seed: { cwd: "/home/user/gone", cwdRawVisible: false, worktreeEnabled: false },
|
||||
});
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
const form = handle.el;
|
||||
|
||||
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
|
||||
expect(form.querySelector<HTMLElement>(".cwd-raw-wrap")!.hidden).toBe(false);
|
||||
expect(raw.value).toBe("/home/user/gone");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user