feat(maestro): daemon spawns tmux sessions with 256-color TERM (NAP-252)
ci / rust (push) Failing after 3m7s
ci / rust (push) Failing after 3m7s
`maestrod` running from a stripped systemd env leaves the spawned shell without `TERM`/`COLORTERM`, and the pane process inherits tmux's `default-terminal` (which defaults to `screen`, 8 colors). Browser attaches via xterm.js render ANSI sequences as plain text. Fix: - `Tmux::new_session` now passes `-e TERM=xterm-256color -e COLORTERM=truecolor` to `tmux new-session`. - New `Tmux::set_option_global` chains `start-server \; set-option -g` so it works before any session exists; the daemon calls it on startup to set `default-terminal=tmux-256color`. Operators wanting 24-bit color should add `set-option -ga terminal-overrides ",xterm-256color:RGB"` to their own tmux config — left out of the daemon since that's operator territory. Tests: - Unit: `new_session_args` returns the expected argv including the `-e TERM=…` / `COLORTERM=…` pair. - Integration: spawn a session under an isolated tmux server, set `default-terminal`, run `printenv` + `tput colors`, assert the pane sees `TERM=tmux-256color`, `COLORTERM=truecolor`, and 256 colors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -121,6 +121,12 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
let sidecar = SidecarStore::new(config.sidecar_dir());
|
||||
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
|
||||
let tmux = Tmux::new();
|
||||
// The pane process's TERM comes from tmux's `default-terminal`, which on
|
||||
// most distros is `screen` (8 colors). Promote it so attach clients see
|
||||
// 256-color output. NAP-252.
|
||||
if let Err(e) = tmux.set_option_global("default-terminal", "tmux-256color") {
|
||||
tracing::warn!(error = ?e, "failed to set tmux default-terminal — sessions may lack color");
|
||||
}
|
||||
let manager = SessionManager::new_with_replay(
|
||||
config.daemon_id.clone(),
|
||||
tmux,
|
||||
|
||||
@@ -77,17 +77,31 @@ impl Tmux {
|
||||
c
|
||||
}
|
||||
|
||||
/// `tmux new-session -d -s maestro-<id> -c <cwd> <command>`.
|
||||
/// `tmux new-session -d -e TERM=xterm-256color -e COLORTERM=truecolor -s maestro-<id> -c <cwd> <command>`.
|
||||
///
|
||||
/// `command` is passed as a single shell-line argument to tmux, which
|
||||
/// invokes it via `$SHELL -c`.
|
||||
/// invokes it via `$SHELL -c`. The `-e TERM=…`/`COLORTERM=…` flags ensure
|
||||
/// the spawned shell sees sensible color env vars even when `maestrod`
|
||||
/// runs from a stripped systemd unit. Pair with [`Self::set_option_global`]
|
||||
/// of `default-terminal` for true 256-color output in the pane.
|
||||
pub fn new_session(&self, id: Uuid, cwd: &Path, command: &str) -> Result<(), TmuxError> {
|
||||
let name = session_name(id);
|
||||
let out = self
|
||||
.cmd()
|
||||
.args(["new-session", "-d", "-s", &name, "-c"])
|
||||
.arg(cwd)
|
||||
.arg(command)
|
||||
.args(new_session_args(&name, cwd, command))
|
||||
.output()?;
|
||||
check(out)
|
||||
}
|
||||
|
||||
/// `tmux start-server \; set-option -g <name> <value>`. Used on daemon
|
||||
/// startup to set `default-terminal` to `tmux-256color` so the pane's
|
||||
/// `TERM` is a 256-color terminfo entry (the distro default `screen` caps
|
||||
/// at 8). The chained `start-server` makes the call work before any
|
||||
/// session exists.
|
||||
pub fn set_option_global(&self, name: &str, value: &str) -> Result<(), TmuxError> {
|
||||
let out = self
|
||||
.cmd()
|
||||
.args(["start-server", ";", "set-option", "-g", name, value])
|
||||
.output()?;
|
||||
check(out)
|
||||
}
|
||||
@@ -205,6 +219,25 @@ pub fn session_name(id: Uuid) -> String {
|
||||
format!("{SESSION_PREFIX}{id}")
|
||||
}
|
||||
|
||||
/// Args passed to `tmux` for `new_session`, excluding the binary itself and
|
||||
/// any socket/tmpdir flags injected by [`Tmux::cmd`]. Split out so the argv
|
||||
/// can be asserted without invoking tmux.
|
||||
fn new_session_args(name: &str, cwd: &Path, command: &str) -> Vec<OsString> {
|
||||
vec![
|
||||
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(),
|
||||
OsString::from(command),
|
||||
]
|
||||
}
|
||||
|
||||
fn check(out: Output) -> Result<(), TmuxError> {
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
@@ -215,3 +248,30 @@ fn check(out: Output) -> Result<(), TmuxError> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_session_args_pass_256color_env() {
|
||||
let args = new_session_args("maestro-abc", Path::new("/tmp/proj"), "sleep 30");
|
||||
let strs: Vec<&str> = args.iter().map(|s| s.to_str().unwrap()).collect();
|
||||
assert_eq!(
|
||||
strs,
|
||||
vec![
|
||||
"new-session",
|
||||
"-d",
|
||||
"-e",
|
||||
"TERM=xterm-256color",
|
||||
"-e",
|
||||
"COLORTERM=truecolor",
|
||||
"-s",
|
||||
"maestro-abc",
|
||||
"-c",
|
||||
"/tmp/proj",
|
||||
"sleep 30",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,49 @@ fn resize_window_succeeds() {
|
||||
fx.tmux.kill_session(id).expect("kill_session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_session_sets_256color_env_and_default_terminal() {
|
||||
let fx = TmuxFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let sink = TempDir::new().unwrap();
|
||||
let sink_path = sink.path().join("env.log");
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
fx.tmux
|
||||
.set_option_global("default-terminal", "tmux-256color")
|
||||
.expect("set default-terminal");
|
||||
|
||||
fx.tmux
|
||||
.new_session(id, cwd.path(), "bash --noprofile --norc")
|
||||
.expect("new_session");
|
||||
|
||||
wait_for(Duration::from_secs(3), || {
|
||||
fx.tmux.list_sessions().unwrap().contains(&id)
|
||||
});
|
||||
|
||||
let sink_command = format!("cat >> {}", shell_quote(&sink_path.display().to_string()));
|
||||
fx.tmux.pipe_pane(id, &sink_command).expect("pipe_pane");
|
||||
|
||||
fx.tmux
|
||||
.send_keys(
|
||||
id,
|
||||
b"printf 'TERM=%s\\nCOLORTERM=%s\\nCOLORS=%s\\n' \"$TERM\" \"$COLORTERM\" \"$(tput colors)\"\n",
|
||||
)
|
||||
.expect("send_keys");
|
||||
|
||||
wait_for(Duration::from_secs(5), || {
|
||||
std::fs::read_to_string(&sink_path)
|
||||
.map(|s| {
|
||||
s.contains("TERM=tmux-256color")
|
||||
&& s.contains("COLORTERM=truecolor")
|
||||
&& s.contains("COLORS=256")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
fx.tmux.kill_session(id).expect("kill_session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_sessions_returns_empty_when_no_server() {
|
||||
let fx = TmuxFixture::new();
|
||||
|
||||
Reference in New Issue
Block a user