From cda3d5e4e3826c681b4da8b3806d8f1429dfc248 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:39:23 -0700 Subject: [PATCH 1/2] mux: server/client control commands (ping, reload-config, window-title, scroll-changed) Adds four control commands and one event to the cmux-mux protocol (protocol stays v6, additive), clean-room: - ping: { ok, version, protocol } liveness probe, distinct from identify. - reload-config: re-reads mux.json via config::load() and live-applies theme/colors, tabs, sidebar, scrollbar, and keybindings to the running TUI through the existing event loop (no timers); headless is a no-op. - set-window-title / clear-window-title: write OSC 0/2 to the local and each attached client's own terminal (sanitized), no focus change. - scroll-changed event: { surface, offset, at_bottom }, emitted from the shared scroll/viewport helpers, coalesced; subscribe gets all, attach gets its surface. Server + CLI verbs + Python binding + conformance fixture + spec. NOTE: local verification was blocked by a host zig/ghostty toolchain regression (libSystem link failure, unrelated to this change); fmt is clean and the fixtures/python parse, but the Rust build/tests must be validated by CI (test-linux/macos/bindings-e2e). --- mux/bindings/conformance/fixtures.json | 23 +++ mux/bindings/conformance/runner.py | 4 + mux/bindings/python/cmux/client.py | 40 ++++ mux/crates/mux-core/src/mux.rs | 30 ++- mux/crates/mux-core/src/server.rs | 228 +++++++++++++++++++++-- mux/crates/mux-core/src/surface.rs | 63 +++++++ mux/crates/mux-tui/src/app.rs | 103 ++++++---- mux/crates/mux-tui/src/cli.rs | 35 ++++ mux/crates/mux-tui/src/config.rs | 12 ++ mux/crates/mux-tui/src/main.rs | 12 +- mux/crates/mux-tui/src/session/mod.rs | 58 ++++++ mux/crates/mux-tui/src/session/remote.rs | 15 ++ mux/crates/mux-tui/tests/cli.rs | 10 + mux/spec/cli.md | 4 + mux/spec/commands.md | 126 ++++++++++++- mux/spec/events.md | 72 ++++++- 16 files changed, 761 insertions(+), 74 deletions(-) diff --git a/mux/bindings/conformance/fixtures.json b/mux/bindings/conformance/fixtures.json index ff6c74ae0272..97c187608c73 100644 --- a/mux/bindings/conformance/fixtures.json +++ b/mux/bindings/conformance/fixtures.json @@ -12,6 +12,29 @@ } ] }, + { + "name": "server-client-control", + "steps": [ + { + "type": "command", + "request": { "id": 1, "cmd": "ping" }, + "expect": { "id": 1, "ok": true, "data": { "ok": true, "protocol": 6 } }, + "match": "partial" + }, + { + "type": "command", + "request": { "id": 2, "cmd": "set-window-title", "title": "cmux-conformance" }, + "expect": { "id": 2, "ok": true, "data": {} }, + "match": "exact" + }, + { + "type": "command", + "request": { "id": 3, "cmd": "clear-window-title" }, + "expect": { "id": 3, "ok": true, "data": {} }, + "match": "exact" + } + ] + }, { "name": "workspace-tab-split-rename", "steps": [ diff --git a/mux/bindings/conformance/runner.py b/mux/bindings/conformance/runner.py index 6f790c6200aa..291040d4c99b 100755 --- a/mux/bindings/conformance/runner.py +++ b/mux/bindings/conformance/runner.py @@ -189,6 +189,10 @@ def check_requires(fixture: Dict[str, Any], socket_path: str) -> None: def dispatch(client: CmuxClient, cmd: str, params: Dict[str, Any]) -> Any: mapping = { "identify": client.identify, + "ping": client.ping, + "reload-config": client.reload_config, + "set-window-title": client.set_window_title, + "clear-window-title": lambda **kw: client.clear_window_title(), "list-workspaces": client.list_workspaces, "export-layout": client.export_layout, "apply-layout": client.apply_layout, diff --git a/mux/bindings/python/cmux/client.py b/mux/bindings/python/cmux/client.py index 1db3bf98db40..f35579e85ce6 100644 --- a/mux/bindings/python/cmux/client.py +++ b/mux/bindings/python/cmux/client.py @@ -47,6 +47,19 @@ class IdentifyResult: pid: int +@dataclass(frozen=True) +class PingResult: + ok: bool + version: str + protocol: int + + +@dataclass(frozen=True) +class ReloadConfigResult: + reloaded: bool + path: Optional[str] + + @dataclass(frozen=True) class SurfaceResult: surface: int @@ -136,6 +149,8 @@ class Event: rows: Optional[int] = None data: Optional[str] = None replay: Optional[str] = None + offset: Optional[int] = None + at_bottom: Optional[bool] = None @property def bytes_data(self) -> Optional[bytes]: @@ -302,6 +317,21 @@ def identify(self) -> IdentifyResult: self._protocol = result.protocol return result + def ping(self) -> PingResult: + data = self._request("ping") + return PingResult( + ok=bool(data["ok"]), + version=str(data["version"]), + protocol=int(data["protocol"]), + ) + + def reload_config(self) -> ReloadConfigResult: + data = self._request("reload-config") + return ReloadConfigResult( + reloaded=bool(data.get("reloaded", False)), + path=data.get("path"), + ) + def list_workspaces(self) -> Tree: return _parse_tree(self._request("list-workspaces")) @@ -410,6 +440,14 @@ def set_default_colors(self, fg: Optional[str] = None, bg: Optional[str] = None) self._request("set-default-colors", fg=fg, bg=bg) return EmptyResult() + def set_window_title(self, title: str) -> EmptyResult: + self._request("set-window-title", title=title) + return EmptyResult() + + def clear_window_title(self) -> EmptyResult: + self._request("clear-window-title") + return EmptyResult() + def close_surface(self, surface: int) -> EmptyResult: self._request("close-surface", surface=surface) return EmptyResult() @@ -570,4 +608,6 @@ def _parse_event(value: Dict[str, Any]) -> Event: rows=value.get("rows"), data=value.get("data"), replay=value.get("replay"), + offset=value.get("offset"), + at_bottom=value.get("at_bottom"), ) diff --git a/mux/crates/mux-core/src/mux.rs b/mux/crates/mux-core/src/mux.rs index c2c690e1b71a..f5661c71a9cf 100644 --- a/mux/crates/mux-core/src/mux.rs +++ b/mux/crates/mux-core/src/mux.rs @@ -31,6 +31,16 @@ pub enum MuxEvent { Bell(SurfaceId), Notification(NotificationEvent), Status(String), + /// A frontend should reload its local mux configuration and redraw. + ConfigReloadRequested, + /// A frontend should set its host terminal window title. Empty clears it. + WindowTitleRequested(String), + /// A PTY surface viewport moved within its scrollback. + ScrollChanged { + surface: SurfaceId, + offset: u64, + at_bottom: bool, + }, /// The workspace/screen/pane/tab tree changed (from any frontend or /// the control socket). TreeChanged, @@ -192,7 +202,7 @@ pub struct Mux { next_id: AtomicU64, next_notification_id: AtomicU64, next_active_at: AtomicU64, - surface_options: SurfaceOptions, + surface_options: Mutex, browser_runtime: Mutex>>, cell_pixels: Mutex<(u16, u16)>, default_colors: Mutex, @@ -227,7 +237,7 @@ impl Mux { next_id: AtomicU64::new(1), next_notification_id: AtomicU64::new(1), next_active_at: AtomicU64::new(1), - surface_options, + surface_options: Mutex::new(surface_options), browser_runtime: Mutex::new(None), cell_pixels: Mutex::new((8, 16)), default_colors: Mutex::new(DefaultColors::default()), @@ -286,7 +296,7 @@ impl Mux { size: Option<(u16, u16)>, ) -> anyhow::Result> { let id = self.next_id(); - let mut opts = self.surface_options.clone(); + let mut opts = self.surface_options.lock().unwrap().clone(); if cwd.is_some() { opts.cwd = cwd; } @@ -326,7 +336,7 @@ impl Mux { size: Option<(u16, u16)>, ) -> Arc { let id = self.next_id(); - let opts = self.surface_options.clone(); + let opts = self.surface_options.lock().unwrap().clone(); let size = size.unwrap_or((opts.cols, opts.rows)); let cell_pixels = *self.cell_pixels.lock().unwrap(); let surface = browser::new_surface(id, url.clone(), size, cell_pixels, &opts); @@ -340,7 +350,8 @@ impl Mux { if let Some(existing) = runtime.as_ref().filter(|existing| !existing.is_closed()) { return Ok(existing.clone()); } - let created = BrowserRuntime::connect(&self.surface_options)?; + let opts = self.surface_options.lock().unwrap().clone(); + let created = BrowserRuntime::connect(&opts)?; *runtime = Some(created.clone()); Ok(created) } @@ -509,6 +520,13 @@ impl Mux { } } + /// Update options used for future surface/browser launches. + pub fn update_surface_options(&self, update: impl FnOnce(&mut SurfaceOptions)) { + let mut options = self.surface_options.lock().unwrap(); + update(&mut options); + options.browser_session_name = self.session.clone(); + } + pub fn set_cell_pixel_size(&self, width_px: u16, height_px: u16) { let next = (width_px.max(1), height_px.max(1)); { @@ -890,7 +908,7 @@ impl Mux { (pane_id, size) }; let id = self.next_id(); - let opts = self.surface_options.clone(); + let opts = self.surface_options.lock().unwrap().clone(); let size = size.unwrap_or((opts.cols, opts.rows)); let cell_pixels = *self.cell_pixels.lock().unwrap(); let surface = browser::new_surface(id, url.clone(), size, cell_pixels, &opts); diff --git a/mux/crates/mux-core/src/server.rs b/mux/crates/mux-core/src/server.rs index acf76821ea61..7889b1a41e58 100644 --- a/mux/crates/mux-core/src/server.rs +++ b/mux/crates/mux-core/src/server.rs @@ -56,6 +56,12 @@ struct Request { #[serde(tag = "cmd", rename_all = "kebab-case")] enum Command { Identify, + Ping, + ReloadConfig, + SetWindowTitle { + title: String, + }, + ClearWindowTitle, ListWorkspaces, ExportLayout { #[serde(default)] @@ -440,6 +446,21 @@ pub fn serve(mux: Arc, path: Option) -> anyhow::Result { Ok(path) } +pub fn window_title_osc(title: &str) -> Vec { + let title = sanitize_window_title(title); + format!("\x1b]0;{title}\x07\x1b]2;{title}\x07").into_bytes() +} + +fn sanitize_window_title(title: &str) -> String { + title + .chars() + .map(|ch| match ch { + '\u{00}'..='\u{1f}' | '\u{7f}' => ' ', + _ => ch, + }) + .collect() +} + fn handle_connection(mux: Arc, stream: Box) { let Ok(write_half) = stream.try_clone_box() else { return }; let writer = LineWriter(Arc::new(Mutex::new(write_half))); @@ -801,20 +822,31 @@ fn spawn_attach_notification_stream( .name("mux-attach-notifications".into()) .spawn(move || { while let Ok(event) = events.recv() { - let MuxEvent::Notification(notification) = event else { - continue; + let value = match event { + MuxEvent::Notification(notification) + if notification.surface == Some(surface_id) => + { + json!({ + "event": "notification", + "notification": notification.notification, + "title": notification.title, + "body": notification.body, + "level": notification.level.as_str(), + "surface": notification.surface, + }) + } + MuxEvent::ScrollChanged { surface, offset, at_bottom } + if surface == surface_id => + { + json!({ + "event": "scroll-changed", + "surface": surface, + "offset": offset, + "at_bottom": at_bottom, + }) + } + _ => continue, }; - if notification.surface != Some(surface_id) { - continue; - } - let value = json!({ - "event": "notification", - "notification": notification.notification, - "title": notification.title, - "body": notification.body, - "level": notification.level.as_str(), - "surface": notification.surface, - }); if writer.send(&value).is_err() { break; } @@ -832,6 +864,26 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: "session": mux.session, "pid": std::process::id(), })), + Command::Ping => Ok(json!({ + "ok": true, + "version": env!("CARGO_PKG_VERSION"), + "protocol": PROTOCOL_VERSION, + })), + Command::ReloadConfig => { + mux.emit(MuxEvent::ConfigReloadRequested); + Ok(json!({ + "reloaded": true, + "path": platform::config_path().map(|path| path.display().to_string()), + })) + } + Command::SetWindowTitle { title } => { + mux.emit(MuxEvent::WindowTitleRequested(title)); + Ok(json!({})) + } + Command::ClearWindowTitle => { + mux.emit(MuxEvent::WindowTitleRequested(String::new())); + Ok(json!({})) + } Command::ListWorkspaces => { let notifications = mux.surface_notifications(); Ok(mux.with_state(|state| workspaces_json(state, ¬ifications))) @@ -952,8 +1004,8 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: } let mut encoder = KeyEncoder::new()?; let mut encoded = Vec::new(); + surface.scroll_to_bottom()?; surface.try_with_terminal(|term| { - term.scroll_to_bottom(); encoder.sync_from_terminal(term); for key in &keys { let Some(input) = key_input_from_chord(key) else { @@ -1288,7 +1340,7 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: Command::ScrollSurface { surface, delta } => { let surface = get_surface(mux, surface)?; require_pty(&surface)?; - surface.try_with_terminal(|t| t.scroll_delta(delta))?; + surface.scroll_delta(delta)?; Ok(json!({})) } Command::Subscribe => { @@ -1326,6 +1378,18 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: MuxEvent::Status(message) => { json!({"event": "status", "message": message}) } + MuxEvent::ConfigReloadRequested => { + json!({"event": "config-reload-requested"}) + } + MuxEvent::WindowTitleRequested(title) => { + json!({"event": "window-title-requested", "title": title}) + } + MuxEvent::ScrollChanged { surface, offset, at_bottom } => json!({ + "event": "scroll-changed", + "surface": surface, + "offset": offset, + "at_bottom": at_bottom, + }), MuxEvent::TreeChanged => json!({"event": "tree-changed"}), MuxEvent::LayoutChanged(screen) => { json!({"event": "layout-changed", "screen": screen}) @@ -1414,3 +1478,137 @@ fn handle_command(mux: &Arc, cmd: Command, writer: &LineWriter) -> anyhow:: pub fn cleanup(path: &Path) { let _ = std::fs::remove_file(path); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::SurfaceOptions; + use std::io::{Read, Write}; + use std::sync::mpsc::TryRecvError; + use std::time::Duration; + + struct NullStream; + + impl Read for NullStream { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Ok(0) + } + } + + impl Write for NullStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl transport::Stream for NullStream { + fn try_clone_box(&self) -> std::io::Result> { + Ok(Box::new(NullStream)) + } + + fn set_read_timeout(&self, _timeout: Option) -> std::io::Result<()> { + Ok(()) + } + } + + fn test_mux() -> Arc { + Mux::new_for_test("test", SurfaceOptions::default()) + } + + fn test_writer() -> LineWriter { + LineWriter(Arc::new(Mutex::new(Box::new(NullStream) as Box))) + } + + #[test] + fn ping_returns_version_and_protocol() { + let mux = test_mux(); + let data = handle_command(&mux, Command::Ping, &test_writer()).unwrap(); + assert_eq!(data["ok"].as_bool(), Some(true)); + assert_eq!(data["version"].as_str(), Some(env!("CARGO_PKG_VERSION"))); + assert_eq!(data["protocol"].as_u64(), Some(PROTOCOL_VERSION as u64)); + } + + #[test] + fn reload_config_returns_path_and_emits_request() { + let mux = test_mux(); + let events = mux.subscribe(); + let data = handle_command(&mux, Command::ReloadConfig, &test_writer()).unwrap(); + assert_eq!(data["reloaded"].as_bool(), Some(true)); + assert!(data.get("path").is_some()); + assert!(matches!( + events.recv_timeout(Duration::from_secs(1)), + Ok(MuxEvent::ConfigReloadRequested) + )); + } + + #[test] + fn window_title_commands_emit_requests() { + let mux = test_mux(); + let events = mux.subscribe(); + + let data = handle_command( + &mux, + Command::SetWindowTitle { title: "hello".to_string() }, + &test_writer(), + ) + .unwrap(); + assert_eq!(data, json!({})); + assert!(matches!( + events.recv_timeout(Duration::from_secs(1)), + Ok(MuxEvent::WindowTitleRequested(title)) if title == "hello" + )); + + handle_command(&mux, Command::ClearWindowTitle, &test_writer()).unwrap(); + assert!(matches!( + events.recv_timeout(Duration::from_secs(1)), + Ok(MuxEvent::WindowTitleRequested(title)) if title.is_empty() + )); + } + + #[test] + fn window_title_osc_uses_osc_0_and_2_and_strips_controls() { + assert_eq!(window_title_osc("hello").as_slice(), b"\x1b]0;hello\x07\x1b]2;hello\x07"); + assert_eq!(window_title_osc("a\x1bb\x07c").as_slice(), b"\x1b]0;a b c\x07\x1b]2;a b c\x07"); + } + + #[test] + fn scroll_surface_emits_one_scroll_changed_event() { + let mux = test_mux(); + let surface = mux.new_workspace(None, Some((20, 4))).unwrap(); + surface + .try_with_terminal(|term| { + for i in 0..20 { + term.vt_write(format!("line{i}\r\n").as_bytes()); + } + }) + .unwrap(); + let events = mux.subscribe(); + + handle_command( + &mux, + Command::ScrollSurface { surface: surface.id, delta: -5 }, + &test_writer(), + ) + .unwrap(); + + let event = events.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(matches!( + event, + MuxEvent::ScrollChanged { surface: id, offset, at_bottom: false } + if id == surface.id && offset > 0 + )); + assert!(matches!(events.try_recv(), Err(TryRecvError::Empty))); + + handle_command( + &mux, + Command::ScrollSurface { surface: surface.id, delta: 0 }, + &test_writer(), + ) + .unwrap(); + assert!(matches!(events.try_recv(), Err(TryRecvError::Empty))); + } +} diff --git a/mux/crates/mux-core/src/surface.rs b/mux/crates/mux-core/src/surface.rs index f39ce59fd929..05617d0c9c6d 100644 --- a/mux/crates/mux-core/src/surface.rs +++ b/mux/crates/mux-core/src/surface.rs @@ -160,6 +160,7 @@ pub struct PtySurface { title: Mutex, pwd: Mutex>, size: Mutex<(u16, u16)>, + mux: Weak, /// Live output subscribers (attach streams). Guarded by the terminal /// lock ordering: the reader thread broadcasts while holding the /// terminal lock, and [`Surface::attach_stream`] registers taps under @@ -258,6 +259,7 @@ impl Surface { title: Mutex::new(String::new()), pwd: Mutex::new(None), size: Mutex::new((opts.cols, opts.rows)), + mux: mux.clone(), taps: Mutex::new(Vec::new()), })); @@ -273,9 +275,12 @@ impl Surface { Ok(n) => n, }; let pty = surface.as_pty().expect("surface reader got non-pty surface"); + let mut scroll_changed = None; { let mut term = pty.term.lock().unwrap(); + let before = terminal_scroll_position(&term); term.vt_write(&buf[..n]); + let after = terminal_scroll_position(&term); { let mut taps = pty.taps.lock().unwrap(); if !taps.is_empty() { @@ -293,6 +298,18 @@ impl Surface { if let Some(pwd) = term.pwd() { *pty.pwd.lock().unwrap() = Some(pwd); } + if before != after { + scroll_changed = Some(after); + } + } + if let Some((offset, at_bottom)) = scroll_changed { + if let Some(mux) = mux.upgrade() { + mux.emit(MuxEvent::ScrollChanged { + surface: surface.id, + offset, + at_bottom, + }); + } } let responses = std::mem::take(&mut *pending_responses.lock().unwrap()); if !responses.is_empty() { @@ -366,6 +383,7 @@ impl Surface { title: Mutex::new(String::new()), pwd: Mutex::new(None), size: Mutex::new((opts.cols, opts.rows)), + mux, taps: Mutex::new(Vec::new()), }))) } @@ -420,6 +438,44 @@ impl Surface { Ok(f(&mut pty.term.lock().unwrap())) } + pub fn scroll_delta(&self, delta: isize) -> anyhow::Result<()> { + let Some(pty) = self.as_pty() else { + anyhow::bail!("browser surface does not have a VT terminal"); + }; + let changed = { + let mut term = pty.term.lock().unwrap(); + let before = terminal_scroll_position(&term); + term.scroll_delta(delta); + let after = terminal_scroll_position(&term); + (before != after).then_some(after) + }; + if let Some((offset, at_bottom)) = changed { + if let Some(mux) = pty.mux.upgrade() { + mux.emit(MuxEvent::ScrollChanged { surface: self.id, offset, at_bottom }); + } + } + Ok(()) + } + + pub fn scroll_to_bottom(&self) -> anyhow::Result<()> { + let Some(pty) = self.as_pty() else { + anyhow::bail!("browser surface does not have a VT terminal"); + }; + let changed = { + let mut term = pty.term.lock().unwrap(); + let before = terminal_scroll_position(&term); + term.scroll_to_bottom(); + let after = terminal_scroll_position(&term); + (before != after).then_some(after) + }; + if let Some((offset, at_bottom)) = changed { + if let Some(mux) = pty.mux.upgrade() { + mux.emit(MuxEvent::ScrollChanged { surface: self.id, offset, at_bottom }); + } + } + Ok(()) + } + pub fn set_default_colors(&self, colors: DefaultColors) { if let Some(pty) = self.as_pty() { pty.term.lock().unwrap().set_default_colors(colors.fg, colors.bg); @@ -736,3 +792,10 @@ impl PtySurface { true } } + +fn terminal_scroll_position(term: &Terminal) -> (u64, bool) { + match term.scrollbar() { + Some(scrollbar) => (scrollbar.offset, !scrollbar.scrolled_back()), + None => (0, true), + } +} diff --git a/mux/crates/mux-tui/src/app.rs b/mux/crates/mux-tui/src/app.rs index 816b24a2aead..099c3ec8ea5c 100644 --- a/mux/crates/mux-tui/src/app.rs +++ b/mux/crates/mux-tui/src/app.rs @@ -840,6 +840,21 @@ impl App { } } + fn reload_config(&mut self) { + let config = crate::config::load(); + self.session.apply_config(&config); + self.config = config; + } + + fn write_window_title(&self, title: &str) -> anyhow::Result<()> { + let lock = self.stdout_lock.clone(); + let _guard = lock.lock().unwrap(); + let mut stdout = std::io::stdout(); + stdout.write_all(&mux_core::server::window_title_osc(title))?; + stdout.flush()?; + Ok(()) + } + /// Refresh the tree snapshot, recompute the active screen's layout /// (each pane's border box eats one cell on every side), and push /// content sizes to surfaces. @@ -929,6 +944,14 @@ impl App { self.status_message = Some(message); Ok(RenderAction::Draw) } + AppEvent::Mux(MuxEvent::ConfigReloadRequested) => { + self.reload_config(); + Ok(RenderAction::Draw) + } + AppEvent::Mux(MuxEvent::WindowTitleRequested(title)) => { + self.write_window_title(&title)?; + Ok(RenderAction::None) + } AppEvent::Mux(MuxEvent::SurfaceOutput(id)) => { if self.frame_only_browser_update(id) { Ok(RenderAction::Graphics) @@ -1048,14 +1071,7 @@ impl App { }; let Some(surface_id) = self.selection.map(|sel| sel.surface) else { return false }; let Some(surface) = self.session.surface(surface_id) else { return false }; - let moved = surface - .with_terminal(|t| { - let before = t.scrollbar().map(|sb| sb.offset).unwrap_or(0); - t.scroll_delta(dir as isize); - let after = t.scrollbar().map(|sb| sb.offset).unwrap_or(0); - before != after - }) - .unwrap_or(false); + let moved = surface.scroll_delta(dir as isize).unwrap_or(false); let edge_row = if dir < 0 { 0 } else { content.height.saturating_sub(1) }; let offset = self.surface_scroll_offset(surface_id); if let Some(sel) = self.selection.as_mut() { @@ -1618,7 +1634,7 @@ impl App { if surface.kind() == SurfaceKind::Browser { return; } - let _ = surface.with_terminal(|t| t.scroll_delta(delta)); + let _ = surface.scroll_delta(delta); } } @@ -1633,9 +1649,8 @@ impl App { let Some(input) = keys::key_input_from(key) else { return }; let Some(surface) = self.active_surface_handle() else { return }; self.encode_buf.clear(); + let _ = surface.scroll_to_bottom(); let Some(encoded) = surface.with_terminal(|term| { - // New input snaps the viewport back to the live screen. - term.scroll_to_bottom(); self.encoder.sync_from_terminal(term); self.encoder.encode(&input, &mut self.encode_buf) }) else { @@ -2317,23 +2332,27 @@ impl App { /// outside it jumps first, then anchors at the clicked position. fn start_scrollbar_drag(&mut self, surface: SurfaceId, track: Rect, y: u16) { let Some(handle) = self.session.surface(surface) else { return }; - let mut anchor_offset = None; - let _ = handle.with_terminal(|t| { - let Some(sb) = t.scrollbar() else { return }; - let rel_y = y.saturating_sub(track.y).min(track.height.saturating_sub(1)); - let (thumb_y, thumb_len) = thumb_geometry(&sb, track.height); - let on_thumb = rel_y >= thumb_y && rel_y < thumb_y + thumb_len; - if !on_thumb { + let jump_delta = handle + .with_terminal(|t| { + let sb = t.scrollbar()?; + let rel_y = y.saturating_sub(track.y).min(track.height.saturating_sub(1)); + let (thumb_y, thumb_len) = thumb_geometry(&sb, track.height); + let on_thumb = rel_y >= thumb_y && rel_y < thumb_y + thumb_len; + if on_thumb { + return None; + } let denom = track.height.saturating_sub(1).max(1) as f64; let frac = (rel_y as f64 / denom).clamp(0.0, 1.0); let target = ((sb.total - sb.len) as f64 * frac).round() as i64; let delta = target - sb.offset as i64; - if delta != 0 { - t.scroll_delta(delta as isize); - } - } - anchor_offset = t.scrollbar().map(|after| after.offset); - }); + (delta != 0).then_some(delta as isize) + }) + .flatten(); + if let Some(delta) = jump_delta { + let _ = handle.scroll_delta(delta); + } + let anchor_offset = + handle.with_terminal(|t| t.scrollbar().map(|scrollbar| scrollbar.offset)).flatten(); if let Some(anchor_offset) = anchor_offset { self.drag = Some(Drag::Scrollbar { surface, track, anchor_y: y, anchor_offset }); } @@ -2349,20 +2368,23 @@ impl App { y: u16, ) { let Some(handle) = self.session.surface(surface) else { return }; - handle.with_terminal(|t| { - let Some(sb) = t.scrollbar() else { return }; - let (_, thumb_len) = thumb_geometry(&sb, track.height); - let range = sb.total.saturating_sub(sb.len); - let travel = track.height.saturating_sub(thumb_len).max(1) as i128; - let dy = y as i128 - anchor_y as i128; - let delta = dy * range as i128 / travel; - let target = (anchor_offset as i128 + delta).clamp(0, range as i128) as i64; - let current = sb.offset as i64; - let scroll_delta = target - current; - if scroll_delta != 0 { - t.scroll_delta(scroll_delta as isize); - } - }); + let delta = handle + .with_terminal(|t| { + let sb = t.scrollbar()?; + let (_, thumb_len) = thumb_geometry(&sb, track.height); + let range = sb.total.saturating_sub(sb.len); + let travel = track.height.saturating_sub(thumb_len).max(1) as i128; + let dy = y as i128 - anchor_y as i128; + let delta = dy * range as i128 / travel; + let target = (anchor_offset as i128 + delta).clamp(0, range as i128) as i64; + let current = sb.offset as i64; + let scroll_delta = target - current; + (scroll_delta != 0).then_some(scroll_delta as isize) + }) + .flatten(); + if let Some(delta) = delta { + let _ = handle.scroll_delta(delta); + } } fn resize_focused_split(&mut self, delta: f32) { @@ -2527,20 +2549,21 @@ impl App { } let Some(sent_arrows) = surface.with_terminal(|term| { if term.active_screen() == Screen::Alternate && !term.mouse_tracking() { - term.scroll_to_bottom(); true } else { - term.scroll_delta(if down { 3 } else { -3 }); false } }) else { return Ok(RenderAction::None); }; if sent_arrows { + let _ = surface.scroll_to_bottom(); // Alt-screen apps without mouse support get arrow keys // (the usual alternate-scroll behavior). let seq: &[u8] = if down { b"\x1b[B\x1b[B\x1b[B" } else { b"\x1b[A\x1b[A\x1b[A" }; surface.write_bytes(seq); + } else { + let _ = surface.scroll_delta(if down { 3 } else { -3 }); } Ok(RenderAction::Draw) } diff --git a/mux/crates/mux-tui/src/cli.rs b/mux/crates/mux-tui/src/cli.rs index ed64cc7a88c0..c67e2fd6213e 100644 --- a/mux/crates/mux-tui/src/cli.rs +++ b/mux/crates/mux-tui/src/cli.rs @@ -48,6 +48,28 @@ const VERBS: &[VerbSpec] = &[ print: print_identify, stream: false, }, + VerbSpec { name: "ping", allowed: &[], build: build_no_args, print: print_ping, stream: false }, + VerbSpec { + name: "reload-config", + allowed: &[], + build: build_no_args, + print: print_empty, + stream: false, + }, + VerbSpec { + name: "set-window-title", + allowed: &["title"], + build: build_set_window_title, + print: print_empty, + stream: false, + }, + VerbSpec { + name: "clear-window-title", + allowed: &[], + build: build_no_args, + print: print_empty, + stream: false, + }, VerbSpec { name: "list-workspaces", allowed: &[], @@ -908,6 +930,10 @@ fn build_set_default_colors(flags: &FlagMap) -> Result { Ok(value) } +fn build_set_window_title(flags: &FlagMap) -> Result { + Ok(json!({ "title": flags.required("title")? })) +} + fn build_rename_pane(flags: &FlagMap) -> Result { Ok(json!({ "pane": flags.required_u64("pane")?, "name": flags.required("name")? })) } @@ -1103,6 +1129,15 @@ fn print_identify(data: &Value, out: &mut dyn Write) -> io::Result<()> { ) } +fn print_ping(data: &Value, out: &mut dyn Write) -> io::Result<()> { + writeln!( + out, + "cmux-mux version={} protocol={}", + data.get("version").and_then(Value::as_str).unwrap_or(""), + data.get("protocol").and_then(Value::as_u64).unwrap_or(0) + ) +} + fn print_read_screen(data: &Value, out: &mut dyn Write) -> io::Result<()> { write!(out, "{}", data.get("text").and_then(Value::as_str).unwrap_or("")) } diff --git a/mux/crates/mux-tui/src/config.rs b/mux/crates/mux-tui/src/config.rs index e5151611863c..9ce96496785b 100644 --- a/mux/crates/mux-tui/src/config.rs +++ b/mux/crates/mux-tui/src/config.rs @@ -62,6 +62,7 @@ use std::collections::HashMap; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use mux_core::platform; +use mux_core::SurfaceOptions; use ratatui::style::Color; use serde::{Deserialize, Deserializer}; use serde_json::Value; @@ -729,6 +730,17 @@ pub fn load() -> Config { config } +pub fn apply_browser_to_surface_options(config: &Config, options: &mut SurfaceOptions) { + options.chrome_binary = config.browser.chrome_binary.clone(); + options.cdp_url = config.browser.cdp_url.clone(); + options.browser_discover = config.browser.discover; + options.browser_discover_ports = config.browser.discover_ports.clone(); + options.browser_user_data_dir = config.browser.user_data_dir.clone(); + options.browser_ephemeral = config.browser.ephemeral; + options.browser_max_capture_megapixels = config.browser.max_capture_megapixels; + options.browser_capture_scale = config.browser.capture_scale; +} + /// The label for a tab: user name if set, otherwise its 1-based number /// plus a recognized agent program name (or the full title when /// `show_titles` is on). diff --git a/mux/crates/mux-tui/src/main.rs b/mux/crates/mux-tui/src/main.rs index d30ce7912c5c..6403d3acd5be 100644 --- a/mux/crates/mux-tui/src/main.rs +++ b/mux/crates/mux-tui/src/main.rs @@ -73,7 +73,8 @@ MOUSE screen entries to switch screens (+ for a new screen). CLI VERBS - identify, list-workspaces, export-layout, apply-layout, send, + identify, ping, reload-config, set-window-title, clear-window-title, + list-workspaces, export-layout, apply-layout, send, read-screen, vt-state, new-tab, new-browser-tab, new-workspace, new-screen, split, set-ratio, pane-neighbor, focus-direction, swap-pane, zoom-pane, process-info, set-default-colors, @@ -157,14 +158,7 @@ fn run_attach(args: Args) -> anyhow::Result<()> { fn run_server(args: Args) -> anyhow::Result<()> { let mut surface_options = SurfaceOptions::default(); let config = config::load(); - surface_options.chrome_binary = config.browser.chrome_binary.clone(); - surface_options.cdp_url = config.browser.cdp_url.clone(); - surface_options.browser_discover = config.browser.discover; - surface_options.browser_discover_ports = config.browser.discover_ports.clone(); - surface_options.browser_user_data_dir = config.browser.user_data_dir.clone(); - surface_options.browser_ephemeral = config.browser.ephemeral; - surface_options.browser_max_capture_megapixels = config.browser.max_capture_megapixels; - surface_options.browser_capture_scale = config.browser.capture_scale; + config::apply_browser_to_surface_options(&config, &mut surface_options); if let Some(term) = args.term { surface_options.term = term; } diff --git a/mux/crates/mux-tui/src/session/mod.rs b/mux/crates/mux-tui/src/session/mod.rs index 5dd3d6fbafd9..a19577c2a43b 100644 --- a/mux/crates/mux-tui/src/session/mod.rs +++ b/mux/crates/mux-tui/src/session/mod.rs @@ -93,6 +93,14 @@ impl Session { } } + pub fn apply_config(&self, config: &crate::config::Config) { + if let Session::Local(mux) = self { + mux.update_surface_options(|options| { + crate::config::apply_browser_to_surface_options(config, options); + }); + } + } + pub fn tree(&self) -> TreeView { match self { Session::Local(mux) => { @@ -475,6 +483,56 @@ impl SurfaceHandle { } } + pub fn scroll_delta(&self, delta: isize) -> Option { + match self { + SurfaceHandle::Local(surface) => { + let before = surface + .with_terminal(|term| term.scrollbar().map(|sb| sb.offset)) + .flatten() + .unwrap_or(0); + surface.scroll_delta(delta).ok()?; + let after = surface + .with_terminal(|term| term.scrollbar().map(|sb| sb.offset)) + .flatten() + .unwrap_or(0); + Some(before != after) + } + SurfaceHandle::Remote(surface, _) if surface.kind == SurfaceKind::Pty => { + let mut term = surface.term.lock().unwrap(); + let before = term.scrollbar().map(|sb| sb.offset).unwrap_or(0); + term.scroll_delta(delta); + let after = term.scrollbar().map(|sb| sb.offset).unwrap_or(0); + Some(before != after) + } + SurfaceHandle::Remote(_, _) | SurfaceHandle::RemoteBrowserUnsupported => None, + } + } + + pub fn scroll_to_bottom(&self) -> Option { + match self { + SurfaceHandle::Local(surface) => { + let before = surface + .with_terminal(|term| term.scrollbar().map(|sb| sb.offset)) + .flatten() + .unwrap_or(0); + surface.scroll_to_bottom().ok()?; + let after = surface + .with_terminal(|term| term.scrollbar().map(|sb| sb.offset)) + .flatten() + .unwrap_or(0); + Some(before != after) + } + SurfaceHandle::Remote(surface, _) if surface.kind == SurfaceKind::Pty => { + let mut term = surface.term.lock().unwrap(); + let before = term.scrollbar().map(|sb| sb.offset).unwrap_or(0); + term.scroll_to_bottom(); + let after = term.scrollbar().map(|sb| sb.offset).unwrap_or(0); + Some(before != after) + } + SurfaceHandle::Remote(_, _) | SurfaceHandle::RemoteBrowserUnsupported => None, + } + } + pub fn browser_frame(&self) -> Option { match self { SurfaceHandle::Local(surface) => surface.browser_frame(), diff --git a/mux/crates/mux-tui/src/session/remote.rs b/mux/crates/mux-tui/src/session/remote.rs index e4481c8b21e8..0ef656a0ec88 100644 --- a/mux/crates/mux-tui/src/session/remote.rs +++ b/mux/crates/mux-tui/src/session/remote.rs @@ -377,6 +377,21 @@ impl RemoteSession { self.emit(MuxEvent::Status(message.to_string())); } } + Some("config-reload-requested") => self.emit(MuxEvent::ConfigReloadRequested), + Some("window-title-requested") => { + if let Some(title) = value.get("title").and_then(|v| v.as_str()) { + self.emit(MuxEvent::WindowTitleRequested(title.to_string())); + } + } + Some("scroll-changed") => { + if let (Some(surface), Some(offset), Some(at_bottom)) = ( + surface_id(), + value.get("offset").and_then(|v| v.as_u64()), + value.get("at_bottom").and_then(|v| v.as_bool()), + ) { + self.emit(MuxEvent::ScrollChanged { surface, offset, at_bottom }); + } + } Some("empty") => self.emit(MuxEvent::Empty), Some(_) => {} } diff --git a/mux/crates/mux-tui/tests/cli.rs b/mux/crates/mux-tui/tests/cli.rs index a5cbb6c8c42e..ea0e3b7c9d7d 100644 --- a/mux/crates/mux-tui/tests/cli.rs +++ b/mux/crates/mux-tui/tests/cli.rs @@ -65,6 +65,16 @@ fn cli_verbs_cover_command_output_errors_and_streams() { assert_eq!(value.get("app").and_then(|v| v.as_str()), Some("cmux-mux")); assert!(value.get("protocol").and_then(|v| v.as_u64()).unwrap_or(0) >= 5); + let ping_json = cli(&server, &["--json", "ping"]); + assert_success(&ping_json); + let ping: serde_json::Value = serde_json::from_slice(&ping_json.stdout).unwrap(); + assert_eq!(ping.get("ok").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(ping.get("protocol").and_then(|v| v.as_u64()), Some(6)); + + let title = cli(&server, &["set-window-title", "--title", "hello"]); + assert_success(&title); + assert!(title.stdout.is_empty(), "set-window-title should be quiet on success"); + let workspace = cli(&server, &["new-workspace", "--name", "cli-test"]); assert_success(&workspace); let surface = String::from_utf8(workspace.stdout).unwrap().trim().parse::().unwrap(); diff --git a/mux/spec/cli.md b/mux/spec/cli.md index 1d1e3819105d..0dc606dd3387 100644 --- a/mux/spec/cli.md +++ b/mux/spec/cli.md @@ -51,6 +51,10 @@ The generated CLI requires one of `--index` or `--delta` for `select-tab`, `sele | Verb | Status | Required flags/args | Optional flags | Human stdout | | --- | --- | --- | --- | --- | | `identify` | implemented | none | global flags | one metadata line | +| `ping` | implemented | none | global flags | one liveness line | +| `reload-config` | implemented | none | global flags | none | +| `set-window-title` | implemented | `--title ` | global flags | none | +| `clear-window-title` | implemented | none | global flags | none | | `list-workspaces` | implemented | none | global flags | tree lines | | `export-layout` | implemented | none | `--screen <id>` | JSON result object | | `apply-layout` | implemented | `--layout <json>` | `--workspace <id>`, `--name <name>` | screen and pane/surface lines | diff --git a/mux/spec/commands.md b/mux/spec/commands.md index eccfc77cadd1..81dc9e5f170e 100644 --- a/mux/spec/commands.md +++ b/mux/spec/commands.md @@ -1,6 +1,6 @@ # Command Contract -This file specifies the JSON command contract for the cmux-mux protocol. Implemented commands match protocol v5 in `mux/crates/mux-core/src/server.rs`. Proposed commands are future protocol v6 design. +This file specifies the JSON command contract for the cmux-mux protocol. Implemented commands match protocol v6 in `mux/crates/mux-core/src/server.rs`. ## Notation @@ -131,7 +131,129 @@ Example: ```json {"id":1,"cmd":"identify"} -{"id":1,"ok":true,"data":{"app":"cmux-mux","version":"0.1.0","protocol":5,"session":"main","pid":12345}} +{"id":1,"ok":true,"data":{"app":"cmux-mux","version":"0.1.0","protocol":6,"session":"main","pid":12345}} +``` + +### ping + +| Field | Value | +| --- | --- | +| name | `ping` | +| status | implemented | +| since | protocol 6 | + +Lightweight liveness probe. Unlike `identify`, this does not return session metadata. + +Params: none. + +Result: + +```text +object{ok:true,version:string,protocol:uint32} +``` + +Errors: `bad request: ...`. + +CLI mapping: verb `ping`; flags none; plain stdout prints `cmux-mux version=<version> protocol=<protocol>`; JSON stdout prints the exact result object. + +Example: + +```json +{"id":2,"cmd":"ping"} +{"id":2,"ok":true,"data":{"ok":true,"version":"0.1.0","protocol":6}} +``` + +### reload-config + +| Field | Value | +| --- | --- | +| name | `reload-config` | +| status | implemented | +| since | protocol 6 | + +Requests that attached TUI frontends re-read the mux config from the same source as startup config loading (`CMUX_MUX_CONFIG`, then XDG config, then `~/.config/cmux/mux.json`) and redraw. Headless servers acknowledge the command but have no TUI state to update. + +Params: none. + +Result: + +```text +object{reloaded:true,path:string|null} +``` + +Live reapply: theme/colors, tab display settings, sidebar width settings, scrollbar placement, and keybindings apply on the next TUI frame. Browser config updates local server launch options for future browser surfaces when a local TUI is present; existing browser runtimes, already-open browser surfaces, and remote headless servers may require restart for browser endpoint/profile/binary changes. + +Errors: `bad request: ...`. + +CLI mapping: verb `reload-config`; flags none; plain stdout prints nothing; JSON stdout prints the exact result object. + +Example: + +```json +{"id":3,"cmd":"reload-config"} +{"id":3,"ok":true,"data":{"reloaded":true,"path":"/Users/me/.config/cmux/mux.json"}} +``` + +### set-window-title + +| Field | Value | +| --- | --- | +| name | `set-window-title` | +| status | implemented | +| since | protocol 6 | + +Requests attached TUI frontends to set the outer terminal emulator window title by writing OSC 0 and OSC 2 sequences to their controlling stdout. This is display-only and does not change focus or selection. + +Params: + +| Name | JSON type | Required/default | Constraints | +| --- | --- | --- | --- | +| `title` | `string` | required | C0 controls are sanitized before OSC output | + +Result: + +```text +object{} +``` + +Errors: `bad request: ...`. + +CLI mapping: verb `set-window-title`; flags `--title <title>`; plain stdout and JSON stdout are empty result object behavior. + +Example: + +```json +{"id":4,"cmd":"set-window-title","title":"hello"} +{"id":4,"ok":true,"data":{}} +``` + +### clear-window-title + +| Field | Value | +| --- | --- | +| name | `clear-window-title` | +| status | implemented | +| since | protocol 6 | + +Requests attached TUI frontends to restore the default outer terminal window title. The current TUI default is empty. + +Params: none. + +Result: + +```text +object{} +``` + +Errors: `bad request: ...`. + +CLI mapping: verb `clear-window-title`; flags none; plain stdout and JSON stdout are empty result object behavior. + +Example: + +```json +{"id":5,"cmd":"clear-window-title"} +{"id":5,"ok":true,"data":{}} ``` ### list-workspaces diff --git a/mux/spec/events.md b/mux/spec/events.md index ce8c61d7bad8..60c2563dfef4 100644 --- a/mux/spec/events.md +++ b/mux/spec/events.md @@ -6,9 +6,9 @@ Implemented event lines can appear on two stream types: | Stream | How to start | Event names | | --- | --- | --- | -| Subscribe stream | `subscribe` command | `tree-changed`, `layout-changed`, `surface-output`, `surface-resized`, `surface-exited`, `title-changed`, `bell`, `notification`, `empty` | +| Subscribe stream | `subscribe` command | `tree-changed`, `layout-changed`, `surface-output`, `scroll-changed`, `surface-resized`, `surface-exited`, `title-changed`, `bell`, `notification`, `config-reload-requested`, `window-title-requested`, `empty` | | Attach stream v5 | `attach-surface` command | `vt-state`, `output`, `detached` | -| Attach stream v6 | `attach-surface` command | `vt-state`, `resized`, `output`, `detached` | +| Attach stream v6 | `attach-surface` command | `vt-state`, `resized`, `output`, `scroll-changed`, `detached` | Events and command responses share one JSON-lines connection. Clients must route lines by checking for `event`. If `event` is absent, the line is a command response and should be matched by `id`. @@ -96,6 +96,30 @@ Example: {"event":"surface-output","surface":1} ``` +### scroll-changed + +| Field | Value | +| --- | --- | +| event | `scroll-changed` | +| status | implemented | +| since | protocol 6 | + +Payload: + +```text +object{event:"scroll-changed",surface:Id,offset:uint64,at_bottom:boolean} +``` + +Meaning: A PTY surface viewport moved within its scrollback. The event is emitted after a settled viewport mutation from user scrolling, `scroll-surface`, input snapping the viewport to the live bottom, or PTY output that changes the viewport position. `offset` is the same row offset used by the scrollbar geometry, and `at_bottom` is true when the viewport is pinned to the live bottom. + +Subscribe streams receive all scroll changes. Attach streams receive scroll changes for the attached surface only. + +Example: + +```json +{"event":"scroll-changed","surface":1,"offset":12,"at_bottom":false} +``` + ### surface-resized | Field | Value | @@ -206,6 +230,50 @@ Example: {"event":"notification","notification":44,"title":"Build failed","body":"api tests failed","level":"error","surface":1} ``` +### config-reload-requested + +| Field | Value | +| --- | --- | +| event | `config-reload-requested` | +| status | implemented | +| since | protocol 6 | + +Payload: + +```text +object{event:"config-reload-requested"} +``` + +Meaning: Emitted by `reload-config` so attached TUI frontends can re-read local mux config and redraw. + +Example: + +```json +{"event":"config-reload-requested"} +``` + +### window-title-requested + +| Field | Value | +| --- | --- | +| event | `window-title-requested` | +| status | implemented | +| since | protocol 6 | + +Payload: + +```text +object{event:"window-title-requested",title:string} +``` + +Meaning: Emitted by `set-window-title` and `clear-window-title` so attached TUI frontends can write OSC 0/2 to their controlling stdout. Empty `title` clears the title. + +Example: + +```json +{"event":"window-title-requested","title":"hello"} +``` + ### empty | Field | Value | From 07589e4f3bdec90d2bb63ab8657e4cabc32fe38c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:53:50 -0700 Subject: [PATCH 2/2] mux: fix clippy needless-bool in scroll-changed arrow-key path --- mux/crates/mux-tui/src/app.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/mux/crates/mux-tui/src/app.rs b/mux/crates/mux-tui/src/app.rs index 099c3ec8ea5c..a4f1790d9d25 100644 --- a/mux/crates/mux-tui/src/app.rs +++ b/mux/crates/mux-tui/src/app.rs @@ -2548,11 +2548,7 @@ impl App { return Ok(RenderAction::None); } let Some(sent_arrows) = surface.with_terminal(|term| { - if term.active_screen() == Screen::Alternate && !term.mouse_tracking() { - true - } else { - false - } + term.active_screen() == Screen::Alternate && !term.mouse_tracking() }) else { return Ok(RenderAction::None); };