aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-03-07 16:33:05 +0100
committerGitHub <noreply@github.com>2026-03-07 16:33:05 +0100
commit58cb2267fd7994ad9abb2df9792ec452a2deab45 (patch)
treed0ab9dbbc147bd5db5b7dee5c1687069cc946d99
parentUpdate CHANGELOG.md (diff)
downloadsl0ppij-58cb2267fd7994ad9abb2df9792ec452a2deab45.tar.gz
sl0ppij-58cb2267fd7994ad9abb2df9792ec452a2deab45.tar.xz
sl0ppij-58cb2267fd7994ad9abb2df9792ec452a2deab45.zip
Allow subscribing to pane viewport updates (renders) from the CLI (#4814)
* initial subscribe implementation * tests for subscribe * update cli help * rustfmt * add pr
-rw-r--r--CHANGELOG.md1
-rw-r--r--src/commands.rs57
-rw-r--r--src/main.rs4
-rw-r--r--zellij-client/src/cli_client.rs112
-rw-r--r--zellij-client/src/lib.rs3
-rw-r--r--zellij-client/src/web_client/server_listener.rs3
-rw-r--r--zellij-server/src/panes/floating_panes/mod.rs4
-rw-r--r--zellij-server/src/panes/grid.rs13
-rw-r--r--zellij-server/src/panes/plugin_pane.rs3
-rw-r--r--zellij-server/src/panes/terminal_pane.rs4
-rw-r--r--zellij-server/src/panes/tiled_panes/mod.rs4
-rw-r--r--zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs1
-rw-r--r--zellij-server/src/panes/unit/grid_tests.rs88
-rw-r--r--zellij-server/src/route.rs15
-rw-r--r--zellij-server/src/screen.rs259
-rw-r--r--zellij-server/src/tab/mod.rs13
-rw-r--r--zellij-server/src/unit/screen_tests.rs959
-rw-r--r--zellij-utils/assets/prost_ipc/client_server_contract.rs38
-rw-r--r--zellij-utils/src/cli.rs115
-rw-r--r--zellij-utils/src/client_server_contract/client_to_server.proto6
-rw-r--r--zellij-utils/src/client_server_contract/server_to_client.proto14
-rw-r--r--zellij-utils/src/errors.rs2
-rw-r--r--zellij-utils/src/ipc.rs15
-rw-r--r--zellij-utils/src/ipc/protobuf_conversion.rs65
-rw-r--r--zellij-utils/src/ipc/tests/roundtrip_tests.rs36
25 files changed, 1808 insertions, 26 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a794806..86f472c1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -72,6 +72,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
* build: fix redundant proto codegen, plugin batch building and testing, and profile mismatch between build and run (https://github.com/zellij-org/zellij/pull/4799, https://github.com/zellij-org/zellij/pull/4801)
* feat: click to open file-paths in viewport, allow plugins to highlight viewport text (https://github.com/zellij-org/zellij/pull/4802 and https://github.com/zellij-org/zellij/pull/4809)
* fix: better handle STDIN segmentation with latency (eg. over SSH) (https://github.com/zellij-org/zellij/pull/4623)
+* feat: `zellij subscribe` -> allow to subscribe to pane viewport/scrollback updates from the cli (https://github.com/zellij-org/zellij/pull/4814)
## [0.43.1] - 2025-08-08
diff --git a/src/commands.rs b/src/commands.rs
index d00d1d04..77bdecec 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -442,6 +442,63 @@ pub(crate) fn send_action_to_session(
},
};
}
+pub(crate) fn subscribe_to_session(
+ subscribe_cli: zellij_utils::cli::SubscribeCli,
+ requested_session_name: Option<String>,
+ _config: Option<Config>,
+) {
+ let session_name = match get_active_session() {
+ ActiveSession::None => {
+ eprintln!("There is no active session!");
+ std::process::exit(1);
+ },
+ ActiveSession::One(session_name) => {
+ if let Some(ref requested) = requested_session_name {
+ if *requested != session_name {
+ eprintln!(
+ "Session '{}' not found. The following sessions are active:",
+ requested
+ );
+ eprintln!("{}", session_name);
+ std::process::exit(1);
+ }
+ }
+ session_name
+ },
+ ActiveSession::Many => {
+ let existing_sessions: Vec<String> = get_sessions()
+ .unwrap_or_default()
+ .iter()
+ .map(|s| s.0.clone())
+ .collect();
+ if let Some(session_name) = requested_session_name {
+ if existing_sessions.contains(&session_name) {
+ session_name
+ } else {
+ eprintln!(
+ "Session '{}' not found. The following sessions are active:",
+ session_name
+ );
+ list_sessions(false, false, true);
+ std::process::exit(1);
+ }
+ } else if let Ok(session_name) = envs::get_session_name() {
+ session_name
+ } else {
+ eprintln!("Please specify the session name to subscribe to. The following sessions are active:");
+ list_sessions(false, false, true);
+ std::process::exit(1);
+ }
+ },
+ };
+ let os_input = get_os_input(zellij_client::os_input_output::get_cli_client_os_input);
+ zellij_client::cli_client::start_subscribe_client(
+ Box::new(os_input),
+ &session_name,
+ subscribe_cli,
+ );
+}
+
pub(crate) fn convert_old_config_file(old_config_file: PathBuf) {
match File::open(&old_config_file) {
Ok(mut handle) => {
diff --git a/src/main.rs b/src/main.rs
index 1a0db7e1..ddca0f27 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -25,6 +25,10 @@ fn main() {
commands::send_action_to_session(*cli_action, opts.session, config);
std::process::exit(0);
}
+ if let Some(Command::Subscribe(subscribe_cli)) = opts.command {
+ commands::subscribe_to_session(subscribe_cli, opts.session, config);
+ std::process::exit(0);
+ }
if let Some(Command::Sessions(Sessions::Run {
command,
direction,
diff --git a/zellij-client/src/cli_client.rs b/zellij-client/src/cli_client.rs
index a82e677e..909fa8a2 100644
--- a/zellij-client/src/cli_client.rs
+++ b/zellij-client/src/cli_client.rs
@@ -1,13 +1,16 @@
//! The `[cli_client]` is used to attach to a running server session
//! and dispatch actions, that are specified through the command line.
-use std::collections::BTreeMap;
-use std::io::BufRead;
+use std::collections::{BTreeMap, HashSet};
+use std::io::{self, BufRead, Write};
use std::process;
+use std::str::FromStr;
use std::{fs, path::PathBuf};
use crate::os_input_output::ClientOsApi;
use uuid::Uuid;
use zellij_utils::{
+ cli::{SubscribeCli, SubscribeFormat},
+ data::PaneId,
errors::prelude::*,
input::actions::Action,
ipc::{ClientToServerMsg, ExitReason, ServerToClientMsg},
@@ -241,3 +244,108 @@ fn individual_messages_client(
}
}
}
+
+pub fn start_subscribe_client(
+ mut os_input: Box<dyn ClientOsApi>,
+ session_name: &str,
+ subscribe_cli: SubscribeCli,
+) {
+ let zellij_ipc_pipe: PathBuf = {
+ let mut sock_dir = zellij_utils::consts::ZELLIJ_SOCK_DIR.clone();
+ fs::create_dir_all(&sock_dir).unwrap();
+ zellij_utils::shared::set_permissions(&sock_dir, 0o700).unwrap();
+ sock_dir.push(session_name);
+ sock_dir
+ };
+ os_input.connect_to_server(&*zellij_ipc_pipe);
+
+ // Parse pane IDs
+ let pane_ids: Vec<PaneId> = subscribe_cli
+ .pane_id
+ .iter()
+ .map(|s| {
+ PaneId::from_str(s).unwrap_or_else(|e| {
+ eprintln!("Invalid pane ID '{}': {}", s, e);
+ process::exit(2);
+ })
+ })
+ .collect();
+
+ // Send subscribe message
+ os_input.send_to_server(ClientToServerMsg::SubscribeToPaneRenders {
+ pane_ids: pane_ids.clone(),
+ scrollback: subscribe_cli.scrollback,
+ });
+
+ // Track remaining panes for exit-on-all-closed
+ let mut remaining_panes: HashSet<PaneId> = pane_ids.into_iter().collect();
+
+ // Streaming receive loop
+ let stdout = io::stdout();
+ let mut stdout = stdout.lock();
+
+ loop {
+ match os_input.recv_from_server() {
+ Some((
+ ServerToClientMsg::PaneRenderUpdate {
+ pane_id,
+ viewport,
+ scrollback,
+ is_initial,
+ },
+ _,
+ )) => match subscribe_cli.format {
+ SubscribeFormat::Raw => {
+ if let Some(ref scrollback_lines) = scrollback {
+ for line in scrollback_lines {
+ let _ = writeln!(stdout, "{}", line);
+ }
+ }
+ for line in &viewport {
+ let _ = writeln!(stdout, "{}", line);
+ }
+ let _ = stdout.flush();
+ },
+ SubscribeFormat::Json => {
+ let json = serde_json::json!({
+ "event": "pane_update",
+ "pane_id": pane_id.to_string(),
+ "viewport": viewport,
+ "scrollback": scrollback,
+ "is_initial": is_initial,
+ });
+ let _ = writeln!(stdout, "{}", json);
+ let _ = stdout.flush();
+ },
+ },
+ Some((ServerToClientMsg::SubscribedPaneClosed { pane_id }, _)) => {
+ remaining_panes.remove(&pane_id);
+ match subscribe_cli.format {
+ SubscribeFormat::Raw => {},
+ SubscribeFormat::Json => {
+ let json = serde_json::json!({
+ "event": "pane_closed",
+ "pane_id": pane_id.to_string(),
+ });
+ let _ = writeln!(stdout, "{}", json);
+ let _ = stdout.flush();
+ },
+ }
+ if remaining_panes.is_empty() {
+ break;
+ }
+ },
+ Some((ServerToClientMsg::Exit { .. }, _)) => break,
+ Some((ServerToClientMsg::LogError { lines }, _)) => {
+ for line in lines {
+ eprintln!("{}", line);
+ }
+ process::exit(2);
+ },
+ None => break,
+ _ => {},
+ }
+ }
+
+ os_input.send_to_server(ClientToServerMsg::ClientExited);
+}
diff --git a/zellij-client/src/lib.rs b/zellij-client/src/lib.rs
index dcb4bebb..002fa048 100644
--- a/zellij-client/src/lib.rs
+++ b/zellij-client/src/lib.rs
@@ -184,6 +184,9 @@ impl From<ServerToClientMsg> for ClientInstruction {
ServerToClientMsg::StartWebServer => ClientInstruction::StartWebServer,
ServerToClientMsg::RenamedSession { name } => ClientInstruction::RenamedSession(name),
ServerToClientMsg::ConfigFileUpdated => ClientInstruction::ConfigFileUpdated,
+ // Subscribe-only messages — not handled by regular interactive clients
+ ServerToClientMsg::PaneRenderUpdate { .. } => ClientInstruction::UnblockInputThread,
+ ServerToClientMsg::SubscribedPaneClosed { .. } => ClientInstruction::UnblockInputThread,
}
}
}
diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs
index 4d2b6b7b..bcd83425 100644
--- a/zellij-client/src/web_client/server_listener.rs
+++ b/zellij-client/src/web_client/server_listener.rs
@@ -225,6 +225,9 @@ pub fn zellij_server_listener(
}
}
},
+ // Subscribe-only messages — not relevant for web clients
+ Some(ServerToClientMsg::PaneRenderUpdate { .. }) => {},
+ Some(ServerToClientMsg::SubscribedPaneClosed { .. }) => {},
None => {
if unknown_message_count >= 1000 {
log::error!("Error: Received more than 1000 consecutive unknown server messages, disconnecting.");
diff --git a/zellij-server/src/panes/floating_panes/mod.rs b/zellij-server/src/panes/floating_panes/mod.rs
index cfe27812..ed0bb54c 100644
--- a/zellij-server/src/panes/floating_panes/mod.rs
+++ b/zellij-server/src/panes/floating_panes/mod.rs
@@ -406,7 +406,7 @@ impl FloatingPanes {
output.add_pane_contents(
&connected_clients,
pane.pid(),
- pane.pane_contents(None, false),
+ pane.pane_contents(None, false, None),
);
},
PaneId::Plugin(_) => {
@@ -414,7 +414,7 @@ impl FloatingPanes {
output.add_pane_contents(
&[*client_id],
pane.pid(),
- pane.pane_contents(Some(*client_id), false),
+ pane.pane_contents(Some(*client_id), false, None),
);
}
},
diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs
index b6b64abf..f551abe7 100644
--- a/zellij-server/src/panes/grid.rs
+++ b/zellij-server/src/panes/grid.rs
@@ -3025,7 +3025,11 @@ impl Grid {
pub fn has_selection(&self) -> bool {
!self.selection.is_empty()
}
- pub fn pane_contents(&self, get_full_scrollback: bool) -> PaneContents {
+ pub fn pane_contents(
+ &self,
+ get_full_scrollback: bool,
+ max_scrollback_lines: Option<usize>,
+ ) -> PaneContents {
let mut viewport: Vec<String> = Vec::with_capacity(self.viewport.len());
for row in &self.viewport {
let s: String = (&row.columns).into_iter().map(|x| x.character).collect();
@@ -3037,6 +3041,13 @@ impl Grid {
let s: String = (&row.columns).into_iter().map(|x| x.character).collect();
lines_above_viewport.push(s);
}
+ // Truncate to last N lines if max specified (Some(0) means "all" — no truncation)
+ if let Some(max) = max_scrollback_lines {
+ if max > 0 && lines_above_viewport.len() > max {
+ let start = lines_above_viewport.len() - max;
+ lines_above_viewport = lines_above_viewport.split_off(start);
+ }
+ }
let mut lines_below_viewport: Vec<String> = Vec::with_capacity(self.lines_below.len());
for row in &self.lines_below {
let s: String = (&row.columns).into_iter().map(|x| x.character).collect();
diff --git a/zellij-server/src/panes/plugin_pane.rs b/zellij-server/src/panes/plugin_pane.rs
index b318f539..c2960949 100644
--- a/zellij-server/src/panes/plugin_pane.rs
+++ b/zellij-server/src/panes/plugin_pane.rs
@@ -885,10 +885,11 @@ impl Pane for PluginPane {
&self,
client_id: Option<ClientId>,
get_full_scrollback: bool,
+ max_scrollback_lines: Option<usize>,
) -> PaneContents {
client_id
.and_then(|c| self.grids.get(&c))
- .map(|g| g.pane_contents(get_full_scrollback))
+ .map(|g| g.pane_contents(get_full_scrollback, max_scrollback_lines))
.unwrap_or_else(Default::default)
}
}
diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs
index 52be384d..d4b43297 100644
--- a/zellij-server/src/panes/terminal_pane.rs
+++ b/zellij-server/src/panes/terminal_pane.rs
@@ -943,8 +943,10 @@ impl Pane for TerminalPane {
&self,
_client_id: Option<ClientId>,
get_full_scrollback: bool,
+ max_scrollback_lines: Option<usize>,
) -> PaneContents {
- self.grid.pane_contents(get_full_scrollback)
+ self.grid
+ .pane_contents(get_full_scrollback, max_scrollback_lines)
}
fn update_exit_status(&mut self, exit_status: i32) {
if let Some(notification_end) = self.notification_end.as_mut() {
diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs
index 4c8020ac..42bb4f0b 100644
--- a/zellij-server/src/panes/tiled_panes/mod.rs
+++ b/zellij-server/src/panes/tiled_panes/mod.rs
@@ -1046,7 +1046,7 @@ impl TiledPanes {
output.add_pane_contents(
&connected_clients,
pane.pid(),
- pane.pane_contents(None, false),
+ pane.pane_contents(None, false, None),
);
},
PaneId::Plugin(_) => {
@@ -1054,7 +1054,7 @@ impl TiledPanes {
output.add_pane_contents(
&[*client_id],
pane.pid(),
- pane.pane_contents(Some(*client_id), false),
+ pane.pane_contents(Some(*client_id), false, None),
);
}
},
diff --git a/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs b/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs
index e4adfbe4..651b61dd 100644
--- a/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs
+++ b/zellij-server/src/panes/tiled_panes/unit/stacked_panes_tests.rs
@@ -1240,6 +1240,7 @@ impl Pane for MockPane {
&self,
_client_id: Option<ClientId>,
_get_full_scrollback: bool,
+ _max_scrollback_lines: Option<usize>,
) -> PaneContents {
unimplemented!()
}
diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs
index d5ce6a6a..59956f8e 100644
--- a/zellij-server/src/panes/unit/grid_tests.rs
+++ b/zellij-server/src/panes/unit/grid_tests.rs
@@ -4860,3 +4860,91 @@ fn highlight_style_variants_resolve_colors() {
assert!(fg.is_some());
assert!(bg.is_none());
}
+
+fn create_grid_with_scrollback() -> Grid {
+ let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default()));
+ let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new()));
+ let mut grid = Grid::new(
+ 5,
+ 40,
+ Rc::new(RefCell::new(Palette::default())),
+ terminal_emulator_color_codes,
+ Rc::new(RefCell::new(LinkHandler::new())),
+ Rc::new(RefCell::new(None)),
+ sixel_image_store,
+ Style::default(),
+ false,
+ true,
+ true,
+ true,
+ false,
+ );
+ let mut parser = vte::Parser::new();
+ for i in 0..25 {
+ let line = format!("scrollback line {}\r\n", i);
+ for byte in line.as_bytes() {
+ parser.advance(&mut grid, *byte);
+ }
+ }
+ grid
+}
+
+#[test]
+fn pane_contents_scrollback_no_truncation_when_max_none() {
+ let grid = create_grid_with_scrollback();
+ let result = grid.pane_contents(true, None);
+ assert_eq!(
+ result.lines_above_viewport.len(),
+ 21,
+ "All scrollback lines should be returned when max is None"
+ );
+}
+
+#[test]
+fn pane_contents_scrollback_no_truncation_when_max_zero() {
+ let grid = create_grid_with_scrollback();
+ let result = grid.pane_contents(true, Some(0));
+ assert_eq!(
+ result.lines_above_viewport.len(),
+ 21,
+ "Some(0) is sentinel for all scrollback — no truncation"
+ );
+}
+
+#[test]
+fn pane_contents_scrollback_truncates_to_last_n() {
+ let grid = create_grid_with_scrollback();
+ let result = grid.pane_contents(true, Some(5));
+ assert_eq!(result.lines_above_viewport.len(), 5);
+ let full = grid.pane_contents(true, None);
+ let expected: Vec<String> = full
+ .lines_above_viewport
+ .iter()
+ .rev()
+ .take(5)
+ .rev()
+ .cloned()
+ .collect();
+ assert_eq!(result.lines_above_viewport, expected);
+}
+
+#[test]
+fn pane_contents_scrollback_no_truncation_when_n_exceeds_total() {
+ let grid = create_grid_with_scrollback();
+ let result = grid.pane_contents(true, Some(100));
+ assert_eq!(
+ result.lines_above_viewport.len(),
+ 21,
+ "No truncation when N exceeds total scrollback lines"
+ );
+}
+
+#[test]
+fn pane_contents_no_scrollback_when_flag_false() {
+ let grid = create_grid_with_scrollback();
+ let result = grid.pane_contents(false, Some(5));
+ assert!(
+ result.lines_above_viewport.is_empty(),
+ "get_full_scrollback=false should never collect scrollback"
+ );
+}
diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs
index 5a5c5c15..540f398f 100644
--- a/zellij-server/src/route.rs
+++ b/zellij-server/src/route.rs
@@ -2289,6 +2289,21 @@ pub(crate) fn route_thread_main(
let _ =
to_server.send(ServerInstruction::FailedToStartWebServer(error));
},
+ ClientToServerMsg::SubscribeToPaneRenders {
+ ref pane_ids,
+ ref scrollback,
+ } => {
+ send_to_screen_or_retry_queue!(
+ senders,
+ ScreenInstruction::SubscribeToPaneRenders {
+ client_id,
+ pane_ids: pane_ids.clone(),
+ scrollback: *scrollback,
+ },
+ instruction,
+ retry_queue
+ );
+ },
}
Ok(should_break)
};
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index b84c2058..c907cfd6 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -42,9 +42,9 @@ use log::{debug, warn};
use zellij_utils::data::{
CommandOrPlugin, Direction, FloatingPaneCoordinates, GetFocusedPaneInfoResponse,
KeyWithModifier, LayoutInfo, LayoutWithError, ListPanesResponse, ListTabsResponse,
- NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest, PaneScrollbackResponse,
- PluginPermission, RegexHighlight, Resize, ResizeStrategy, SessionInfo, Styling, TabInfo,
- WebSharing,
+ NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest, PaneRenderReport,
+ PaneScrollbackResponse, PluginPermission, RegexHighlight, Resize, ResizeStrategy, SessionInfo,
+ Styling, TabInfo, WebSharing,
};
use zellij_utils::errors::prelude::*;
use zellij_utils::input::command::RunCommand;
@@ -52,6 +52,7 @@ use zellij_utils::input::config::Config;
use zellij_utils::input::keybinds::Keybinds;
use zellij_utils::input::mouse::MouseEvent;
use zellij_utils::input::options::Clipboard;
+use zellij_utils::ipc::{ExitReason, ServerToClientMsg};
use zellij_utils::pane_size::{PaneGeom, Size, SizeInPixels};
use zellij_utils::shared::clean_string_from_control_and_linebreak;
use zellij_utils::{
@@ -88,7 +89,7 @@ use zellij_utils::{
data::{Event, InputMode, ModeInfo, Palette, PaletteColor, PluginCapabilities, Style},
errors::{ContextType, ScreenContext},
input::get_mode_info,
- ipc::{ClientAttributes, PixelDimensions, ServerToClientMsg},
+ ipc::{ClientAttributes, PixelDimensions},
};
/// Get the active tab and call a closure on it
@@ -681,6 +682,14 @@ pub enum ScreenInstruction {
plugin_id: u32,
},
ClearAllPluginHighlights(u32), // plugin_id — clears across all panes
+ SubscribeToPaneRenders {
+ client_id: ClientId,
+ pane_ids: Vec<zellij_utils::data::PaneId>,
+ scrollback: Option<usize>,
+ },
+ NotifyPaneClosedToSubscribers {
+ pane_id: zellij_utils::data::PaneId,
+ },
}
impl From<&ScreenInstruction> for ScreenContext {
@@ -949,6 +958,12 @@ impl From<&ScreenInstruction> for ScreenContext {
},
ScreenInstruction::ClearPluginHighlights { .. } => ScreenContext::ClearPluginHighlights,
ScreenInstruction::ClearAllPluginHighlights(..) => ScreenContext::ClearPluginHighlights,
+ ScreenInstruction::SubscribeToPaneRenders { .. } => {
+ ScreenContext::SubscribeToPaneRenders
+ },
+ ScreenInstruction::NotifyPaneClosedToSubscribers { .. } => {
+ ScreenContext::NotifyPaneClosedToSubscribers
+ },
}
}
}
@@ -1070,6 +1085,11 @@ impl WatcherState {
}
}
+struct PaneRenderSubscription {
+ pane_ids: HashSet<zellij_utils::data::PaneId>,
+ previous_viewports: HashMap<zellij_utils::data::PaneId, Vec<String>>,
+}
+
/// A [`Screen`] holds multiple [`Tab`]s, each one holding multiple [`panes`](crate::client::panes).
/// It only directly controls which tab is active, delegating the rest to the individual `Tab`.
pub(crate) struct Screen {
@@ -1134,6 +1154,7 @@ pub(crate) struct Screen {
followed_client_id: Option<ClientId>,
cached_layouts: Vec<LayoutInfo>,
cached_layout_errors: Vec<LayoutWithError>,
+ pane_render_subscribers: HashMap<ClientId, PaneRenderSubscription>,
}
impl Screen {
@@ -1228,6 +1249,7 @@ impl Screen {
followed_client_id: None,
cached_layouts: vec![],
cached_layout_errors: vec![],
+ pane_render_subscribers: HashMap::new(),
}
}
@@ -1609,6 +1631,11 @@ impl Screen {
.collect(),
));
+ // Notify pane render subscribers of each closed pane
+ for p_id in &pane_ids {
+ self.notify_pane_closed_to_subscribers((*p_id).into());
+ }
+
// below we don't check the result of sending the CloseTab instruction to the pty thread
// because this might be happening when the app is closing, at which point the pty thread
// has already closed and this would result in an error
@@ -1784,6 +1811,12 @@ impl Screen {
}
let pane_render_report = output.drain_pane_render_report();
+
+ // Subscriber delivery — gated behind is_empty() for zero overhead
+ if !self.pane_render_subscribers.is_empty() {
+ self.deliver_to_pane_subscribers_from_report(&pane_render_report);
+ }
+
let _ = self
.bus
.senders
@@ -1859,6 +1892,11 @@ impl Screen {
} else {
// No regular clients, output is not dirty
non_watcher_output_was_dirty = false;
+
+ // No regular clients but subscribers exist — query panes directly
+ if !self.pane_render_subscribers.is_empty() {
+ self.deliver_to_pane_subscribers_directly();
+ }
}
// === PHASE 2: Render for watchers ===
@@ -2375,6 +2413,7 @@ impl Screen {
self.tab_history.remove(&client_id);
}
self.connected_clients.borrow_mut().remove(&client_id);
+ self.pane_render_subscribers.remove(&client_id);
self.log_and_report_session_state()
.with_context(err_context)
}
@@ -4231,6 +4270,206 @@ impl Screen {
}
}
}
+ fn subscribe_to_pane_renders(
+ &mut self,
+ subscriber_client_id: ClientId,
+ pane_ids: Vec<zellij_utils::data::PaneId>,
+ scrollback: Option<usize>,
+ ) {
+ let mut previous_viewports = HashMap::new();
+ let mut valid_pane_ids = HashSet::new();
+
+ // Get a regular client ID for plugin pane content queries
+ let regular_client_id = self
+ .connected_clients
+ .borrow()
+ .keys()
+ .find(|id| !self.watcher_clients.contains_key(id))
+ .copied();
+
+ for pane_id in &pane_ids {
+ let server_pane_id: PaneId = (*pane_id).into();
+ let mut found = false;
+
+ for tab in self.tabs.values() {
+ if let Some(pane) = tab.get_pane_with_id(server_pane_id) {
+ let get_full_scrollback = scrollback.is_some();
+ let max_lines = scrollback.and_then(|n| if n == 0 { None } else { Some(n) });
+
+ // For plugin panes, use a regular client_id; for terminal panes, None is fine
+ let query_client_id = match server_pane_id {
+ PaneId::Plugin(_) => regular_client_id,
+ PaneId::Terminal(_) => None,
+ };
+ let contents =
+ pane.pane_contents(query_client_id, get_full_scrollback, max_lines);
+
+ if let Some(os_input) = &self.bus.os_input {
+ let scrollback_data = if scrollback.is_some() {
+ Some(contents.lines_above_viewport.clone())
+ } else {
+ None
+ };
+ let _ = os_input.send_to_client(
+ subscriber_client_id,
+ ServerToClientMsg::PaneRenderUpdate {
+ pane_id: *pane_id,
+ viewport: contents.viewport.clone(),
+ scrollback: scrollback_data,
+ is_initial: true,
+ },
+ );
+ }
+
+ previous_viewports.insert(*pane_id, contents.viewport);
+ valid_pane_ids.insert(*pane_id);
+ found = true;
+ break;
+ }
+ }
+
+ if !found {
+ if let Some(os_input) = &self.bus.os_input {
+ let _ = os_input.send_to_client(
+ subscriber_client_id,
+ ServerToClientMsg::LogError {
+ lines: vec![format!("Pane {} not found", pane_id)],
+ },
+ );
+ }
+ }
+ }
+
+ if !valid_pane_ids.is_empty() {
+ self.pane_render_subscribers.insert(
+ subscriber_client_id,
+ PaneRenderSubscription {
+ pane_ids: valid_pane_ids,
+ previous_viewports,
+ },
+ );
+ }
+ }
+ fn deliver_to_pane_subscribers_from_report(&mut self, report: &PaneRenderReport) {
+ let Some(pane_map) = report.all_pane_contents.values().next() else {
+ return;
+ };
+ self.deliver_subscriber_updates_from_map(pane_map);
+ }
+ fn deliver_to_pane_subscribers_directly(&mut self) {
+ // Collect unique pane IDs across all subscribers
+ let all_subscribed_ids: HashSet<zellij_utils::data::PaneId> = self
+ .pane_render_subscribers
+ .values()
+ .flat_map(|sub| sub.pane_ids.iter().copied())
+ .collect();
+
+ // Query pane contents directly from tabs
+ let mut pane_map: HashMap<zellij_utils::data::PaneId, PaneContents> = HashMap::new();
+ for pane_id in &all_subscribed_ids {
+ let server_pane_id: PaneId = (*pane_id).into();
+ for tab in self.tabs.values() {
+ if let Some(pane) = tab.get_pane_with_id(server_pane_id) {
+ pane_map.insert(*pane_id, pane.pane_contents(None, false, None));
+ break;
+ }
+ }
+ }
+
+ self.deliver_subscriber_updates_from_map(&pane_map);
+ }
+ fn deliver_subscriber_updates_from_map(
+ &mut self,
+ pane_map: &HashMap<zellij_utils::data::PaneId, PaneContents>,
+ ) {
+ // Collect updates to send, avoiding borrow conflicts
+ let mut updates_to_send: Vec<(ClientId, ServerToClientMsg)> = Vec::new();
+ let mut dead_subscribers: Vec<ClientId> = Vec::new();
+
+ for (subscriber_id, subscription) in &self.pane_render_subscribers {
+ for pane_id in &subscription.pane_ids {
+ if let Some(contents) = pane_map.get(pane_id) {
+ let changed = subscription
+ .previous_viewports
+ .get(pane_id)
+ .map(|prev| prev != &contents.viewport)
+ .unwrap_or(true);
+
+ if changed {
+ updates_to_send.push((
+ *subscriber_id,
+ ServerToClientMsg::PaneRenderUpdate {
+ pane_id: *pane_id,
+ viewport: contents.viewport.clone(),
+ scrollback: None,
+ is_initial: false,
+ },
+ ));
+ }
+ }
+ }
+ }
+
+ // Send updates and track dead subscribers
+ for (subscriber_id, msg) in &updates_to_send {
+ if let Some(os_input) = &self.bus.os_input {
+ if os_input
+ .send_to_client(*subscriber_id, msg.clone())
+ .is_err()
+ {
+ dead_subscribers.push(*subscriber_id);
+ }
+ }
+ }
+
+ // Update previous viewports for successful sends
+ for (subscriber_id, msg) in updates_to_send {
+ if dead_subscribers.contains(&subscriber_id) {
+ continue;
+ }
+ if let ServerToClientMsg::PaneRenderUpdate {
+ pane_id, viewport, ..
+ } = msg
+ {
+ if let Some(subscription) = self.pane_render_subscribers.get_mut(&subscriber_id) {
+ subscription.previous_viewports.insert(pane_id, viewport);
+ }
+ }
+ }
+
+ for id in dead_subscribers {
+ self.pane_render_subscribers.remove(&id);
+ }
+ }
+ fn notify_pane_closed_to_subscribers(&mut self, pane_id: zellij_utils::data::PaneId) {
+ let mut dead_subscribers = Vec::new();
+
+ for (subscriber_id, subscription) in &mut self.pane_render_subscribers {
+ if subscription.pane_ids.remove(&pane_id) {
+ if let Some(os_input) = &self.bus.os_input {
+ let _ = os_input.send_to_client(
+ *subscriber_id,
+ ServerToClientMsg::SubscribedPaneClosed { pane_id },
+ );
+ }
+ if subscription.pane_ids.is_empty() {
+ if let Some(os_input) = &self.bus.os_input {
+ let _ = os_input.send_to_client(
+ *subscriber_id,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::Normal,
+ },
+ );
+ }
+ dead_subscribers.push(*subscriber_id);
+ }
+ }
+ }
+
+ for id in dead_subscribers {
+ self.pane_render_subscribers.remove(&id);
+ }
+ }
}
#[cfg(not(test))]
@@ -5042,7 +5281,7 @@ pub(crate) fn screen_thread_main(
for tab in screen.get_tabs_mut().values() {
if let Some(pane) = tab.get_pane_with_id(pane_id) {
pane_contents =
- Some(pane.pane_contents(Some(client_id), get_full_scrollback));
+ Some(pane.pane_contents(Some(client_id), get_full_scrollback, None));
break;
}
}
@@ -7695,6 +7934,16 @@ pub(crate) fn screen_thread_main(
}
screen.render(None)?;
},
+ ScreenInstruction::SubscribeToPaneRenders {
+ client_id,
+ pane_ids,
+ scrollback,
+ } => {
+ screen.subscribe_to_pane_renders(client_id, pane_ids, scrollback);
+ },
+ ScreenInstruction::NotifyPaneClosedToSubscribers { pane_id } => {
+ screen.notify_pane_closed_to_subscribers(pane_id);
+ },
}
}
Ok(())
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index 59a79126..7ade6b68 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -28,7 +28,7 @@ use zellij_utils::shared::clean_string_from_control_and_linebreak;
use crate::background_jobs::BackgroundJob;
use crate::pane_groups::PaneGroups;
use crate::pty_writer::PtyWriteInstruction;
-use crate::screen::CopyOptions;
+use crate::screen::{CopyOptions, ScreenInstruction};
use crate::ui::{loading_indication::LoadingIndication, pane_boundaries_frame::FrameParams};
use layout_applier::LayoutApplier;
use swap_layouts::SwapLayouts;
@@ -604,6 +604,7 @@ pub trait Pane {
&self,
client_id: Option<ClientId>,
_get_full_scrollback: bool,
+ _max_scrollback_lines: Option<usize>,
) -> PaneContents;
fn update_exit_status(&mut self, _exit_status: i32) {}
fn set_plugin_regex_highlights(
@@ -2249,6 +2250,11 @@ impl Tab {
None,
Event::PaneClosed(pane_id.into()),
)]));
+ let _ = self
+ .senders
+ .send_to_screen(ScreenInstruction::NotifyPaneClosedToSubscribers {
+ pane_id: pane_id.into(),
+ });
drop(replaced_pane);
}
}
@@ -3817,6 +3823,11 @@ impl Tab {
None,
Event::PaneClosed(id.into()),
)]));
+ let _ = self
+ .senders
+ .send_to_screen(ScreenInstruction::NotifyPaneClosedToSubscribers {
+ pane_id: id.into(),
+ });
}
pub fn extract_pane(
&mut self,
diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs
index 3d021f43..2536ee85 100644
--- a/zellij-server/src/unit/screen_tests.rs
+++ b/zellij-server/src/unit/screen_tests.rs
@@ -49,8 +49,10 @@ use crate::panes::grid::Grid;
use crate::panes::link_handler::LinkHandler;
use crate::panes::sixel::SixelImageStore;
use std::cell::RefCell;
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::rc::Rc;
+use zellij_utils::data::{PaneContents, PaneRenderReport};
+use zellij_utils::ipc::ExitReason;
fn take_snapshot_and_cursor_coordinates(
ansi_instructions: &str,
@@ -361,7 +363,7 @@ impl MockScreen {
let client_attributes = self.client_attributes.clone();
let screen_bus = Bus::new(
vec![self.screen_receiver.take().unwrap()],
- None,
+ Some(&self.to_screen.clone()),
Some(&self.to_pty.clone()),
Some(&self.to_plugin.clone()),
Some(&self.to_server.clone()),
@@ -447,7 +449,7 @@ impl MockScreen {
let client_attributes = self.client_attributes.clone();
let screen_bus = Bus::new(
vec![self.screen_receiver.take().unwrap()],
- None,
+ Some(&self.to_screen.clone()),
Some(&self.to_pty.clone()),
Some(&self.to_plugin.clone()),
Some(&self.to_server.clone()),
@@ -5130,3 +5132,954 @@ pub fn send_cli_launch_or_focus_plugin_in_place_with_close_replaced_pane() {
assert_snapshot!(format!("{:#?}", fill_plugin_cwd_instruction));
}
+
+fn create_new_screen_with_message_capture(
+ size: Size,
+) -> (
+ Screen,
+ Arc<Mutex<HashMap<ClientId, Vec<ServerToClientMsg>>>>,
+) {
+ let mut bus: Bus<ScreenInstruction> = Bus::empty();
+ let fake_os_input = FakeInputOutput::default();
+ let messages = fake_os_input.server_to_client_messages.clone();
+ bus.os_input = Some(Box::new(fake_os_input));
+ let client_attributes = ClientAttributes {
+ size,
+ ..Default::default()
+ };
+ let max_panes = None;
+ let mut mode_info = ModeInfo::default();
+ mode_info.session_name = Some("zellij-test".into());
+ let draw_pane_frames = false;
+ let auto_layout = true;
+ let session_is_mirrored = true;
+ let copy_options = CopyOptions::default();
+ let default_layout = Box::new(Layout::default());
+ let default_layout_name = None;
+ let default_shell = PathBuf::from("my_default_shell");
+ let session_serialization = true;
+ let serialize_pane_viewport = false;
+ let scrollback_lines_to_serialize = None;
+ let layout_dir = None;
+ let debug = false;
+ let styled_underlines = true;
+ let osc8_hyperlinks = true;
+ let arrow_fonts = true;
+ let explicitly_disable_kitty_keyboard_protocol = false;
+ let stacked_resize = true;
+ let web_sharing = WebSharing::Off;
+ let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
+ let web_server_port = 8080;
+ let visual_bell = true;
+ let screen = Screen::new(
+ bus,
+ &client_attributes,
+ max_panes,
+ mode_info,
+ draw_pane_frames,
+ auto_layout,
+ session_is_mirrored,
+ copy_options,
+ debug,
+ default_layout,
+ default_layout_name,
+ default_shell,
+ session_serialization,
+ serialize_pane_viewport,
+ scrollback_lines_to_serialize,
+ styled_underlines,
+ osc8_hyperlinks,
+ arrow_fonts,
+ layout_dir,
+ explicitly_disable_kitty_keyboard_protocol,
+ stacked_resize,
+ None,
+ false,
+ web_sharing,
+ true,
+ true,
+ visual_bell,
+ web_server_ip,
+ web_server_port,
+ );
+ (screen, messages)
+}
+
+#[test]
+fn subscriber_receives_initial_delivery() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+
+ screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None);
+
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ assert_eq!(client_msgs.len(), 1);
+ match &client_msgs[0] {
+ ServerToClientMsg::PaneRenderUpdate {
+ is_initial,
+ scrollback,
+ ..
+ } => {
+ assert!(*is_initial);
+ assert!(scrollback.is_none());
+ },
+ other => panic!("Expected PaneRenderUpdate, got {:?}", other),
+ }
+}
+
+#[test]
+fn subscriber_receives_initial_with_scrollback() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+
+ screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], Some(0));
+
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ assert_eq!(client_msgs.len(), 1);
+ match &client_msgs[0] {
+ ServerToClientMsg::PaneRenderUpdate {
+ is_initial,
+ scrollback,
+ ..
+ } => {
+ assert!(*is_initial);
+ assert!(scrollback.is_some());
+ },
+ other => panic!("Expected PaneRenderUpdate, got {:?}", other),
+ }
+}
+
+#[test]
+fn subscriber_no_update_on_unchanged_viewport() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+
+ screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None);
+
+ let initial_viewport = {
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ match &client_msgs[0] {
+ ServerToClientMsg::PaneRenderUpdate { viewport, .. } => viewport.clone(),
+ _ => panic!("Expected PaneRenderUpdate"),
+ }
+ };
+
+ let mut pane_map = HashMap::new();
+ pane_map.insert(
+ zellij_utils::data::PaneId::Terminal(1),
+ PaneContents {
+ viewport: initial_viewport,
+ ..Default::default()
+ },
+ );
+ screen.deliver_subscriber_updates_from_map(&pane_map);
+
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ assert_eq!(client_msgs.len(), 1);
+}
+
+#[test]
+fn subscriber_receives_update_on_changed_viewport() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+
+ screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None);
+
+ let mut pane_map = HashMap::new();
+ pane_map.insert(
+ zellij_utils::data::PaneId::Terminal(1),
+ PaneContents {
+ viewport: vec!["changed line".to_string()],
+ ..Default::default()
+ },
+ );
+ screen.deliver_subscriber_updates_from_map(&pane_map);
+
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ assert_eq!(client_msgs.len(), 2);
+ match &client_msgs[1] {
+ ServerToClientMsg::PaneRenderUpdate {
+ is_initial,
+ scrollback,
+ viewport,
+ ..
+ } => {
+ assert!(!is_initial);
+ assert!(scrollback.is_none());
+ assert_eq!(viewport, &vec!["changed line".to_string()]);
+ },
+ other => panic!("Expected PaneRenderUpdate, got {:?}", other),
+ }
+}
+
+#[test]
+fn subscriber_error_for_nonexistent_pane() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+
+ screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(999)], None);
+
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ assert_eq!(client_msgs.len(), 1);
+ match &client_msgs[0] {
+ ServerToClientMsg::LogError { lines } => {
+ let joined = lines.join(" ");
+ assert!(
+ joined.contains("not found"),
+ "Error message should contain 'not found', got: {}",
+ joined
+ );
+ },
+ other => panic!("Expected LogError, got {:?}", other),
+ }
+ assert!(
+ !screen.pane_render_subscribers.contains_key(&100),
+ "Subscriber should not be registered for nonexistent pane"
+ );
+}
+
+#[test]
+fn subscriber_state_registered_for_multiple_panes() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, _messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+ new_tab(&mut screen, 2, 1);
+
+ screen.subscribe_to_pane_renders(
+ 100,
+ vec![
+ zellij_utils::data::PaneId::Terminal(1),
+ zellij_utils::data::PaneId::Terminal(2),
+ ],
+ None,
+ );
+
+ let sub = screen.pane_render_subscribers.get(&100).unwrap();
+ assert_eq!(sub.pane_ids.len(), 2);
+ assert!(sub
+ .pane_ids
+ .contains(&zellij_utils::data::PaneId::Terminal(1)));
+ assert!(sub
+ .pane_ids
+ .contains(&zellij_utils::data::PaneId::Terminal(2)));
+}
+
+#[test]
+fn multiple_subscribers_receive_updates() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+
+ screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None);
+ screen.subscribe_to_pane_renders(101, vec![zellij_utils::data::PaneId::Terminal(1)], None);
+
+ let mut pane_map = HashMap::new();
+ pane_map.insert(
+ zellij_utils::data::PaneId::Terminal(1),
+ PaneContents {
+ viewport: vec!["new content".to_string()],
+ ..Default::default()
+ },
+ );
+ screen.deliver_subscriber_updates_from_map(&pane_map);
+
+ let msgs = messages.lock().unwrap();
+ let client_100_msgs = msgs.get(&100).unwrap();
+ let client_101_msgs = msgs.get(&101).unwrap();
+ assert_eq!(client_100_msgs.len(), 2);
+ assert_eq!(client_101_msgs.len(), 2);
+}
+
+#[test]
+fn subscriber_removed_on_remove_client() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, _messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+
+ screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None);
+ assert!(screen.pane_render_subscribers.contains_key(&100));
+
+ let _ = screen.remove_client(100);
+ assert!(!screen.pane_render_subscribers.contains_key(&100));
+}
+
+#[test]
+fn subscriber_removed_when_all_panes_closed() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+
+ screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None);
+
+ screen.notify_pane_closed_to_subscribers(zellij_utils::data::PaneId::Terminal(1));
+
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ let has_pane_closed = client_msgs.iter().any(|m| {
+ matches!(
+ m,
+ ServerToClientMsg::SubscribedPaneClosed {
+ pane_id: zellij_utils::data::PaneId::Terminal(1),
+ }
+ )
+ });
+ let has_exit = client_msgs.iter().any(|m| {
+ matches!(
+ m,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::Normal,
+ }
+ )
+ });
+ assert!(has_pane_closed, "Should send SubscribedPaneClosed");
+ assert!(has_exit, "Should send Exit when all panes closed");
+ assert!(!screen.pane_render_subscribers.contains_key(&100));
+}
+
+#[test]
+fn subscriber_partial_close() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+ new_tab(&mut screen, 2, 1);
+
+ screen.subscribe_to_pane_renders(
+ 100,
+ vec![
+ zellij_utils::data::PaneId::Terminal(1),
+ zellij_utils::data::PaneId::Terminal(2),
+ ],
+ None,
+ );
+
+ screen.notify_pane_closed_to_subscribers(zellij_utils::data::PaneId::Terminal(1));
+
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ let has_pane_closed = client_msgs.iter().any(|m| {
+ matches!(
+ m,
+ ServerToClientMsg::SubscribedPaneClosed {
+ pane_id: zellij_utils::data::PaneId::Terminal(1),
+ }
+ )
+ });
+ let has_exit = client_msgs
+ .iter()
+ .any(|m| matches!(m, ServerToClientMsg::Exit { .. }));
+ assert!(
+ has_pane_closed,
+ "Should send SubscribedPaneClosed for closed pane"
+ );
+ assert!(!has_exit, "Should NOT send Exit when panes remain");
+ assert!(screen.pane_render_subscribers.contains_key(&100));
+ assert_eq!(
+ screen
+ .pane_render_subscribers
+ .get(&100)
+ .unwrap()
+ .pane_ids
+ .len(),
+ 1
+ );
+}
+
+#[test]
+fn subscriber_full_close_sequence() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+ new_tab(&mut screen, 2, 1);
+
+ screen.subscribe_to_pane_renders(
+ 100,
+ vec![
+ zellij_utils::data::PaneId::Terminal(1),
+ zellij_utils::data::PaneId::Terminal(2),
+ ],
+ None,
+ );
+
+ screen.notify_pane_closed_to_subscribers(zellij_utils::data::PaneId::Terminal(1));
+ screen.notify_pane_closed_to_subscribers(zellij_utils::data::PaneId::Terminal(2));
+
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ let pane_closed_count = client_msgs
+ .iter()
+ .filter(|m| matches!(m, ServerToClientMsg::SubscribedPaneClosed { .. }))
+ .count();
+ let exit_count = client_msgs
+ .iter()
+ .filter(|m| {
+ matches!(
+ m,
+ ServerToClientMsg::Exit {
+ exit_reason: ExitReason::Normal,
+ }
+ )
+ })
+ .count();
+ assert_eq!(pane_closed_count, 2, "Two SubscribedPaneClosed messages");
+ assert_eq!(exit_count, 1, "One Exit message after all panes closed");
+ assert!(!screen.pane_render_subscribers.contains_key(&100));
+}
+
+#[test]
+fn delivery_path_a_and_b_produce_same_content() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+
+ screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None);
+
+ messages.lock().unwrap().get_mut(&100).unwrap().clear();
+
+ let contents = {
+ let server_pane_id = PaneId::Terminal(1);
+ let mut found_contents = None;
+ for tab in screen.tabs.values() {
+ if let Some(pane) = tab.get_pane_with_id(server_pane_id) {
+ found_contents = Some(pane.pane_contents(None, false, None));
+ break;
+ }
+ }
+ found_contents.expect("Pane should exist")
+ };
+
+ screen
+ .pane_render_subscribers
+ .get_mut(&100)
+ .unwrap()
+ .previous_viewports
+ .insert(
+ zellij_utils::data::PaneId::Terminal(1),
+ vec!["old".to_string()],
+ );
+
+ let mut pane_map = HashMap::new();
+ pane_map.insert(zellij_utils::data::PaneId::Terminal(1), contents.clone());
+ let mut all_pane_contents = HashMap::new();
+ all_pane_contents.insert(1 as ClientId, pane_map);
+ let report = PaneRenderReport { all_pane_contents };
+ screen.deliver_to_pane_subscribers_from_report(&report);
+
+ let viewport_a = {
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ match &client_msgs[0] {
+ ServerToClientMsg::PaneRenderUpdate { viewport, .. } => viewport.clone(),
+ other => panic!("Expected PaneRenderUpdate, got {:?}", other),
+ }
+ };
+
+ messages.lock().unwrap().get_mut(&100).unwrap().clear();
+ screen
+ .pane_render_subscribers
+ .get_mut(&100)
+ .unwrap()
+ .previous_viewports
+ .insert(
+ zellij_utils::data::PaneId::Terminal(1),
+ vec!["old".to_string()],
+ );
+
+ screen.deliver_to_pane_subscribers_directly();
+
+ let viewport_b = {
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ match &client_msgs[0] {
+ ServerToClientMsg::PaneRenderUpdate { viewport, .. } => viewport.clone(),
+ other => panic!("Expected PaneRenderUpdate, got {:?}", other),
+ }
+ };
+
+ assert_eq!(
+ viewport_a, viewport_b,
+ "Both delivery paths should produce identical viewport"
+ );
+}
+
+#[test]
+fn close_tab_notifies_subscribers() {
+ let size = Size { cols: 80, rows: 20 };
+ let (mut screen, messages) = create_new_screen_with_message_capture(size);
+ new_tab(&mut screen, 1, 0);
+ new_tab(&mut screen, 2, 1);
+
+ screen.subscribe_to_pane_renders(100, vec![zellij_utils::data::PaneId::Terminal(1)], None);
+
+ messages.lock().unwrap().get_mut(&100).unwrap().clear();
+
+ let _ = screen.go_to_tab(1, 1);
+ let _ = screen.close_tab(1);
+
+ let msgs = messages.lock().unwrap();
+ let client_msgs = msgs.get(&100).unwrap();
+ let has_pane_closed = client_msgs.iter().any(|m| {
+ matches!(
+ m,
+ ServerToClientMsg::SubscribedPaneClosed {
+ pane_id: zellij_utils::data::PaneId::Terminal(1),
+ }
+ )
+ });
+ assert!(
+ has_pane_closed,
+ "Closing tab should notify subscriber of pane closure"
+ );
+}
+
+#[test]
+fn close_pane_notifies_subscribers_via_instruction() {
+ let size = Size { cols: 80, rows: 20 };
+ let mut mock_screen = MockScreen::new(size);
+ mock_screen.drop_all_pty_messages();
+ let session_metadata = mock_screen.clone_session_metadata();
+
+ let mut initial_layout = TiledPaneLayout::default();
+ initial_layout.children_split_direction = SplitDirection::Vertical;
+ initial_layout.children = vec![TiledPaneLayout::default(), TiledPaneLayout::default()];
+
+ let screen_thread = mock_screen.run(Some(initial_layout), vec![]);
+
+ let server_receiver = mock_screen.server_receiver.take().unwrap();
+ let received_server_instructions = Arc::new(Mutex::new(vec![]));
+ let server_thread = log_actions_in_thread!(
+ received_server_instructions,
+ ServerInstruction::KillSession,
+ server_receiver
+ );
+ let plugin_receiver = mock_screen.plugin_receiver.take().unwrap();
+ let received_plugin_instructions = Arc::new(Mutex::new(vec![]));
+ let plugin_thread = log_actions_in_thread!(
+ received_plugin_instructions,
+ PluginInstruction::Exit,
+ plugin_receiver
+ );
+
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::SubscribeToPaneRenders {
+ client_id: 100,
+ pane_ids: vec![
+ zellij_utils::data::PaneId::Terminal(0),
+ zellij_utils::data::PaneId::Terminal(1),
+ ],
+ scrollback: None,
+ });
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::CloseFocusedPane(1, None));
+ std::thread::sleep(std::time::Duration::from_millis(200));
+
+ mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]);
+
+ let msgs = mock_screen
+ .os_input
+ .server_to_client_messages
+ .lock()
+ .unwrap();
+ let client_msgs = msgs.get(&100).unwrap_or(&vec![]).clone();
+ let has_pane_closed = client_msgs
+ .iter()
+ .any(|m| matches!(m, ServerToClientMsg::SubscribedPaneClosed { .. }));
+ assert!(
+ has_pane_closed,
+ "Closing pane via CLI action should notify subscriber. Messages: {:?}",
+ client_msgs
+ );
+}
+
+#[test]
+fn integration_pty_bytes_delivered_to_subscriber() {
+ let size = Size { cols: 80, rows: 20 };
+ let mut mock_screen = MockScreen::new(size);
+ mock_screen.drop_all_pty_messages();
+ let screen_thread = mock_screen.run(None, vec![]);
+
+ let server_receiver = mock_screen.server_receiver.take().unwrap();
+ let received_server_instructions = Arc::new(Mutex::new(vec![]));
+ let server_thread = log_actions_in_thread!(
+ received_server_instructions,
+ ServerInstruction::KillSession,
+ server_receiver
+ );
+ let plugin_receiver = mock_screen.plugin_receiver.take().unwrap();
+ let received_plugin_instructions = Arc::new(Mutex::new(vec![]));
+ let plugin_thread = log_actions_in_thread!(
+ received_plugin_instructions,
+ PluginInstruction::Exit,
+ plugin_receiver
+ );
+
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::SubscribeToPaneRenders {
+ client_id: 100,
+ pane_ids: vec![zellij_utils::data::PaneId::Terminal(0)],
+ scrollback: None,
+ });
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ let _ = mock_screen.to_screen.send(ScreenInstruction::PtyBytes(
+ 0,
+ "hello world\r\n".as_bytes().to_vec(),
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]);
+
+ let msgs = mock_screen
+ .os_input
+ .server_to_client_messages
+ .lock()
+ .unwrap();
+ let subscriber_msgs = msgs.get(&100).unwrap_or(&vec![]).clone();
+
+ assert!(
+ subscriber_msgs.len() >= 2,
+ "Should have at least initial + update, got {}",
+ subscriber_msgs.len()
+ );
+
+ match &subscriber_msgs[0] {
+ ServerToClientMsg::PaneRenderUpdate { is_initial, .. } => {
+ assert!(*is_initial, "First message should be initial");
+ },
+ other => panic!("Expected PaneRenderUpdate, got {:?}", other),
+ }
+
+ let has_hello = subscriber_msgs.iter().any(|m| match m {
+ ServerToClientMsg::PaneRenderUpdate {
+ is_initial: false,
+ viewport,
+ ..
+ } => viewport.iter().any(|line| line.contains("hello world")),
+ _ => false,
+ });
+ assert!(
+ has_hello,
+ "Subsequent message should contain 'hello world' in viewport. Messages: {:?}",
+ subscriber_msgs
+ );
+}
+
+#[test]
+fn integration_pty_bytes_not_delivered_when_viewport_unchanged() {
+ let size = Size { cols: 80, rows: 20 };
+ let mut mock_screen = MockScreen::new(size);
+ mock_screen.drop_all_pty_messages();
+ let screen_thread = mock_screen.run(None, vec![]);
+
+ let server_receiver = mock_screen.server_receiver.take().unwrap();
+ let received_server_instructions = Arc::new(Mutex::new(vec![]));
+ let server_thread = log_actions_in_thread!(
+ received_server_instructions,
+ ServerInstruction::KillSession,
+ server_receiver
+ );
+ let plugin_receiver = mock_screen.plugin_receiver.take().unwrap();
+ let received_plugin_instructions = Arc::new(Mutex::new(vec![]));
+ let plugin_thread = log_actions_in_thread!(
+ received_plugin_instructions,
+ PluginInstruction::Exit,
+ plugin_receiver
+ );
+
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::SubscribeToPaneRenders {
+ client_id: 100,
+ pane_ids: vec![zellij_utils::data::PaneId::Terminal(0)],
+ scrollback: None,
+ });
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::RenderToClients);
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]);
+
+ let msgs = mock_screen
+ .os_input
+ .server_to_client_messages
+ .lock()
+ .unwrap();
+ let subscriber_msgs = msgs.get(&100).unwrap_or(&vec![]).clone();
+
+ let render_update_count = subscriber_msgs
+ .iter()
+ .filter(|m| matches!(m, ServerToClientMsg::PaneRenderUpdate { .. }))
+ .count();
+ assert_eq!(
+ render_update_count, 1,
+ "Only the initial delivery should be present, not a duplicate. Got {} messages: {:?}",
+ render_update_count, subscriber_msgs
+ );
+}
+
+#[test]
+fn integration_scrollback_from_pre_subscription_pty_bytes() {
+ let size = Size { cols: 80, rows: 20 };
+ let mut mock_screen = MockScreen::new(size);
+ mock_screen.drop_all_pty_messages();
+ let screen_thread = mock_screen.run(None, vec![]);
+
+ let server_receiver = mock_screen.server_receiver.take().unwrap();
+ let received_server_instructions = Arc::new(Mutex::new(vec![]));
+ let server_thread = log_actions_in_thread!(
+ received_server_instructions,
+ ServerInstruction::KillSession,
+ server_receiver
+ );
+ let plugin_receiver = mock_screen.plugin_receiver.take().unwrap();
+ let received_plugin_instructions = Arc::new(Mutex::new(vec![]));
+ let plugin_thread = log_actions_in_thread!(
+ received_plugin_instructions,
+ PluginInstruction::Exit,
+ plugin_receiver
+ );
+
+ let mut data = String::new();
+ for i in 0..30 {
+ data.push_str(&format!("line {}\r\n", i));
+ }
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::PtyBytes(0, data.as_bytes().to_vec()));
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::SubscribeToPaneRenders {
+ client_id: 100,
+ pane_ids: vec![zellij_utils::data::PaneId::Terminal(0)],
+ scrollback: Some(0),
+ });
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ let _ = mock_screen.to_screen.send(ScreenInstruction::PtyBytes(
+ 0,
+ "post subscribe line\r\n".as_bytes().to_vec(),
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]);
+
+ let msgs = mock_screen
+ .os_input
+ .server_to_client_messages
+ .lock()
+ .unwrap();
+ let subscriber_msgs = msgs.get(&100).unwrap_or(&vec![]).clone();
+
+ let initial_msg = subscriber_msgs.iter().find(|m| {
+ matches!(
+ m,
+ ServerToClientMsg::PaneRenderUpdate {
+ is_initial: true,
+ ..
+ }
+ )
+ });
+ assert!(initial_msg.is_some(), "Should have an initial message");
+ match initial_msg.unwrap() {
+ ServerToClientMsg::PaneRenderUpdate {
+ scrollback,
+ is_initial,
+ ..
+ } => {
+ assert!(*is_initial);
+ assert!(
+ scrollback.is_some(),
+ "Initial message should include scrollback"
+ );
+ let sb = scrollback.as_ref().unwrap();
+ assert!(
+ !sb.is_empty(),
+ "Scrollback should contain pre-subscription lines"
+ );
+ let has_early_lines = sb.iter().any(|line| line.contains("line 0"));
+ assert!(
+ has_early_lines,
+ "Scrollback should contain early lines. Got: {:?}",
+ sb
+ );
+ },
+ _ => unreachable!(),
+ }
+
+ let subsequent_updates: Vec<_> = subscriber_msgs
+ .iter()
+ .filter(|m| {
+ matches!(
+ m,
+ ServerToClientMsg::PaneRenderUpdate {
+ is_initial: false,
+ ..
+ }
+ )
+ })
+ .collect();
+ for msg in &subsequent_updates {
+ match msg {
+ ServerToClientMsg::PaneRenderUpdate { scrollback, .. } => {
+ assert!(
+ scrollback.is_none(),
+ "Subsequent updates should not include scrollback"
+ );
+ },
+ _ => unreachable!(),
+ }
+ }
+}
+
+#[test]
+fn integration_no_scrollback_when_not_requested() {
+ let size = Size { cols: 80, rows: 20 };
+ let mut mock_screen = MockScreen::new(size);
+ mock_screen.drop_all_pty_messages();
+ let screen_thread = mock_screen.run(None, vec![]);
+
+ let server_receiver = mock_screen.server_receiver.take().unwrap();
+ let received_server_instructions = Arc::new(Mutex::new(vec![]));
+ let server_thread = log_actions_in_thread!(
+ received_server_instructions,
+ ServerInstruction::KillSession,
+ server_receiver
+ );
+ let plugin_receiver = mock_screen.plugin_receiver.take().unwrap();
+ let received_plugin_instructions = Arc::new(Mutex::new(vec![]));
+ let plugin_thread = log_actions_in_thread!(
+ received_plugin_instructions,
+ PluginInstruction::Exit,
+ plugin_receiver
+ );
+
+ let mut data = String::new();
+ for i in 0..30 {
+ data.push_str(&format!("line {}\r\n", i));
+ }
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::PtyBytes(0, data.as_bytes().to_vec()));
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::SubscribeToPaneRenders {
+ client_id: 100,
+ pane_ids: vec![zellij_utils::data::PaneId::Terminal(0)],
+ scrollback: None,
+ });
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ let _ = mock_screen.to_screen.send(ScreenInstruction::PtyBytes(
+ 0,
+ "post subscribe\r\n".as_bytes().to_vec(),
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]);
+
+ let msgs = mock_screen
+ .os_input
+ .server_to_client_messages
+ .lock()
+ .unwrap();
+ let subscriber_msgs = msgs.get(&100).unwrap_or(&vec![]).clone();
+
+ for msg in &subscriber_msgs {
+ match msg {
+ ServerToClientMsg::PaneRenderUpdate { scrollback, .. } => {
+ assert!(
+ scrollback.is_none(),
+ "Scrollback should be None when not requested. Got: {:?}",
+ scrollback
+ );
+ },
+ _ => {},
+ }
+ }
+}
+
+#[test]
+fn integration_subscriber_survives_after_regular_client_detach() {
+ let size = Size { cols: 80, rows: 20 };
+ let mut mock_screen = MockScreen::new(size);
+ mock_screen.drop_all_pty_messages();
+ let screen_thread = mock_screen.run(None, vec![]);
+
+ let server_receiver = mock_screen.server_receiver.take().unwrap();
+ let received_server_instructions = Arc::new(Mutex::new(vec![]));
+ let server_thread = log_actions_in_thread!(
+ received_server_instructions,
+ ServerInstruction::KillSession,
+ server_receiver
+ );
+ let plugin_receiver = mock_screen.plugin_receiver.take().unwrap();
+ let received_plugin_instructions = Arc::new(Mutex::new(vec![]));
+ let plugin_thread = log_actions_in_thread!(
+ received_plugin_instructions,
+ PluginInstruction::Exit,
+ plugin_receiver
+ );
+
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::SubscribeToPaneRenders {
+ client_id: 100,
+ pane_ids: vec![zellij_utils::data::PaneId::Terminal(0)],
+ scrollback: None,
+ });
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::RemoveClient(1));
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ let _ = mock_screen.to_screen.send(ScreenInstruction::PtyBytes(
+ 0,
+ "after detach\r\n".as_bytes().to_vec(),
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(100));
+
+ mock_screen.teardown(vec![server_thread, plugin_thread, screen_thread]);
+
+ let msgs = mock_screen
+ .os_input
+ .server_to_client_messages
+ .lock()
+ .unwrap();
+ let subscriber_msgs = msgs.get(&100).unwrap_or(&vec![]).clone();
+
+ let has_after_detach = subscriber_msgs.iter().any(|m| match m {
+ ServerToClientMsg::PaneRenderUpdate { viewport, .. } => {
+ viewport.iter().any(|line| line.contains("after detach"))
+ },
+ _ => false,
+ });
+ assert!(
+ has_after_detach,
+ "Subscriber should receive updates after regular client detach. Messages: {:?}",
+ subscriber_msgs
+ );
+}
diff --git a/zellij-utils/assets/prost_ipc/client_server_contract.rs b/zellij-utils/assets/prost_ipc/client_server_contract.rs
index f2bc91c9..e7fdd27d 100644
--- a/zellij-utils/assets/prost_ipc/client_server_contract.rs
+++ b/zellij-utils/assets/prost_ipc/client_server_contract.rs
@@ -2583,7 +2583,7 @@ impl WebSharing {
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ClientToServerMsg {
- #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16")]
+ #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17")]
pub message: ::core::option::Option<client_to_server_msg::Message>,
}
/// Nested message and enum types in `ClientToServerMsg`.
@@ -2623,6 +2623,8 @@ pub mod client_to_server_msg {
FailedToStartWebServer(super::FailedToStartWebServerMsg),
#[prost(message, tag="16")]
AttachWatcherClient(super::AttachWatcherClientMsg),
+ #[prost(message, tag="17")]
+ SubscribeToPaneRenders(super::SubscribeToPaneRendersMsg),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -2740,8 +2742,16 @@ pub struct FailedToStartWebServerMsg {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct SubscribeToPaneRendersMsg {
+ #[prost(message, repeated, tag="1")]
+ pub pane_ids: ::prost::alloc::vec::Vec<PaneId>,
+ #[prost(uint32, optional, tag="2")]
+ pub scrollback: ::core::option::Option<u32>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ServerToClientMsg {
- #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13")]
+ #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15")]
pub message: ::core::option::Option<server_to_client_msg::Message>,
}
/// Nested message and enum types in `ServerToClientMsg`.
@@ -2775,6 +2785,10 @@ pub mod server_to_client_msg {
RenamedSession(super::RenamedSessionMsg),
#[prost(message, tag="13")]
ConfigFileUpdated(super::ConfigFileUpdatedMsg),
+ #[prost(message, tag="14")]
+ PaneRenderUpdate(super::PaneRenderUpdateMsg),
+ #[prost(message, tag="15")]
+ SubscribedPaneClosed(super::SubscribedPaneClosedMsg),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -2854,3 +2868,23 @@ pub struct RenamedSessionMsg {
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ConfigFileUpdatedMsg {
}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct PaneRenderUpdateMsg {
+ #[prost(message, optional, tag="1")]
+ pub pane_id: ::core::option::Option<PaneId>,
+ #[prost(string, repeated, tag="2")]
+ pub viewport: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+ #[prost(string, repeated, tag="3")]
+ pub scrollback: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+ #[prost(bool, tag="4")]
+ pub has_scrollback: bool,
+ #[prost(bool, tag="5")]
+ pub is_initial: bool,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct SubscribedPaneClosedMsg {
+ #[prost(message, optional, tag="1")]
+ pub pane_id: ::core::option::Option<PaneId>,
+}
diff --git a/zellij-utils/src/cli.rs b/zellij-utils/src/cli.rs
index 8ebbeca7..63c7777f 100644
--- a/zellij-utils/src/cli.rs
+++ b/zellij-utils/src/cli.rs
@@ -4,7 +4,7 @@ use crate::{
consts::{ZELLIJ_CONFIG_DIR_ENV, ZELLIJ_CONFIG_FILE_ENV},
input::{layout::PluginUserConfiguration, options::Options},
};
-use clap::{Args, Parser, Subcommand};
+use clap::{ArgEnum, Args, Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use std::path::PathBuf;
@@ -119,6 +119,46 @@ pub enum Command {
/// Explore existing zellij sessions
#[clap(flatten)]
Sessions(Sessions),
+
+ /// Subscribe to pane render updates (viewport and scrollback)
+ #[clap(override_usage(
+ "zellij [--session <OTHER SESSION NAME>] subscribe [OPTIONS] --pane-id..."
+ ))]
+ Subscribe(SubscribeCli),
+}
+
+#[derive(Debug, Parser, Clone, Serialize, Deserialize)]
+pub struct SubscribeCli {
+ /// Pane ID(s) to subscribe to (e.g. terminal_1, plugin_2, or bare number like 1)
+ #[clap(
+ short,
+ long,
+ required = true,
+ multiple_values = true,
+ multiple_occurrences = true
+ )]
+ pub pane_id: Vec<String>,
+
+ /// Include scrollback lines in initial delivery.
+ /// Bare --scrollback = all scrollback, --scrollback N = last N lines.
+ #[clap(
+ short,
+ long,
+ min_values = 0,
+ max_values = 1,
+ default_missing_value = "0"
+ )]
+ pub scrollback: Option<usize>,
+
+ /// Output format
+ #[clap(short, long, default_value = "raw", arg_enum)]
+ pub format: SubscribeFormat,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, ArgEnum)]
+pub enum SubscribeFormat {
+ Raw,
+ Json,
}
#[derive(Debug, Clone, Args, Serialize, Deserialize)]
@@ -1373,3 +1413,76 @@ tail -f /tmp/my-live-logfile | zellij action pipe --name logs --plugin https://e
reset: bool,
},
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use clap::Parser;
+
+ fn parse_subscribe(args: &[&str]) -> SubscribeCli {
+ let mut full_args = vec!["zellij"];
+ full_args.extend_from_slice(args);
+ let cli = CliArgs::try_parse_from(full_args).unwrap();
+ match cli.command {
+ Some(Command::Subscribe(s)) => s,
+ other => panic!("Expected Subscribe, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn subscribe_scrollback_bare_flag() {
+ let s = parse_subscribe(&["subscribe", "--pane-id", "terminal_1", "--scrollback"]);
+ assert_eq!(s.scrollback, Some(0));
+ }
+
+ #[test]
+ fn subscribe_scrollback_with_value() {
+ let s = parse_subscribe(&[
+ "subscribe",
+ "--pane-id",
+ "terminal_1",
+ "--scrollback",
+ "100",
+ ]);
+ assert_eq!(s.scrollback, Some(100));
+ }
+
+ #[test]
+ fn subscribe_scrollback_absent() {
+ let s = parse_subscribe(&["subscribe", "--pane-id", "terminal_1"]);
+ assert_eq!(s.scrollback, None);
+ }
+
+ #[test]
+ fn subscribe_format_json() {
+ let s = parse_subscribe(&["subscribe", "--pane-id", "terminal_1", "--format", "json"]);
+ assert!(matches!(s.format, SubscribeFormat::Json));
+ }
+
+ #[test]
+ fn subscribe_format_default_raw() {
+ let s = parse_subscribe(&["subscribe", "--pane-id", "terminal_1"]);
+ assert!(matches!(s.format, SubscribeFormat::Raw));
+ }
+
+ #[test]
+ fn subscribe_multiple_pane_ids() {
+ let s = parse_subscribe(&[
+ "subscribe",
+ "--pane-id",
+ "terminal_1",
+ "--pane-id",
+ "plugin_2",
+ ]);
+ assert_eq!(
+ s.pane_id,
+ vec!["terminal_1".to_string(), "plugin_2".to_string()]
+ );
+ }
+
+ #[test]
+ fn subscribe_requires_pane_id() {
+ let result = CliArgs::try_parse_from(["zellij", "subscribe"]);
+ assert!(result.is_err());
+ }
+}
diff --git a/zellij-utils/src/client_server_contract/client_to_server.proto b/zellij-utils/src/client_server_contract/client_to_server.proto
index 7d922e6d..8fdedac2 100644
--- a/zellij-utils/src/client_server_contract/client_to_server.proto
+++ b/zellij-utils/src/client_server_contract/client_to_server.proto
@@ -21,6 +21,7 @@ message ClientToServerMsg {
WebServerStartedMsg web_server_started = 14;
FailedToStartWebServerMsg failed_to_start_web_server = 15;
AttachWatcherClientMsg attach_watcher_client = 16;
+ SubscribeToPaneRendersMsg subscribe_to_pane_renders = 17;
}
}
@@ -97,3 +98,8 @@ message WebServerStartedMsg {
message FailedToStartWebServerMsg {
string error = 1;
}
+
+message SubscribeToPaneRendersMsg {
+ repeated PaneId pane_ids = 1;
+ optional uint32 scrollback = 2;
+}
diff --git a/zellij-utils/src/client_server_contract/server_to_client.proto b/zellij-utils/src/client_server_contract/server_to_client.proto
index 57813ebb..e0b8b529 100644
--- a/zellij-utils/src/client_server_contract/server_to_client.proto
+++ b/zellij-utils/src/client_server_contract/server_to_client.proto
@@ -18,6 +18,8 @@ message ServerToClientMsg {
StartWebServerMsg start_web_server = 11;
RenamedSessionMsg renamed_session = 12;
ConfigFileUpdatedMsg config_file_updated = 13;
+ PaneRenderUpdateMsg pane_render_update = 14;
+ SubscribedPaneClosedMsg subscribed_pane_closed = 15;
}
}
@@ -74,3 +76,15 @@ message RenamedSessionMsg {
message ConfigFileUpdatedMsg {
// Empty message
}
+
+message PaneRenderUpdateMsg {
+ PaneId pane_id = 1;
+ repeated string viewport = 2;
+ repeated string scrollback = 3;
+ bool has_scrollback = 4;
+ bool is_initial = 5;
+}
+
+message SubscribedPaneClosedMsg {
+ PaneId pane_id = 1;
+}
diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs
index aec1d0f0..221a7b96 100644
--- a/zellij-utils/src/errors.rs
+++ b/zellij-utils/src/errors.rs
@@ -415,6 +415,8 @@ pub enum ScreenContext {
ClearMouseHelpText,
SetPluginRegexHighlights,
ClearPluginHighlights,
+ SubscribeToPaneRenders,
+ NotifyPaneClosedToSubscribers,
}
/// Stack call representations corresponding to the different types of [`PtyInstruction`]s.
diff --git a/zellij-utils/src/ipc.rs b/zellij-utils/src/ipc.rs
index bff396a1..656bec4c 100644
--- a/zellij-utils/src/ipc.rs
+++ b/zellij-utils/src/ipc.rs
@@ -1,6 +1,6 @@
//! IPC stuff for starting to split things into a client and server model.
use crate::{
- data::{ClientId, ConnectToSession, KeyWithModifier, Style},
+ data::{ClientId, ConnectToSession, KeyWithModifier, PaneId, Style},
errors::{prelude::*, ErrorContext},
input::{actions::Action, cli_assets::CliAssets},
pane_size::{Size, SizeInPixels},
@@ -148,6 +148,10 @@ pub enum ClientToServerMsg {
FailedToStartWebServer {
error: String,
},
+ SubscribeToPaneRenders {
+ pane_ids: Vec<PaneId>,
+ scrollback: Option<usize>,
+ },
}
// Types of messages sent from the server to the client
@@ -183,6 +187,15 @@ pub enum ServerToClientMsg {
name: String,
},
ConfigFileUpdated,
+ PaneRenderUpdate {
+ pane_id: PaneId,
+ viewport: Vec<String>,
+ scrollback: Option<Vec<String>>,
+ is_initial: bool,
+ },
+ SubscribedPaneClosed {
+ pane_id: PaneId,
+ },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
diff --git a/zellij-utils/src/ipc/protobuf_conversion.rs b/zellij-utils/src/ipc/protobuf_conversion.rs
index 1d228340..d843dc9d 100644
--- a/zellij-utils/src/ipc/protobuf_conversion.rs
+++ b/zellij-utils/src/ipc/protobuf_conversion.rs
@@ -6,13 +6,14 @@ use crate::{
ConnStatusMsg, ConnectedMsg, DetachSessionMsg, ExitMsg, ExitReason as ProtoExitReason,
FailedToStartWebServerMsg, FirstClientConnectedMsg, ForegroundColorMsg,
InputMode as ProtoInputMode, KeyMsg, KillSessionMsg, LayoutMetadata as ProtoLayoutMetadata,
- LogErrorMsg, LogMsg, PaneMetadata as ProtoPaneMetadata, QueryTerminalSizeMsg,
- RenamedSessionMsg, RenderMsg, ServerToClientMsg as ProtoServerToClientMsg,
- StartWebServerMsg, SwitchSessionMsg, TabMetadata as ProtoTabMetadata,
+ LogErrorMsg, LogMsg, PaneMetadata as ProtoPaneMetadata, PaneRenderUpdateMsg,
+ QueryTerminalSizeMsg, RenamedSessionMsg, RenderMsg,
+ ServerToClientMsg as ProtoServerToClientMsg, StartWebServerMsg, SubscribeToPaneRendersMsg,
+ SubscribedPaneClosedMsg, SwitchSessionMsg, TabMetadata as ProtoTabMetadata,
TerminalPixelDimensionsMsg, TerminalResizeMsg, UnblockCliPipeInputMsg,
UnblockInputThreadMsg, WebServerStartedMsg,
},
- data::InputMode,
+ data::{InputMode, PaneId},
errors::prelude::*,
ipc::{
ClientToServerMsg, ColorRegister, ExitReason, PaneReference, PixelDimensions,
@@ -114,6 +115,13 @@ impl From<ClientToServerMsg> for ProtoClientToServerMsg {
error,
})
},
+ ClientToServerMsg::SubscribeToPaneRenders {
+ pane_ids,
+ scrollback,
+ } => client_to_server_msg::Message::SubscribeToPaneRenders(SubscribeToPaneRendersMsg {
+ pane_ids: pane_ids.into_iter().map(|id| id.into()).collect(),
+ scrollback: scrollback.map(|s| s as u32),
+ }),
};
ProtoClientToServerMsg {
@@ -228,6 +236,14 @@ impl TryFrom<ProtoClientToServerMsg> for ClientToServerMsg {
error: failed.error,
})
},
+ Some(client_to_server_msg::Message::SubscribeToPaneRenders(msg)) => {
+ let pane_ids: Result<Vec<PaneId>> =
+ msg.pane_ids.into_iter().map(|p| p.try_into()).collect();
+ Ok(ClientToServerMsg::SubscribeToPaneRenders {
+ pane_ids: pane_ids?,
+ scrollback: msg.scrollback.map(|s| s as usize),
+ })
+ },
None => Err(anyhow!("Empty ClientToServerMsg message")),
}
}
@@ -290,6 +306,23 @@ impl From<ServerToClientMsg> for ProtoServerToClientMsg {
ServerToClientMsg::ConfigFileUpdated => {
server_to_client_msg::Message::ConfigFileUpdated(ConfigFileUpdatedMsg {})
},
+ ServerToClientMsg::PaneRenderUpdate {
+ pane_id,
+ viewport,
+ scrollback,
+ is_initial,
+ } => server_to_client_msg::Message::PaneRenderUpdate(PaneRenderUpdateMsg {
+ pane_id: Some(pane_id.into()),
+ viewport,
+ scrollback: scrollback.clone().unwrap_or_default(),
+ has_scrollback: scrollback.is_some(),
+ is_initial,
+ }),
+ ServerToClientMsg::SubscribedPaneClosed { pane_id } => {
+ server_to_client_msg::Message::SubscribedPaneClosed(SubscribedPaneClosedMsg {
+ pane_id: Some(pane_id.into()),
+ })
+ },
};
ProtoServerToClientMsg {
@@ -372,6 +405,30 @@ impl TryFrom<ProtoServerToClientMsg> for ServerToClientMsg {
Some(server_to_client_msg::Message::ConfigFileUpdated(_)) => {
Ok(ServerToClientMsg::ConfigFileUpdated)
},
+ Some(server_to_client_msg::Message::PaneRenderUpdate(msg)) => {
+ let pane_id: PaneId = msg
+ .pane_id
+ .ok_or_else(|| anyhow!("Missing pane_id"))?
+ .try_into()?;
+ let scrollback = if msg.has_scrollback {
+ Some(msg.scrollback)
+ } else {
+ None
+ };
+ Ok(ServerToClientMsg::PaneRenderUpdate {
+ pane_id,
+ viewport: msg.viewport,
+ scrollback,
+ is_initial: msg.is_initial,
+ })
+ },
+ Some(server_to_client_msg::Message::SubscribedPaneClosed(msg)) => {
+ let pane_id: PaneId = msg
+ .pane_id
+ .ok_or_else(|| anyhow!("Missing pane_id"))?
+ .try_into()?;
+ Ok(ServerToClientMsg::SubscribedPaneClosed { pane_id })
+ },
None => Err(anyhow!("Empty ServerToClientMsg message")),
}
}
diff --git a/zellij-utils/src/ipc/tests/roundtrip_tests.rs b/zellij-utils/src/ipc/tests/roundtrip_tests.rs
index fb8e28f8..aee81757 100644
--- a/zellij-utils/src/ipc/tests/roundtrip_tests.rs
+++ b/zellij-utils/src/ipc/tests/roundtrip_tests.rs
@@ -3040,6 +3040,18 @@ fn test_client_messages() {
test_client_roundtrip!(ClientToServerMsg::FailedToStartWebServer {
error: "Port already in use".to_string(),
});
+ test_client_roundtrip!(ClientToServerMsg::SubscribeToPaneRenders {
+ pane_ids: vec![PaneId::Terminal(1), PaneId::Plugin(2)],
+ scrollback: Some(100),
+ });
+ test_client_roundtrip!(ClientToServerMsg::SubscribeToPaneRenders {
+ pane_ids: vec![PaneId::Terminal(0)],
+ scrollback: None,
+ });
+ test_client_roundtrip!(ClientToServerMsg::SubscribeToPaneRenders {
+ pane_ids: vec![PaneId::Terminal(1)],
+ scrollback: Some(0),
+ });
}
fn test_server_messages() {
@@ -3151,6 +3163,30 @@ fn test_server_messages() {
cwd: Some(PathBuf::from("/path/to/cwd")),
}
});
+ test_server_roundtrip!(ServerToClientMsg::PaneRenderUpdate {
+ pane_id: PaneId::Terminal(1),
+ viewport: vec!["hello".to_string(), "world".to_string()],
+ scrollback: Some(vec!["line1".to_string()]),
+ is_initial: true,
+ });
+ test_server_roundtrip!(ServerToClientMsg::PaneRenderUpdate {
+ pane_id: PaneId::Plugin(2),
+ viewport: vec!["viewport line".to_string()],
+ scrollback: None,
+ is_initial: false,
+ });
+ test_server_roundtrip!(ServerToClientMsg::PaneRenderUpdate {
+ pane_id: PaneId::Terminal(0),
+ viewport: vec![],
+ scrollback: Some(vec![]),
+ is_initial: true,
+ });
+ test_server_roundtrip!(ServerToClientMsg::SubscribedPaneClosed {
+ pane_id: PaneId::Terminal(1),
+ });
+ test_server_roundtrip!(ServerToClientMsg::SubscribedPaneClosed {
+ pane_id: PaneId::Plugin(3),
+ });
}
#[test]