Compare commits

..
4 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 28778b2224 feat(maestro): add maestro send for typing into a session
ci / web (push) Successful in 1m56s
ci / rust (push) Successful in 5m13s
ci / flake (push) Successful in 8m8s
Adds POST /api/sessions/:id/input and a CLI command on top of it, so a
script (or another agent) can hand an instruction to a running session
without attaching to it.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:10:18 +01:00
Miguel Palhas 766eabbcb6 fix(maestro): bind terminal touch scroll to .xterm
ci / web (push) Successful in 38s
ci / rust (push) Successful in 4m8s
ci / flake (push) Successful in 6m39s
The touch handlers sat on `.xterm-viewport`, but xterm appends the
absolutely-positioned viewport before the `position: relative` screen, so
`.xterm-screen` paints over it and takes every touch. The viewport is not an
ancestor of the screen, so the listeners never fired and mobile drags did
nothing. Moves them, and `touch-action: none`, to `.xterm`.
2026-09-01 13:10:18 +01:00
Miguel Palhas c67fa2bd4e feat(maestro): force-kill prompt in the web client
ci / web (push) Successful in 35s
ci / rust (push) Successful in 4m1s
ci / flake (push) Successful in 6m55s
The kill button now sends the unforced DELETE immediately instead of
asking first. Only the daemon's guard refusal raises a prompt, and it is
a real dialog rather than confirm(), so it can offer the override the
daemon already implements.

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

Adds `maestro_protocol::display::command_program`, the Rust counterpart
of the web's `splitCommand`.
2026-09-01 11:11:39 +01:00
27 changed files with 1105 additions and 115 deletions
+2 -1
View File
@@ -11,6 +11,7 @@ persistence, WebSocket for transport.
maestro daemon tmux wrapper + session API on 127.0.0.1:7070 maestro daemon tmux wrapper + session API on 127.0.0.1:7070
├── GET/POST /api/sessions list / spawn ├── GET/POST /api/sessions list / spawn
├── DELETE /api/sessions/:id kill ├── DELETE /api/sessions/:id kill
├── POST /api/sessions/:id/input type text into a session
├── GET /api/info hostname, version, home dir, spawn presets ├── GET /api/info hostname, version, home dir, spawn presets
├── GET /api/dirs directory listing (spawn autocomplete) ├── GET /api/dirs directory listing (spawn autocomplete)
├── GET /api/recent-dirs spawn history ├── GET /api/recent-dirs spawn history
@@ -18,7 +19,7 @@ maestro daemon tmux wrapper + session API on 127.0.0.1:7070
├── WS /api/events Snapshot + session deltas ├── WS /api/events Snapshot + session deltas
└── WS /api/attach/:id raw PTY bytes └── WS /api/attach/:id raw PTY bytes
maestro list/spawn/attach/kill CLI client maestro list/spawn/attach/kill/send CLI client
maestro tui ratatui client maestro tui ratatui client
maestro-web serves the SPA, reverse-proxies /api/* to the daemon maestro-web serves the SPA, reverse-proxies /api/* to the daemon
``` ```
+51
View File
@@ -41,6 +41,9 @@ pub enum Command {
Attach(AttachArgs), Attach(AttachArgs),
/// Kill a session by ID. /// Kill a session by ID.
Kill(KillArgs), Kill(KillArgs),
/// Type text into a running session, as if it had been typed on an
/// attached terminal.
Send(SendArgs),
/// Install or update the agent activity hook (#106). /// Install or update the agent activity hook (#106).
Hooks(HooksArgs), Hooks(HooksArgs),
} }
@@ -103,6 +106,20 @@ pub struct AttachArgs {
pub detach_key: String, pub detach_key: String,
} }
/// `maestro send <session> <text...>`
#[derive(Debug, clap::Args)]
pub struct SendArgs {
/// Session to type into: its ID, a unique ID prefix, or its name.
pub session: String,
/// Text to send. Omit it to read the text from stdin.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub text: Vec<String>,
/// Leave the text sitting in the session's prompt instead of pressing
/// Enter after it.
#[arg(long)]
pub no_enter: bool,
}
#[derive(Debug, clap::Args)] #[derive(Debug, clap::Args)]
pub struct KillArgs { pub struct KillArgs {
/// Session ID to kill. /// Session ID to kill.
@@ -245,6 +262,40 @@ mod tests {
} }
} }
#[test]
fn parses_send_joining_the_message_words() {
let cli =
Cli::try_parse_from(["maestro", "send", "planner", "run", "the", "tests"]).unwrap();
let Command::Send(a) = cli.command else {
panic!("expected send")
};
assert_eq!(a.session, "planner");
assert_eq!(a.text, vec!["run", "the", "tests"]);
assert!(!a.no_enter);
}
/// The message is trailing_var_arg, so a word starting with `-` inside it
/// is text, not a flag clap should reject.
#[test]
fn send_message_may_start_with_a_dash() {
let cli =
Cli::try_parse_from(["maestro", "send", "--no-enter", "planner", "--force"]).unwrap();
let Command::Send(a) = cli.command else {
panic!("expected send")
};
assert!(a.no_enter);
assert_eq!(a.text, vec!["--force"]);
}
#[test]
fn send_text_may_be_omitted_for_stdin() {
let cli = Cli::try_parse_from(["maestro", "send", "planner"]).unwrap();
let Command::Send(a) = cli.command else {
panic!("expected send")
};
assert!(a.text.is_empty());
}
#[test] #[test]
fn rejects_env_without_equals() { fn rejects_env_without_equals() {
let err = Cli::try_parse_from([ let err = Cli::try_parse_from([
+9 -1
View File
@@ -10,7 +10,7 @@ use maestro_protocol::DaemonClient;
use crate::attach::{parse_detach_key, run_attach_url}; use crate::attach::{parse_detach_key, run_attach_url};
use crate::cli::Command; use crate::cli::Command;
use crate::sessions::{run_kill, run_list, run_spawn}; use crate::sessions::{run_kill, run_list, run_send, run_spawn};
use maestro_config::{default_config_path, MaestroConfigFile}; use maestro_config::{default_config_path, MaestroConfigFile};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -24,6 +24,10 @@ pub enum CliError {
Attach(#[from] crate::attach::AttachError), Attach(#[from] crate::attach::AttachError),
#[error("{0}")] #[error("{0}")]
Hooks(#[from] maestro_hooks::HooksError), Hooks(#[from] maestro_hooks::HooksError),
#[error("{0}")]
Resolve(#[from] crate::sessions::ResolveError),
#[error("nothing to send: pass the text as arguments or pipe it on stdin")]
NothingToSend,
#[error("could not determine the home directory ($HOME unset?)")] #[error("could not determine the home directory ($HOME unset?)")]
NoHome, NoHome,
#[error("{0}")] #[error("{0}")]
@@ -82,6 +86,10 @@ pub async fn dispatch(
run_kill(&daemon, &args).await?; run_kill(&daemon, &args).await?;
println!("killed {}", args.session_id); println!("killed {}", args.session_id);
} }
Command::Send(args) => {
let id = run_send(&daemon, &args, &mut std::io::stdin()).await?;
println!("sent to {id}");
}
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"), Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
} }
Ok(()) Ok(())
+147 -3
View File
@@ -5,12 +5,13 @@ use std::io::Write;
use maestro_protocol::display::abbreviate_dir; use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{ use maestro_protocol::{
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session, ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
SessionStatus, WorktreeSpec, Session, SessionStatus, WorktreeSpec,
}; };
use serde_json::Value; use serde_json::Value;
use crate::cli::{KillArgs, ListArgs, SpawnArgs}; use crate::cli::{KillArgs, ListArgs, SendArgs, SpawnArgs};
use crate::dispatch::CliError;
/// `maestro list` /// `maestro list`
pub async fn run_list( pub async fn run_list(
@@ -66,6 +67,92 @@ pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), Clie
client.kill_session(args.session_id, args.force).await client.kill_session(args.session_id, args.force).await
} }
/// Nothing on the daemon answers to what the operator named.
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("no session matches {0:?} — run `maestro list` to see what is running")]
NoMatch(String),
#[error("{0:?} matches {1} sessions; use a session id instead")]
Ambiguous(String, usize),
}
/// Turn what the operator typed into a session id: a full ID, a unique ID
/// prefix, or a session's name (#125), matched case-insensitively. Names come
/// first — an operator who named a session after a hex string meant the name.
pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid, ResolveError> {
let needle = needle.trim();
let by_name: Vec<&Session> = sessions
.iter()
.filter(|s| {
maestro_protocol::display::custom_name(s)
.is_some_and(|n| n.eq_ignore_ascii_case(needle))
})
.collect();
match by_name.len() {
1 => return Ok(by_name[0].id),
0 => {}
n => return Err(ResolveError::Ambiguous(needle.to_string(), n)),
}
let by_id: Vec<&Session> = sessions
.iter()
.filter(|s| {
s.id.to_string()
.to_ascii_lowercase()
.starts_with(&needle.to_ascii_lowercase())
})
.collect();
match by_id.len() {
1 => Ok(by_id[0].id),
0 => Err(ResolveError::NoMatch(needle.to_string())),
n => Err(ResolveError::Ambiguous(needle.to_string(), n)),
}
}
/// `maestro send`. Answers with the session that was typed into, so the
/// caller can name it once the daemon has accepted the text.
///
/// A bare ID skips the catalog fetch; anything else needs the list to match
/// against. Text comes from the arguments, or from stdin when there is none —
/// the shape a heredoc or a pipe wants.
#[allow(clippy::result_large_err)]
pub async fn run_send(
client: &DaemonClient,
args: &SendArgs,
stdin: &mut impl std::io::Read,
) -> Result<uuid::Uuid, CliError> {
let text = if args.text.is_empty() {
let mut buf = String::new();
stdin.read_to_string(&mut buf)?;
// One trailing newline is how a pipe ends, not part of the message —
// keeping it would submit the text twice.
buf.strip_suffix('\n')
.map(|t| t.strip_suffix('\r').unwrap_or(t))
.unwrap_or(&buf)
.to_string()
} else {
args.text.join(" ")
};
if text.is_empty() {
return Err(CliError::NothingToSend);
}
let id = match uuid::Uuid::parse_str(args.session.trim()) {
Ok(id) => id,
Err(_) => {
let sessions = client.list_sessions().await?;
resolve_session(&sessions, &args.session)?
}
};
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
Ok(id)
}
#[allow(clippy::write_literal)] #[allow(clippy::write_literal)]
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) { fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
if sessions.is_empty() { if sessions.is_empty() {
@@ -218,6 +305,63 @@ mod tests {
assert!(out.contains("bash [claude]"), "{out}"); assert!(out.contains("bash [claude]"), "{out}");
} }
fn named(id: u128, name: &str) -> Session {
let mut s = session("/tmp", "claude", id);
s.metadata
.insert("name".into(), serde_json::Value::String(name.into()));
s
}
#[test]
fn resolves_by_name_id_prefix_and_reports_what_it_cannot() {
use super::{resolve_session, ResolveError};
// Ids that differ in their first characters, so a short prefix is
// enough to pick one — the ergonomics the prefix match exists for.
let (a, b) = (1u128 << 124, 2u128 << 124);
let sessions = vec![named(a, "planner"), named(b, "builder")];
let one = Uuid::from_u128(a);
assert_eq!(resolve_session(&sessions, "planner").unwrap(), one);
// Names match case-insensitively and ignore surrounding space.
assert_eq!(resolve_session(&sessions, " Planner ").unwrap(), one);
// An id prefix works when it picks exactly one session.
let prefix = &one.to_string()[..8];
assert_eq!(resolve_session(&sessions, prefix).unwrap(), one);
assert!(matches!(
resolve_session(&sessions, "nope"),
Err(ResolveError::NoMatch(_))
));
}
/// Two sessions sharing a name is legal — the daemon never enforced
/// uniqueness — so `send` must refuse rather than pick one.
#[test]
fn an_ambiguous_name_is_refused() {
use super::{resolve_session, ResolveError};
let sessions = vec![named(1 << 124, "worker"), named(2 << 124, "worker")];
assert!(matches!(
resolve_session(&sessions, "worker"),
Err(ResolveError::Ambiguous(_, 2))
));
}
/// A session named after its own id prefix means the name: an operator
/// who typed a name they chose should not be routed by hex coincidence.
#[test]
fn names_win_over_id_prefixes() {
use super::resolve_session;
let two = Uuid::from_u128(2 << 124);
let prefix = two.to_string()[..8].to_string();
let sessions = vec![named(1 << 124, &prefix), session("/tmp", "bash", 2 << 124)];
assert_eq!(
resolve_session(&sessions, &prefix).unwrap(),
Uuid::from_u128(1 << 124)
);
}
#[test] #[test]
fn empty_sessions() { fn empty_sessions() {
let mut buf = Vec::new(); let mut buf = Vec::new();
@@ -108,3 +108,110 @@ async fn spawn_round_trips_through_the_daemon() {
.expect("kill"); .expect("kill");
assert_eq!(mock.session_count(), 0); assert_eq!(mock.session_count(), 0);
} }
#[tokio::test]
async fn send_types_text_and_submits_by_default() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(7);
mock.add_session(session(id, "/home/mock/work", "claude"));
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["run".into(), "the".into(), "tests".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
let inputs = mock.inputs();
assert_eq!(inputs.len(), 1);
assert_eq!(inputs[0].0, id);
assert_eq!(inputs[0].1.text, "run the tests");
assert!(inputs[0].1.submit, "Enter is the default");
}
/// `--no-enter` leaves the text in the prompt; the flag has to survive the
/// whole way to the daemon or the message is sent anyway.
#[tokio::test]
async fn send_without_enter_leaves_the_text_unsubmitted() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(8);
mock.add_session(session(id, "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["ls".into()],
no_enter: true,
};
maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert!(!mock.inputs()[0].1.submit);
}
/// No text arguments → the message comes from stdin, minus the trailing
/// newline a pipe or heredoc adds.
#[tokio::test]
async fn send_reads_stdin_and_drops_its_trailing_newline() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(9);
mock.add_session(session(id, "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: Vec::new(),
no_enter: false,
};
let mut stdin = std::io::Cursor::new(b"multi\nline\n".to_vec());
maestro_cli::sessions::run_send(&mock.client(), &args, &mut stdin)
.await
.expect("send");
assert_eq!(mock.inputs()[0].1.text, "multi\nline");
}
/// A session named through `PATCH /api/sessions/:id` is addressable by that
/// name, so scripts don't have to carry a UUID around.
#[tokio::test]
async fn send_resolves_a_session_by_name() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(10);
let mut named = session(id, "/tmp", "claude");
named
.metadata
.insert("name".into(), serde_json::Value::String("planner".into()));
mock.add_session(named);
mock.add_session(session(Uuid::from_u128(11), "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: "Planner".into(),
text: vec!["go".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
}
#[tokio::test]
async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await;
mock.add_session(session(Uuid::from_u128(12), "/tmp", "bash"));
let args = maestro_cli::cli::SendArgs {
session: "nope".into(),
text: vec!["hi".into()],
no_enter: false,
};
let err = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect_err("should not resolve");
assert!(err.to_string().contains("no session matches"), "{err}");
assert!(mock.inputs().is_empty());
}
+27 -1
View File
@@ -20,7 +20,7 @@ use axum::{Json, Router};
use maestro_config::SpawnPreset; use maestro_config::SpawnPreset;
use maestro_protocol::{ use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION,
}; };
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@@ -44,6 +44,7 @@ pub fn router(state: LocalApiState) -> Router {
Router::new() Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity)) .route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route( .route(
routes::patterns::SESSION, routes::patterns::SESSION,
delete(kill_session).patch(rename_session), delete(kill_session).patch(rename_session),
@@ -100,6 +101,9 @@ fn session_error_status(code: &str) -> StatusCode {
"invalid_env" => StatusCode::BAD_REQUEST, "invalid_env" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79). // A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT, "kill_guarded" => StatusCode::CONFLICT,
// The session outlived its pane: nothing to type into, and nothing
// the daemon can do about it.
"not_running" => StatusCode::CONFLICT,
_ => StatusCode::BAD_GATEWAY, _ => StatusCode::BAD_GATEWAY,
} }
} }
@@ -247,6 +251,28 @@ async fn report_activity(
} }
} }
/// `POST /api/sessions/:id/input`: type text into a session's pane, then
/// press Enter unless the request turns `submit` off. Answers `204 No
/// Content`. A session that has exited is refused (409) rather than accepted
/// into nowhere.
async fn send_input(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<SendInputRequest>,
) -> Response {
match s.manager.send_input(id, &body.text, body.submit).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let info = e.to_error_info();
error_response(
session_error_status(&info.code),
info.code.as_str(),
info.message.as_str(),
)
}
}
}
fn error_response(status: StatusCode, code: &str, message: &str) -> Response { fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(ApiError::new(code, message))).into_response() (status, Json(ApiError::new(code, message))).into_response()
} }
+42
View File
@@ -40,6 +40,15 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
/// attempt, which is all #83 needs. /// attempt, which is all #83 needs.
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15); pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
/// Gap between the text of a `send` and the Enter that submits it.
///
/// Agents that run a full-screen prompt (Claude Code, opencode) coalesce a
/// burst of input into one paste; a newline inside that burst becomes a line
/// break in the message instead of sending it. Landing the Enter as its own
/// write, after the reader has drained the text, is what makes the message
/// actually go.
const SUBMIT_DELAY: Duration = Duration::from_millis(150);
/// Per-session daemon-local bookkeeping that complements the public `Session`. /// Per-session daemon-local bookkeeping that complements the public `Session`.
#[derive(Debug)] #[derive(Debug)]
pub struct SessionRecord { pub struct SessionRecord {
@@ -78,6 +87,11 @@ pub enum SessionError {
NotFound(Uuid), NotFound(Uuid),
#[error("invalid environment variable name: {0:?}")] #[error("invalid environment variable name: {0:?}")]
InvalidEnv(String), InvalidEnv(String),
/// The session exists but its pane is gone, so there is nothing to type
/// into. Writing to a dead session is a client bug worth reporting, unlike
/// a stray keystroke on an attach socket, which is dropped.
#[error("session is no longer running: {0}")]
NotRunning(Uuid),
/// The kill was refused (#79): the session's worktree holds unsaved work. /// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched, /// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work. /// so it stays the operator's handle on their own unfinished work.
@@ -94,6 +108,7 @@ impl SessionError {
SessionError::Io(_) => "io_failed", SessionError::Io(_) => "io_failed",
SessionError::NotFound(_) => "not_found", SessionError::NotFound(_) => "not_found",
SessionError::InvalidEnv(_) => "invalid_env", SessionError::InvalidEnv(_) => "invalid_env",
SessionError::NotRunning(_) => "not_running",
SessionError::KillGuarded(_) => "kill_guarded", SessionError::KillGuarded(_) => "kill_guarded",
}; };
let unsaved_work = match self { let unsaved_work = match self {
@@ -671,6 +686,33 @@ impl SessionManager {
.map_err(SessionError::Tmux) .map_err(SessionError::Tmux)
} }
/// Type `text` into a session's pane, with an Enter after it when
/// `submit` is set.
///
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
/// asked for text to land somewhere, and a silent success would let a
/// script believe an agent had been told something it never heard.
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
match self.status(id).await {
None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
Some(SessionStatus::Running) => {}
}
let tmux = self.inner.tmux.clone();
let bytes = text.as_bytes().to_vec();
tmux_blocking(move || tmux.send_keys(id, &bytes))
.await
.map_err(SessionError::Tmux)?;
if submit {
tokio::time::sleep(SUBMIT_DELAY).await;
let tmux = self.inner.tmux.clone();
tmux_blocking(move || tmux.send_keys(id, b"\r"))
.await
.map_err(SessionError::Tmux)?;
}
Ok(())
}
/// Resize a session's window. No-op for a dead session, as with /// Resize a session's window. No-op for a dead session, as with
/// [`Self::send_keys`]. /// [`Self::send_keys`].
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> { pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
+137
View File
@@ -1250,6 +1250,143 @@ async fn cli_list_reads_from_daemon() {
); );
} }
/// `maestro send` end to end: the shipped binary, a real daemon, a real tmux
/// pane. The shell only runs the command if the Enter arrived as its own
/// write after the text, so the file appearing is the whole path working.
#[tokio::test(flavor = "multi_thread")]
async fn cli_send_types_a_command_into_a_live_shell() {
let (daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let sess = client
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
.await
.expect("spawn bash");
let home = TempDir::new().unwrap();
let cfg_path = home.path().join("config.toml");
let out = run_cli(
&["send", &sess.id.to_string(), "touch", "marker.txt"],
&[
("HOME", home.path().to_str().unwrap()),
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
("MAESTRO_DAEMON_URL", &daemon.http_base()),
],
);
assert!(
out.status.success(),
"send failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let marker = cwd.path().join("marker.txt");
wait_for_async(
"the shell runs the sent command",
Duration::from_secs(10),
|| {
let marker = marker.clone();
async move { marker.exists() }
},
)
.await;
client.kill_session(sess.id).await.expect("kill");
}
/// `--no-enter` fills the prompt without submitting: the command must sit
/// there unrun. Same setup as the test above, so the only difference is the
/// flag.
#[tokio::test(flavor = "multi_thread")]
async fn send_without_enter_does_not_run_the_command() {
let (daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let sess = client
.spawn_session(&create_req(cwd.path(), "bash --norc --noprofile"))
.await
.expect("spawn bash");
let home = TempDir::new().unwrap();
let out = run_cli(
&[
"send",
"--no-enter",
&sess.id.to_string(),
"touch",
"unsubmitted.txt",
],
&[
("HOME", home.path().to_str().unwrap()),
(
"MAESTRO_CONFIG",
home.path().join("c.toml").to_str().unwrap(),
),
("MAESTRO_DAEMON_URL", &daemon.http_base()),
],
);
assert!(
out.status.success(),
"send failed: {}",
String::from_utf8_lossy(&out.stderr)
);
tokio::time::sleep(Duration::from_secs(2)).await;
assert!(
!cwd.path().join("unsubmitted.txt").exists(),
"the command ran despite --no-enter"
);
client.kill_session(sess.id).await.expect("kill");
}
/// A session whose command exited still exists in the catalog, but its pane
/// is gone. Typing into it is refused instead of being swallowed — a script
/// that sends an instruction to a dead agent has to hear about it.
#[tokio::test(flavor = "multi_thread")]
async fn send_to_a_dead_session_is_refused() {
use maestro_protocol::{SendInputRequest, SessionStatus};
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let sess = client
.spawn_session(&create_req(cwd.path(), "true"))
.await
.expect("spawn true");
wait_for_async(
"the session to be seen dead",
Duration::from_secs(15),
|| {
let client = client.clone();
let id = sess.id;
async move {
client
.list_sessions()
.await
.map(|ss| {
ss.iter()
.any(|s| s.id == id && s.status == SessionStatus::Dead)
})
.unwrap_or(false)
}
},
)
.await;
let err = client
.daemon()
.send_input(
sess.id,
&SendInputRequest {
text: "hello".into(),
submit: true,
},
)
.await
.expect_err("a dead session cannot be typed into");
assert!(
err.to_string().contains("no longer running"),
"unexpected error: {err}"
);
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn cli_list_fails_when_daemon_is_down() { async fn cli_list_fails_when_daemon_is_down() {
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port()); let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
+12 -1
View File
@@ -16,7 +16,7 @@ use uuid::Uuid;
use crate::routes::{self, UrlError}; use crate::routes::{self, UrlError};
use crate::{ use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, Session, Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
}; };
/// Time budget for establishing a TCP connection to the daemon. /// Time budget for establishing a TCP connection to the daemon.
@@ -155,6 +155,17 @@ impl DaemonClient {
Ok(()) Ok(())
} }
/// `POST /api/sessions/:id/input`: type text into a session's pane, with
/// an Enter after it unless `submit` is off. Answers `204 No Content`; a
/// session that has exited is refused rather than silently accepted.
pub async fn send_input(&self, id: Uuid, req: &SendInputRequest) -> Result<(), ClientError> {
let resp = self
.send(Method::POST, &routes::session_input(id), &[], Some(req))
.await?;
check(resp).await?;
Ok(())
}
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> { pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await self.get(routes::INFO, &[]).await
} }
+90
View File
@@ -7,6 +7,15 @@ use std::path::{Path, PathBuf};
use crate::Session; use crate::Session;
/// The name a session was given (#125), if any. `None` for one that was never
/// renamed — clients fall back to the command.
pub fn custom_name(s: &Session) -> Option<&str> {
match s.metadata.get("name") {
Some(serde_json::Value::String(name)) => Some(name.as_str()),
_ => None,
}
}
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path /// Collapse a daemon's home dir to `~`. `home` empty leaves the path
/// untouched. Mirrors the web's `abbreviateCwd`. /// untouched. Mirrors the web's `abbreviateCwd`.
pub fn abbreviate_dir(path: &Path, home: &str) -> String { pub fn abbreviate_dir(path: &Path, home: &str) -> String {
@@ -140,6 +149,63 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
cur cur
} }
/// Command wrappers that run another program: the program is what follows.
const WRAPPERS: [&str; 8] = [
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
];
/// The program a command line runs, basenamed and stripped of its arguments —
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
pub fn command_program(command: &str) -> String {
let words: Vec<&str> = command.split_whitespace().collect();
let mut i = 0;
while i < words.len() {
let word = words[i];
if is_env_assignment(word) {
i += 1;
continue;
}
let wrapper = basename(word).to_lowercase();
if WRAPPERS.contains(&wrapper.as_str()) {
i += 1;
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
i += 1;
}
continue;
}
break;
}
match words.get(i) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there rather than
// rendering an empty fragment.
None => command.trim().to_string(),
}
}
/// `FOO=bar`, the shell's own prefix for one command's environment.
fn is_env_assignment(word: &str) -> bool {
let mut chars = word.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
let rest: String = chars.collect();
match rest.split_once('=') {
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
None => false,
}
}
fn basename(word: &str) -> &str {
match word.rsplit_once('/') {
Some((_, tail)) => tail,
None => word,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -318,4 +384,28 @@ mod tests {
}; };
assert_eq!(nested.name, "a/b"); assert_eq!(nested.name, "a/b");
} }
#[test]
fn command_program_drops_arguments() {
assert_eq!(
command_program("claude --dangerously-skip-permissions"),
"claude"
);
assert_eq!(command_program(" zsh -l "), "zsh");
assert_eq!(command_program("hx src/main.rs"), "hx");
}
#[test]
fn command_program_looks_through_paths_env_and_wrappers() {
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
assert_eq!(command_program("npx opencode"), "opencode");
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
}
#[test]
fn command_program_keeps_a_line_that_is_only_wrappers() {
assert_eq!(command_program("sudo"), "sudo");
assert_eq!(command_program(""), "");
}
} }
+18
View File
@@ -512,6 +512,24 @@ pub struct NameBatch {
pub names: Vec<String>, pub names: Vec<String>,
} }
/// HTTP `POST /api/sessions/:id/input` body: text typed into a session's
/// pane, as if the operator had typed it on an attached terminal.
///
/// `submit` presses Enter after the text — what makes `maestro send` a way to
/// give an agent an instruction rather than only to fill its prompt. It goes
/// out as its own write a beat later, because a TUI agent that reads the text
/// as one paste would otherwise swallow the newline into the message.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SendInputRequest {
pub text: String,
#[serde(default = "submit_default")]
pub submit: bool,
}
fn submit_default() -> bool {
true
}
/// Longest session name the daemon stores (#125), in characters. A name is a /// Longest session name the daemon stores (#125), in characters. A name is a
/// sidebar card's line 1; past this it only truncates on every client anyway. /// sidebar card's line 1; past this it only truncates on every client anyway.
pub const MAX_SESSION_NAME_LEN: usize = 64; pub const MAX_SESSION_NAME_LEN: usize = 64;
+14
View File
@@ -49,6 +49,11 @@ pub fn session_activity(id: Uuid) -> String {
format!("{SESSIONS}/{id}/activity") format!("{SESSIONS}/{id}/activity")
} }
/// `POST /api/sessions/:id/input`: type text into a session's pane.
pub fn session_input(id: Uuid) -> String {
format!("{SESSIONS}/{id}/input")
}
/// `WS /api/attach/:id` — raw PTY bytes. /// `WS /api/attach/:id` — raw PTY bytes.
pub fn attach(id: Uuid) -> String { pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}") format!("{API_PREFIX}/attach/{id}")
@@ -59,6 +64,7 @@ pub fn attach(id: Uuid) -> String {
pub mod patterns { pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id"; pub const SESSION: &str = "/api/sessions/:id";
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity"; pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
pub const SESSION_INPUT: &str = "/api/sessions/:id/input";
pub const ATTACH: &str = "/api/attach/:id"; pub const ATTACH: &str = "/api/attach/:id";
} }
@@ -170,6 +176,10 @@ mod tests {
session_activity(id()), session_activity(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity" "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
); );
assert_eq!(
session_input(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input"
);
assert_eq!( assert_eq!(
attach(id()), attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -192,6 +202,10 @@ mod tests {
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"), patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
session(id()).trim_end_matches(&id().to_string()) session(id()).trim_end_matches(&id().to_string())
); );
assert_eq!(
patterns::SESSION_INPUT.trim_end_matches(":id/input"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!( assert_eq!(
patterns::ATTACH.trim_end_matches(":id"), patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string()) attach(id()).trim_end_matches(&id().to_string())
+32 -2
View File
@@ -16,13 +16,13 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get}; use axum::routing::{any, delete, get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use chrono::Utc; use chrono::Utc;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{ use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing, routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, Session, SessionStatus, PROTOCOL_VERSION, GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION,
}; };
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot}; use tokio::sync::{broadcast, oneshot};
@@ -55,6 +55,8 @@ struct Inner {
info: Option<DaemonInfo>, info: Option<DaemonInfo>,
/// When set, every route answers with this instead of its normal response. /// When set, every route answers with this instead of its normal response.
forced_error: Option<(u16, ApiError)>, forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -86,6 +88,7 @@ impl MockDaemon {
let app = Router::new() let app = Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session)) .route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input))
.route(routes::INFO, get(daemon_info)) .route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir)) .route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs)) .route(routes::RECENT_DIRS, get(recent_dirs))
@@ -167,6 +170,11 @@ impl MockDaemon {
self.state.inner.lock().expect("poisoned").forced_error = None; self.state.inner.lock().expect("poisoned").forced_error = None;
} }
/// Every `send` this daemon received, oldest first.
pub fn inputs(&self) -> Vec<(Uuid, SendInputRequest)> {
self.state.inner.lock().expect("poisoned").inputs.clone()
}
pub fn session_count(&self) -> usize { pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len() self.state.inner.lock().expect("poisoned").sessions.len()
} }
@@ -233,6 +241,28 @@ async fn kill_session(State(s): State<AppState>, Path(id): Path<Uuid>) -> Respon
StatusCode::NO_CONTENT.into_response() StatusCode::NO_CONTENT.into_response()
} }
/// Records the text instead of typing it — the subject under test is the
/// client's request, not tmux.
async fn send_input(
State(s): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<SendInputRequest>,
) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let mut guard = s.inner.lock().expect("poisoned");
if !guard.sessions.iter().any(|x| x.id == id) {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
guard.inputs.push((id, req));
StatusCode::NO_CONTENT.into_response()
}
async fn daemon_info(State(s): State<AppState>) -> Response { async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) { if let Some(r) = forced(&s) {
return r; return r;
+29 -4
View File
@@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use chrono::Utc; use chrono::Utc;
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path}; use maestro_protocol::display::{
abbreviate_dir, abbreviate_folder_name, command_program, session_tree_path,
};
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind}; use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
@@ -297,7 +299,8 @@ impl Sidebar {
if dead { if dead {
tail.push(Span::styled(" exited", muted)); tail.push(Span::styled(" exited", muted));
} }
let command = Span::styled(format!(" {}", s.command), muted); // docs/SIDEBAR.md: the card names the program, never its arguments.
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
// Reserve the triggered status fragments before detail and command. // Reserve the triggered status fragments before detail and command.
// If the row is exceptionally narrow, keep one column for the // If the row is exceptionally narrow, keep one column for the
@@ -874,12 +877,34 @@ mod tests {
); );
} }
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
#[test]
fn meta_line_drops_the_commands_arguments() {
let mut s = session_named(1, "handle");
s.command = "claude --dangerously-skip-permissions".into();
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 60, 6));
let meta = content(&all[3]);
assert!(
meta.contains("claude"),
"line 2 must name the program: {meta:?}"
);
assert!(
!meta.contains("dangerously"),
"line 2 must not carry arguments: {meta:?}"
);
}
/// The sidebar is narrow: the command truncates with `…` and never wraps /// The sidebar is narrow: the command truncates with `…` and never wraps
/// onto a third row (docs/SIDEBAR.md). /// onto a third row (docs/SIDEBAR.md).
#[test] #[test]
fn meta_line_command_truncates_instead_of_wrapping() { fn meta_line_command_truncates_instead_of_wrapping() {
let mut s = session_named(1, "claude"); let mut s = session_named(1, "claude");
s.command = "a command that cannot fit".into(); s.command = "a-program-name-that-cannot-fit".into();
s.activity_since = Utc::now() - Duration::seconds(5); s.activity_since = Utc::now() - Duration::seconds(5);
s.blocked_work = Some(InTheWay { s.blocked_work = Some(InTheWay {
uncommitted_files: 120, uncommitted_files: 120,
@@ -1099,7 +1124,7 @@ mod tests {
#[test] #[test]
fn blocked_warning_survives_before_a_truncated_command() { fn blocked_warning_survives_before_a_truncated_command() {
let mut s = session_named(1, "name"); let mut s = session_named(1, "name");
s.command = "a command that cannot fit".into(); s.command = "a-program-name-that-cannot-fit".into();
s.blocked_work = Some(InTheWay { s.blocked_work = Some(InTheWay {
uncommitted_files: 1, uncommitted_files: 1,
..Default::default() ..Default::default()
+14
View File
@@ -57,6 +57,10 @@ enum Command {
/// Kill a session by ID. /// Kill a session by ID.
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
Kill(maestro_cli::cli::KillArgs), Kill(maestro_cli::cli::KillArgs),
/// Type text into a running session, as if it had been typed on an
/// attached terminal.
#[cfg(feature = "cli")]
Send(maestro_cli::cli::SendArgs),
/// Install or update the agent activity hook (#106). /// Install or update the agent activity hook (#106).
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
Hooks(maestro_cli::cli::HooksArgs), Hooks(maestro_cli::cli::HooksArgs),
@@ -144,6 +148,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
.await? .await?
} }
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
Command::Send(args) => {
maestro_cli::dispatch(
maestro_cli::cli::Command::Send(args),
cli.config,
cli.daemon_url,
)
.await?
}
#[cfg(feature = "cli")]
Command::Hooks(args) => { Command::Hooks(args) => {
maestro_cli::dispatch( maestro_cli::dispatch(
maestro_cli::cli::Command::Hooks(args), maestro_cli::cli::Command::Hooks(args),
@@ -190,6 +203,7 @@ fn is_cli_subcommand(command: &Option<Command>) -> bool {
| Some(Command::Spawn(_)) | Some(Command::Spawn(_))
| Some(Command::Attach(_)) | Some(Command::Attach(_))
| Some(Command::Kill(_)) | Some(Command::Kill(_))
| Some(Command::Send(_))
| Some(Command::Hooks(_)) | Some(Command::Hooks(_))
) )
} }
+19 -14
View File
@@ -88,21 +88,26 @@ of `maestro_protocol::InTheWay`'s `Display` does not disappear: it is the web
card's tooltip and the wording of the kill refusal, so no two clients describe card's tooltip and the wording of the kill refusal, so no two clients describe
the same tree differently. the same tree differently.
`<command>` is unconditional and muted: the session's command line, shown on `<command>` is unconditional and muted: it names the **program** the session
every card in both clients — no omission in the TUI, no name-vs-command runs — never its arguments. `claude --dangerously-skip-permissions` renders as
comparison gating it. A session named by its command line then shows the same `claude`. Flags say how a session was invoked, which is not what a card is for,
string on both lines; that is accepted, the price of a row that never changes and a long one crowds out every other fragment on the line. It shows on every
height. card in both clients — no omission in the TUI, no name-vs-command comparison
gating it. A session named by its program then shows the same string on both
lines; that is accepted, the price of a row that never changes height.
In the **web client** the command's leading program is replaced by that tool's The program is found by looking through a path, leading `FOO=bar` assignments
icon when it is one we have a mark for — Claude Code, Codex, opencode, Gemini and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename:
CLI, Aider, Cursor, pi, and any shell — and only the remaining words of the `/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers
command line follow as text. Recognition looks through a path, leading is shown as written rather than as an empty fragment.
`FOO=bar` assignments and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …); an
unrecognised program keeps its full text, so the card never shows a mark In the **web client** the program is replaced by that tool's icon when it is
standing for the wrong tool. The full command line stays in the card's tooltip one we have a mark for — Claude Code, Codex, opencode, Gemini CLI, Aider,
either way. The **TUI** keeps the command as text: a terminal cannot be assumed Cursor, pi, and any shell — and nothing follows it as text. An unrecognised
to have a font carrying tool glyphs. program keeps its name as text, so the card never shows a mark standing for the
wrong tool. The full command line, arguments and all, stays in the card's
tooltip either way. The **TUI** keeps the program as text: a terminal cannot be
assumed to have a font carrying tool glyphs.
## Colour per activity state ## Colour per activity state
+5
View File
@@ -79,6 +79,11 @@ would have blocked. A tree whose guard is clear never needed the override, so a
forced kill removes it as usual — force changes what happens to blocked work, forced kill removes it as usual — force changes what happens to blocked work,
not ordinary cleanup. not ordinary cleanup.
In the web client the kill button sends the unforced `DELETE` straight away —
there is no pre-confirmation, because the daemon is the one that decides. Only
the refusal raises a prompt, and that prompt names what is in the way and offers
"Force kill", which repeats the request with `force=true`.
Ending a session and destroying its work are two separate acts. Maestro only Ending a session and destroying its work are two separate acts. Maestro only
performs the first one. Removing a tree it left behind is the operator's own performs the first one. Removing a tree it left behind is the operator's own
`git worktree remove`, chosen while looking at exactly what would be lost. `git worktree remove`, chosen while looking at exactly what would be lost.
+32 -7
View File
@@ -1,6 +1,7 @@
import * as routes from "./routes.js"; import * as routes from "./routes.js";
import type { import type {
ApiError, ApiError,
InTheWay,
Branches, Branches,
CreateSessionRequest, CreateSessionRequest,
DaemonInfo, DaemonInfo,
@@ -13,19 +14,38 @@ import type {
Uuid, Uuid,
} from "./types.js"; } from "./types.js";
/** A `/api/*` failure with the daemon's own error envelope kept intact, so a
* caller can branch on `code` — the kill guard's `kill_guarded` (#79) — and
* render `unsavedWork` rather than re-parsing the message. */
export class ApiRequestError extends Error {
readonly status: number;
readonly code: string;
readonly detail: string;
readonly unsavedWork: InTheWay | null;
constructor(res: Response, info?: ApiError["error"], text?: string) {
const detail = info ? `${info.code}: ${info.message}` : (text ?? "");
super(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
this.name = "ApiRequestError";
this.status = res.status;
this.code = info?.code ?? "";
this.detail = info?.message ?? text ?? "";
this.unsavedWork = info?.unsaved_work ?? null;
}
}
async function jsonOrError<T>(res: Response): Promise<T> { async function jsonOrError<T>(res: Response): Promise<T> {
if (res.ok) { if (res.ok) {
if (res.status === 204) return undefined as unknown as T; if (res.status === 204) return undefined as unknown as T;
return (await res.json()) as T; return (await res.json()) as T;
} }
let detail = "";
try { try {
const body = (await res.json()) as ApiError; const body = (await res.json()) as ApiError;
detail = `${body.error.code}: ${body.error.message}`; throw new ApiRequestError(res, body.error);
} catch { } catch (e) {
detail = await res.text(); if (e instanceof ApiRequestError) throw e;
throw new ApiRequestError(res, undefined, await res.text().catch(() => ""));
} }
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
} }
export async function listSessions(): Promise<Session[]> { export async function listSessions(): Promise<Session[]> {
@@ -57,9 +77,14 @@ export async function renameSession(id: Uuid, name: string): Promise<Session> {
); );
} }
export async function killSession(id: Uuid): Promise<void> { /** Kill a session. With `force` (#81) the daemon overrides its refusal over a
* worktree holding unsaved work and leaves that worktree on disk. */
export async function killSession(id: Uuid, force = false): Promise<void> {
const url = force
? `${routes.session(id)}?${routes.FORCE_QUERY}=true`
: routes.session(id);
await jsonOrError( await jsonOrError(
await fetch(routes.session(id), { await fetch(url, {
method: "DELETE", method: "DELETE",
credentials: "same-origin", credentials: "same-origin",
}), }),
+11 -8
View File
@@ -56,12 +56,13 @@ export interface AttachInfo {
command: string; command: string;
} }
/** Header echoes the sidebar: the command's leading program becomes its tool /** Header echoes the sidebar: the program becomes its tool icon when we have a
* icon when we have a mark for it, remaining words stay as text. */ * mark for it and stays as its bare name when we do not. Arguments are
* dropped, as on the card. */
function commandFragment(command: string): string { function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command); const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null; const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command; const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`; return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
} }
@@ -142,8 +143,10 @@ export function createAttachConnection(
// • alt screen with no mouse tracking → nothing to do; the app owns input. // • alt screen with no mouse tracking → nothing to do; the app owns input.
// `touch-action: none` (CSS) keeps the browser from cancelling the custom // `touch-action: none` (CSS) keeps the browser from cancelling the custom
// gesture stream; only the terminal surface opts out of native page panning. // gesture stream; only the terminal surface opts out of native page panning.
// Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a
// later sibling of the viewport, so it paints over it and takes every touch.
{ {
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null; const root = term.element as HTMLElement | undefined;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null; const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
const cellH = () => { const cellH = () => {
const core = (term as unknown as { const core = (term as unknown as {
@@ -151,11 +154,11 @@ export function createAttachConnection(
})._core; })._core;
return core?._renderService?.dimensions?.css?.cell?.height || 16; return core?._renderService?.dimensions?.css?.cell?.height || 16;
}; };
if (vp) { if (root) {
let lastY = 0; let lastY = 0;
let lastX = 0; let lastX = 0;
let remainder = 0; let remainder = 0;
vp.addEventListener( root.addEventListener(
"touchstart", "touchstart",
(e) => { (e) => {
if (e.touches.length !== 1) return; if (e.touches.length !== 1) return;
@@ -165,7 +168,7 @@ export function createAttachConnection(
}, },
{ passive: true }, { passive: true },
); );
vp.addEventListener( root.addEventListener(
"touchmove", "touchmove",
(e) => { (e) => {
if (e.touches.length !== 1) return; if (e.touches.length !== 1) return;
+79 -28
View File
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest"; import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js"; import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js"; import type { CatalogListener, CatalogStore } from "./events.js";
@@ -16,6 +16,10 @@ beforeAll(() => {
window.fetch = () => Promise.reject(new Error("offline")); window.fetch = () => Promise.reject(new Error("offline"));
}); });
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
});
function session(overrides: Partial<Session>): Session { function session(overrides: Partial<Session>): Session {
return { return {
id: "11111111-2222-3333-4444-555555555555", id: "11111111-2222-3333-4444-555555555555",
@@ -46,6 +50,11 @@ function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`); return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
} }
/** Let the kill chain's awaits (fetch, its json body) settle. */
async function flush(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
describe("blocked-work indicator (#83)", () => { describe("blocked-work indicator (#83)", () => {
it("marks the row of a blocked session in diff notation", () => { it("marks the row of a blocked session in diff notation", () => {
const blocked = session({ const blocked = session({
@@ -89,37 +98,77 @@ describe("blocked-work indicator (#83)", () => {
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull(); expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
}); });
it("restates the warning at the kill confirm", async () => { it("kills straight away, without a first confirmation", async () => {
const confirmations: string[] = []; const calls: string[] = [];
window.confirm = (msg?: string) => { window.fetch = ((url: string, init?: RequestInit) => {
confirmations.push(msg ?? ""); calls.push(`${init?.method ?? "GET"} ${url}`);
return false; return Promise.resolve(new Response(null, { status: 204 }));
}; }) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
});
it("offers the override when the daemon refuses a guarded kill", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "kill_guarded",
message: "worktree has unsaved work",
unsaved_work: { untracked_files: 1 },
},
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
const blocked = session({ const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000004", id: "aaaaaaaa-0000-0000-0000-000000000004",
command: "bash", command: "bash",
blocked_work: { untracked_files: 1 }, blocked_work: { untracked_files: 1 },
}); });
const el = renderSidebar(storeWith([blocked])).el; const el = renderSidebar(storeWith([blocked])).el;
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill"); rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
kill?.click(); await flush();
await Promise.resolve(); const dialog = document.querySelector(".kill-dialog");
expect(confirmations).toHaveLength(1); expect(dialog?.textContent).toContain("1 untracked file");
expect(confirmations[0]).toContain("1 untracked file"); dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
expect(confirmations[0]).toContain("refused unless forced"); await flush();
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
expect(document.querySelector(".kill-dialog")).toBeNull();
}); });
it("confirms plainly when nothing blocks", async () => { it("closes the override prompt without killing on cancel", async () => {
const confirmations: string[] = []; const calls: string[] = [];
window.confirm = (msg?: string) => { window.fetch = ((url: string, init?: RequestInit) => {
confirmations.push(msg ?? ""); calls.push(`${init?.method ?? "GET"} ${url}`);
return false; return Promise.resolve(
}; new Response(
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" }); JSON.stringify({
const el = renderSidebar(storeWith([plain])).el; error: { code: "kill_guarded", message: "worktree has unsaved work" },
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click(); }),
await Promise.resolve(); { status: 409, headers: { "content-type": "application/json" } },
expect(confirmations).toEqual(['Kill session "claude"?']); ),
);
}) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000006",
blocked_work: { unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
}); });
}); });
@@ -209,15 +258,16 @@ describe("session cards (#107)", () => {
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull(); expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
}); });
it("keeps the command's arguments as text beside the icon", () => { it("drops the command's arguments, keeping them in the tooltip", () => {
const s = session({ const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001c", id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --resume", command: "claude --dangerously-skip-permissions",
}); });
const el = renderSidebar(storeWith([s])).el; const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd"); const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude"); expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("--resume"); expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
}); });
it("keeps an unknown command as plain text", () => { it("keeps an unknown command as plain text", () => {
@@ -228,7 +278,8 @@ describe("session cards (#107)", () => {
const el = renderSidebar(storeWith([s])).el; const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd"); const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")).toBeNull(); expect(cmd?.querySelector(".tool-icon")).toBeNull();
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx src/main.rs"); expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
}); });
it("maps each activity state to its contract slug", () => { it("maps each activity state to its contract slug", () => {
+26 -15
View File
@@ -1,5 +1,6 @@
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { getDaemonInfo, killSession, renameSession } from "./api.js"; import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js"; import { openContextMenu } from "./contextMenu.js";
import { isTypingTarget, toggleHelp } from "./help.js"; import { isTypingTarget, toggleHelp } from "./help.js";
import { CatalogStore } from "./events.js"; import { CatalogStore } from "./events.js";
@@ -96,13 +97,14 @@ function blockedFragment(blockers: InTheWay): string {
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`; return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
} }
/** docs/SIDEBAR.md: the command's leading program becomes its tool icon when /** docs/SIDEBAR.md: the card names the program only — arguments never reach
* we have a mark for it, and only the remaining words stay as text. The full * the card. The program becomes its tool icon when we have a mark for it, and
* command line stays in the tooltip either way. */ * stays as its bare name when we do not. The full command line, arguments and
* all, stays in the tooltip either way. */
function commandFragment(command: string): string { function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command); const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null; const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command; const text = icon ? "" : program;
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`; return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
} }
@@ -179,7 +181,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
killBtn.addEventListener("click", (ev) => { killBtn.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
void confirmAndKill(s, name); void requestKill(s, name);
}); });
row.addEventListener("contextmenu", (ev) => { row.addEventListener("contextmenu", (ev) => {
ev.preventDefault(); ev.preventDefault();
@@ -191,22 +193,31 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
onSelect: () => onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })), document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })),
}, },
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) }, { label: "Kill session", danger: true, onSelect: () => void requestKill(s, name) },
]); ]);
}); });
return row; return row;
} }
/** #83: the confirm restates the warning at the moment of decision. */ /**
async function confirmAndKill(s: Session, name: string): Promise<void> { * Kill on the first click (#81): the daemon is the one that decides whether a
const blockers = sessionBlockers(s); * session may go, and it refuses over a worktree holding unsaved work. Only
const question = blockers * that refusal is worth a prompt, and it needs one `confirm()` cannot give —
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.` * a forced kill. Anything else surfaces as an alert.
: `Kill session "${name}"?`; */
if (!confirm(question)) return; async function requestKill(s: Session, name: string): Promise<void> {
try { try {
await killSession(s.id); await killSession(s.id);
} catch (e) { } catch (e) {
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
openForceKillDialog({
name,
blockers: e.unsavedWork ?? sessionBlockers(s),
reason: e.detail,
onForce: () => killSession(s.id, true),
});
return;
}
alert(`Failed to kill: ${(e as Error).message}`); alert(`Failed to kill: ${(e as Error).message}`);
} }
} }
+104
View File
@@ -0,0 +1,104 @@
/**
* Forced-kill prompt (#81).
*
* The daemon refuses to kill a session whose worktree still holds uncommitted
* or unpushed work. `confirm()` can only ask the same question twice, so the
* refusal gets its own dialog: it names what is in the way and offers the
* override the daemon's `force=true` already implements. A kill the daemon
* accepts never reaches here — there is nothing to warn about.
*/
import { formatInTheWay, type InTheWay } from "./types.js";
export interface ForceKillPrompt {
/** Session name, as the sidebar shows it. */
name: string;
/** What the daemon reported in the way, when it reported anything. */
blockers: InTheWay | null;
/** Message from the daemon's refusal, shown when there is no breakdown. */
reason: string;
/** Run the forced kill. A rejection is rendered in the dialog, which stays
* open so the override can be retried. */
onForce: () => Promise<void>;
}
let open: { el: HTMLElement; onKeydown: (ev: KeyboardEvent) => void } | null = null;
export function closeForceKillDialog(): void {
if (!open) return;
window.removeEventListener("keydown", open.onKeydown, true);
open.el.remove();
open = null;
}
/** Mount the prompt. Opening while one is up replaces it, so a second refusal
* can never stack two dialogs. */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
closeForceKillDialog();
const el = document.createElement("div");
el.className = "kill-dialog";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", `Kill ${p.name}`);
const panel = document.createElement("div");
panel.className = "kill-dialog-panel";
const title = document.createElement("h2");
title.className = "kill-dialog-title";
title.textContent = `Kill "${p.name}"?`;
const body = document.createElement("p");
body.className = "kill-dialog-body";
body.textContent = p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`;
const error = document.createElement("p");
error.className = "kill-dialog-error";
error.hidden = true;
const actions = document.createElement("div");
actions.className = "kill-dialog-actions";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn btn-ghost kill-dialog-cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", closeForceKillDialog);
const force = document.createElement("button");
force.type = "button";
force.className = "btn btn-danger kill-dialog-force";
force.textContent = "Force kill";
force.addEventListener("click", () => {
force.disabled = true;
void p
.onForce()
.then(closeForceKillDialog)
.catch((e: unknown) => {
force.disabled = false;
error.hidden = false;
error.textContent = `Failed to kill: ${(e as Error).message}`;
});
});
actions.append(cancel, force);
panel.append(title, body, error, actions);
// A press on the scrim cancels; one inside the panel is left alone.
el.addEventListener("click", (ev) => {
if (!panel.contains(ev.target as Node)) closeForceKillDialog();
});
el.appendChild(panel);
document.body.appendChild(el);
const onKeydown = (ev: KeyboardEvent): void => {
if (ev.key !== "Escape") return;
ev.preventDefault();
ev.stopPropagation();
closeForceKillDialog();
};
window.addEventListener("keydown", onKeydown, true);
open = { el, onKeydown };
cancel.focus();
return el;
}
+4
View File
@@ -18,6 +18,10 @@ export const EVENTS = "/api/events";
/** Name of the query parameter `DIRS` and `GITROOT` both take. */ /** Name of the query parameter `DIRS` and `GITROOT` both take. */
export const PATH_QUERY = "path"; export const PATH_QUERY = "path";
/** Name of the query parameter `DELETE /api/sessions/:id` takes (#81):
* `force=true` overrides the kill refusal over a guarded worktree. */
export const FORCE_QUERY = "force";
/** Name of the query parameter `NAMES` takes. */ /** Name of the query parameter `NAMES` takes. */
export const COUNT_QUERY = "count"; export const COUNT_QUERY = "count";
+64 -4
View File
@@ -1058,6 +1058,64 @@ input.leaf-rename:focus {
border-color: color-mix(in oklab, var(--danger) 40%, transparent); border-color: color-mix(in oklab, var(--danger) 40%, transparent);
} }
/* ---------- forced-kill prompt (#81) ---------- */
.kill-dialog {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: grid;
place-items: center;
padding: var(--sp-4);
background: oklch(0 0 0 / 0.5);
backdrop-filter: blur(4px);
}
.kill-dialog-panel {
width: min(400px, 100%);
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
box-shadow: var(--shadow-2);
}
.kill-dialog-title {
margin: 0 0 var(--sp-2);
font-family: var(--font-mono);
font-size: var(--fs-15);
color: var(--ink);
}
.kill-dialog-body {
margin: 0;
color: var(--muted);
font-size: var(--fs-13);
line-height: 1.5;
}
.kill-dialog-error {
margin: var(--sp-2) 0 0;
color: var(--danger);
font-size: var(--fs-13);
}
.kill-dialog-actions {
display: flex;
justify-content: flex-end;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.btn-danger {
color: var(--danger);
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
}
.btn-danger:hover {
background: var(--danger-tint);
border-color: var(--danger);
}
/* ---------- empty / loading ---------- */ /* ---------- empty / loading ---------- */
.empty { .empty {
@@ -1274,10 +1332,12 @@ input.leaf-rename:focus {
.attach-body .xterm .xterm-viewport { .attach-body .xterm .xterm-viewport {
overflow-y: auto; overflow-y: auto;
scrollbar-width: none; scrollbar-width: none;
/* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands }
the gesture to the browser, which may cancel the event stream before the
terminal sees it; `none` keeps a drag inside the terminal under our /* Maestro drives scrollback from touchmove (see attach.ts), so the browser must
control while the rest of the page retains native scrolling. */ not claim the gesture. On `.xterm`, not the viewport: `.xterm-screen` paints
over the viewport and is what a finger lands on. */
.attach-body .xterm {
touch-action: none; touch-action: none;
} }
+7 -7
View File
@@ -20,12 +20,12 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
} }
}); });
it("keeps everything after the program as text", () => { it("drops everything after the program", () => {
expect(splitCommand("claude --resume -p hi")).toEqual({ expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
tool: "claude", tool: "claude",
rest: "--resume -p hi", program: "claude",
}); });
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", rest: "-l" }); expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", program: "zsh" });
}); });
it("looks through paths, env assignments and wrappers", () => { it("looks through paths, env assignments and wrappers", () => {
@@ -33,13 +33,13 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude"); expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
expect(splitCommand("npx opencode").tool).toBe("opencode"); expect(splitCommand("npx opencode").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode"); expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").rest).toBe("run"); expect(splitCommand("pnpm dlx opencode run").program).toBe("opencode");
}); });
it("leaves an unrecognised program as text, mark-less", () => { it("leaves an unrecognised program as its bare name, mark-less", () => {
expect(splitCommand("hx src/main.rs")).toEqual({ expect(splitCommand("hx src/main.rs")).toEqual({
tool: null, tool: null,
rest: "hx src/main.rs", program: "hx",
}); });
// A wrapper with nothing after it is not a tool either. // A wrapper with nothing after it is not a tool either.
expect(splitCommand("sudo").tool).toBeNull(); expect(splitCommand("sudo").tool).toBeNull();
+17 -18
View File
@@ -2,11 +2,11 @@
* *
* A session's command line starts with the program that runs it, and that * A session's command line starts with the program that runs it, and that
* program is nearly always one of a handful of agents or a shell. Rendering it * program is nearly always one of a handful of agents or a shell. Rendering it
* as its own mark buys back most of the fragment's width, which the sidebar * as its own mark buys back the whole fragment's width — the arguments are
* spends on the rest of the command line instead. * dropped, so a card says which tool runs, not how it was invoked.
* *
* Recognition is deliberately narrow: an unrecognised program keeps its text, * Recognition is deliberately narrow: an unrecognised program keeps its name
* so the fragment never renders a mark that stands for the wrong tool. * as text, so the fragment never renders a mark that stands for the wrong tool.
*/ */
/** Command wrappers that run another program: the tool is what follows. */ /** Command wrappers that run another program: the tool is what follows. */
@@ -48,15 +48,15 @@ export const TOOL_LABELS: Record<string, string> = {
export interface CommandParts { export interface CommandParts {
/** Icon id, or `null` when the program is not one we have a mark for. */ /** Icon id, or `null` when the program is not one we have a mark for. */
tool: string | null; tool: string | null;
/** What is left of the command line once the icon stands for the program. /** The program the command runs, basenamed and stripped of its arguments —
* Empty when the icon says everything the command line said. */ * what a card shows when there is no icon to show instead. */
rest: string; program: string;
} }
/** Split a command line into the tool its icon stands for and the remainder. /** Split a command line into the tool its icon stands for and the program name.
* *
* `claude --resume` → `{tool: "claude", rest: "--resume"}`; an unrecognised * `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
* `hx src/` keeps the whole line as `rest` with no tool. */ * `hx src/` keeps `hx` as the program with no tool. */
export function splitCommand(command: string): CommandParts { export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean); const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0; let i = 0;
@@ -68,23 +68,22 @@ export function splitCommand(command: string): CommandParts {
i += 1; i += 1;
continue; continue;
} }
const program = basename(word); const wrapper = basename(word).toLowerCase();
if (WRAPPERS.has(program)) { if (WRAPPERS.has(wrapper)) {
i += 1; i += 1;
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1; if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
continue; continue;
} }
break; break;
} }
if (i >= words.length) return { tool: null, rest: command.trim() }; if (i >= words.length) return { tool: null, program: command.trim() };
const tool = BY_PROGRAM[basename(words[i])] ?? null; const program = basename(words[i]);
if (!tool) return { tool: null, rest: command.trim() }; return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
return { tool, rest: words.slice(i + 1).join(" ") };
} }
function basename(word: string): string { function basename(word: string): string {
const cut = word.lastIndexOf("/"); const cut = word.lastIndexOf("/");
return (cut >= 0 ? word.slice(cut + 1) : word).toLowerCase(); return cut >= 0 ? word.slice(cut + 1) : word;
} }
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */ /** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
+6 -1
View File
@@ -253,7 +253,12 @@ export type ClientControl =
| { type: "Error"; code: string; message: string }; | { type: "Error"; code: string; message: string };
export interface ApiError { export interface ApiError {
error: { code: string; message: string }; error: {
code: string;
message: string;
/** Set only by the kill guard (#79): what blocked the unforced kill. */
unsaved_work?: InTheWay;
};
} }
export function sessionName(s: Session): string { export function sessionName(s: Session): string {