From 8c83c9618e7d2162b539da9b1b94673ebb9a1dcd Mon Sep 17 00:00:00 2001 From: bigph00t Date: Tue, 12 May 2026 12:07:10 -0700 Subject: [PATCH] feat: opt-in tmux config support + enable extended-keys by default Problem: - mobilecli spawns tmux with -f /dev/null, which ignores ~/.tmux.conf - This causes 'extended-keys is off' warnings in modern terminals/TUIs - Power users have no escape hatch to customize their tmux environment Solution: - Explicitly set 'extended-keys on' during session setup (surgical fix) - Add opt-in custom tmux config resolution: 1. MOBILECLI_TMUX_CONFIG env var (highest priority) 2. ~/.mobilecli/tmux.conf file fallback 3. Null device (/dev/null or NUL) as safe default - MobileCLI still enforces its required options (history-limit, mouse, window-size, etc.) after loading the custom config, preserving reliable streaming behavior while allowing colors, key bindings, and other user preferences to come through. Cross-platform: - Extracts tmux_null_device() helper to avoid duplicating the Windows/NUL vs Unix /dev/null logic - Paths are passed through to_string_lossy() for tmux compatibility Safety fixes: - Use path.is_file() instead of path.exists() to reject directories - Refactor unnecessary unwrap to if-let (clippy::unnecessary_unwrap) - Allow clippy::too_many_arguments on setup_tmux_session (pre-existing +1) Tests: - Add tmux_session_enables_extended_keys_by_default test - Update all existing tests to pass explicit None for config path Docs: - Add tmux.conf to ~/.mobilecli/ file table in README - Document MOBILECLI_TMUX_CONFIG, enforced options, and warnings about dangerous tmux settings (destroy-unattached, remain-on-exit) - Document headless daemon session behavior difference --- README.md | 13 +++- cli/src/pty_wrapper.rs | 158 +++++++++++++++++++++++++++++++++-------- 2 files changed, 142 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e03e420..7fbb3ef 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,7 @@ All config lives in `~/.mobilecli/`: |------|---------| | `config.json` | Device identity, connection URL, auth token hash | | `sessions.json` | Persisted session metadata (names, history) | +| `tmux.conf` | Optional custom tmux config for mobilecli sessions | | `daemon.pid` | Running daemon's process ID | | `daemon.port` | Active WebSocket port (default: `9847`) | | `daemon.log` | Debug log output | @@ -420,7 +421,17 @@ MobileCLI/ 2. The terminal auto-resizes to fit your phone screen. TUI applications (like `htop` or `vim`) should adapt automatically. 3. Desktop terminal geometry is preserved by default. If you explicitly want mirrored desktop window resizing, launch with `MOBILECLI_DESKTOP_RESIZE_POLICY=mirror`. 4. On Linux, tmux mouse mode is disabled by default so desktop terminals like Konsole keep normal drag-select clipboard behavior. Re-enable tmux mouse features with `MOBILECLI_TMUX_MOUSE=on mobilecli`. -5. If a session looks garbled after switching tabs, tap the session to re-enter it — the terminal refits on activation. +5. **Custom tmux config:** By default mobilecli runs tmux with an isolated config to ensure reliable streaming. Power users can opt-in to a custom config by creating `~/.mobilecli/tmux.conf` or setting `MOBILECLI_TMUX_CONFIG=/path/to/tmux.conf`. MobileCLI will still enforce its required options after loading your config: + - `history-limit 200000` + - `extended-keys on` + - `window-size latest` + - `mouse` (platform default) + - `status off`, `allow-rename off` + + ⚠️ Avoid setting `destroy-unattached` or `remain-on-exit` in your custom config, as these can interfere with session lifecycle management. + + *Note: Daemon-spawned headless sessions (phone-only, no desktop terminal) use the default tmux server and load your regular `~/.tmux.conf`, not `~/.mobilecli/tmux.conf`.* +6. If a session looks garbled after switching tabs, tap the session to re-enter it — the terminal refits on activation.
diff --git a/cli/src/pty_wrapper.rs b/cli/src/pty_wrapper.rs index d9bf674..54b543e 100644 --- a/cli/src/pty_wrapper.rs +++ b/cli/src/pty_wrapper.rs @@ -16,6 +16,7 @@ use futures_util::{SinkExt, StreamExt}; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use std::borrow::Cow; use std::io::{IsTerminal, Read, Write}; +use std::path::Path; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -182,18 +183,27 @@ fn request_terminal_resize(cols: u16, rows: u16) { set_stdout_winsize(cols, rows); } -fn tmux_base_command(socket_name: &str) -> Command { - let mut cmd = Command::new("tmux"); - // Use platform-appropriate null device +fn tmux_null_device() -> &'static str { #[cfg(windows)] - let null_dev = "NUL"; + { + "NUL" + } #[cfg(not(windows))] - let null_dev = "/dev/null"; + { + "/dev/null" + } +} + +fn tmux_base_command(socket_name: &str, config_path: Option<&Path>) -> Command { + let mut cmd = Command::new("tmux"); + let config_arg = config_path + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| tmux_null_device().to_string()); cmd.arg("-L") .arg(socket_name) .arg("-f") - .arg(null_dev) + .arg(config_arg) .env_remove("TMUX"); cmd } @@ -327,6 +337,32 @@ fn resolve_tmux_mouse_mode() -> TmuxMouseMode { default_mode } +/// Resolve an optional user-provided tmux config path. +/// Priority: MOBILECLI_TMUX_CONFIG env var → ~/.mobilecli/tmux.conf → None +fn resolve_tmux_config_path() -> Option { + if let Ok(raw) = std::env::var("MOBILECLI_TMUX_CONFIG") { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + let path = std::path::PathBuf::from(trimmed); + if path.is_file() { + return Some(path); + } + tracing::warn!( + "MOBILECLI_TMUX_CONFIG='{}' does not exist or is not a file. Falling back to null config.", + trimmed + ); + } + } + + let default = crate::platform::config_dir().join("tmux.conf"); + if default.is_file() { + return Some(default); + } + + None +} + +#[allow(clippy::too_many_arguments)] fn setup_tmux_session( socket_name: &str, session_name: &str, @@ -336,6 +372,7 @@ fn setup_tmux_session( terminal_size: (u16, u16), tmux_mouse_mode: TmuxMouseMode, headless: bool, + config_path: Option<&Path>, ) -> Result<(), WrapError> { let (cols, rows) = terminal_size; @@ -351,7 +388,7 @@ fn setup_tmux_session( // will have default history-limit (2000), but we set global options for // future windows. This is a tmux limitation - history-limit can only be // set globally and affects windows created after it's set. - let mut new_session = tmux_base_command(socket_name); + let mut new_session = tmux_base_command(socket_name, config_path); new_session .arg("new-session") .arg("-d") @@ -371,7 +408,7 @@ fn setup_tmux_session( // Set global options for future windows. The first window already // exists with default settings, but new windows will inherit these. - let mut set_history = tmux_base_command(socket_name); + let mut set_history = tmux_base_command(socket_name, config_path); set_history .arg("set-option") .arg("-g") @@ -394,7 +431,7 @@ fn setup_tmux_session( // The mobile app can still capture scrollback via capture-pane on the main // buffer — it just won't see TUI alt-screen content, which is acceptable. if headless { - let mut disable_altscreen_client = tmux_base_command(socket_name); + let mut disable_altscreen_client = tmux_base_command(socket_name, config_path); disable_altscreen_client .arg("set-option") .arg("-g") @@ -405,7 +442,7 @@ fn setup_tmux_session( // Mouse mode is configurable. Linux defaults to off so desktop emulators // (e.g. Konsole) preserve normal drag-select clipboard behavior. - let mut set_mouse_mode = tmux_base_command(socket_name); + let mut set_mouse_mode = tmux_base_command(socket_name, config_path); set_mouse_mode .arg("set-option") .arg("-g") @@ -413,6 +450,17 @@ fn setup_tmux_session( .arg(tmux_mouse_mode.as_tmux_value()); let _ = run_tmux_checked(&mut set_mouse_mode, "mouse"); + // Enable extended key sequences so modern terminals and TUIs (e.g. + // applications using kitty keyboard protocol) do not emit warnings + // about extended-keys being disabled. + let mut set_extended_keys = tmux_base_command(socket_name, config_path); + set_extended_keys + .arg("set-option") + .arg("-g") + .arg("extended-keys") + .arg("on"); + let _ = run_tmux_checked(&mut set_extended_keys, "extended-keys"); + // history-limit is set globally before new-session so the window is // allocated with the full 200K-line buffer from the start. let mut option_sets: Vec<(&str, &str, &str, &str)> = vec![ @@ -431,7 +479,7 @@ fn setup_tmux_session( )); } for (command, target, key, value) in option_sets { - let mut option_cmd = tmux_base_command(socket_name); + let mut option_cmd = tmux_base_command(socket_name, config_path); option_cmd .arg(command) .arg("-t") @@ -451,8 +499,8 @@ fn setup_tmux_session( Ok(()) } -fn cleanup_tmux_session(ctx: &TmuxContext) { - let mut cmd = tmux_base_command(&ctx.socket_name); +fn cleanup_tmux_session(ctx: &TmuxContext, config_path: Option<&Path>) { + let mut cmd = tmux_base_command(&ctx.socket_name, config_path); cmd.arg("kill-server"); if let Err(err) = run_tmux_checked(&mut cmd, "kill-server") { tracing::debug!( @@ -638,6 +686,11 @@ pub async fn run_wrapped(config: WrapConfig) -> Result { }) .map_err(|e| WrapError::Pty(e.to_string()))?; + let tmux_config_path = resolve_tmux_config_path(); + if let Some(path) = &tmux_config_path { + tracing::info!(path = %path.display(), "Using custom tmux config"); + } + let mut tmux_context: Option = None; if runtime_mode == RuntimeMode::Tmux { tracing::info!( @@ -660,6 +713,7 @@ pub async fn run_wrapped(config: WrapConfig) -> Result { (cols, rows), tmux_mouse_mode, false, // headless=false: preserve alt-screen for desktop terminal + tmux_config_path.as_deref(), )?; tmux_context = Some(ctx); } @@ -667,16 +721,16 @@ pub async fn run_wrapped(config: WrapConfig) -> Result { // Build command let mut cmd = if let Some(ctx) = &tmux_context { let mut c = CommandBuilder::new("tmux"); - #[cfg(windows)] - let null_dev = "NUL"; - #[cfg(not(windows))] - let null_dev = "/dev/null"; + let config_arg = tmux_config_path + .as_deref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| tmux_null_device().to_string()); c.args([ "-L", ctx.socket_name.as_str(), "-f", - null_dev, + &config_arg, "attach-session", "-t", ctx.session_name.as_str(), @@ -712,7 +766,7 @@ pub async fn run_wrapped(config: WrapConfig) -> Result { let mut child = pair.slave.spawn_command(cmd).map_err(|e| { tracing::error!("Failed to spawn PTY command: {}", e); if let Some(ctx) = &tmux_context { - cleanup_tmux_session(ctx); + cleanup_tmux_session(ctx, tmux_config_path.as_deref()); } WrapError::Pty(e.to_string()) })?; @@ -1128,7 +1182,7 @@ pub async fn run_wrapped(config: WrapConfig) -> Result { let _ = reader_handle.join(); if let Some(ctx) = &tmux_context { - cleanup_tmux_session(ctx); + cleanup_tmux_session(ctx, tmux_config_path.as_deref()); } // Reset terminal state after tmux teardown. Tmux with mouse mode enabled @@ -1311,10 +1365,11 @@ mod tests { (80, 24), TmuxMouseMode::default_for_platform(), true, // headless: tests run without a desktop terminal + None, ) .expect("setup tmux session"); - let mut has_session = tmux_base_command(&ctx.socket_name); + let mut has_session = tmux_base_command(&ctx.socket_name, None); let output = has_session .arg("has-session") .arg("-t") @@ -1327,7 +1382,7 @@ mod tests { String::from_utf8_lossy(&output.stderr) ); - let mut show_window_size = tmux_base_command(&ctx.socket_name); + let mut show_window_size = tmux_base_command(&ctx.socket_name, None); let output = show_window_size .arg("show-window-options") .arg("-v") @@ -1347,7 +1402,7 @@ mod tests { "expected tmux window-size to remain dynamic for client-driven resize propagation" ); - let mut show_alt_screen = tmux_base_command(&ctx.socket_name); + let mut show_alt_screen = tmux_base_command(&ctx.socket_name, None); let output = show_alt_screen .arg("show-window-options") .arg("-v") @@ -1367,7 +1422,7 @@ mod tests { "expected tmux alternate-screen to be disabled to protect host terminal scrollback" ); - let mut show_mouse_mode = tmux_base_command(&ctx.socket_name); + let mut show_mouse_mode = tmux_base_command(&ctx.socket_name, None); let output = show_mouse_mode .arg("show-options") .arg("-gv") @@ -1386,9 +1441,9 @@ mod tests { "expected tmux mouse option to follow platform default policy" ); - cleanup_tmux_session(&ctx); + cleanup_tmux_session(&ctx, None); - let mut has_after_cleanup = tmux_base_command(&ctx.socket_name); + let mut has_after_cleanup = tmux_base_command(&ctx.socket_name, None); let output = has_after_cleanup .arg("has-session") .arg("-t") @@ -1422,10 +1477,11 @@ mod tests { (80, 24), TmuxMouseMode::On, true, // headless: tests run without a desktop terminal + None, ) .expect("setup tmux session with mouse override"); - let mut show_mouse_mode = tmux_base_command(&ctx.socket_name); + let mut show_mouse_mode = tmux_base_command(&ctx.socket_name, None); let output = show_mouse_mode .arg("show-options") .arg("-gv") @@ -1443,6 +1499,52 @@ mod tests { "expected explicit tmux mouse override to enable mouse mode" ); - cleanup_tmux_session(&ctx); + cleanup_tmux_session(&ctx, None); + } + + #[test] + fn tmux_session_enables_extended_keys_by_default() { + if which::which("tmux").is_err() { + return; + } + + let token = sanitize_tmux_token(&uuid::Uuid::new_v4().to_string()[..12]); + let ctx = TmuxContext { + socket_name: format!("mcli-test-{}", token), + session_name: format!("mcli-test-{}", token), + }; + + setup_tmux_session( + &ctx.socket_name, + &ctx.session_name, + "/bin/sh", + &vec!["-lc".to_string(), "sleep 10 & wait".to_string()], + ".", + (80, 24), + TmuxMouseMode::default_for_platform(), + true, + None, + ) + .expect("setup tmux session for extended-keys test"); + + let mut show_extended_keys = tmux_base_command(&ctx.socket_name, None); + let output = show_extended_keys + .arg("show-options") + .arg("-gv") + .arg("extended-keys") + .output() + .expect("show-options extended-keys output"); + assert!( + output.status.success(), + "expected extended-keys query success: {}", + String::from_utf8_lossy(&output.stderr) + ); + let extended_keys_mode = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + extended_keys_mode, "on", + "expected tmux extended-keys to be enabled by default" + ); + + cleanup_tmux_session(&ctx, None); } }