diff --git a/crates/maestro-daemon/src/main.rs b/crates/maestro-daemon/src/main.rs index a37a934..e0c108d 100644 --- a/crates/maestro-daemon/src/main.rs +++ b/crates/maestro-daemon/src/main.rs @@ -121,6 +121,12 @@ async fn run(config_override: Option) -> Result<(), Box> { 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, diff --git a/crates/maestro-daemon/src/tmux.rs b/crates/maestro-daemon/src/tmux.rs index 929d24b..bf47ba8 100644 --- a/crates/maestro-daemon/src/tmux.rs +++ b/crates/maestro-daemon/src/tmux.rs @@ -77,17 +77,31 @@ impl Tmux { c } - /// `tmux new-session -d -s maestro- -c `. + /// `tmux new-session -d -e TERM=xterm-256color -e COLORTERM=truecolor -s maestro- -c `. /// /// `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 `. 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 { + 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", + ] + ); + } +} diff --git a/crates/maestro-daemon/tests/tmux.rs b/crates/maestro-daemon/tests/tmux.rs index 9184755..a6264e1 100644 --- a/crates/maestro-daemon/tests/tmux.rs +++ b/crates/maestro-daemon/tests/tmux.rs @@ -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();