This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-daemon/src/tmux.rs
T
Miguel Palhas eb9b4b4ae8 fix(maestro): replay pane mouse modes on attach
A full-screen app enables mouse reporting once, at startup. A client
attaching later never saw those sequences, so the web terminal read the
pane as a plain alternate screen with mouseTrackingMode "none" and its
touch and wheel gestures had nowhere to go — scrolling did nothing in a
browser opened after the session started.

Read the pane's mouse flags in the same display-message call and emit
the matching DECSETs ahead of the screen content. tmux expands an
unknown format name to the empty string, so the parser only trusts the
mouse tail when every field is present.

Closes #128
2026-08-31 22:07:22 +01:00

1036 lines
36 KiB
Rust

//! Subprocess wrapper around the `tmux` binary.
//!
//! All sessions managed through this wrapper are named `maestro-<uuid>` so that
//! the daemon can recover its own sessions on restart (and ignore foreign ones).
use std::collections::BTreeMap;
use std::env;
use std::ffi::{OsStr, OsString};
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use uuid::Uuid;
/// Prefix used for every tmux session the daemon owns.
pub const SESSION_PREFIX: &str = "maestro-";
#[derive(Debug, thiserror::Error)]
pub enum TmuxError {
#[error("failed to spawn tmux: {0}")]
Spawn(#[from] std::io::Error),
#[error("tmux exited with code {code}: {stderr}")]
NonZero { code: i32, stderr: String },
}
/// Handle to a tmux server. Cheap to clone.
#[derive(Debug, Clone)]
pub struct Tmux {
binary: PathBuf,
socket_name: Option<String>,
tmpdir: Option<PathBuf>,
}
impl Default for Tmux {
fn default() -> Self {
Self {
binary: PathBuf::from("tmux"),
socket_name: None,
tmpdir: None,
}
}
}
impl Tmux {
pub fn new() -> Self {
Self::default()
}
/// Pass `-L <name>` to every tmux invocation. Useful to isolate test
/// servers from the user's normal tmux session.
pub fn with_socket(mut self, name: impl Into<String>) -> Self {
self.socket_name = Some(name.into());
self
}
/// Set `TMUX_TMPDIR` for every tmux invocation. The tmux server keeps its
/// socket under this directory.
pub fn with_tmpdir(mut self, dir: impl Into<PathBuf>) -> Self {
self.tmpdir = Some(dir.into());
self
}
pub fn socket_name(&self) -> Option<&str> {
self.socket_name.as_deref()
}
pub fn tmpdir(&self) -> Option<&Path> {
self.tmpdir.as_deref()
}
fn cmd(&self) -> Command {
let mut c = Command::new(&self.binary);
if let Some(s) = &self.socket_name {
c.arg("-L").arg(s);
}
if let Some(d) = &self.tmpdir {
c.env("TMUX_TMPDIR", d);
}
// A daemon started from inside a tmux pane inherits `TMUX`, and tmux
// resolves that ahead of `TMUX_TMPDIR` — so without this the daemon
// drives the developer's own server and `tmux_tmpdir` does nothing.
c.env_remove("TMUX");
c
}
/// `tmux set-option -g default-terminal tmux-256color \; new-session -d -e TERM=xterm-256color -e COLORTERM=truecolor -s maestro-<id> -c <cwd> <command>`.
///
/// `command` is wrapped by [`login_shell_command`] (so the spawned process
/// inherits the user's rc-configured PATH) and passed as a single shell-line
/// argument to tmux, which invokes it via `$SHELL -c`. Two layers ensure
/// attach clients see color:
/// the `-e TERM=…`/`COLORTERM=…` flags populate the spawned shell's env
/// even when `maestrod` runs from a stripped systemd unit, and the chained
/// `set-option -g default-terminal tmux-256color` overrides tmux's
/// distro-default `screen` (8-color) terminfo for the pane process. The
/// `set-option` is chained into the same tmux invocation because a
/// stand-alone server with no sessions exits immediately under tmux's
/// default `exit-empty on`.
pub fn new_session(&self, id: Uuid, cwd: &Path, command: &str) -> Result<(), TmuxError> {
let name = session_name(id);
let command = login_shell_command(env::var_os("SHELL").as_deref(), command);
let out = self
.cmd()
.args(new_session_args(
&name,
cwd,
&command,
&BTreeMap::new(),
None,
))
.output()?;
check(out)
}
/// Like [`Self::new_session`], but chains `pipe-pane -t maestro-<id>
/// <sink_command>` into the same tmux invocation. Use this from
/// `SessionManager::create` so the pipe is in place before the freshly
/// `exec`-ed child writes its first byte; calling `new_session` + a
/// separate `pipe_pane` opens a window in which initial output is
/// silently dropped (NAP-253).
pub fn new_session_piped(
&self,
id: Uuid,
cwd: &Path,
command: &str,
session_env: &BTreeMap<String, String>,
sink_command: &str,
) -> Result<(), TmuxError> {
let name = session_name(id);
let command = login_shell_command(env::var_os("SHELL").as_deref(), command);
let out = self
.cmd()
.args(new_session_args(
&name,
cwd,
&command,
session_env,
Some(sink_command),
))
.output()?;
check(out)
}
/// `tmux kill-session -t maestro-<id>`.
pub fn kill_session(&self, id: Uuid) -> Result<(), TmuxError> {
let name = session_name(id);
let out = self.cmd().args(["kill-session", "-t", &name]).output()?;
check(out)
}
/// `tmux list-sessions -F '#{session_name}'`, filtered to `maestro-*` and
/// parsed back into UUIDs. Returns an empty vector if no tmux server is
/// running (so callers don't need to special-case startup).
pub fn list_sessions(&self) -> Result<Vec<Uuid>, TmuxError> {
let out = self
.cmd()
.args(["list-sessions", "-F", "#{session_name}"])
.output()?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if is_no_server_error(&stderr) {
return Ok(Vec::new());
}
return Err(TmuxError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: stderr.into_owned(),
});
}
let stdout = String::from_utf8_lossy(&out.stdout);
let mut ids = Vec::new();
for line in stdout.lines() {
if let Some(rest) = line.strip_prefix(SESSION_PREFIX) {
if let Ok(u) = Uuid::parse_str(rest) {
ids.push(u);
}
}
}
Ok(ids)
}
/// `tmux list-panes -t maestro-<id> -F '#{pane_pid} #{pane_current_command}
/// #{pane_tty} #{pane_current_path}'` for the session's (single) pane. Used
/// by the activity watcher (NAP-340) to learn the foreground command, the
/// pane's process, its tty, and its *current* working directory. The path is
/// the last field, so [`parse_pane_line`] takes the first three
/// space-separated tokens and keeps the remainder verbatim — a cwd that
/// itself contains spaces survives intact. Returns `Ok(None)` when the
/// session/pane is gone or tmux isn't running, so the watcher degrades to
/// "unknown" rather than erroring.
pub fn pane_info(&self, id: Uuid) -> Result<Option<PaneInfo>, TmuxError> {
let name = session_name(id);
let out = self
.cmd()
.args([
"list-panes",
"-t",
&name,
"-F",
"#{pane_pid} #{pane_current_command} #{pane_tty} #{pane_current_path}",
])
.output()?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
// No server, or the session/pane vanished between the liveness check
// and now — both are "no info", not a hard error.
if is_no_server_error(&stderr) || stderr.contains("can't find") {
return Ok(None);
}
return Err(TmuxError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: stderr.into_owned(),
});
}
let stdout = String::from_utf8_lossy(&out.stdout);
Ok(stdout.lines().next().and_then(parse_pane_line))
}
/// `tmux send-keys -t maestro-<id> -l <bytes>`. The `-l` flag tells tmux to
/// treat the payload as literal input (no key-name translation), which is
/// what we want for forwarded PTY input.
pub fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), TmuxError> {
let name = session_name(id);
let mut arg = OsString::new();
arg.push(OsStr::from_bytes(bytes));
let out = self
.cmd()
.args(["send-keys", "-t", &name, "-l"])
.arg(arg)
.output()?;
check(out)
}
/// `tmux pipe-pane -t maestro-<id> '<sink_command>'`.
///
/// `sink_command` is a shell command that receives pane output on stdin
/// (e.g. `cat >> /tmp/sess.log`). Without `-o`, tmux closes any pipe
/// already running on the pane before opening this one — important on
/// `SessionManager::recover`, where the previous daemon process may have
/// left a `pipe-pane` writing to a FIFO that no one is reading anymore.
pub fn pipe_pane(&self, id: Uuid, sink_command: &str) -> Result<(), TmuxError> {
let name = session_name(id);
let out = self
.cmd()
.args(["pipe-pane", "-t", &name, sink_command])
.output()?;
check(out)
}
/// Escape-sequence snapshot of a pane's current screen (#…), for clients
/// that just attached.
///
/// The raw byte replay buffer cannot reconstruct a full-screen program:
/// its tail starts mid-stream, long after the app set up the modes,
/// colours and cursor position that give those bytes meaning. A browser
/// refresh therefore painted garbage until something forced the app to
/// repaint. tmux already holds the authoritative screen, so ask it.
///
/// Returns `Ok(None)` when the session or the tmux server is gone — the
/// caller falls back to the byte replay, which is all a dead session has.
pub fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, TmuxError> {
let name = session_name(id);
let start = format!("-{CAPTURE_HISTORY_LINES}");
// One invocation, so tmux runs both commands in a single pass of its
// command queue: the cursor position cannot belong to an older screen
// than the captured content. Two invocations raced — a program that
// switched to the alternate screen in between produced a snapshot with
// the new screen and the old cursor.
let out = self
.cmd()
.args([
"display-message",
"-p",
"-t",
&name,
"-F",
SCREEN_GEOMETRY_FORMAT,
";",
"capture-pane",
"-p",
"-e",
// Keep trailing spaces, so a line's background colour reaches
// the edge instead of stopping at the last glyph and letting
// the client's own background show through.
"-N",
"-t",
&name,
"-S",
&start,
])
.output()?;
let Some(stdout) = soft_output(out)? else {
return Ok(None);
};
let Some((head, content)) = stdout.split_once('\n') else {
return Ok(None);
};
let Some(geom) = parse_screen_geometry(head) else {
return Ok(None);
};
let mut lines: Vec<&str> = content
.strip_suffix('\n')
.unwrap_or(content)
.split('\n')
.collect();
// Scrollback belongs to the normal screen. Replaying it under an
// alternate-screen program would paste the shell's history behind it.
if geom.alternate {
lines.drain(..lines.len().saturating_sub(geom.pane_height));
}
Ok(Some(render_screen_snapshot(&lines, geom)))
}
/// `tmux resize-window -t maestro-<id> -x <cols> -y <rows>`.
pub fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
let name = session_name(id);
let out = self
.cmd()
.args([
"resize-window",
"-t",
&name,
"-x",
&cols.to_string(),
"-y",
&rows.to_string(),
])
.output()?;
check(out)
}
/// `tmux show-environment -t maestro-<id>`, parsed into KEY=VALUE pairs.
/// Test-facing view of what a spawned session actually inherits.
pub fn show_environment(&self, id: Uuid) -> Result<BTreeMap<String, String>, TmuxError> {
let name = session_name(id);
let out = self
.cmd()
.args(["show-environment", "-t", &name])
.output()?;
check(out.clone())?;
let stdout = String::from_utf8_lossy(&out.stdout);
let mut env = BTreeMap::new();
for line in stdout.lines() {
if let Some((k, v)) = line.split_once('=') {
env.insert(k.to_string(), v.to_string());
}
}
Ok(env)
}
/// `tmux kill-server`. Best-effort cleanup used mostly by tests.
pub fn kill_server(&self) -> Result<(), TmuxError> {
let out = self.cmd().arg("kill-server").output()?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
if is_no_server_error(&stderr) {
return Ok(());
}
Err(TmuxError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: stderr.into_owned(),
})
}
}
/// Phrasings tmux uses for "there is no server on this socket". They vary by
/// version, and `list_sessions` turning one of these into an `Err` stops the
/// liveness watcher reaping for that pass — so a missing phrasing is silent.
const NO_SERVER_PHRASES: &[&str] = &[
"no server running",
"no sessions",
"error connecting",
// tmux >= 3.7 when the server shuts down while this client is connected,
// which is exactly what killing the last session does.
"server exited unexpectedly",
];
/// True if tmux's stderr indicates "there is no tmux server running on this
/// socket" (multiple phrasings across versions).
fn is_no_server_error(stderr: &str) -> bool {
NO_SERVER_PHRASES.iter().any(|p| stderr.contains(p))
}
/// A session pane's process id, foreground command, controlling tty, and
/// current working directory, as reported by `tmux list-panes` (NAP-340).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneInfo {
pub pane_pid: i32,
pub command: String,
pub tty: String,
/// `#{pane_current_path}` — the pane's live cwd. Empty if tmux reported none.
pub current_path: String,
}
/// Parse one `#{pane_pid} #{pane_current_command} #{pane_tty} #{pane_current_path}`
/// line. `pane_pid`, `pane_current_command`, and `pane_tty` are single tokens, so
/// we split on the first three spaces and treat the remainder as the path —
/// which keeps a cwd intact even when it contains spaces. The path field is
/// absent on older format strings, in which case it defaults to empty.
fn parse_pane_line(line: &str) -> Option<PaneInfo> {
let mut parts = line.splitn(4, ' ');
let pane_pid: i32 = parts.next()?.trim().parse().ok()?;
let command = parts.next()?;
let tty = parts.next()?;
let current_path = parts.next().unwrap_or("");
if command.is_empty() {
return None;
}
Some(PaneInfo {
pane_pid,
command: command.to_string(),
tty: tty.to_string(),
current_path: current_path.to_string(),
})
}
pub fn session_name(id: Uuid) -> String {
format!("{SESSION_PREFIX}{id}")
}
/// Wrap a session command so it runs through the user's login + interactive
/// shell.
///
/// `maestrod` usually runs from a stripped systemd user unit whose `PATH`
/// lacks per-user toolchain dirs (`~/.local/bin`, `~/.opencode/bin`, …). tmux
/// runs a new-session command via `$SHELL -c` *non-interactively*, so a bare
/// command like `claude` or `opencode` — which lives only on the PATH the user
/// exports from `~/.zshrc` / `~/.bashrc` — fails with "command not found".
/// Re-execing an interactive login shell sources that rc first, restoring the
/// PATH the user sees in a normal terminal. `exec` keeps the process tree flat
/// so pane-command inference still sees the real child.
///
/// Falls back to the bare command when `$SHELL` is unset/empty (nothing better
/// to wrap with).
fn login_shell_command(shell: Option<&OsStr>, command: &str) -> String {
match shell {
Some(shell) if !shell.is_empty() => {
format!(
"exec {} -lic {}",
shell.to_string_lossy(),
single_quote(command)
)
}
_ => command.to_string(),
}
}
/// POSIX single-quote a string: wrap in `'…'`, rewriting embedded `'` as
/// `'\''`. Safe to splice into a shell command line.
fn single_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for ch in s.chars() {
if ch == '\'' {
out.push_str("'\\''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
/// Args passed to `tmux` for `new_session` / `new_session_piped`, excluding
/// the binary itself and any socket/tmpdir flags injected by [`Tmux::cmd`].
/// When `pipe_sink` is `Some`, a chained `pipe-pane -t <name> <sink>` is
/// appended so the pipe attaches in the same tmux invocation that creates
/// the session.
fn new_session_args(
name: &str,
cwd: &Path,
command: &str,
env: &BTreeMap<String, String>,
pipe_sink: Option<&str>,
) -> Vec<OsString> {
let mut args = vec![
OsString::from("set-option"),
OsString::from("-g"),
OsString::from("default-terminal"),
OsString::from("tmux-256color"),
OsString::from(";"),
OsString::from("new-session"),
OsString::from("-d"),
OsString::from("-e"),
OsString::from("TERM=xterm-256color"),
OsString::from("-e"),
OsString::from("COLORTERM=truecolor"),
OsString::from("-s"),
OsString::from(name),
OsString::from("-c"),
cwd.as_os_str().to_os_string(),
];
// Caller-supplied environment. Sorted so the argv is deterministic, and
// therefore assertable.
for (k, v) in env {
args.push(OsString::from("-e"));
args.push(OsString::from(format!("{k}={v}")));
}
args.push(OsString::from(command));
if let Some(sink) = pipe_sink {
args.extend([
OsString::from(";"),
OsString::from("pipe-pane"),
OsString::from("-t"),
OsString::from(name),
OsString::from(sink),
]);
}
args
}
/// How much scrollback a fresh attach inherits from tmux's history, in lines.
/// Enough to land in context after a browser refresh without making the first
/// 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 {
cursor_x: usize,
cursor_y: usize,
pane_height: usize,
alternate: bool,
cursor_visible: bool,
mouse: MouseModes,
}
fn parse_screen_geometry(s: &str) -> Option<ScreenGeometry> {
let line = s.lines().next()?;
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 = 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;
}
Some(ScreenGeometry {
cursor_x,
cursor_y,
pane_height,
alternate,
cursor_visible,
mouse,
})
}
/// Turn captured pane lines into bytes a fresh terminal can replay: clear,
/// print the lines, then put the cursor back where the pane has it.
///
/// The cursor is restored with *relative* moves so the snapshot renders the
/// same whatever height the client's terminal has — it may not match the
/// pane's yet, since the client's first `Resize` arrives after this.
fn render_screen_snapshot(lines: &[&str], geom: ScreenGeometry) -> Vec<u8> {
// Screen rows are the last `pane_height` captured lines; anything before
// them is scrollback.
let screen_top = lines.len().saturating_sub(geom.pane_height);
let cursor_row = (screen_top + geom.cursor_y).min(lines.len().saturating_sub(1));
// Trailing blank rows would scroll the real content off a short client
// terminal, so drop them — but never the cursor's own row or anything
// above it, which is where a program that draws below its cursor lives.
let last = lines
.iter()
.rposition(|l| !strip_sgr(l).trim().is_empty())
.unwrap_or(0)
.max(cursor_row);
let mut out = Vec::new();
// Put the client on the same buffer the pane is using, so a program's
// repaints stay out of the client's scrollback while it runs.
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 {
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(line.as_bytes());
}
out.extend_from_slice(b"\x1b[m\r");
if last > cursor_row {
out.extend_from_slice(format!("\x1b[{}A", last - cursor_row).as_bytes());
}
if geom.cursor_x > 0 {
out.extend_from_slice(format!("\x1b[{}C", geom.cursor_x).as_bytes());
}
if !geom.cursor_visible {
out.extend_from_slice(b"\x1b[?25l");
}
out
}
/// Text of a line with SGR escapes removed, for emptiness checks.
fn strip_sgr(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(c) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
// Skip CSI ... final-byte. Anything else escape-ish is not produced by
// `capture-pane -e`, so treating it the same way is harmless.
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
out
}
/// `Ok(None)` when tmux failed because the server or the target is gone,
/// `Ok(Some(stdout))` on success, `Err` for anything else.
fn soft_output(out: Output) -> Result<Option<String>, TmuxError> {
if out.status.success() {
return Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()));
}
let stderr = String::from_utf8_lossy(&out.stderr);
if is_no_server_error(&stderr) || stderr.contains("can't find") {
return Ok(None);
}
Err(TmuxError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: stderr.into_owned(),
})
}
fn check(out: Output) -> Result<(), TmuxError> {
if out.status.success() {
Ok(())
} else {
Err(TmuxError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_screen_geometry_reads_cursor_and_alternate_flag() {
let g = parse_screen_geometry("12 3 40 1 0 0 1 0 0 1\n").unwrap();
assert_eq!(
g,
ScreenGeometry {
cursor_x: 12,
cursor_y: 3,
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();
assert!(!g.alternate);
assert!(g.cursor_visible, "missing cursor_flag means visible");
assert!(parse_screen_geometry("0 0 0 0").is_none());
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.
let lines = ["old", "older", "prompt> hi", "", ""];
let out = render_screen_snapshot(
&lines,
ScreenGeometry {
cursor_x: 10,
cursor_y: 0,
pane_height: 3,
alternate: false,
cursor_visible: true,
mouse: MouseModes::default(),
},
);
let out = String::from_utf8(out).unwrap();
assert_eq!(
out,
"\x1b[H\x1b[2J\x1b[mold\r\nolder\r\nprompt> hi\x1b[m\r\x1b[10C"
);
}
#[test]
fn snapshot_keeps_rows_below_the_cursor_and_moves_back_up() {
let lines = ["prompt> hi", "hint line", ""];
let out = render_screen_snapshot(
&lines,
ScreenGeometry {
cursor_x: 0,
cursor_y: 0,
pane_height: 3,
alternate: true,
cursor_visible: false,
mouse: MouseModes::default(),
},
);
let out = String::from_utf8(out).unwrap();
assert!(out.starts_with("\x1b[?1049h\x1b[H\x1b[2J"), "{out:?}");
assert!(
out.ends_with("prompt> hi\r\nhint line\x1b[m\r\x1b[1A\x1b[?25l"),
"{out:?}"
);
}
#[test]
fn snapshot_ignores_colour_only_rows_when_trimming() {
let lines = ["text", "\x1b[38;5;4m\x1b[m", ""];
let out = String::from_utf8(render_screen_snapshot(
&lines,
ScreenGeometry {
cursor_x: 4,
cursor_y: 0,
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]
fn new_session_args_emit_sorted_env_flags_before_the_command() {
let env: BTreeMap<String, String> = [
("ZED".to_string(), "9".to_string()),
("ALPHA".to_string(), "1".to_string()),
]
.into_iter()
.collect();
let args = new_session_args("maestro-x", Path::new("/tmp"), "bash", &env, None);
let flat: Vec<String> = args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let alpha = flat.iter().position(|a| a == "ALPHA=1").expect("ALPHA");
let zed = flat.iter().position(|a| a == "ZED=9").expect("ZED");
let command = flat.iter().rposition(|a| a == "bash").expect("command");
assert!(alpha < zed, "env flags must be sorted for a stable argv");
assert!(zed < command, "env must precede the command argument");
assert_eq!(flat[alpha - 1], "-e");
assert_eq!(flat[zed - 1], "-e");
}
/// `TMUX` beats `TMUX_TMPDIR` when tmux looks for a server, so leaving an
/// inherited one in place would silently ignore the configured tmpdir.
#[test]
fn cmd_clears_an_inherited_tmux_and_sets_the_configured_tmpdir() {
let tmux = Tmux::new().with_tmpdir("/run/maestro-dev");
let cmd = tmux.cmd();
let envs: Vec<_> = cmd.get_envs().collect();
assert!(
envs.contains(&(OsStr::new("TMUX"), None)),
"TMUX must be removed, got {envs:?}"
);
assert!(
envs.contains(&(
OsStr::new("TMUX_TMPDIR"),
Some(OsStr::new("/run/maestro-dev"))
)),
"TMUX_TMPDIR must be set, got {envs:?}"
);
}
/// The removal is unconditional: a daemon manages its own server whether or
/// not a tmpdir was configured.
#[test]
fn cmd_clears_an_inherited_tmux_without_a_configured_tmpdir() {
let cmd = Tmux::new().cmd();
let envs: Vec<_> = cmd.get_envs().collect();
assert!(
envs.contains(&(OsStr::new("TMUX"), None)),
"TMUX must be removed, got {envs:?}"
);
}
#[test]
fn is_no_server_error_matches_every_known_phrasing() {
for phrase in NO_SERVER_PHRASES {
assert!(is_no_server_error(phrase), "unmatched: {phrase}");
assert!(
is_no_server_error(&format!("{phrase}\n")),
"unmatched with trailing newline: {phrase}"
);
}
}
#[test]
fn is_no_server_error_rejects_unrelated_failures() {
assert!(!is_no_server_error("can't find session: maestro-x"));
assert!(!is_no_server_error("usage: kill-session"));
assert!(!is_no_server_error(""));
}
#[test]
fn parse_pane_line_splits_pid_command_tty_path() {
let p = parse_pane_line("12345 claude /dev/pts/3 /home/naps62/maestro").unwrap();
assert_eq!(p.pane_pid, 12345);
assert_eq!(p.command, "claude");
assert_eq!(p.tty, "/dev/pts/3");
assert_eq!(p.current_path, "/home/naps62/maestro");
}
#[test]
fn parse_pane_line_keeps_spaces_in_path() {
// The path is the trailing field (split on the first three spaces only),
// so spaces inside it survive intact.
let p = parse_pane_line("9 bash /dev/pts/0 /home/naps62/My Projects/x").unwrap();
assert_eq!(p.current_path, "/home/naps62/My Projects/x");
}
#[test]
fn parse_pane_line_tolerates_missing_path_field() {
// A daemon/tmux that omits the path field still yields the other fields.
let p = parse_pane_line("12345 claude /dev/pts/3").unwrap();
assert_eq!(p.command, "claude");
assert_eq!(p.current_path, "");
}
#[test]
fn parse_pane_line_rejects_malformed() {
assert!(parse_pane_line("").is_none());
assert!(parse_pane_line("notapid bash /dev/pts/0").is_none());
}
#[test]
fn new_session_args_pass_256color_env_and_default_terminal() {
let args = new_session_args(
"maestro-abc",
Path::new("/tmp/proj"),
"sleep 30",
&BTreeMap::new(),
None,
);
let strs: Vec<&str> = args.iter().map(|s| s.to_str().unwrap()).collect();
assert_eq!(
strs,
vec![
"set-option",
"-g",
"default-terminal",
"tmux-256color",
";",
"new-session",
"-d",
"-e",
"TERM=xterm-256color",
"-e",
"COLORTERM=truecolor",
"-s",
"maestro-abc",
"-c",
"/tmp/proj",
"sleep 30",
]
);
}
#[test]
fn login_shell_command_wraps_in_interactive_login_shell() {
assert_eq!(
login_shell_command(Some(OsStr::new("/usr/bin/zsh")), "claude"),
"exec /usr/bin/zsh -lic 'claude'"
);
}
#[test]
fn login_shell_command_quotes_command_with_spaces_and_quotes() {
assert_eq!(
login_shell_command(Some(OsStr::new("/bin/bash")), "sh -c 'echo hi'"),
r#"exec /bin/bash -lic 'sh -c '\''echo hi'\'''"#
);
}
#[test]
fn login_shell_command_passes_through_without_shell() {
assert_eq!(login_shell_command(None, "claude"), "claude");
assert_eq!(
login_shell_command(Some(OsStr::new("")), "claude"),
"claude"
);
}
#[test]
fn new_session_args_chain_pipe_pane_when_sink_present() {
let args = new_session_args(
"maestro-abc",
Path::new("/tmp/proj"),
"sleep 30",
&BTreeMap::new(),
Some("cat >> '/tmp/fifo'"),
);
let strs: Vec<&str> = args.iter().map(|s| s.to_str().unwrap()).collect();
// The pipe-pane chain must come after new-session in the same argv
// so tmux attaches the pipe before the child writes its first byte
// (NAP-253).
assert_eq!(
&strs[strs.len() - 5..],
&[";", "pipe-pane", "-t", "maestro-abc", "cat >> '/tmp/fifo'"]
);
}
}